From 7d13a3ea8b437d74b0ccc1d66eb768558d56019c Mon Sep 17 00:00:00 2001 From: Fabian Stamm Date: Tue, 18 Feb 2025 19:26:42 +0100 Subject: [PATCH] First commit --- .gitignore | 2 + package.json | 29 +++++++++ src/context.ts | 15 +++++ src/hooks.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 + tsconfig.json | 12 ++++ yarn.lock | 47 +++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/context.ts create mode 100644 src/hooks.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73f63fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b61f72 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "solid-pb", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "main": "./dist/index.js", + "exports": { + ".": { + "default": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "scripts": { + "dev": "tsc -w", + "build": "tsc -w", + "prepublish": "tsc" + }, + "dependencies": { + "@nedpals/pbf": "^1.3.2" + }, + "peerDependencies": { + "pocketbase": "^0.25.1", + "solid-js": "^1.9.4", + "zod": "^3.24.2" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..e026618 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "solid-js"; +import PocketBaseClient from "pocketbase"; + + +const PBContext = createContext(); + +export const usePB = () => { + const ctx = useContext(PBContext) + if (!ctx) throw new Error("This hook must be used within a PBContext.Provider!"); + + return ctx; +}; + + +export default PBContext; \ No newline at end of file diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..9785f76 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,159 @@ +import { createEffect, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import { RecordModel, RecordSubscription } from "pocketbase"; +import * as pbf from "@nedpals/pbf"; +import { z } from "zod"; + +import { usePB } from "./context.js"; + +interface IUseRecordOptions { + realtime?: boolean; + validator?: z.AnyZodObject; + expand?: string[]; +} + +export function useRecord(collection: string, id: () => string, options?: IUseRecordOptions) { + const pb = usePB(); + const requestKey = crypto.randomUUID(); + + const [record, setRecord] = createStore<{ + loading: boolean, + record: T & RecordModel | null, + error: Error | null, + }>({ + loading: true, + error: null, + record: null, + }); + + const v = options?.validator ?? ({ parse: (i) => id }); + + createEffect(() => { + const cid = id(); + + const setRecordChecked: typeof setRecord = (...args) => { + if (cid !== id()) return; + (setRecord as any)(...args); + } + + setRecordChecked("loading", () => true) + setRecordChecked("error", () => null) + pb.collection(collection).getOne(cid, { + expand: options?.expand ? options.expand.join(",") : undefined, + requestKey, + }).then((value) => { + setRecordChecked("record", () => v.parse(value) as T & RecordModel) + }).catch((error) => { + setRecordChecked("error", () => error as Error) + }).finally(() => { + setRecordChecked("loading", () => false) + }); + + const sub = pb.realtime.subscribe(collection + "/" + cid, (value) => { + //TODO: Check what actions exist and if some needs special handling + setRecordChecked("record", () => v.parse(value.record) as T & RecordModel) + }, { + query: { + expand: options?.expand ? options.expand.join(",") : undefined, + requestKey: requestKey + "-rt", + } + }); + + sub.catch(err => { console.error("Error in realtime subscription", err) }); + + onCleanup(() => { + pb.cancelRequest(requestKey); + pb.cancelRequest(requestKey + "-rt"); + sub.then(s => s()); + }); + }) + + return record; +} + +interface IUseRecordsOptions { + realtime?: boolean; + validator?: z.AnyZodObject; + filter?: () => pbf.MaybeFilter | undefined, + expand?: string[]; +} + +export function useRecords(collection: string, options: IUseRecordsOptions = { expand: [] }) { + const pb = usePB(); + const requestKey = crypto.randomUUID(); + + const fstr = () => options.filter && options.filter() ? pbf.stringify(options.filter()) : undefined; + const v = options.validator ?? ({ parse: (i) => i }); + + const [records, setRecords] = createStore<{ + loading: boolean, + records: (T & RecordModel)[], + error: Error | null, + }>({ + records: [], + loading: true, + error: null, + }); + + createEffect(() => { + const cf = fstr(); + + const setRecordChecked: typeof setRecords = (...args) => { + if (cf !== fstr()) return; + (setRecords as any)(...args); + } + + setRecordChecked("loading", () => true) + setRecordChecked("error", () => null) + + pb.collection(collection).getFullList({ + requestKey, + filter: cf, + expand: options?.expand ? options.expand.join(",") : undefined, + }).then((values) => { + setRecordChecked("records", () => values.map(value => v.parse(value) as T & RecordModel)) + }).catch((error) => { + setRecordChecked("error", () => error as Error) + }).finally(() => { + setRecordChecked("loading", () => false) + }); + + const sub = pb.realtime.subscribe(collection, (event: RecordSubscription) => { + console.log("Event", event); + const rec = v.parse(event.record) as T & RecordModel; + switch (event.action) { + case "create": + setRecordChecked("records", (records) => [...records, rec]); + break; + + case "update": + setRecordChecked("records", (records) => records.map(r => r.id === rec.id ? rec : r)); + break; + + case "delete": + setRecordChecked("records", (records) => records.filter(r => r.id !== rec.id)); + break; + + default: + console.log("Unknown action", event.action); + } + }, { + requestKey: requestKey + "-rt", + query: { + filter: cf, + expand: options?.expand ? options.expand.join(",") : undefined, + } + }); + + sub.then(unsub => { console.log("Subscribed to realtime changes!"); }); + sub.catch(err => { console.error("Error in realtime subscription", err) }); + + onCleanup(() => { + pb.cancelRequest(requestKey); + pb.cancelRequest(requestKey + "-rt"); + sub.then(s => s()); + }); + }) + + return records; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e1b4151 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export { default as PBContext, usePB } from "./context.js"; +export { useRecord, useRecords } from "./hooks.js"; + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5b1e784 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "target": "ESNext", + "declaration": true, + "outDir": "dist", + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..36df51f --- /dev/null +++ b/yarn.lock @@ -0,0 +1,47 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@nedpals/pbf@^1.3.2": + version "1.3.2" + resolved "https://npm.hibas123.de/@nedpals/pbf/-/pbf-1.3.2.tgz#53b00c65103fbaa90c217ea0a4183119d9f9817e" + integrity sha512-vTEUIbVF8AiufclVzor2HWMoTrnIZn74NTu+B7HlNlB+QR9HEq/9Rf/hufX9QOt54iOoJukP2x3ASsDUKl48MQ== + +csstype@^3.1.0: + version "3.1.3" + resolved "https://npm.hibas123.de/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +pocketbase@^0.25.1: + version "0.25.1" + resolved "https://npm.hibas123.de/pocketbase/-/pocketbase-0.25.1.tgz#e2bb606d2e39a992d9b328d67a24a9879ba7337d" + integrity sha512-2IH0KLI/qMNR/E17C7BGWX2FxW7Tead+igLHOWZ45P56v/NyVT18Jnmddeft+3qWWGL1Hog2F8bc4orWV/+Fcg== + +seroval-plugins@^1.1.0: + version "1.2.1" + resolved "https://npm.hibas123.de/seroval-plugins/-/seroval-plugins-1.2.1.tgz#fa535e70ade8af553634b2b5c80d8a6fd8c2ff72" + integrity sha512-H5vs53+39+x4Udwp4J5rNZfgFuA+Lt+uU+09w1gYBVWomtAl98B+E9w7yC05Xc81/HgLvJdlyqJbU0fJCKCmdw== + +seroval@^1.1.0: + version "1.2.1" + resolved "https://npm.hibas123.de/seroval/-/seroval-1.2.1.tgz#fc671d63445923ab64f7abaf3967c83901382f40" + integrity sha512-yBxFFs3zmkvKNmR0pFSU//rIsYjuX418TnlDmc2weaq5XFDqDIV/NOMPBoLrbxjLH42p4UzRuXHryXh9dYcKcw== + +solid-js@^1.9.4: + version "1.9.4" + resolved "https://npm.hibas123.de/solid-js/-/solid-js-1.9.4.tgz#da9b5645f10875a631d93335cd50525ff36b6c27" + integrity sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g== + dependencies: + csstype "^3.1.0" + seroval "^1.1.0" + seroval-plugins "^1.1.0" + +typescript@^5.7.3: + version "5.7.3" + resolved "https://npm.hibas123.de/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" + integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== + +zod@^3.24.2: + version "3.24.2" + resolved "https://npm.hibas123.de/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" + integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==