From 6a8e530aa20e2e4c2c540fbda7a6707637ecc4be Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:08:17 +0000 Subject: [PATCH 1/2] feat: add PocketBase collection integration Add @tanstack/pocketbase-db-collection package with support for real-time synchronization, mutations, and schema validation. Includes overloads for schema-based type inference following the same pattern as rxdb-db-collection. --- .../pocketbase-db-collection/package.json | 62 +++ .../pocketbase-db-collection/src/index.ts | 4 + .../src/pocketbase.ts | 148 +++++++ .../tests/pocketbase.test.ts | 369 ++++++++++++++++++ .../tsconfig.docs.json | 9 + .../pocketbase-db-collection/tsconfig.json | 18 + .../pocketbase-db-collection/vite.config.ts | 21 + pnpm-lock.yaml | 27 ++ 8 files changed, 658 insertions(+) create mode 100644 packages/pocketbase-db-collection/package.json create mode 100644 packages/pocketbase-db-collection/src/index.ts create mode 100644 packages/pocketbase-db-collection/src/pocketbase.ts create mode 100644 packages/pocketbase-db-collection/tests/pocketbase.test.ts create mode 100644 packages/pocketbase-db-collection/tsconfig.docs.json create mode 100644 packages/pocketbase-db-collection/tsconfig.json create mode 100644 packages/pocketbase-db-collection/vite.config.ts diff --git a/packages/pocketbase-db-collection/package.json b/packages/pocketbase-db-collection/package.json new file mode 100644 index 000000000..976b122ec --- /dev/null +++ b/packages/pocketbase-db-collection/package.json @@ -0,0 +1,62 @@ +{ + "name": "@tanstack/pocketbase-db-collection", + "description": "PocketBase collection for TanStack DB", + "version": "1.0.0", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@tanstack/db": "workspace:*", + "@tanstack/store": "^0.8.0", + "pocketbase": "^0.26.3" + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "packageManager": "pnpm@10.22.0", + "author": "PocketBase", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/db.git", + "directory": "packages/pocketbase-db-collection" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "pocketbase", + "nosql", + "realtime", + "local-first", + "sync-engine", + "sync", + "replication", + "opfs", + "indexeddb", + "localstorage", + "optimistic", + "typescript" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "npx vitest --run" + }, + "sideEffects": false, + "type": "module", + "types": "dist/esm/index.d.ts" +} diff --git a/packages/pocketbase-db-collection/src/index.ts b/packages/pocketbase-db-collection/src/index.ts new file mode 100644 index 000000000..1e178d695 --- /dev/null +++ b/packages/pocketbase-db-collection/src/index.ts @@ -0,0 +1,4 @@ +export { + pocketbaseCollectionOptions, + type PocketBaseCollectionConfig, +} from "./pocketbase" diff --git a/packages/pocketbase-db-collection/src/pocketbase.ts b/packages/pocketbase-db-collection/src/pocketbase.ts new file mode 100644 index 000000000..0fbe7b0bd --- /dev/null +++ b/packages/pocketbase-db-collection/src/pocketbase.ts @@ -0,0 +1,148 @@ +import type { + BaseCollectionConfig, + CollectionConfig, + DeleteMutationFnParams, + InferSchemaOutput, + InsertMutationFnParams, + SyncConfig, + UpdateMutationFnParams, +} from "@tanstack/db" +import type { StandardSchemaV1 } from "@standard-schema/spec" +import type { RecordModel, RecordService } from "pocketbase" + +type PocketBaseRecord = Omit & { + id: string +} + +export interface PocketBaseCollectionConfig< + TItem extends PocketBaseRecord, + TSchema extends StandardSchemaV1 = never, +> extends Omit< + BaseCollectionConfig, + `onInsert` | `onUpdate` | `onDelete` | `getKey` + > { + recordService: RecordService +} + +export function pocketbaseCollectionOptions( + config: PocketBaseCollectionConfig< + InferSchemaOutput & PocketBaseRecord, + TSchema + > & { + schema: TSchema + } +): CollectionConfig, string, TSchema> & { + schema: TSchema +} +export function pocketbaseCollectionOptions( + config: PocketBaseCollectionConfig & { + schema?: never + } +): CollectionConfig & { + schema?: never +} +export function pocketbaseCollectionOptions( + config: PocketBaseCollectionConfig +): CollectionConfig { + const { recordService, ...restConfig } = config + type TItem = + typeof config extends PocketBaseCollectionConfig + ? T + : PocketBaseRecord + const getKey = (item: TItem) => { + return item.id + } + + type SyncParams = Parameters[`sync`]>[0] + const sync = { + sync: (params: SyncParams) => { + const { begin, write, commit, markReady } = params + + let unsubscribeFn: (() => Promise) | undefined + + async function initialFetch() { + const records = await recordService.getFullList() + + begin() + for (const record of records) { + write({ type: `insert`, value: record }) + } + commit() + } + + async function listen() { + unsubscribeFn = await recordService.subscribe(`*`, (event) => { + begin() + switch (event.action) { + case `create`: + write({ type: `insert`, value: event.record }) + break + case `update`: + write({ type: `update`, value: event.record }) + break + case `delete`: + write({ type: `delete`, value: event.record }) + break + } + commit() + }) + } + + async function start() { + await listen() + + try { + await initialFetch() + } catch (e) { + if (unsubscribeFn) { + await unsubscribeFn() + unsubscribeFn = undefined + } + throw e + } finally { + markReady() + } + } + + start() + + // Return cleanup function to unsubscribe when collection is cleaned up + return async () => { + if (unsubscribeFn) { + await unsubscribeFn() + } + } + }, + } + + return { + ...restConfig, + getKey, + sync, + onInsert: async (params: InsertMutationFnParams) => { + return await Promise.all( + params.transaction.mutations.map(async (mutation) => { + const { id: _, ...changes } = mutation.changes + const result = await recordService.create(changes) + return result.id + }) + ) + }, + onUpdate: async (params: UpdateMutationFnParams) => { + return await Promise.all( + params.transaction.mutations.map(async ({ key, changes }) => { + await recordService.update(key, changes) + return key + }) + ) + }, + onDelete: async (params: DeleteMutationFnParams) => { + return await Promise.all( + params.transaction.mutations.map(async (mutation) => { + await recordService.delete(mutation.key) + return mutation.key + }) + ) + }, + } +} diff --git a/packages/pocketbase-db-collection/tests/pocketbase.test.ts b/packages/pocketbase-db-collection/tests/pocketbase.test.ts new file mode 100644 index 000000000..cf2a78adb --- /dev/null +++ b/packages/pocketbase-db-collection/tests/pocketbase.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it, vi } from "vitest" +import { createCollection } from "@tanstack/db" +import { pocketbaseCollectionOptions } from "../src/pocketbase" +import type { RecordService, RecordSubscription } from "pocketbase" + +type Data = { + id: string + data: string + updated?: number +} + +type UnsubscribeFunc = () => Promise + +class MockRecordService { + collectionIdOrName = `test` + baseCrudPath = `/api/collections/test` + baseCollectionPath = `/api/collections/test` + isSuperusers = false + + private records: Map = new Map() + private subscriptions: Map< + string, + Array<(data: RecordSubscription) => void> + > = new Map() + + getFullList = vi.fn( + (_options?: unknown): Promise> => + Promise.resolve(Array.from(this.records.values())) + ) + + subscribe = vi.fn( + ( + topic: string, + callback: (data: RecordSubscription) => void, + _options?: unknown + ): Promise => { + let subscription = this.subscriptions.get(topic) + if (!subscription) { + subscription = [] + this.subscriptions.set(topic, subscription) + } + subscription.push(callback) + + return Promise.resolve(async () => { + const index = subscription.indexOf(callback) + if (index > -1) { + subscription.splice(index, 1) + } + if (subscription.length === 0) { + this.subscriptions.delete(topic) + await this.unsubscribe(topic) + } + return Promise.resolve() + }) + } + ) + + unsubscribe = vi.fn((_topic?: string): Promise => { + if (_topic) { + this.subscriptions.delete(_topic) + } else { + this.subscriptions.clear() + } + return Promise.resolve() + }) + + create = vi.fn( + ( + bodyParams?: { [key: string]: any } | FormData, + _options?: unknown + ): Promise => { + const body = bodyParams as { [key: string]: any } + // For testing: if body doesn't have id, we need to generate one + // But in real PocketBase, the id is generated server-side + // For our tests, we'll use a simple approach: generate id if missing + const id = body.id || `generated-${Date.now()}-${Math.random()}` + const record = { ...body, id } as T + this.records.set(id, record) + this.emitSubscription(`*`, `create`, record) + return Promise.resolve(record) + } + ) + + update = vi.fn( + ( + id: string, + bodyParams?: { [key: string]: any } | FormData, + _options?: unknown + ): Promise => { + const existing = this.records.get(id) + if (!existing) { + throw new Error(`Record not found`) + } + const updated = { ...existing, ...bodyParams } as T + this.records.set(id, updated) + this.emitSubscription(`*`, `update`, updated) + this.emitSubscription(id, `update`, updated) + return Promise.resolve(updated) + } + ) + + delete = vi.fn((id: string, _options?: unknown): Promise => { + const record = this.records.get(id) + if (!record) { + throw new Error(`Record not found`) + } + this.records.delete(id) + this.emitSubscription(`*`, `delete`, record) + this.emitSubscription(id, `delete`, record) + return Promise.resolve(true) + }) + + // Helper method to inject events for testing + emitSubscription(topic: string, action: string, record: T) { + const callbacks = this.subscriptions.get(topic) + if (callbacks) { + callbacks.forEach((callback) => { + callback({ action, record }) + }) + } + } + + // Helper method to set initial records + setInitialRecords(records: Array) { + this.records.clear() + records.forEach((record) => { + this.records.set(record.id, record) + }) + } + + // Helper method to add a record + addRecord(record: T) { + this.records.set(record.id, record) + } + + // Mock other required methods (not used in tests but required by interface) + getList = vi.fn() + getFirstListItem = vi.fn() + getOne = vi.fn() + decode = vi.fn() +} + +function setUp(recordService: MockRecordService) { + const options = pocketbaseCollectionOptions({ + recordService: recordService as unknown as RecordService, + }) + + return options +} + +describe(`PocketBase Integration`, () => { + it(`should initialize and fetch initial data`, async () => { + const records: Array = [ + { + id: `1`, + data: `first`, + updated: 0, + }, + { + id: `2`, + data: `second`, + updated: 0, + }, + ] + + const recordService = new MockRecordService() + recordService.setInitialRecords(records) + + const options = setUp(recordService) + const collection = createCollection(options) + + // Wait for initial fetch + await collection.stateWhenReady() + + expect(recordService.getFullList).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(records.length) + expect(collection.get(`1`)).toEqual(records[0]) + expect(collection.get(`2`)).toEqual(records[1]) + }) + + it(`should receive create, update and delete events`, async () => { + const recordService = new MockRecordService() + recordService.setInitialRecords([]) + + const options = setUp(recordService) + const collection = createCollection(options) + + // Wait for initial fetch + await collection.stateWhenReady() + expect(collection.size).toBe(0) + + // Inject a create event + const newRecord: Data = { + id: `1`, + data: `new`, + updated: 0, + } + recordService.emitSubscription(`*`, `create`, newRecord) + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(collection.size).toBe(1) + expect(collection.get(`1`)).toEqual(newRecord) + + // Inject an update event + const updatedRecord: Data = { + ...newRecord, + data: `updated`, + updated: 1, + } + recordService.emitSubscription(`*`, `update`, updatedRecord) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(collection.size).toBe(1) + expect(collection.get(`1`)).toEqual(updatedRecord) + + // Inject a delete event + recordService.emitSubscription(`*`, `delete`, updatedRecord) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(collection.size).toBe(0) + expect(collection.get(`1`)).toBeUndefined() + }) + + it(`should handle local inserts, updates and deletes`, async () => { + const recordService = new MockRecordService() + recordService.setInitialRecords([]) + + const options = setUp(recordService) + const collection = createCollection(options) + + await collection.stateWhenReady() + expect(collection.size).toBe(0) + + // Insert + const data: Data = { + id: `1`, + data: `first`, + updated: 0, + } + + // Mock the create to return the record with the original id + recordService.create.mockImplementation( + (bodyParams?: { [key: string]: any } | FormData) => { + const body = bodyParams as { [key: string]: any } + const record = { ...body, id: data.id } as Data + recordService.setInitialRecords([record]) + recordService.emitSubscription(`*`, `create`, record) + return Promise.resolve(record) + } + ) + + const insertTx = collection.insert(data) + expect(recordService.create).toHaveBeenCalledTimes(1) + expect(recordService.create).toHaveBeenCalledWith({ + data: data.data, + updated: data.updated, + }) + + await insertTx.isPersisted.promise + expect(collection.size).toBe(1) + expect(collection.get(`1`)).toEqual(data) + + // Update + const updateTx = collection.update(`1`, (old: Data) => { + old.data = `updated` + old.updated = 1 + }) + + expect(recordService.update).toHaveBeenCalledTimes(1) + expect(recordService.update).toHaveBeenCalledWith(`1`, { + data: `updated`, + updated: 1, + }) + + await updateTx.isPersisted.promise + expect(collection.get(`1`)?.data).toBe(`updated`) + expect(collection.get(`1`)?.updated).toBe(1) + + // Delete + const deleteTx = collection.delete(`1`) + + expect(recordService.delete).toHaveBeenCalledTimes(1) + expect(recordService.delete).toHaveBeenCalledWith(`1`) + + await deleteTx.isPersisted.promise + expect(collection.size).toBe(0) + expect(collection.get(`1`)).toBeUndefined() + }) + + it(`should unsubscribe when collection is cleaned up`, async () => { + const recordService = new MockRecordService() + recordService.setInitialRecords([]) + + const options = setUp(recordService) + const collection = createCollection(options) + + await collection.stateWhenReady() + + // Verify subscription was set up + expect(recordService.subscribe).toHaveBeenCalledWith( + `*`, + expect.any(Function) + ) + + await collection.cleanup() + + // Verify unsubscribe was called during cleanup + expect(recordService.unsubscribe).toHaveBeenCalled() + // The collection should be in cleaned-up state + expect(collection.status).toBe(`cleaned-up`) + }) + + it(`should handle multiple concurrent inserts`, async () => { + const recordService = new MockRecordService() + recordService.setInitialRecords([]) + + const options = setUp(recordService) + const collection = createCollection(options) + + await collection.stateWhenReady() + + const records: Array = [ + { id: `1`, data: `first` }, + { id: `2`, data: `second` }, + { id: `3`, data: `third` }, + ] + + // Mock create to return records with their original ids + recordService.create.mockImplementation( + (bodyParams?: { [key: string]: any } | FormData) => { + const body = bodyParams as { [key: string]: any } + // Find the matching record by data + const record = records.find((r) => r.data === body.data) + const id = record?.id || `generated-${Date.now()}-${Math.random()}` + const result = { ...body, id } as Data + recordService.addRecord(result) + recordService.emitSubscription(`*`, `create`, result) + return Promise.resolve(result) + } + ) + + const transactions = records.map((record) => collection.insert(record)) + + await Promise.all(transactions.map((tx) => tx.isPersisted.promise)) + + expect(recordService.create).toHaveBeenCalledTimes(3) + expect(collection.size).toBe(3) + expect(collection.get(`1`)).toEqual(records[0]) + expect(collection.get(`2`)).toEqual(records[1]) + expect(collection.get(`3`)).toEqual(records[2]) + }) + + it(`should handle empty initial fetch`, async () => { + const recordService = new MockRecordService() + recordService.setInitialRecords([]) + + const options = setUp(recordService) + const collection = createCollection(options) + + await collection.stateWhenReady() + + expect(collection.size).toBe(0) + expect(recordService.getFullList).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/pocketbase-db-collection/tsconfig.docs.json b/packages/pocketbase-db-collection/tsconfig.docs.json new file mode 100644 index 000000000..5a73feb02 --- /dev/null +++ b/packages/pocketbase-db-collection/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"] + } + }, + "include": ["src"] +} diff --git a/packages/pocketbase-db-collection/tsconfig.json b/packages/pocketbase-db-collection/tsconfig.json new file mode 100644 index 000000000..5102af1f4 --- /dev/null +++ b/packages/pocketbase-db-collection/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2024"], + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react" + }, + "include": ["src", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/pocketbase-db-collection/vite.config.ts b/packages/pocketbase-db-collection/vite.config.ts new file mode 100644 index 000000000..c7968f28a --- /dev/null +++ b/packages/pocketbase-db-collection/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, mergeConfig } from "vitest/config" +import { tanstackViteConfig } from "@tanstack/config/vite" +import packageJson from "./package.json" + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: `./tests`, + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + }) +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee93f2adb..d0e6e8773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,6 +836,28 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.24)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0(postcss@8.5.6))(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + packages/pocketbase-db-collection: + dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 + '@tanstack/db': + specifier: workspace:* + version: link:../db + '@tanstack/store': + specifier: ^0.8.0 + version: 0.8.0 + debug: + specifier: ^4.4.3 + version: 4.4.3 + pocketbase: + specifier: ^0.26.3 + version: 0.26.3 + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + packages/powersync-db-collection: dependencies: '@standard-schema/spec': @@ -7348,6 +7370,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pocketbase@0.26.3: + resolution: {integrity: sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -16186,6 +16211,8 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pocketbase@0.26.3: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): From cf089a91fa85b589b20ccc78648aa2d5a1e709ae Mon Sep 17 00:00:00 2001 From: Kevin Bonnoron <2421321+KevinBonnoron@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:09:29 +0000 Subject: [PATCH 2/2] docs: add PocketBase collection documentation Add comprehensive documentation for @tanstack/pocketbase-db-collection including usage examples, configuration options, real-time subscriptions, and mutation handling. --- docs/collections/pocketbase-collection.md | 202 ++++++++++++++++++++++ docs/config.json | 4 + docs/guides/collection-options-creator.md | 1 + docs/overview.md | 2 + 4 files changed, 209 insertions(+) create mode 100644 docs/collections/pocketbase-collection.md diff --git a/docs/collections/pocketbase-collection.md b/docs/collections/pocketbase-collection.md new file mode 100644 index 000000000..98e65a22a --- /dev/null +++ b/docs/collections/pocketbase-collection.md @@ -0,0 +1,202 @@ +--- +title: PocketBase Collection +--- + +# PocketBase Collection + +PocketBase collections provide seamless integration between TanStack DB and [PocketBase](https://pocketbase.io), enabling real-time data synchronization with PocketBase's open-source backend. + +## Overview + +[PocketBase](https://pocketbase.io) is an open-source backend consisting of an embedded database (SQLite), real-time subscriptions, built-in auth, file storage, and an admin dashboard. + +The `@tanstack/pocketbase-db-collection` package allows you to create collections that: +- Automatically sync data from PocketBase collections +- Support real-time subscriptions for live updates +- Handle optimistic updates with automatic rollback on errors +- Provide built-in mutation handlers for create, update, and delete operations + +## Installation + +```bash +npm install @tanstack/pocketbase-db-collection @tanstack/react-db pocketbase +``` + +## Basic Usage + +```typescript +import { createCollection } from '@tanstack/react-db' +import { pocketbaseCollectionOptions } from '@tanstack/pocketbase-db-collection' +import PocketBase from 'pocketbase' + +const pb = new PocketBase('https://your-pocketbase-instance.com') + +// Authenticate if needed +await pb.collection("users").authWithPassword('test@example.com', '1234567890'); + +const todosCollection = createCollection( + pocketbaseCollectionOptions({ + recordService: pb.collection('todos'), + }) +) +``` + +## Configuration Options + +The `pocketbaseCollectionOptions` function accepts the following options: + +### Required Options + +- `recordService`: PocketBase RecordService instance created via `pb.collection(collectionName)` + +### Optional Options + +- `id`: Unique identifier for the collection +- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Effect) for client-side validation + +## Real-time Subscriptions + +PocketBase supports real-time subscriptions out of the box. The collection automatically subscribes to changes and updates in real-time: + +```typescript +const todosCollection = createCollection( + pocketbaseCollectionOptions({ + recordService: pb.collection('todos'), + }) +) + +// Changes from other clients will automatically update +// the collection in real-time via PocketBase subscriptions +``` + +The sync implementation subscribes to all records (`*`) and handles `create`, `update`, and `delete` events automatically. + +## Mutations + +The collection provides built-in mutation handlers that persist changes to PocketBase: + +```typescript +// Insert a new record +const tx = todosCollection.insert({ + id: 'todo-1', + text: 'Buy milk', + completed: false, +}) +await tx.isPersisted.promise + +// Update a record +const updateTx = todosCollection.update('todo-1', (draft) => { + draft.completed = true +}) +await updateTx.isPersisted.promise + +// Delete a record +const deleteTx = todosCollection.delete('todo-1') +await deleteTx.isPersisted.promise +``` + +## Data Types + +PocketBase records must have an `id` field (string) and can include any other fields. The `collectionId` and `collectionName` fields from PocketBase's `RecordModel` are automatically excluded: + +```typescript +type Todo = { + id: string // Required - PocketBase uses string IDs + text: string + completed: boolean + // You can add any other fields from your PocketBase collection +} + +const todosCollection = createCollection( + pocketbaseCollectionOptions({ + recordService: pb.collection('todos'), + }) +) +``` + +## Complete Example + +```typescript +import { createCollection } from '@tanstack/react-db' +import { pocketbaseCollectionOptions } from '@tanstack/pocketbase-db-collection' +import PocketBase from 'pocketbase' +import { z } from 'zod' + +const pb = new PocketBase('https://your-pocketbase-instance.com') + +// Authenticate +await pb.collection('users').authWithPassword('user@example.com', 'password') + +// Define schema +const todoSchema = z.object({ + id: z.string(), + text: z.string(), + completed: z.boolean(), + created: z.string(), + updated: z.string(), +}) + +type Todo = z.infer + +// Create collection +export const todosCollection = createCollection( + pocketbaseCollectionOptions({ + id: 'todos', + recordService: pb.collection('todos'), + schema: todoSchema, + }) +) + +// Use in component +function TodoList() { + const { data: todos } = useLiveQuery((q) => + q.from({ todo: todosCollection }) + .where(({ todo }) => !todo.completed) + .orderBy(({ todo }) => todo.created, 'desc') + ) + + const addTodo = (text: string) => { + todosCollection.insert({ + id: crypto.randomUUID(), + text, + completed: false, + created: new Date().toISOString(), + updated: new Date().toISOString(), + }) + } + + return ( +
+ {todos.map((todo) => ( +
{todo.text}
+ ))} +
+ ) +} +``` + +## Error Handling + +The collection handles errors gracefully: +- If the initial fetch fails, the subscription is automatically cleaned up +- Mutations that fail will automatically rollback optimistic updates +- The collection is marked as ready even if the initial fetch fails to avoid blocking the application + +## Cleanup + +The collection automatically unsubscribes from PocketBase when cleaned up: + +```typescript +// Cleanup is called automatically when the collection is no longer needed +await todosCollection.cleanup() +``` + +This ensures no memory leaks and properly closes the real-time subscription connection. + +## Learn More + +- [PocketBase Documentation](https://pocketbase.io/docs/) +- [PocketBase JavaScript SDK](https://github.com/pocketbase/js-sdk) +- [Optimistic Mutations](../guides/mutations.md) +- [Live Queries](../guides/live-queries.md) + diff --git a/docs/config.json b/docs/config.json index 09ca7c62b..bedef3511 100644 --- a/docs/config.json +++ b/docs/config.json @@ -63,6 +63,10 @@ "label": "TrailBase Collection", "to": "collections/trailbase-collection" }, + { + "label": "PocketBase Collection", + "to": "collections/pocketbase-collection" + }, { "label": "RxDB Collection", "to": "collections/rxdb-collection" diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index d1f4d55c4..dffad4f4b 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -447,6 +447,7 @@ For complete, production-ready examples, see the collection packages in the TanS - **[@tanstack/query-collection](https://github.com/TanStack/db/tree/main/packages/query-collection)** - Pattern A: User-provided handlers with full refetch strategy - **[@tanstack/trailbase-collection](https://github.com/TanStack/db/tree/main/packages/trailbase-collection)** - Pattern B: Built-in handlers with ID-based tracking +- **[@tanstack/pocketbase-collection](https://github.com/TanStack/db/tree/main/packages/pocketbase-db-collection)** - Pattern B: Built-in handlers with real-time subscriptions via PocketBase - **[@tanstack/electric-collection](https://github.com/TanStack/db/tree/main/packages/electric-collection)** - Pattern A: Transaction ID tracking with complex sync protocols - **[@tanstack/rxdb-collection](https://github.com/TanStack/db/tree/main/packages/rxdb-collection)** - Pattern B: Built-in handlers that bridge [RxDB](https://rxdb.info) change streams into TanStack DB's sync lifecycle diff --git a/docs/overview.md b/docs/overview.md index 3f068b7c8..561983f58 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -194,6 +194,8 @@ TanStack DB provides several built-in collection types for different data source - **[TrailBaseCollection](../collections/trailbase-collection.md)** — Sync data into collections using TrailBase's self-hosted backend with real-time subscriptions. +- **[PocketBaseCollection](../collections/pocketbase-collection.md)** — Sync data into collections using PocketBase's open-source backend with real-time subscriptions, built-in auth, and file storage. + - **[RxDBCollection](../collections/rxdb-collection.md)** — Integrate with RxDB for offline-first local persistence with powerful replication and sync capabilities. - **[PowerSyncCollection](../collections/powersync-collection.md)** — Sync with PowerSync's SQLite-based database for offline-first persistence with real-time synchronization with PostgreSQL, MongoDB, and MySQL backends.