Last active
June 19, 2025 01:37
-
-
Save tomatrow/6aa73c861a4987afb611c71d37a0174d to your computer and use it in GitHub Desktop.
Reactive Pocketbase Collection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { type RecordService, type RecordModel, type RecordSubscribeOptions } from "pocketbase" | |
| import { createSubscriber } from "svelte/reactivity" | |
| export class Collection<M extends RecordModel = RecordModel> { | |
| #recordService: RecordService<M> | |
| #records = $state<Record<string, M>>({}) | |
| #subscribe: () => void | |
| constructor(recordService: RecordService<M>, options?: RecordSubscribeOptions) { | |
| this.#recordService = recordService | |
| this.#subscribe = createSubscriber(update => { | |
| this.load() | |
| const unsubscribePromise = this.#recordService.subscribe( | |
| "*", | |
| data => { | |
| const action = data.action as "create" | "update" | "delete" | |
| switch (action) { | |
| case "delete": | |
| delete this.#records[data.record.id] | |
| break | |
| case "create": | |
| case "update": | |
| this.#records[data.record.id] = data.record | |
| break | |
| } | |
| update() | |
| }, | |
| options | |
| ) | |
| return () => { | |
| unsubscribePromise.then(unsubscribe => unsubscribe()) | |
| } | |
| }) | |
| } | |
| async load() { | |
| await this.#recordService.getFullList().then(response => { | |
| response.forEach(response => { | |
| this.#records[response.id] = response | |
| }) | |
| }) | |
| } | |
| async update(recordsUpdate: Record<string, RecordUpdate<M> | undefined>) { | |
| await Promise.all( | |
| Object.entries(recordsUpdate).map(async ([id, recordUpdate]) => { | |
| if (recordUpdate) { | |
| const prevRecord = this.#records[id] | |
| const recordUpdateSnapshot = $state.snapshot(recordUpdate) as RecordUpdate<M> | |
| if (prevRecord) { | |
| const prevRecordSnapshot = $state.snapshot(prevRecord) as M | |
| const updatedRecord = defaultsDeep( | |
| structuredClone(prevRecordSnapshot), | |
| // @ts-expect-error | |
| recordUpdateSnapshot | |
| ) | |
| if (deepEqual(prevRecordSnapshot, updatedRecord)) return | |
| // todo: we should look through the shallow | |
| await this.#recordService.update(id, updatedRecord) | |
| } else { | |
| await this.#recordService.create({ ...recordUpdateSnapshot, id }) | |
| } | |
| } else { | |
| await this.#recordService.delete(id) | |
| } | |
| }) | |
| ) | |
| } | |
| get records() { | |
| this.#subscribe() | |
| return this.#records | |
| } | |
| } | |
| type DeepPartial<T> = { | |
| [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P] | |
| } | |
| /** | |
| * Performs a deep equality comparison between two values. | |
| * Recursively compares objects and arrays, handling null/undefined cases. | |
| * | |
| * @param a - First value to compare | |
| * @param b - Second value to compare | |
| * @returns true if values are deeply equal, false otherwise | |
| */ | |
| function deepEqual(a: unknown, b: unknown): boolean { | |
| if (a === b) return true | |
| if (a == null || b == null) return false | |
| if (typeof a !== typeof b) return false | |
| if (typeof a !== "object") return false | |
| if (Array.isArray(a) !== Array.isArray(b)) return false | |
| if (Array.isArray(a) && Array.isArray(b)) { | |
| if (a.length !== b.length) return false | |
| for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false | |
| return true | |
| } | |
| const keysA = Object.keys(a) | |
| const keysB = Object.keys(b) | |
| if (keysA.length !== keysB.length) return false | |
| for (const key of keysA) { | |
| if (!keysB.includes(key)) return false | |
| if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) | |
| return false | |
| } | |
| return true | |
| } | |
| /** | |
| * Recursively merges properties from multiple source objects into target object. | |
| * - Undefined values in source will delete the corresponding property in target | |
| * - Objects are merged recursively (arrays are replaced entirely) | |
| * - Sources are applied in order (later sources override earlier ones) | |
| * - Mutates the target object and returns it | |
| * | |
| * @param target - The target object to merge into (will be mutated) | |
| * @param sources - The source objects containing partial updates | |
| * @returns The mutated target object | |
| */ | |
| function defaultsDeep<T extends Record<string, unknown>>( | |
| target: T, | |
| ...sources: DeepPartial<T>[] | |
| ): T { | |
| for (const source of sources) | |
| for (const key in source) { | |
| if (!Object.prototype.hasOwnProperty.call(source, key)) continue | |
| const sourceValue = source[key] | |
| const targetValue = target[key] | |
| if (sourceValue === undefined) { | |
| delete target[key] | |
| } else if ( | |
| targetValue && | |
| typeof targetValue === "object" && | |
| !Array.isArray(targetValue) && | |
| sourceValue && | |
| typeof sourceValue === "object" && | |
| !Array.isArray(sourceValue) | |
| ) { | |
| defaultsDeep(targetValue as Record<string, unknown>, sourceValue) | |
| } else { | |
| // @ts-expect-error | |
| target[key] = sourceValue | |
| } | |
| } | |
| return target | |
| } | |
| /** pocketbase interprets nulling a record property as deleting a property */ | |
| export type RecordUpdate<M> = { | |
| [P in Exclude<keyof M, "id" | "collectionId" | "collectionName" | "expand">]?: DeepPartial< | |
| M[P] | |
| > | null | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example usage with a
postscollection