Skip to content

Instantly share code, notes, and snippets.

@tomatrow
Last active June 19, 2025 01:37
Show Gist options
  • Select an option

  • Save tomatrow/6aa73c861a4987afb611c71d37a0174d to your computer and use it in GitHub Desktop.

Select an option

Save tomatrow/6aa73c861a4987afb611c71d37a0174d to your computer and use it in GitHub Desktop.
Reactive Pocketbase Collection
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
}
@tomatrow
Copy link
Author

tomatrow commented Jun 19, 2025

Example usage with a posts collection

<script lang"ts">
  const pageId = "some-page-id"
  const posts = new Collection(pb.collection("posts"))
</script>

<p>
	<label for="title">Title</label>
	<input
		id="title"
		bind:value={
			() => posts.records[pageId]?.title,
			title => posts.update({ [pageId]: { title } })
		}
	/>
</p>

<p>
	<label for="description">Description</label>
	<textarea
		id="description"
		bind:value={
			() => posts.records[pageId]?.description,
			description => posts.update({ [pageId]: { description } })
		}
	></textarea>
</p>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment