diff --git a/.changeset/add-db-skills-package.md b/.changeset/add-db-skills-package.md new file mode 100644 index 000000000..6eac9f7c5 --- /dev/null +++ b/.changeset/add-db-skills-package.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-playbook': patch +--- + +Add @tanstack/db-playbook package with AI-agent-optimized skills for TanStack DB. Includes skills for live queries, mutations, collections, schemas, and all collection types (Query, Electric, PowerSync, RxDB, TrailBase) with a CLI (`list` and `show` commands) for skill discovery. diff --git a/packages/playbook/README.md b/packages/playbook/README.md new file mode 100644 index 000000000..41cc1b529 --- /dev/null +++ b/packages/playbook/README.md @@ -0,0 +1,64 @@ +# @tanstack/db-playbook + +A playbook of skills for AI agents building with [TanStack DB](https://tanstack.com/db). + +## What is a Playbook? + +A **playbook** is the collection of skills, patterns, and tools someone uses in a vibe-coding context — analogous to "stack" but for the less deliberate, more aesthetic-driven way people assemble their setup now. + +This package provides structured documentation designed for AI coding assistants (Claude Code, Cursor, Copilot, etc.) to help them build TanStack DB applications effectively. + +Skills are distilled, task-focused patterns that agents can quickly consume and apply — unlike large documentation that often exceeds context limits. + +## Installation + +```bash +npm install @tanstack/db-playbook +``` + +## CLI Usage + +```bash +# List all available skills +npx @tanstack/db-playbook list + +# Show a specific skill +npx @tanstack/db-playbook show tanstack-db +npx @tanstack/db-playbook show tanstack-db/live-queries +npx @tanstack/db-playbook show tanstack-db/mutations +``` + +## Skills Structure + +| Skill | Purpose | +| -------------------------- | ------------------------------------------------- | +| `tanstack-db` | Router/entry point with routing table | +| `tanstack-db/live-queries` | Reactive queries, joins, aggregations | +| `tanstack-db/mutations` | Optimistic updates, transactions, paced mutations | +| `tanstack-db/collections` | QueryCollection, ElectricCollection, sync modes | +| `tanstack-db/schemas` | Validation, transformations, TInput/TOutput | +| `tanstack-db/electric` | ElectricSQL integration, txid matching | + +Each skill includes: + +- **SKILL.md** - Common patterns and routing table +- **references/** - Deep-dive documentation for specialized topics + +## For AI Agents + +Point your agent to the skills directory or use the CLI to fetch specific skills: + +```bash +# Get the main routing skill +npx @tanstack/db-playbook show tanstack-db + +# Get specific domain skills +npx @tanstack/db-playbook show tanstack-db/live-queries +``` + +The router skill (`tanstack-db`) contains a routing table that helps agents find the right sub-skill for any task. + +## Learn More + +- [TanStack DB Documentation](https://tanstack.com/db) +- [TanStack DB GitHub](https://github.com/TanStack/db) diff --git a/packages/playbook/bin/cli.js b/packages/playbook/bin/cli.js new file mode 100644 index 000000000..c93c08f6f --- /dev/null +++ b/packages/playbook/bin/cli.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const skillsDir = join(__dirname, '..', 'skills') + +function listSkills(dir, prefix = '') { + const items = readdirSync(dir) + + for (const item of items) { + const itemPath = join(dir, item) + const stat = statSync(itemPath) + + if (!stat.isDirectory()) continue + + const skillPath = join(itemPath, 'SKILL.md') + try { + const content = readFileSync(skillPath, 'utf-8') + const nameMatch = content.match(/^name:\s*(.+)$/m) + const descMatch = content.match(/description:\s*\|?\s*\n?\s*(.+)/m) + + const name = nameMatch?.[1] || item + const desc = descMatch?.[1]?.trim() || 'No description' + + console.log(`${prefix}${name}`) + console.log(`${prefix} ${desc}`) + console.log() + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + // No SKILL.md, check subdirectories + } + + listSkills(itemPath, prefix + ' ') + } +} + +function showSkill(skillName) { + const parts = skillName.split('/') + let searchDir = skillsDir + + for (const part of parts) { + const items = readdirSync(searchDir) + const match = items.find( + (item) => + item.toLowerCase() === part.toLowerCase() || + item.toLowerCase().replace(/-/g, '') === + part.toLowerCase().replace(/-/g, ''), + ) + + if (match) { + searchDir = join(searchDir, match) + } else { + console.error(`Skill not found: ${skillName}`) + process.exit(1) + } + } + + const skillPath = join(searchDir, 'SKILL.md') + try { + const content = readFileSync(skillPath, 'utf-8') + console.log(content) + } catch { + console.error(`SKILL.md not found in: ${searchDir}`) + process.exit(1) + } +} + +const args = process.argv.slice(2) +const command = args[0] + +switch (command) { + case 'list': + console.log('TanStack Playbook\n') + listSkills(skillsDir) + break + + case 'show': + if (!args[1]) { + console.error('Usage: db-playbook show ') + console.error('Example: db-playbook show tanstack-db/live-queries') + process.exit(1) + } + showSkill(args[1]) + break + + case 'help': + case '--help': + case '-h': + case undefined: + console.log(`TanStack Playbook CLI + +Usage: + db-playbook list List all available skills + db-playbook show Show a specific skill + db-playbook help Show this help message + +Examples: + db-playbook list + db-playbook show tanstack-db + db-playbook show tanstack-db/live-queries + db-playbook show tanstack-db/mutations +`) + break + + default: + console.error(`Unknown command: ${command}`) + console.log(`Run 'db-playbook help' for usage information.`) + process.exit(1) +} diff --git a/packages/playbook/package.json b/packages/playbook/package.json new file mode 100644 index 000000000..509f3be5f --- /dev/null +++ b/packages/playbook/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tanstack/db-playbook", + "version": "0.0.1", + "description": "TanStack DB Playbook - skills for AI agents building with TanStack DB", + "author": "TanStack", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/playbook" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "tanstack", + "db", + "playbook", + "skills", + "ai", + "llm", + "documentation", + "reactive", + "live-queries", + "optimistic-updates" + ], + "bin": { + "db-playbook": "./bin/cli.js" + }, + "type": "module", + "sideEffects": false, + "files": [ + "bin", + "skills", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "lint": "true" + } +} diff --git a/packages/playbook/skills/tanstack-db/SKILL.md b/packages/playbook/skills/tanstack-db/SKILL.md new file mode 100644 index 000000000..5ad8e7fa9 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/SKILL.md @@ -0,0 +1,179 @@ +--- +name: tanstack-db +description: | + TanStack DB patterns for reactive client-side data with live queries and optimistic mutations. + Use for collections, queries, mutations, schemas, and sync engine integration. +--- + +# TanStack DB Skills + +TanStack DB is the reactive client store for your API. It provides sub-millisecond live queries, instant optimistic updates, and seamless integration with REST APIs and sync engines like ElectricSQL. + +## Routing Table + +| Topic | Directory | When to Use | +| ---------------- | --------------- | ----------------------------------------------------------------------------------------------------- | +| **Live Queries** | `live-queries/` | Querying data: filters, joins, aggregations, groupBy, orderBy, subqueries, reactive updates | +| **Mutations** | `mutations/` | Writing data: insert/update/delete, optimistic updates, transactions, paced mutations, error handling | +| **Collections** | `collections/` | Data sources: overview, sync modes, local collections, choosing collection types | +| **Schemas** | `schemas/` | Validation: schema definition, TInput/TOutput types, transformations, defaults, error handling | +| **Query** | `query/` | QueryCollection: REST API integration, TanStack Query, predicate push-down, refetch | +| **Electric** | `electric/` | ElectricCollection: ElectricSQL, shapes, txid matching, real-time Postgres sync | +| **PowerSync** | `powersync/` | PowerSyncCollection: offline-first, SQLite persistence, bidirectional sync | +| **RxDB** | `rxdb/` | RxDBCollection: RxDB integration, reactive local databases, storage backends | +| **TrailBase** | `trailbase/` | TrailBaseCollection: TrailBase backend, event subscriptions, type conversions | + +## Quick Detection + +**Route to `live-queries/` when:** + +- Building queries with `useLiveQuery` or `createLiveQueryCollection` +- Using `from`, `where`, `select`, `join`, `groupBy`, `orderBy` +- Working with aggregations (`count`, `sum`, `avg`, `min`, `max`) +- Joining data across multiple collections +- Creating derived/materialized views +- Performance questions about query updates + +**Route to `mutations/` when:** + +- Using `collection.insert()`, `collection.update()`, `collection.delete()` +- Creating custom actions with `createOptimisticAction` +- Working with transactions via `createTransaction` +- Implementing paced mutations (debounce, throttle, queue) +- Handling mutation errors or rollbacks +- Questions about optimistic state lifecycle + +**Route to `collections/` when:** + +- Setting up a new collection +- Choosing between QueryCollection, ElectricCollection, LocalStorage, etc. +- Configuring sync modes (eager, on-demand, progressive) +- Understanding collection lifecycle +- Loading data from APIs or sync engines + +**Route to `schemas/` when:** + +- Defining schemas with Zod, Valibot, or other StandardSchema libraries +- Understanding TInput vs TOutput types +- Transforming data (string to Date, etc.) +- Setting default values +- Handling validation errors + +**Route to `query/` when:** + +- Using `queryCollectionOptions` with TanStack Query +- Integrating REST APIs with TanStack DB +- Configuring refetch, polling, or caching behavior +- Using on-demand sync mode with predicate push-down +- Monitoring query state (loading, error, refetching) + +**Route to `electric/` when:** + +- Setting up ElectricSQL integration +- Working with shapes and real-time sync +- Implementing txid matching for mutations +- Debugging sync issues +- Building an Electric proxy + +**Route to `powersync/` when:** + +- Using `powerSyncCollectionOptions` +- Building offline-first applications +- Working with SQLite persistence +- Handling PowerSync schema types +- Custom serialization/deserialization + +**Route to `rxdb/` when:** + +- Using `rxdbCollectionOptions` +- Working with RxDB databases and storage backends +- Setting up RxDB replication +- Migrating RxDB schemas + +**Route to `trailbase/` when:** + +- Using `trailBaseCollectionOptions` +- Integrating with TrailBase backend +- Working with event subscriptions +- Handling type conversions between app and server types + +## Core Concepts + +```tsx +import { createCollection, useLiveQuery, eq } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +// 1. Define a collection (data source) +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => fetch('/api/todos').then((r) => r.json()), + getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + await api.todos.update( + transaction.mutations[0].original.id, + transaction.mutations[0].changes, + ) + }, + }), +) + +// 2. Query with live queries (reactive, incremental updates) +function TodoList() { + const { data: todos } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)) + .orderBy(({ todo }) => todo.createdAt, 'desc'), + ) + + // 3. Mutate with optimistic updates + const toggleTodo = (id: string) => { + todoCollection.update(id, (draft) => { + draft.completed = !draft.completed + }) + } + + return ( + + ) +} +``` + +## Data Flow + +TanStack DB extends unidirectional data flow beyond the client: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OPTIMISTIC LOOP (instant) │ +│ User Action → Optimistic State → UI Update │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ PERSISTENCE LOOP (async) │ +│ Mutation Handler → Server → Sync Back → Confirmed State │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Package Overview + +| Package | Purpose | +| ----------------------------------- | --------------------------------------- | +| `@tanstack/db` | Core: collections, queries, mutations | +| `@tanstack/react-db` | React hooks: useLiveQuery, etc. | +| `@tanstack/query-db-collection` | REST API integration via TanStack Query | +| `@tanstack/electric-db-collection` | ElectricSQL real-time Postgres sync | +| `@tanstack/powersync-db-collection` | PowerSync offline-first SQLite sync | +| `@tanstack/rxdb-db-collection` | RxDB reactive local databases | +| `@tanstack/trailbase-db-collection` | TrailBase real-time backend | +| `@tanstack/vue-db` | Vue adapter | +| `@tanstack/angular-db` | Angular adapter | +| `@tanstack/svelte-db` | Svelte adapter | +| `@tanstack/solid-db` | Solid adapter | diff --git a/packages/playbook/skills/tanstack-db/collections/SKILL.md b/packages/playbook/skills/tanstack-db/collections/SKILL.md new file mode 100644 index 000000000..3c23a398c --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/SKILL.md @@ -0,0 +1,280 @@ +--- +name: tanstack-db-collections +description: | + Collection types and configuration in TanStack DB. + Use for QueryCollection, ElectricCollection, local collections, and sync modes. +--- + +# Collections + +Collections are typed data stores that decouple data loading from data binding. They can be populated from REST APIs, sync engines, or local storage, then queried uniformly with live queries. + +## Collection Types + +| Type | Package | Use Case | +| -------------------------- | ----------------------------------- | ------------------------------------ | +| **QueryCollection** | `@tanstack/query-db-collection` | REST APIs via TanStack Query | +| **ElectricCollection** | `@tanstack/electric-db-collection` | Real-time Postgres sync via Electric | +| **PowerSyncCollection** | `@tanstack/powersync-db-collection` | Offline-first with PowerSync | +| **RxDBCollection** | `@tanstack/rxdb-db-collection` | RxDB local persistence | +| **TrailBaseCollection** | `@tanstack/trailbase-db-collection` | TrailBase real-time backend | +| **LocalStorageCollection** | `@tanstack/db` | Browser localStorage persistence | +| **LocalOnlyCollection** | `@tanstack/db` | In-memory state (no persistence) | + +## Common Patterns + +### QueryCollection (REST APIs) + +```tsx +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => { + const response = await fetch('/api/todos') + return response.json() + }, + getKey: (item) => item.id, + schema: todoSchema, // Optional: Zod, Valibot, etc. + + onInsert: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + fetch('/api/todos', { + method: 'POST', + body: JSON.stringify(m.modified), + }), + ), + ) + }, + + onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + fetch(`/api/todos/${m.original.id}`, { + method: 'PUT', + body: JSON.stringify(m.modified), + }), + ), + ) + }, + + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + fetch(`/api/todos/${m.original.id}`, { method: 'DELETE' }), + ), + ) + }, + }), +) +``` + +### Sync Modes + +Control how data loads into collections: + +```tsx +const productsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['products'], + queryFn: async (ctx) => { + // Query predicates available in ctx.meta for on-demand mode + const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + return api.getProducts(params) + }, + getKey: (item) => item.id, + + // Choose sync mode: + syncMode: 'eager', // Default: Load all upfront (<10k rows) + // syncMode: 'on-demand', // Load only what queries request (>50k rows) + // syncMode: 'progressive', // Load subset first, sync full in background + }), +) +``` + +| Mode | Behavior | Best For | +| ------------- | ------------------------------------------ | --------------------------------------- | +| `eager` | Load entire collection upfront | <10k rows, mostly static data | +| `on-demand` | Load only what queries request | >50k rows, search interfaces, catalogs | +| `progressive` | Load query subset, sync full in background | Collaborative apps, instant first paint | + +### ElectricCollection (Real-time Sync) + +```tsx +import { createCollection } from '@tanstack/react-db' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' + +const todoCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + schema: todoSchema, + getKey: (item) => item.id, + + shapeOptions: { + url: '/api/todos', // Your Electric proxy + params: { table: 'todos' }, + }, + + onInsert: async ({ transaction }) => { + const response = await api.todos.create(transaction.mutations[0].modified) + return { txid: response.txid } // Return txid to wait for sync + }, + + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0] + const response = await api.todos.update(original.id, changes) + return { txid: response.txid } + }, + }), +) +``` + +### LocalStorageCollection + +```tsx +import { + createCollection, + localStorageCollectionOptions, +} from '@tanstack/react-db' + +const settingsCollection = createCollection( + localStorageCollectionOptions({ + id: 'user-settings', + storageKey: 'app-settings', + getKey: (item) => item.id, + schema: settingsSchema, + }), +) + +// Data persists across sessions and syncs across tabs +settingsCollection.insert({ id: 'theme', value: 'dark' }) +``` + +### LocalOnlyCollection + +```tsx +import { + createCollection, + localOnlyCollectionOptions, +} from '@tanstack/react-db' + +const uiStateCollection = createCollection( + localOnlyCollectionOptions({ + id: 'ui-state', + getKey: (item) => item.id, + }), +) + +// In-memory only, lost on refresh +uiStateCollection.insert({ id: 'sidebar', expanded: true }) +``` + +### Collection with Schema + +```tsx +import { z } from 'zod' + +const todoSchema = z.object({ + id: z.string(), + text: z.string().min(1), + completed: z.boolean().default(false), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === 'string' ? new Date(val) : val)) + .default(() => new Date()), +}) + +const todoCollection = createCollection( + queryCollectionOptions({ + schema: todoSchema, // Validates inserts/updates, transforms types + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + }), +) +``` + +### Using TanStack Query Client + +```tsx +import { QueryClient } from '@tanstack/react-query' + +const queryClient = new QueryClient() + +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + queryClient, // Use your existing query client + }), +) +``` + +## Collection API + +```tsx +// Read operations +collection.get(key) // Get item by key +collection.has(key) // Check if key exists +collection.toArray // Get all items as array +collection.size // Number of items + +// Write operations (trigger handlers) +collection.insert(item) // Insert item(s) +collection.update(key, fn) // Update item(s) with draft function +collection.delete(key) // Delete item(s) + +// Utilities (collection-specific) +collection.utils.refetch() // QueryCollection: refetch from API +collection.utils.awaitTxId() // ElectricCollection: wait for txid +collection.utils.awaitMatch() // ElectricCollection: wait for custom match +collection.utils.acceptMutations() // LocalCollection: accept in manual tx +``` + +## Configuration Options + +```tsx +interface CollectionOptions { + id?: string // Unique identifier + getKey: (item) => Key // Extract unique key from item + schema?: StandardSchema // Validation schema (Zod, Valibot, etc.) + + // Persistence handlers + onInsert?: MutationFn + onUpdate?: MutationFn + onDelete?: MutationFn + + // QueryCollection specific + queryKey?: QueryKey + queryFn?: QueryFn + queryClient?: QueryClient + syncMode?: 'eager' | 'on-demand' | 'progressive' + + // ElectricCollection specific + shapeOptions?: ShapeStreamOptions +} +``` + +## Collection-Specific Skills + +For detailed patterns on each collection type, see the dedicated skill directories: + +| Skill | Directory | When to Use | +| ----------------------- | --------------- | ----------------------------------------------- | +| **QueryCollection** | `../query/` | REST API integration, TanStack Query, refetch | +| **ElectricCollection** | `../electric/` | ElectricSQL, shapes, txid matching, proxy setup | +| **PowerSyncCollection** | `../powersync/` | Offline-first, SQLite, type serialization | +| **RxDBCollection** | `../rxdb/` | RxDB storage backends, replication, migrations | +| **TrailBaseCollection** | `../trailbase/` | TrailBase events, type conversions | + +## Detailed References + +| Reference | When to Use | +| ---------------------------------- | ------------------------------------------- | +| `references/local-collections.md` | LocalStorage, LocalOnly, cross-tab sync | +| `references/sync-modes.md` | Eager vs on-demand vs progressive tradeoffs | +| `references/custom-collections.md` | Building your own collection type | diff --git a/packages/playbook/skills/tanstack-db/collections/references/custom-collections.md b/packages/playbook/skills/tanstack-db/collections/references/custom-collections.md new file mode 100644 index 000000000..92f7753f3 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/references/custom-collections.md @@ -0,0 +1,281 @@ +# Custom Collections + +Build your own collection type for custom data sources. + +## When to Build Custom + +- Integrating with unsupported sync engine +- Custom caching requirements +- Specialized data source (WebSocket, IndexedDB, etc.) +- Unique persistence patterns + +## Collection Interface + +Implement the `Collection` interface from `@tanstack/db`: + +```tsx +interface Collection { + // Identity + id: string + + // Read operations + get(key: TKey): TData | undefined + has(key: TKey): boolean + toArray: TData[] + size: number + + // Write operations + insert(item: TData | TData[], options?: MutationOptions): Transaction + update( + key: TKey | TKey[], + updater: (draft: TData) => void, + options?: MutationOptions, + ): Transaction + delete(key: TKey | TKey[], options?: MutationOptions): Transaction + + // State + state: CollectionState + + // Utilities + utils: CollectionUtils +} +``` + +## Basic Structure + +```tsx +import { createCollection, type CollectionOptions } from '@tanstack/db' + +export function myCollectionOptions( + options: MyCollectionConfig, +): CollectionOptions { + return { + id: options.id, + getKey: options.getKey, + schema: options.schema, + + // Sync configuration + sync: { + // Called to start syncing data + start: async (collection) => { + // Set up data sync + const cleanup = subscribeToDataSource((data) => { + collection.utils.directWrite('insert', data) + }) + + // Return cleanup function + return cleanup + }, + + // Called to stop syncing + stop: async (cleanup) => { + cleanup?.() + }, + }, + + // Mutation handlers + onInsert: options.onInsert, + onUpdate: options.onUpdate, + onDelete: options.onDelete, + } +} +``` + +## Example: WebSocket Collection + +```tsx +export function websocketCollectionOptions(config: { + id: string + url: string + getKey: (item: TData) => TKey + schema?: StandardSchema + onInsert?: MutationFn + onUpdate?: MutationFn + onDelete?: MutationFn +}): CollectionOptions { + return { + id: config.id, + getKey: config.getKey, + schema: config.schema, + + sync: { + start: async (collection) => { + const ws = new WebSocket(config.url) + + ws.onmessage = (event) => { + const message = JSON.parse(event.data) + + switch (message.type) { + case 'initial': + message.data.forEach((item: TData) => { + collection.utils.directWrite('insert', item) + }) + break + case 'insert': + collection.utils.directWrite('insert', message.data) + break + case 'update': + collection.utils.directWrite('update', message.data) + break + case 'delete': + collection.utils.directWrite('delete', message.key) + break + } + } + + return () => ws.close() + }, + }, + + onInsert: + config.onInsert ?? + (async ({ transaction }) => { + // Default: send via WebSocket + ws.send( + JSON.stringify({ + type: 'insert', + data: transaction.mutations.map((m) => m.modified), + }), + ) + }), + + onUpdate: config.onUpdate, + onDelete: config.onDelete, + } +} +``` + +## Example: IndexedDB Collection + +```tsx +import { openDB, type IDBPDatabase } from 'idb' + +export function indexedDBCollectionOptions(config: { + id: string + dbName: string + storeName: string + getKey: (item: TData) => TKey + schema?: StandardSchema +}): CollectionOptions { + let db: IDBPDatabase + + return { + id: config.id, + getKey: config.getKey, + schema: config.schema, + + sync: { + start: async (collection) => { + db = await openDB(config.dbName, 1, { + upgrade(db) { + db.createObjectStore(config.storeName, { + keyPath: 'id', + }) + }, + }) + + // Load initial data + const items = await db.getAll(config.storeName) + items.forEach((item) => { + collection.utils.directWrite('insert', item) + }) + }, + + stop: async () => { + db?.close() + }, + }, + + onInsert: async ({ transaction }) => { + const tx = db.transaction(config.storeName, 'readwrite') + await Promise.all( + transaction.mutations.map((m) => tx.store.add(m.modified)), + ) + await tx.done + }, + + onUpdate: async ({ transaction }) => { + const tx = db.transaction(config.storeName, 'readwrite') + await Promise.all( + transaction.mutations.map((m) => tx.store.put(m.modified)), + ) + await tx.done + }, + + onDelete: async ({ transaction }) => { + const tx = db.transaction(config.storeName, 'readwrite') + await Promise.all( + transaction.mutations.map((m) => tx.store.delete(m.key as IDBValidKey)), + ) + await tx.done + }, + } +} +``` + +## Direct Write API + +Use `directWrite` to update collection from external sources: + +```tsx +// Insert item(s) +collection.utils.directWrite('insert', item) +collection.utils.directWrite('insert', [item1, item2]) + +// Update item(s) +collection.utils.directWrite('update', updatedItem) + +// Delete by key +collection.utils.directWrite('delete', key) +collection.utils.directWrite('delete', [key1, key2]) +``` + +## Collection State + +Track collection status: + +```tsx +interface CollectionState { + isLoading: boolean + isError: boolean + error?: Error + isSyncing: boolean +} + +// Access in components +const state = myCollection.state +if (state.isLoading) return +if (state.isError) return +``` + +## Testing Custom Collections + +```tsx +import { createCollection } from '@tanstack/db' + +describe('MyCustomCollection', () => { + it('loads initial data', async () => { + const collection = createCollection( + myCollectionOptions({ + id: 'test', + getKey: (item) => item.id, + // ... test config + }), + ) + + // Wait for sync + await waitFor(() => collection.size > 0) + + expect(collection.toArray).toHaveLength(expectedCount) + }) +}) +``` + +## Reference Implementations + +Study existing implementations: + +- `@tanstack/query-db-collection` - REST API integration +- `@tanstack/electric-db-collection` - Real-time sync +- Built-in `localOnlyCollectionOptions` - Simple in-memory +- Built-in `localStorageCollectionOptions` - Browser persistence diff --git a/packages/playbook/skills/tanstack-db/collections/references/electric-collection.md b/packages/playbook/skills/tanstack-db/collections/references/electric-collection.md new file mode 100644 index 000000000..fb7ee4abb --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/references/electric-collection.md @@ -0,0 +1,237 @@ +# Electric Collection + +Real-time sync from Postgres via ElectricSQL. + +## Installation + +```bash +npm install @tanstack/electric-db-collection @tanstack/react-db +``` + +## Basic Setup + +```tsx +import { createCollection } from '@tanstack/react-db' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' + +const todoCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + getKey: (item) => item.id, + shapeOptions: { + url: '/api/todos', // Your Electric proxy + }, + }), +) +``` + +## Configuration Options + +```tsx +electricCollectionOptions({ + // Required + getKey: (item) => Key, // Extract unique key + shapeOptions: { + url: string, // Electric proxy URL + params?: Record, // Additional params + }, + + // Optional + id?: string, // Collection identifier + schema?: StandardSchema, // Validation schema + + // Mutation handlers + onInsert?: MutationFn, + onUpdate?: MutationFn, + onDelete?: MutationFn, +}) +``` + +## Shapes + +Shapes define what data syncs. Configure in your proxy: + +| Parameter | Description | Example | +| --------- | -------------- | ------------------- | +| `table` | Postgres table | `todos` | +| `where` | Row filter | `user_id = $1` | +| `columns` | Column filter | `id,text,completed` | + +**Note:** Configure shapes server-side for security, not client-side. + +## Txid Matching + +Return transaction IDs to wait for sync: + +```tsx +onInsert: async ({ transaction }) => { + const item = transaction.mutations[0].modified + const response = await api.todos.create(item) + return { txid: response.txid } +} +``` + +Backend must return txid from the same transaction as the mutation: + +```typescript +async function createTodo(data: TodoInput) { + let txid: number + + await db.transaction(async (tx) => { + // Get txid INSIDE the transaction + const result = await tx.execute( + sql`SELECT pg_current_xact_id()::xid::text as txid`, + ) + txid = parseInt(result.rows[0].txid, 10) + + await tx.execute(sql`INSERT INTO todos ${tx(data)}`) + }) + + return { txid } +} +``` + +## Custom Match Functions + +When txids aren't available: + +```tsx +import { isChangeMessage } from '@tanstack/electric-db-collection' + +onInsert: async ({ transaction, collection }) => { + const item = transaction.mutations[0].modified + await api.todos.create(item) + + await collection.utils.awaitMatch( + (message) => + isChangeMessage(message) && + message.headers.operation === 'insert' && + message.value.text === item.text, + 5000, // timeout ms + ) +} +``` + +## Utility Methods + +```tsx +// Wait for specific txid +await collection.utils.awaitTxId(12345) +await collection.utils.awaitTxId(12345, 10000) // with timeout + +// Wait for custom match +await collection.utils.awaitMatch(matchFn, timeout) +``` + +## Helper Functions + +```tsx +import { + isChangeMessage, + isControlMessage, +} from '@tanstack/electric-db-collection' + +// Check message type +if (isChangeMessage(message)) { + // message.headers.operation: 'insert' | 'update' | 'delete' + // message.value: the row data +} + +if (isControlMessage(message)) { + // 'up-to-date' or 'must-refetch' +} +``` + +## Proxy Example + +```typescript +// routes/api/todos.ts +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +export async function GET(request: Request) { + // Auth check + const user = await getUser(request) + if (!user) return new Response('Unauthorized', { status: 401 }) + + const url = new URL(request.url) + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric params + url.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + // Set shape (server-controlled) + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + + const response = await fetch(originUrl) + const headers = new Headers(response.headers) + headers.delete('content-encoding') + headers.delete('content-length') + + return new Response(response.body, { + status: response.status, + headers, + }) +} +``` + +## Debugging + +Enable debug logging: + +```javascript +localStorage.debug = 'ts/db:electric' +``` + +Common debug output: + +``` +// Working correctly +ts/db:electric awaitTxId called with txid 123 +ts/db:electric new txids synced from pg [123] +ts/db:electric awaitTxId found match for txid 123 + +// Txid mismatch (common bug) +ts/db:electric awaitTxId called with txid 124 +ts/db:electric new txids synced from pg [123] +// Stalls forever - 124 never arrives +``` + +## With Custom Actions + +```tsx +const addTodo = createOptimisticAction<{ text: string }>({ + onMutate: ({ text }) => { + todoCollection.insert({ + id: crypto.randomUUID(), + text, + completed: false, + }) + }, + mutationFn: async ({ text }) => { + const response = await api.todos.create({ text }) + await todoCollection.utils.awaitTxId(response.txid) + }, +}) +``` + +## Real-Time Updates + +Electric automatically streams changes. No polling needed: + +```tsx +// Changes sync automatically when: +// 1. Another user modifies data +// 2. Backend process updates database +// 3. Database trigger fires + +// Your live queries update automatically +const { data } = useLiveQuery((q) => q.from({ todo: todoCollection })) +// `data` updates in real-time +``` diff --git a/packages/playbook/skills/tanstack-db/collections/references/local-collections.md b/packages/playbook/skills/tanstack-db/collections/references/local-collections.md new file mode 100644 index 000000000..3ae69bc8e --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/references/local-collections.md @@ -0,0 +1,222 @@ +# Local Collections + +In-memory and localStorage-backed collections for client-only state. + +## LocalOnlyCollection + +In-memory state, lost on refresh: + +```tsx +import { + createCollection, + localOnlyCollectionOptions, +} from '@tanstack/react-db' + +const uiStateCollection = createCollection( + localOnlyCollectionOptions({ + id: 'ui-state', + getKey: (item) => item.id, + }), +) + +// Use for temporary state +uiStateCollection.insert({ id: 'sidebar', expanded: true }) +uiStateCollection.insert({ id: 'modal', open: false }) + +// Query like any collection +const { data } = useLiveQuery((q) => q.from({ state: uiStateCollection })) +``` + +## LocalStorageCollection + +Persists to localStorage, syncs across tabs: + +```tsx +import { + createCollection, + localStorageCollectionOptions, +} from '@tanstack/react-db' + +const settingsCollection = createCollection( + localStorageCollectionOptions({ + id: 'user-settings', + storageKey: 'app-settings', // localStorage key + getKey: (item) => item.id, + }), +) + +// Persists across sessions +settingsCollection.insert({ id: 'theme', value: 'dark' }) +settingsCollection.insert({ id: 'language', value: 'en' }) + +// Syncs across browser tabs automatically +``` + +## Configuration + +```tsx +localOnlyCollectionOptions({ + id: string, // Collection identifier + getKey: (item) => Key, // Extract unique key + schema?: StandardSchema, // Optional validation +}) + +localStorageCollectionOptions({ + id: string, // Collection identifier + storageKey: string, // localStorage key + getKey: (item) => Key, // Extract unique key + schema?: StandardSchema, // Optional validation +}) +``` + +## No Mutation Handlers + +Local collections don't need `onInsert`/`onUpdate`/`onDelete` since there's no server. Mutations apply directly. + +```tsx +// Just works, no handlers needed +settingsCollection.update('theme', (draft) => { + draft.value = 'light' +}) +``` + +## Side Effect Handlers + +You can add handlers for side effects (analytics, logging): + +```tsx +const settingsCollection = createCollection( + localStorageCollectionOptions({ + id: 'settings', + storageKey: 'app-settings', + getKey: (item) => item.id, + + onUpdate: async ({ transaction }) => { + // Side effect: track setting changes + analytics.track('setting_changed', { + setting: transaction.mutations[0].key, + newValue: transaction.mutations[0].modified.value, + }) + }, + }), +) +``` + +## With Manual Transactions + +Local collections require `acceptMutations` in manual transactions: + +```tsx +const tx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + // For server collections: call API + await api.save(...) + + // For local collections: accept mutations + settingsCollection.utils.acceptMutations(transaction) + }, +}) + +tx.mutate(() => { + settingsCollection.update('theme', (d) => { d.value = 'dark' }) +}) + +await tx.commit() +``` + +## Mixing Local and Server Collections + +Same transaction can modify both: + +```tsx +const tx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + // Server collection mutations + const serverMutations = transaction.mutations.filter( + (m) => m.collection !== localSettings, + ) + if (serverMutations.length > 0) { + await api.save(serverMutations) + } + + // Local collection mutations + localSettings.utils.acceptMutations(transaction) + }, +}) + +tx.mutate(() => { + // Server + userProfile.update('user-1', (d) => { + d.name = 'New Name' + }) + // Local + localSettings.update('theme', (d) => { + d.value = 'dark' + }) +}) + +await tx.commit() +``` + +## Cross-Tab Sync (LocalStorage) + +LocalStorageCollection automatically syncs across tabs: + +```tsx +// Tab 1 +settingsCollection.update('theme', (d) => { + d.value = 'dark' +}) + +// Tab 2 - automatically receives the update +// Live queries update reactively +``` + +## Storage Limits + +localStorage has ~5MB limit per origin. For larger data: + +- Consider IndexedDB (via RxDBCollection or custom) +- Split into multiple collections +- Store references, not full data + +## Use Cases + +| Collection | Use Case | +| ------------ | ----------------------------------------- | +| LocalOnly | Modal state, form drafts, temp selections | +| LocalStorage | User preferences, theme, recently viewed | + +## Schema Validation + +Both support schema validation: + +```tsx +const settingsSchema = z.object({ + id: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), + updatedAt: z.date().default(() => new Date()), +}) + +const settingsCollection = createCollection( + localStorageCollectionOptions({ + id: 'settings', + storageKey: 'app-settings', + getKey: (item) => item.id, + schema: settingsSchema, + }), +) +``` + +## Clearing Data + +```tsx +// Delete all items +const items = settingsCollection.toArray +items.forEach((item) => settingsCollection.delete(item.id)) + +// Or directly clear localStorage (LocalStorageCollection) +localStorage.removeItem('app-settings') +``` diff --git a/packages/playbook/skills/tanstack-db/collections/references/query-collection.md b/packages/playbook/skills/tanstack-db/collections/references/query-collection.md new file mode 100644 index 000000000..8cbeabaf0 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/references/query-collection.md @@ -0,0 +1,254 @@ +# Query Collection + +Load data from REST APIs using TanStack Query. + +## Installation + +```bash +npm install @tanstack/query-db-collection @tanstack/react-db @tanstack/react-query +``` + +## Basic Setup + +```tsx +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/react-query' + +const queryClient = new QueryClient() + +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => { + const response = await fetch('/api/todos') + return response.json() + }, + getKey: (item) => item.id, + queryClient, + }), +) +``` + +## Configuration Options + +```tsx +queryCollectionOptions({ + // Required + queryKey: QueryKey, // TanStack Query cache key + queryFn: QueryFn, // Data fetching function + getKey: (item) => Key, // Extract unique key from item + + // Optional + queryClient?: QueryClient, // Your query client instance + schema?: StandardSchema, // Validation schema + syncMode?: 'eager' | 'on-demand' | 'progressive', + + // Mutation handlers + onInsert?: MutationFn, + onUpdate?: MutationFn, + onDelete?: MutationFn, +}) +``` + +## Predicate Push-Down + +With `syncMode: 'on-demand'`, query predicates are passed to your queryFn: + +```tsx +const productsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['products'], + queryFn: async (ctx) => { + // ctx.meta.loadSubsetOptions contains query predicates + const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + // params: { where: { category: 'electronics', price_lt: 100 }, limit: 50 } + + const url = new URL('/api/products') + if (params.where?.category) { + url.searchParams.set('category', params.where.category) + } + if (params.where?.price_lt) { + url.searchParams.set('price_lt', params.where.price_lt) + } + if (params.limit) { + url.searchParams.set('limit', params.limit) + } + + return fetch(url).then((r) => r.json()) + }, + getKey: (item) => item.id, + syncMode: 'on-demand', + }), +) + +// This query triggers API call with predicates +const { data } = useLiveQuery((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.category, 'electronics')) + .where(({ product }) => lt(product.price, 100)) + .limit(50), +) +``` + +## Delta Loading + +QueryCollection automatically handles delta loading: + +```tsx +// First query loads: category=electronics +const q1 = q.where(({ p }) => eq(p.category, 'electronics')) + +// Second query expands: category=electronics OR category=clothing +// Only loads clothing items, keeps electronics from cache +const q2 = q.where(({ p }) => + or(eq(p.category, 'electronics'), eq(p.category, 'clothing')), +) +``` + +## Refetching + +```tsx +// Manual refetch +await todoCollection.utils.refetch() + +// Refetch on window focus (via TanStack Query) +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: true, + }, + }, +}) +``` + +## Direct Writes + +For real-time updates (WebSocket, SSE), write directly to collection: + +```tsx +// Listen to real-time updates +websocket.on('todo:created', (todo) => { + todoCollection.utils.directWrite('insert', todo) +}) + +websocket.on('todo:updated', (todo) => { + todoCollection.utils.directWrite('update', todo) +}) + +websocket.on('todo:deleted', ({ id }) => { + todoCollection.utils.directWrite('delete', id) +}) +``` + +## With TanStack Query Hooks + +Access underlying query state: + +```tsx +import { useQuery } from '@tanstack/react-query' + +function TodoStats() { + const { isLoading, isError, dataUpdatedAt } = useQuery({ + queryKey: ['todos'], + // Query is managed by collection, this just accesses state + }) + + return ( +
+ {isLoading && Loading...} + {isError && Error loading todos} + Last updated: {new Date(dataUpdatedAt).toLocaleString()} +
+ ) +} +``` + +## Mutation Handlers + +QueryCollection auto-refetches after handlers complete: + +```tsx +queryCollectionOptions({ + onInsert: async ({ transaction }) => { + await fetch('/api/todos', { + method: 'POST', + body: JSON.stringify(transaction.mutations[0].modified), + }) + // Auto-refetch happens after this returns + }, + + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0] + await fetch(`/api/todos/${original.id}`, { + method: 'PATCH', + body: JSON.stringify(changes), + }) + }, + + onDelete: async ({ transaction }) => { + await fetch(`/api/todos/${transaction.mutations[0].original.id}`, { + method: 'DELETE', + }) + }, +}) +``` + +## Stale Time and Caching + +Control cache behavior via TanStack Query options: + +```tsx +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }, + }, +}) +``` + +## Error Handling + +```tsx +const todoCollection = createCollection( + queryCollectionOptions({ + queryFn: async () => { + const response = await fetch('/api/todos') + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + return response.json() + }, + }), +) + +// Check collection state +if (todoCollection.state.isError) { + console.log(todoCollection.state.error) +} +``` + +## Multiple Query Keys + +For filtered views, use different query keys: + +```tsx +const activeTodos = createCollection( + queryCollectionOptions({ + queryKey: ['todos', 'active'], + queryFn: () => fetch('/api/todos?status=active').then((r) => r.json()), + getKey: (item) => item.id, + }), +) + +const completedTodos = createCollection( + queryCollectionOptions({ + queryKey: ['todos', 'completed'], + queryFn: () => fetch('/api/todos?status=completed').then((r) => r.json()), + getKey: (item) => item.id, + }), +) +``` diff --git a/packages/playbook/skills/tanstack-db/collections/references/sync-modes.md b/packages/playbook/skills/tanstack-db/collections/references/sync-modes.md new file mode 100644 index 000000000..ef34a07d8 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/collections/references/sync-modes.md @@ -0,0 +1,228 @@ +# Sync Modes + +Control how data loads into QueryCollections. + +## Overview + +| Mode | Load Behavior | Best For | +| ------------- | ------------------------------------- | -------------------------- | +| `eager` | Load all upfront | Small datasets (<10k rows) | +| `on-demand` | Load only what queries request | Large datasets (>50k rows) | +| `progressive` | Load subset first, full in background | Collaborative apps | + +## Eager Mode (Default) + +Loads entire dataset on first access: + +```tsx +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + syncMode: 'eager', // Default + }), +) +``` + +**Pros:** + +- Simplest mental model +- All data available immediately for queries +- No network requests when filtering/sorting + +**Cons:** + +- Slow initial load for large datasets +- Memory usage for full dataset + +**Best for:** + +- User preferences +- Small reference tables +- Data that's mostly accessed + +## On-Demand Mode + +Loads only what queries request: + +```tsx +const productsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['products'], + queryFn: async (ctx) => { + const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + return api.products.search(params) + }, + getKey: (item) => item.id, + syncMode: 'on-demand', + }), +) + +// Only loads electronics products +const { data } = useLiveQuery((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.category, 'electronics')) + .limit(50), +) +``` + +**Pros:** + +- Fast initial load +- Low memory usage +- Scales to any dataset size + +**Cons:** + +- Network request on each new query pattern +- Can't query data that isn't loaded + +**Best for:** + +- Product catalogs +- Search interfaces +- Large datasets where most data isn't accessed + +## Progressive Mode + +Loads query subset immediately, syncs full dataset in background: + +```tsx +const issuesCollection = createCollection( + queryCollectionOptions({ + queryKey: ['issues'], + queryFn: async (ctx) => { + if (ctx.meta?.loadSubsetOptions) { + // First: load what query needs + return api.issues.search( + parseLoadSubsetOptions(ctx.meta.loadSubsetOptions), + ) + } + // Then: load everything in background + return api.issues.getAll() + }, + getKey: (item) => item.id, + syncMode: 'progressive', + }), +) +``` + +**Pros:** + +- Instant first paint +- Eventually has all data for fast local queries +- Good for collaborative apps + +**Cons:** + +- More complex implementation +- Uses more memory than on-demand + +**Best for:** + +- Issue trackers (like Linear) +- Collaborative documents +- Apps that benefit from local-first after initial sync + +## Predicate Push-Down + +With `on-demand` and `progressive`, query predicates are passed to queryFn: + +```tsx +queryFn: async (ctx) => { + const options = ctx.meta?.loadSubsetOptions + // options contains: + // - where: query conditions + // - limit: row limit + // - offset: pagination offset + // - orderBy: sort specification + + const params = parseLoadSubsetOptions(options) + return api.fetch(params) +} +``` + +### parseLoadSubsetOptions + +Helper to convert predicates to API params: + +```tsx +import { parseLoadSubsetOptions } from '@tanstack/query-db-collection' + +const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) +// { +// where: { category: 'electronics', price_lt: 100 }, +// limit: 50, +// offset: 0, +// orderBy: [{ field: 'price', direction: 'asc' }] +// } +``` + +## Delta Loading + +TanStack DB automatically optimizes loading: + +```tsx +// First query loads category=electronics +const q1 = q.where(({ p }) => eq(p.category, 'electronics')) + +// Second query expands to include clothing +const q2 = q.where(({ p }) => + or(eq(p.category, 'electronics'), eq(p.category, 'clothing')), +) +// Only loads clothing, keeps electronics from cache +``` + +## Choosing a Mode + +``` +┌─────────────────────────────────────────────────────────────┐ +│ How much data? │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + < 10k rows 10k-50k rows > 50k rows + │ │ │ + ▼ ▼ ▼ + EAGER PROGRESSIVE ON-DEMAND +``` + +**Additional considerations:** + +- Need instant filtering on all data? → eager or progressive +- Search/filter is primary use case? → on-demand +- Collaborative with real-time updates? → progressive +- Memory-constrained environment? → on-demand + +## Combining Modes + +Different collections can use different modes: + +```tsx +// Small reference data: eager +const categoriesCollection = createCollection( + queryCollectionOptions({ + syncMode: 'eager', + ... + }) +) + +// Large catalog: on-demand +const productsCollection = createCollection( + queryCollectionOptions({ + syncMode: 'on-demand', + ... + }) +) + +// Collaborative data: progressive +const issuesCollection = createCollection( + queryCollectionOptions({ + syncMode: 'progressive', + ... + }) +) +``` diff --git a/packages/playbook/skills/tanstack-db/electric/SKILL.md b/packages/playbook/skills/tanstack-db/electric/SKILL.md new file mode 100644 index 000000000..9436f1556 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/electric/SKILL.md @@ -0,0 +1,274 @@ +--- +name: tanstack-db-electric +description: | + ElectricSQL integration with TanStack DB. + Use for real-time Postgres sync, shapes, txid matching, proxy setup, and debugging. +--- + +# Electric Integration + +Electric collections enable real-time sync between TanStack DB and Postgres via ElectricSQL. Data streams automatically from your database to the client, with optimistic mutations that confirm via transaction ID matching. + +## Common Patterns + +### Basic Setup + +```tsx +import { createCollection } from '@tanstack/react-db' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' + +const todoCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + schema: todoSchema, + getKey: (item) => item.id, + + shapeOptions: { + url: '/api/todos', // Your Electric proxy + }, + + onInsert: async ({ transaction }) => { + const newItem = transaction.mutations[0].modified + const response = await api.todos.create(newItem) + return { txid: response.txid } + }, + + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0] + const response = await api.todos.update(original.id, changes) + return { txid: response.txid } + }, + + onDelete: async ({ transaction }) => { + const response = await api.todos.delete( + transaction.mutations[0].original.id, + ) + return { txid: response.txid } + }, + }), +) +``` + +### Txid Matching (Recommended) + +Return txid from handlers to wait for sync confirmation: + +```tsx +onInsert: async ({ transaction }) => { + const response = await api.todos.create(transaction.mutations[0].modified) + return { txid: response.txid } // Wait for this txid in Electric stream +} +``` + +**Backend txid extraction (Postgres):** + +```typescript +async function createTodo(data: TodoInput, tx: Transaction) { + // Query txid INSIDE the same transaction as the mutation + const result = await tx.execute( + sql`SELECT pg_current_xact_id()::xid::text as txid`, + ) + const txid = parseInt(result.rows[0].txid, 10) + + await tx.execute(sql`INSERT INTO todos ${tx(data)}`) + + return { txid } +} +``` + +**Critical:** `pg_current_xact_id()` must be called INSIDE the same transaction as the mutation, not before or after. + +### Custom Match Functions + +When txids aren't available, use custom matching: + +```tsx +import { isChangeMessage } from '@tanstack/electric-db-collection' + +onInsert: async ({ transaction, collection }) => { + const newItem = transaction.mutations[0].modified + await api.todos.create(newItem) + + // Wait for matching message in stream + await collection.utils.awaitMatch( + (message) => { + return ( + isChangeMessage(message) && + message.headers.operation === 'insert' && + message.value.text === newItem.text + ) + }, + 5000, // timeout ms (optional, default 3000) + ) +} +``` + +### Simple Timeout (Prototyping) + +For quick prototyping when you're confident about timing: + +```tsx +onInsert: async ({ transaction }) => { + await api.todos.create(transaction.mutations[0].modified) + await new Promise((resolve) => setTimeout(resolve, 2000)) +} +``` + +### Electric Proxy Setup + +Electric should run behind a proxy for security and shape configuration: + +```typescript +// TanStack Start example: routes/api/todos.ts +import { createServerFileRoute } from '@tanstack/react-start/server' +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +const serve = async ({ request }: { request: Request }) => { + // Check user authorization here + const url = new URL(request.url) + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric protocol params + url.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + // Set shape parameters (server-controlled, not client) + originUrl.searchParams.set('table', 'todos') + // originUrl.searchParams.set('where', 'user_id = $1') + // originUrl.searchParams.set('columns', 'id,text,completed') + + const response = await fetch(originUrl) + const headers = new Headers(response.headers) + headers.delete('content-encoding') + headers.delete('content-length') + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +export const ServerRoute = createServerFileRoute('/api/todos').methods({ + GET: serve, +}) +``` + +### Custom Actions with Electric + +```tsx +import { createOptimisticAction } from '@tanstack/react-db' + +const addTodo = createOptimisticAction<{ text: string }>({ + onMutate: ({ text }) => { + todoCollection.insert({ + id: crypto.randomUUID(), + text, + completed: false, + created_at: new Date(), + }) + }, + mutationFn: async ({ text }) => { + const response = await api.todos.create({ text, completed: false }) + await todoCollection.utils.awaitTxId(response.txid) + }, +}) +``` + +### Utility Methods + +```tsx +// Wait for specific transaction ID +await todoCollection.utils.awaitTxId(12345) +await todoCollection.utils.awaitTxId(12345, 10000) // with timeout + +// Wait for custom match +await todoCollection.utils.awaitMatch( + (message) => isChangeMessage(message) && message.value.id === '123', + 5000, +) + +// Helper functions +import { + isChangeMessage, + isControlMessage, +} from '@tanstack/electric-db-collection' + +isChangeMessage(message) // insert/update/delete +isControlMessage(message) // up-to-date/must-refetch +``` + +## Debugging + +### Enable Debug Logging + +```javascript +// Browser console +localStorage.debug = 'ts/db:electric' +``` + +### Common Issue: awaitTxId Stalls + +**Symptom:** `awaitTxId` hangs forever, data persists but optimistic state never resolves. + +**Cause:** Txid queried outside the mutation's transaction. + +``` +// Debug output showing mismatch: +ts/db:electric awaitTxId called with txid 124 +ts/db:electric new txids synced from pg [123] // ← 124 never arrives! + +// Debug output when working: +ts/db:electric awaitTxId called with txid 123 +ts/db:electric new txids synced from pg [123] +ts/db:electric awaitTxId found match for txid 123 +``` + +**Fix:** Query `pg_current_xact_id()` INSIDE the same transaction: + +```typescript +// ❌ WRONG +async function createTodo(data) { + const txid = await generateTxId(sql) // Separate transaction! + await sql.begin(async (tx) => { + await tx`INSERT INTO todos ${tx(data)}` + }) + return { txid } // Won't match! +} + +// ✅ CORRECT +async function createTodo(data) { + let txid: number + await sql.begin(async (tx) => { + txid = await generateTxId(tx) // Same transaction + await tx`INSERT INTO todos ${tx(data)}` + }) + return { txid } +} +``` + +## Shape Configuration + +Shapes define what data syncs to the client: + +| Parameter | Description | Example | +| --------- | ------------------------------ | ------------------- | +| `table` | Postgres table name | `todos` | +| `where` | Row filter clause | `user_id = $1` | +| `columns` | Columns to sync (default: all) | `id,text,completed` | + +**Important:** Configure shapes server-side in your proxy, not client-side, for security. + +## Detailed References + +| Reference | When to Use | +| ----------------------------- | --------------------------------------------- | +| `references/txid-matching.md` | Transaction ID patterns, backend setup | +| `references/shapes.md` | Shape configuration, filtering, security | +| `references/proxy-setup.md` | Electric proxy patterns, authentication | +| `references/debugging.md` | Debug logging, common issues, troubleshooting | diff --git a/packages/playbook/skills/tanstack-db/electric/references/debugging.md b/packages/playbook/skills/tanstack-db/electric/references/debugging.md new file mode 100644 index 000000000..dbaf39909 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/electric/references/debugging.md @@ -0,0 +1,219 @@ +# Debugging Electric + +Diagnose sync and txid issues. + +## Enable Debug Logging + +In browser console: + +```javascript +localStorage.debug = 'ts/db:electric' +``` + +Uses the [debug](https://www.npmjs.com/package/debug) package. Refresh the page after setting. + +## Common Debug Output + +### Successful Txid Match + +``` +ts/db:electric awaitTxId called with txid 123 +ts/db:electric new txids synced from pg [123] +ts/db:electric awaitTxId found match for txid 123 +``` + +### Txid Mismatch (Common Bug) + +``` +ts/db:electric awaitTxId called with txid 124 +ts/db:electric new txids synced from pg [123] +// Stalls forever - 124 never arrives! +``` + +**Cause**: Txid was queried outside the mutation transaction. + +**Fix**: Query `pg_current_xact_id()` inside the same transaction as your mutation. + +## Common Issues + +### 1. awaitTxId Stalls/Times Out + +**Symptom**: Mutation persists to database, but `awaitTxId` never resolves. + +**Cause**: Txid returned from API doesn't match actual mutation transaction. + +**Debug**: + +```javascript +localStorage.debug = 'ts/db:electric' +// Watch for mismatch between requested txid and received txids +``` + +**Fix**: + +```typescript +// WRONG - different transaction +async function createTodo(data) { + const txid = await generateTxId(sql) // Separate transaction! + await sql.begin(async (tx) => { + await tx`INSERT INTO todos ${tx(data)}` + }) + return { txid } +} + +// CORRECT - same transaction +async function createTodo(data) { + let txid!: number + await sql.begin(async (tx) => { + txid = await generateTxId(tx) // Same transaction! + await tx`INSERT INTO todos ${tx(data)}` + }) + return { txid } +} +``` + +### 2. Shape Not Syncing + +**Symptom**: Collection stays empty or stale. + +**Debug**: + +1. Check browser Network tab for requests to your proxy +2. Verify proxy returns 200 status +3. Check proxy logs for errors + +**Common causes**: + +- Proxy authentication failing +- Electric service not running +- Shape configuration errors (invalid table/column names) + +### 3. Optimistic Updates Flash + +**Symptom**: Insert appears, disappears, then reappears. + +**Cause**: Not waiting for sync before removing optimistic state. + +**Fix**: Return `{ txid }` from mutation handlers or use `awaitMatch`: + +```tsx +onInsert: async ({ transaction }) => { + const response = await api.create(...) + return { txid: response.txid } // Wait for sync +} +``` + +### 4. awaitMatch Times Out + +**Symptom**: Custom match function never finds a match. + +**Debug**: Log incoming messages to verify your match logic: + +```tsx +await collection.utils.awaitMatch((message) => { + console.log('Received message:', message) + return isChangeMessage(message) && message.value.id === expectedId +}, 10000) +``` + +**Common causes**: + +- Wrong field names in match condition +- Message already arrived before `awaitMatch` was called +- Shape doesn't include the expected row + +### 5. Connection Errors + +**Symptom**: "Failed to fetch" or network errors. + +**Debug**: + +1. Verify Electric service is running +2. Check proxy URL is correct +3. Test proxy endpoint directly: `curl http://localhost:3000/api/todos` + +## Network Tab Debugging + +1. Open DevTools → Network +2. Filter by your proxy endpoint (e.g., `/api/todos`) +3. Check: + - Request URL and params + - Response status + - Response body (should be Electric message stream) + +## Logging in Handlers + +Add logging to mutation handlers: + +```tsx +onInsert: async ({ transaction }) => { + console.log('[onInsert] Starting mutation:', transaction.mutations[0].modified) + + const response = await api.create(...) + console.log('[onInsert] API response:', response) + + if (response.txid) { + console.log('[onInsert] Waiting for txid:', response.txid) + } + + return { txid: response.txid } +} +``` + +## Backend Logging + +Log txid generation: + +```typescript +async function generateTxId(tx: any): Promise { + const result = await tx`SELECT pg_current_xact_id()::xid::text as txid` + const txid = parseInt(result[0]?.txid, 10) + console.log('[generateTxId] Generated txid:', txid) + return txid +} +``` + +## Collection State + +Check collection state for errors: + +```tsx +console.log(collection.state) +// { +// isLoading: false, +// isError: false, +// error: undefined, +// isSyncing: true +// } + +if (collection.state.isError) { + console.error('Collection error:', collection.state.error) +} +``` + +## Disable Debug Logging + +```javascript +localStorage.removeItem('debug') +``` + +Or set to empty: + +```javascript +localStorage.debug = '' +``` + +## Debug Namespace Patterns + +Target specific areas: + +```javascript +// All TanStack DB debug output +localStorage.debug = 'ts/db:*' + +// Only Electric-specific +localStorage.debug = 'ts/db:electric' + +// Multiple namespaces +localStorage.debug = 'ts/db:electric,ts/db:mutations' +``` diff --git a/packages/playbook/skills/tanstack-db/electric/references/proxy-setup.md b/packages/playbook/skills/tanstack-db/electric/references/proxy-setup.md new file mode 100644 index 000000000..7e99d4b34 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/electric/references/proxy-setup.md @@ -0,0 +1,259 @@ +# Electric Proxy Setup + +Configure a proxy server between clients and Electric. + +## Why Use a Proxy? + +- **Security**: Control shape parameters server-side +- **Authentication**: Verify user identity before granting access +- **Authorization**: Filter data based on user permissions +- **Flexibility**: Transform or augment shape parameters + +## Basic Proxy (TanStack Start) + +```typescript +// routes/api/todos.ts +import { createServerFileRoute } from '@tanstack/react-start/server' +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +const serve = async ({ request }: { request: Request }) => { + // Authentication + const user = await getUser(request) + if (!user) { + return new Response('Unauthorized', { status: 401 }) + } + + const url = new URL(request.url) + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric protocol params + url.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + // Set shape parameters (server-controlled) + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + + // Forward to Electric + const response = await fetch(originUrl) + + // Clean response headers + const headers = new Headers(response.headers) + headers.delete('content-encoding') + headers.delete('content-length') + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +export const ServerRoute = createServerFileRoute('/api/todos').methods({ + GET: serve, +}) +``` + +## Express Proxy + +```typescript +import express from 'express' +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const app = express() +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +app.get('/api/todos', async (req, res) => { + // Authentication + const user = await getUser(req) + if (!user) { + return res.status(401).send('Unauthorized') + } + + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric params + Object.entries(req.query).forEach(([key, value]) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value as string) + } + }) + + // Set shape + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + + const response = await fetch(originUrl) + + // Stream response + response.body?.pipeTo( + new WritableStream({ + write(chunk) { + res.write(chunk) + }, + close() { + res.end() + }, + }), + ) +}) +``` + +## Next.js API Route + +```typescript +// app/api/todos/route.ts +import { NextRequest } from 'next/server' +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +export async function GET(request: NextRequest) { + const user = await getUser(request) + if (!user) { + return new Response('Unauthorized', { status: 401 }) + } + + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric params + request.nextUrl.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + // Set shape + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + + const response = await fetch(originUrl) + + return new Response(response.body, { + status: response.status, + headers: { + 'content-type': + response.headers.get('content-type') || 'application/json', + }, + }) +} +``` + +## Multiple Shapes + +Create separate endpoints for different shapes: + +```typescript +// /api/todos - User's todos +app.get('/api/todos', async (req, res) => { + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + // ... +}) + +// /api/projects - User's projects +app.get('/api/projects', async (req, res) => { + originUrl.searchParams.set('table', 'projects') + originUrl.searchParams.set('where', `org_id = '${user.orgId}'`) + // ... +}) + +// /api/team-members - Team members (read-only) +app.get('/api/team-members', async (req, res) => { + originUrl.searchParams.set('table', 'users') + originUrl.searchParams.set('columns', 'id,name,avatar_url') + originUrl.searchParams.set('where', `org_id = '${user.orgId}'`) + // ... +}) +``` + +## Client Configuration + +Point collections to your proxy endpoints: + +```tsx +const todosCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + getKey: (item) => item.id, + shapeOptions: { + url: '/api/todos', + }, + }), +) + +const projectsCollection = createCollection( + electricCollectionOptions({ + id: 'projects', + getKey: (item) => item.id, + shapeOptions: { + url: '/api/projects', + }, + }), +) +``` + +## Electric Protocol Params + +These params are passed through from client to Electric: + +- `offset` - Sync offset for resuming +- `handle` - Shape handle for reconnection +- `live` - Enable live updates +- `cursor` - Pagination cursor + +**Never** pass `table`, `where`, or `columns` from client - always set these server-side. + +## Environment Configuration + +```typescript +// Use environment variables for Electric URL +const ELECTRIC_URL = + process.env.ELECTRIC_URL || 'http://localhost:3000/v1/shape' + +// Different environments +// Development: http://localhost:3000/v1/shape +// Production: https://electric.yourapp.com/v1/shape +``` + +## Error Handling + +```typescript +app.get('/api/todos', async (req, res) => { + try { + const user = await getUser(req) + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const response = await fetch(originUrl) + + if (!response.ok) { + console.error('Electric error:', response.status) + return res.status(502).json({ error: 'Sync service unavailable' }) + } + + // Forward response... + } catch (error) { + console.error('Proxy error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) +``` + +## CORS Configuration + +If your proxy is on a different domain: + +```typescript +app.use('/api', (req, res, next) => { + res.header('Access-Control-Allow-Origin', 'https://yourapp.com') + res.header('Access-Control-Allow-Credentials', 'true') + next() +}) +``` diff --git a/packages/playbook/skills/tanstack-db/electric/references/shapes.md b/packages/playbook/skills/tanstack-db/electric/references/shapes.md new file mode 100644 index 000000000..851e7f495 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/electric/references/shapes.md @@ -0,0 +1,213 @@ +# Electric Shapes + +Define what data syncs from Postgres. + +## Overview + +Shapes configure which rows and columns Electric syncs to the client. Configure shapes **server-side** in your proxy for security. + +## Shape Parameters + +| Parameter | Description | Example | +| --------- | ------------------- | ------------------- | +| `table` | Postgres table name | `todos` | +| `where` | SQL WHERE clause | `user_id = '123'` | +| `columns` | Columns to include | `id,text,completed` | + +## Proxy Configuration + +Configure shapes in your Electric proxy, not on the client: + +```typescript +// routes/api/todos.ts +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const ELECTRIC_URL = 'http://localhost:3000/v1/shape' + +export async function GET(request: Request) { + const user = await getUser(request) + if (!user) return new Response('Unauthorized', { status: 401 }) + + const url = new URL(request.url) + const originUrl = new URL(ELECTRIC_URL) + + // Pass through Electric protocol params + url.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + // Set shape (server-controlled for security) + originUrl.searchParams.set('table', 'todos') + originUrl.searchParams.set('where', `user_id = '${user.id}'`) + + const response = await fetch(originUrl) + return new Response(response.body, { + status: response.status, + headers: response.headers, + }) +} +``` + +## Client Setup + +Client just points to proxy URL: + +```tsx +const todosCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + getKey: (item) => item.id, + shapeOptions: { + url: '/api/todos', // Your proxy + }, + }), +) +``` + +## Column Filtering + +Sync only needed columns to reduce bandwidth: + +```typescript +// Proxy configuration +originUrl.searchParams.set('table', 'users') +originUrl.searchParams.set('columns', 'id,name,email,avatar_url') +// Excludes large fields like bio, preferences JSON, etc. +``` + +## Row Filtering + +Filter rows with WHERE clauses: + +```typescript +// Only active todos +originUrl.searchParams.set('table', 'todos') +originUrl.searchParams.set('where', 'completed = false') + +// User-scoped data +originUrl.searchParams.set('table', 'documents') +originUrl.searchParams.set('where', `owner_id = '${user.id}'`) + +// Complex conditions +originUrl.searchParams.set( + 'where', + `org_id = '${user.orgId}' AND archived = false`, +) +``` + +## Custom Match Functions + +When txid matching isn't available, use custom match functions: + +```tsx +import { + isChangeMessage, + isControlMessage, +} from '@tanstack/electric-db-collection' + +onInsert: async ({ transaction, collection }) => { + const newItem = transaction.mutations[0].modified + await api.todos.create(newItem) + + await collection.utils.awaitMatch( + (message) => { + if (!isChangeMessage(message)) return false + return ( + message.headers.operation === 'insert' && + message.value.text === newItem.text + ) + }, + 5000, // timeout in ms + ) +} +``` + +## Message Types + +Electric streams two message types: + +### Change Messages + +Data operations from Postgres: + +```tsx +if (isChangeMessage(message)) { + // message.headers.operation: 'insert' | 'update' | 'delete' + // message.value: the row data + console.log(message.headers.operation) + console.log(message.value.id) +} +``` + +### Control Messages + +Stream status updates: + +```tsx +if (isControlMessage(message)) { + // 'up-to-date' - stream is current + // 'must-refetch' - need to refetch shape +} +``` + +## Match Function Patterns + +### Match by ID + +```tsx +await collection.utils.awaitMatch( + (message) => isChangeMessage(message) && message.value.id === expectedId, +) +``` + +### Match by Operation Type + +```tsx +await collection.utils.awaitMatch( + (message) => + isChangeMessage(message) && message.headers.operation === 'delete', +) +``` + +### Match by Multiple Fields + +```tsx +await collection.utils.awaitMatch( + (message) => + isChangeMessage(message) && + message.headers.operation === 'insert' && + message.value.user_id === userId && + message.value.title === title, +) +``` + +## Timeout Handling + +Match functions have configurable timeouts: + +```tsx +try { + await collection.utils.awaitMatch(matchFn, 5000) +} catch (error) { + // Handle timeout - sync didn't arrive in time + console.error('Sync timeout') +} +``` + +## Simple Timeout Approach + +For prototyping when precise matching isn't needed: + +```tsx +onInsert: async ({ transaction }) => { + const newItem = transaction.mutations[0].modified + await api.todos.create(newItem) + + // Simple wait - crude but works for prototyping + await new Promise((resolve) => setTimeout(resolve, 2000)) +} +``` + +**Note:** Use txid matching in production for reliability. diff --git a/packages/playbook/skills/tanstack-db/electric/references/txid-matching.md b/packages/playbook/skills/tanstack-db/electric/references/txid-matching.md new file mode 100644 index 000000000..02e283060 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/electric/references/txid-matching.md @@ -0,0 +1,185 @@ +# Txid Matching + +Wait for Electric sync using PostgreSQL transaction IDs. + +## Overview + +When you persist a mutation to Postgres, Electric streams the change back. To prevent UI glitches (optimistic update removed then re-added), wait for the specific transaction to sync before removing optimistic state. + +## Basic Pattern + +Return `txid` from mutation handlers: + +```tsx +const todosCollection = createCollection( + electricCollectionOptions({ + id: 'todos', + getKey: (item) => item.id, + shapeOptions: { url: '/api/todos' }, + + onInsert: async ({ transaction }) => { + const newItem = transaction.mutations[0].modified + const response = await api.todos.create(newItem) + + // Return txid to wait for sync + return { txid: response.txid } + }, + + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0] + const response = await api.todos.update({ + where: { id: original.id }, + data: changes, + }) + + return { txid: response.txid } + }, + }), +) +``` + +## Backend Implementation + +Query `pg_current_xact_id()` **inside** the same transaction as your mutation: + +```typescript +async function createTodo(data) { + let txid!: number + + const result = await sql.begin(async (tx) => { + // MUST call inside transaction + txid = await generateTxId(tx) + + const [todo] = await tx` + INSERT INTO todos ${tx(data)} + RETURNING * + ` + return todo + }) + + return { todo: result, txid } +} + +async function generateTxId(tx: any): Promise { + // ::xid cast gives raw 32-bit value matching Electric's stream + const result = await tx`SELECT pg_current_xact_id()::xid::text as txid` + const txid = result[0]?.txid + + if (txid === undefined) { + throw new Error('Failed to get transaction ID') + } + + return parseInt(txid, 10) +} +``` + +## Critical: Query Inside Transaction + +**Common Bug**: Querying txid outside the mutation transaction causes matching to fail. + +```typescript +// WRONG - txid from separate transaction +async function createTodo(data) { + const txid = await generateTxId(sql) // Wrong: different transaction + + await sql.begin(async (tx) => { + await tx`INSERT INTO todos ${tx(data)}` + }) + + return { txid } // Won't match! +} + +// CORRECT - txid from same transaction +async function createTodo(data) { + let txid!: number + + await sql.begin(async (tx) => { + txid = await generateTxId(tx) // Correct: same transaction + await tx`INSERT INTO todos ${tx(data)}` + }) + + return { txid } // Matches! +} +``` + +## Manual Waiting + +Use `awaitTxId` utility for custom actions: + +```tsx +const addTodoAction = createOptimisticAction({ + onMutate: ({ text }) => { + todosCollection.insert({ + id: crypto.randomUUID(), + text, + completed: false, + }) + }, + + mutationFn: async ({ text }) => { + const response = await api.todos.create({ text }) + + // Wait for specific txid + await todosCollection.utils.awaitTxId(response.txid) + }, +}) +``` + +## With Custom Timeout + +```tsx +// Default timeout is 30 seconds +await collection.utils.awaitTxId(txid) + +// Custom timeout (10 seconds) +await collection.utils.awaitTxId(txid, 10000) +``` + +## Debugging Txid Issues + +Enable debug logging: + +```javascript +localStorage.debug = 'ts/db:electric' +``` + +### When Working + +``` +ts/db:electric awaitTxId called with txid 123 +ts/db:electric new txids synced from pg [123] +ts/db:electric awaitTxId found match for txid 123 +``` + +### When Broken (Common Bug) + +``` +ts/db:electric awaitTxId called with txid 124 +ts/db:electric new txids synced from pg [123] +// Stalls forever - 124 never arrives! +``` + +The mutation happened in transaction 123, but you queried txid in a separate transaction (124). + +## When Txid Isn't Available + +Use custom match functions instead: + +```tsx +import { isChangeMessage } from '@tanstack/electric-db-collection' + +onInsert: async ({ transaction, collection }) => { + const newItem = transaction.mutations[0].modified + await api.todos.create(newItem) + + await collection.utils.awaitMatch( + (message) => + isChangeMessage(message) && + message.headers.operation === 'insert' && + message.value.text === newItem.text, + 5000, + ) +} +``` + +See [Custom Match Functions](./shapes.md) for more patterns. diff --git a/packages/playbook/skills/tanstack-db/live-queries/SKILL.md b/packages/playbook/skills/tanstack-db/live-queries/SKILL.md new file mode 100644 index 000000000..0713b559f --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/SKILL.md @@ -0,0 +1,264 @@ +--- +name: tanstack-db-live-queries +description: | + Live query patterns in TanStack DB. + Use for filtering, joins, aggregations, sorting, and reactive data binding. +--- + +# Live Queries + +TanStack DB live queries are reactive, type-safe queries that automatically update when underlying data changes. Built on differential dataflow, they update incrementally rather than re-running—achieving sub-millisecond performance even on 100k+ item collections. + +## Common Patterns + +### Basic Query with useLiveQuery + +```tsx +import { useLiveQuery, eq } from '@tanstack/react-db' + +function TodoList() { + const { data: todos, isLoading } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)) + .orderBy(({ todo }) => todo.createdAt, 'desc'), + ) + + if (isLoading) return
Loading...
+ + return ( +
    + {todos?.map((todo) => ( +
  • {todo.text}
  • + ))} +
+ ) +} +``` + +### Filtering with Where Clauses + +```tsx +import { eq, gt, and, or, inArray, like } from '@tanstack/db' + +// Simple equality +.where(({ user }) => eq(user.active, true)) + +// Multiple conditions (chained = AND) +.where(({ user }) => eq(user.active, true)) +.where(({ user }) => gt(user.age, 18)) + +// Complex conditions +.where(({ user }) => + and( + eq(user.active, true), + or( + gt(user.age, 25), + eq(user.role, 'admin') + ) + ) +) + +// Array membership +.where(({ user }) => inArray(user.id, [1, 2, 3])) + +// Pattern matching +.where(({ user }) => like(user.email, '%@company.com')) +``` + +### Select and Transform + +```tsx +import { concat, upper, gt } from '@tanstack/db' + +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + displayName: concat(user.firstName, ' ', user.lastName), + isAdult: gt(user.age, 18), + ...user, // Spread to include all fields + })), +) +``` + +### Joins Across Collections + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.userId), + 'inner', // 'left' | 'right' | 'inner' | 'full' + ) + .select(({ user, post }) => ({ + userName: user.name, + postTitle: post.title, + })), +) + // Convenience methods + .leftJoin({ post: postsCollection }, ({ user, post }) => + eq(user.id, post.userId), + ) + .innerJoin({ post: postsCollection }, ({ user, post }) => + eq(user.id, post.userId), + ) +``` + +### Aggregations with groupBy + +```tsx +import { count, sum, avg, min, max } from '@tanstack/db' + +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalOrders: count(order.id), + totalSpent: sum(order.amount), + avgOrder: avg(order.amount), + })) + .having(({ $selected }) => gt($selected.totalSpent, 1000)) + .orderBy(({ $selected }) => $selected.totalSpent, 'desc'), +) +``` + +### Pagination with Limit and Offset + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, 'asc') + .limit(20) + .offset(page * 20), +) +``` + +### Find Single Record + +```tsx +const { data: user } = useLiveQuery( + (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.id, userId)) + .findOne(), // Returns single object | undefined instead of array + [userId], +) +``` + +### Conditional Queries + +```tsx +const { data, isEnabled } = useLiveQuery( + (q) => { + if (!userId) return undefined // Disable query + + return q + .from({ todo: todosCollection }) + .where(({ todo }) => eq(todo.userId, userId)) + }, + [userId], +) + +if (!isEnabled) return
Select a user
+``` + +### Subqueries + +```tsx +const { data } = useLiveQuery((q) => { + // Build subquery + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + // Use in main query + return q + .from({ activeUser: activeUsers }) + .join({ post: postsCollection }, ({ activeUser, post }) => + eq(activeUser.id, post.userId), + ) +}) +``` + +### React Suspense Support + +```tsx +import { useLiveSuspenseQuery } from '@tanstack/react-db' +import { Suspense } from 'react' + +function UserList() { + // data is always defined (never undefined) + const { data } = useLiveSuspenseQuery((q) => + q.from({ user: usersCollection }), + ) + + return ( +
    + {data.map((user) => ( +
  • {user.name}
  • + ))} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +### Dependency Arrays + +```tsx +// Re-run query when minAge changes +const { data } = useLiveQuery( + (q) => + q.from({ user: usersCollection }).where(({ user }) => gt(user.age, minAge)), + [minAge], // Dependency array +) +``` + +## Expression Functions + +| Function | Usage | Example | +| ------------- | -------------------------------- | ------------------------------------ | +| `eq` | Equality | `eq(user.id, 1)` | +| `gt/gte` | Greater than (or equal) | `gt(user.age, 18)` | +| `lt/lte` | Less than (or equal) | `lt(user.price, 100)` | +| `and` | Logical AND | `and(cond1, cond2)` | +| `or` | Logical OR | `or(cond1, cond2)` | +| `not` | Logical NOT | `not(eq(user.active, false))` | +| `inArray` | Value in array | `inArray(user.id, [1, 2, 3])` | +| `like` | Pattern match (case-sensitive) | `like(user.name, 'John%')` | +| `ilike` | Pattern match (case-insensitive) | `ilike(user.email, '%@gmail.com')` | +| `isNull` | Check for null | `isNull(user.deletedAt)` | +| `isUndefined` | Check for undefined | `isUndefined(profile)` | +| `concat` | String concatenation | `concat(user.first, ' ', user.last)` | +| `upper` | Uppercase | `upper(user.name)` | +| `lower` | Lowercase | `lower(user.email)` | +| `length` | String/array length | `length(user.tags)` | +| `count` | Count (aggregate) | `count(order.id)` | +| `sum` | Sum (aggregate) | `sum(order.amount)` | +| `avg` | Average (aggregate) | `avg(order.amount)` | +| `min` | Minimum (aggregate) | `min(order.amount)` | +| `max` | Maximum (aggregate) | `max(order.amount)` | + +## Detailed References + +| Reference | When to Use | +| ----------------------------------- | --------------------------------------------------- | +| `references/query-builder.md` | Full query builder API, method signatures, chaining | +| `references/joins.md` | Join types, multi-table queries, join optimization | +| `references/aggregations.md` | groupBy, having, aggregate functions, multi-column | +| `references/subqueries.md` | Nested queries, query composition, deduplication | +| `references/functional-variants.md` | fn.where, fn.select for complex JavaScript logic | +| `references/performance.md` | Incremental updates, caching, derived collections | diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/aggregations.md b/packages/playbook/skills/tanstack-db/live-queries/references/aggregations.md new file mode 100644 index 000000000..db1a86556 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/aggregations.md @@ -0,0 +1,197 @@ +# Aggregations + +Group data and compute aggregate values with groupBy, having, and aggregate functions. + +## Aggregate Functions + +| Function | Description | Example | +| -------------- | --------------------- | ------------------- | +| `count(field)` | Count non-null values | `count(order.id)` | +| `sum(field)` | Sum numeric values | `sum(order.amount)` | +| `avg(field)` | Average of values | `avg(order.amount)` | +| `min(field)` | Minimum value | `min(order.amount)` | +| `max(field)` | Maximum value | `max(order.amount)` | + +## Basic Aggregation + +```tsx +import { count, sum, avg } from '@tanstack/db' + +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalOrders: count(order.id), + totalSpent: sum(order.amount), + avgOrder: avg(order.amount), + })), +) +``` + +## Multi-Column Grouping + +Group by multiple fields: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ sale: salesCollection }) + .groupBy(({ sale }) => [sale.year, sale.month, sale.category]) + .select(({ sale }) => ({ + year: sale.year, + month: sale.month, + category: sale.category, + totalSales: sum(sale.amount), + count: count(sale.id), + })), +) +``` + +## Having Clause + +Filter groups after aggregation: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalSpent: sum(order.amount), + orderCount: count(order.id), + })) + .having(({ $selected }) => gt($selected.totalSpent, 1000)), +) +``` + +## Having with Aggregate Functions + +Use aggregates directly in having: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .having(({ order }) => gt(count(order.id), 5)) + .select(({ order }) => ({ + customerId: order.customerId, + orderCount: count(order.id), + })), +) +``` + +## Implicit Single-Group Aggregation + +Aggregates without groupBy treat entire dataset as one group: + +```tsx +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + totalUsers: count(user.id), + avgAge: avg(user.age), + oldestUser: max(user.age), + youngestUser: min(user.age), + })), +) + +// Returns single object: { totalUsers, avgAge, oldestUser, youngestUser } +``` + +## Order By Aggregated Values + +Sort by computed aggregates using `$selected`: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalSpent: sum(order.amount), + })) + .orderBy(({ $selected }) => $selected.totalSpent, 'desc') + .limit(10), +) +``` + +## Accessing Grouped Results + +Results are keyed by group value: + +```tsx +const deptStats = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .groupBy(({ user }) => user.departmentId) + .select(({ user }) => ({ + departmentId: user.departmentId, + count: count(user.id), + })), +) + +// Single column grouping: keyed by actual value +const engineering = deptStats.get(1) + +// Multi-column grouping: keyed by JSON string +const stats = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .groupBy(({ user }) => [user.departmentId, user.role]) + .select(({ user }) => ({ + departmentId: user.departmentId, + role: user.role, + count: count(user.id), + })), +) + +const adminEngineers = stats.get('[1,"admin"]') +``` + +## Rules for groupBy Select + +In grouped queries, select can only include: + +1. Fields used in groupBy +2. Aggregate functions + +```tsx +// ✅ Valid +.groupBy(({ user }) => user.departmentId) +.select(({ user }) => ({ + departmentId: user.departmentId, // ✅ In groupBy + count: count(user.id), // ✅ Aggregate +})) + +// ❌ Invalid +.groupBy(({ user }) => user.departmentId) +.select(({ user }) => ({ + departmentId: user.departmentId, + name: user.name, // ❌ Not in groupBy, not aggregated +})) +``` + +## Combining with Joins + +Aggregate across joined data: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .leftJoin({ order: ordersCollection }, ({ user, order }) => + eq(user.id, order.userId), + ) + .groupBy(({ user }) => user.id) + .select(({ user, order }) => ({ + userId: user.id, + userName: user.name, + orderCount: count(order.id), + totalSpent: sum(order.amount), + })), +) +``` diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/functional-variants.md b/packages/playbook/skills/tanstack-db/live-queries/references/functional-variants.md new file mode 100644 index 000000000..d801f8545 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/functional-variants.md @@ -0,0 +1,186 @@ +# Functional Variants + +Use full JavaScript power when expression functions aren't enough. + +## When to Use + +Functional variants (`fn.where`, `fn.select`, `fn.having`) let you write arbitrary JavaScript instead of expression-based queries. + +**Use when:** + +- Complex string manipulation +- External library calls +- Conditional logic that can't be expressed with `and`/`or` +- Dynamic computed values + +**Avoid when possible:** Functional variants can't be optimized or use indexes. + +## fn.select + +Transform data with JavaScript: + +```tsx +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).fn.select((row) => ({ + id: row.user.id, + displayName: `${row.user.firstName} ${row.user.lastName}`.trim(), + emailDomain: row.user.email.split('@')[1], + ageGroup: getAgeGroup(row.user.age), + isHighEarner: row.user.salary > 75000, + })), +) + +function getAgeGroup(age: number): 'young' | 'adult' | 'senior' { + if (age < 25) return 'young' + if (age < 50) return 'adult' + return 'senior' +} +``` + +## fn.where + +Filter with JavaScript logic: + +```tsx +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).fn.where((row) => { + const user = row.user + return ( + user.active && + (user.age > 25 || user.role === 'admin') && + user.email.endsWith('@company.com') + ) + }), +) +``` + +## fn.having + +Filter groups with JavaScript: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.customerId) + .select(({ order }) => ({ + customerId: order.customerId, + totalSpent: sum(order.amount), + orderCount: count(order.id), + })) + .fn.having(({ $selected }) => { + return $selected.totalSpent > 1000 && $selected.orderCount >= 3 + }), +) +``` + +## Complex Transformations + +Build nested structures: + +```tsx +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).fn.select((row) => { + const user = row.user + const fullName = `${user.firstName} ${user.lastName}`.trim() + const emailParts = user.email.split('@') + + return { + userId: user.id, + displayName: fullName || user.username, + contact: { + email: user.email, + domain: emailParts[1], + isCompanyEmail: emailParts[1] === 'company.com', + }, + demographics: { + age: user.age, + ageGroup: getAgeGroup(user.age), + isAdult: user.age >= 18, + }, + profileStrength: calculateProfileStrength(user), + } + }), +) + +function calculateProfileStrength(user) { + let score = 0 + if (user.firstName) score += 25 + if (user.lastName) score += 25 + if (user.email) score += 25 + if (user.avatar) score += 25 + return score +} +``` + +## Mixing Expression and Functional + +Use expressions where possible, functional where needed: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + // Expression-based (optimizable) + .where(({ user }) => eq(user.active, true)) + // Functional (for complex logic) + .fn.where((row) => row.user.email.includes('@company.com')) + // Expression-based select + .select(({ user }) => ({ + id: user.id, + name: user.name, + email: user.email, + })), +) +``` + +## Type Safety + +Functional variants maintain TypeScript support: + +```tsx +interface ProcessedUser { + id: string + name: string + ageGroup: 'young' | 'adult' | 'senior' +} + +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).fn.select( + (row): ProcessedUser => ({ + id: row.user.id, + name: row.user.name, + ageGroup: getAgeGroup(row.user.age), + }), + ), +) + +// data is ProcessedUser[] +``` + +## Performance Considerations + +| Approach | Optimizable | Use Index | Incremental | +| ----------------------------- | ----------- | --------- | ----------- | +| Expression (`eq`, `gt`, etc.) | ✅ | ✅ | ✅ | +| Functional (`fn.where`, etc.) | ❌ | ❌ | ✅ | + +Functional variants still benefit from incremental updates but can't be optimized by the query planner. + +## External Libraries + +Use any JavaScript library: + +```tsx +import { format, parseISO } from 'date-fns' +import slugify from 'slugify' + +const { data } = useLiveQuery((q) => + q.from({ post: postsCollection }).fn.select((row) => ({ + id: row.post.id, + title: row.post.title, + slug: slugify(row.post.title, { lower: true }), + publishedDate: format(parseISO(row.post.publishedAt), 'MMMM d, yyyy'), + })), +) +``` diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/joins.md b/packages/playbook/skills/tanstack-db/live-queries/references/joins.md new file mode 100644 index 000000000..eb09eeab9 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/joins.md @@ -0,0 +1,180 @@ +# Joins + +Combine data from multiple collections with type-safe joins. + +## Join Types + +| Type | Method | Behavior | +| ----- | --------------------------- | ---------------------------------------------- | +| Left | `.leftJoin()` or `'left'` | All left rows, matched right rows or undefined | +| Right | `.rightJoin()` or `'right'` | All right rows, matched left rows or undefined | +| Inner | `.innerJoin()` or `'inner'` | Only rows that match in both | +| Full | `.fullJoin()` or `'full'` | All rows from both, undefined where no match | + +## Basic Join + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.userId), + 'left', + ), +) + +// Result type: { user: User; post?: Post }[] +``` + +## Convenience Methods + +```tsx +// These are equivalent: +.join({ post: postsCollection }, condition, 'left') +.leftJoin({ post: postsCollection }, condition) + +.join({ post: postsCollection }, condition, 'inner') +.innerJoin({ post: postsCollection }, condition) + +.join({ post: postsCollection }, condition, 'right') +.rightJoin({ post: postsCollection }, condition) + +.join({ post: postsCollection }, condition, 'full') +.fullJoin({ post: postsCollection }, condition) +``` + +## Result Type Inference + +Join types affect optionality: + +```tsx +// Left join: right side optional +.leftJoin({ post }, condition) +// { user: User; post?: Post } + +// Right join: left side optional +.rightJoin({ post }, condition) +// { user?: User; post: Post } + +// Inner join: both required +.innerJoin({ post }, condition) +// { user: User; post: Post } + +// Full join: both optional +.fullJoin({ post }, condition) +// { user?: User; post?: Post } +``` + +## Multiple Joins + +Chain joins to combine many collections: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .leftJoin({ post: postsCollection }, ({ user, post }) => + eq(user.id, post.userId), + ) + .leftJoin({ comment: commentsCollection }, ({ post, comment }) => + eq(post.id, comment.postId), + ) + .select(({ user, post, comment }) => ({ + userName: user.name, + postTitle: post?.title, + commentText: comment?.text, + })), +) +``` + +## Join with Select + +Flatten joined data: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .innerJoin({ post: postsCollection }, ({ user, post }) => + eq(user.id, post.userId), + ) + .select(({ user, post }) => ({ + postId: post.id, + postTitle: post.title, + authorName: user.name, + authorEmail: user.email, + })), +) + +// Result: { postId, postTitle, authorName, authorEmail }[] +``` + +## Join with Subquery + +Join against a filtered subset: + +```tsx +const { data } = useLiveQuery((q) => { + const recentPosts = q + .from({ post: postsCollection }) + .where(({ post }) => gt(post.createdAt, lastWeek)) + + return q + .from({ user: usersCollection }) + .innerJoin({ recentPost: recentPosts }, ({ user, recentPost }) => + eq(user.id, recentPost.userId), + ) +}) +``` + +## Checking for Missing Joins + +Use `isUndefined` to find unmatched rows: + +```tsx +// Users without any posts +const { data } = useLiveQuery((q) => + q + .from({ user: usersCollection }) + .leftJoin({ post: postsCollection }, ({ user, post }) => + eq(user.id, post.userId), + ) + .where(({ post }) => isUndefined(post)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + })), +) +``` + +## Join Conditions + +Joins only support equality conditions: + +```tsx +// ✅ Supported +.join({ post }, ({ user, post }) => eq(user.id, post.userId)) + +// ❌ Not supported (non-equality) +.join({ post }, ({ user, post }) => gt(user.id, post.userId)) +``` + +## Performance Considerations + +- Joins are computed incrementally as data changes +- Inner joins are typically fastest (fewer results) +- Multiple joins on large collections may need derived collections for caching +- Consider filtering before joining to reduce intermediate results + +```tsx +// Better: filter first, then join +const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + +q.from({ activeUser: activeUsers }) + .join({ post: postsCollection }, ...) + +// vs joining everything then filtering +``` diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/performance.md b/packages/playbook/skills/tanstack-db/live-queries/references/performance.md new file mode 100644 index 000000000..90d10d967 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/performance.md @@ -0,0 +1,193 @@ +# Performance + +Understand how live queries achieve sub-millisecond updates and how to optimize for large datasets. + +## How Incremental Updates Work + +TanStack DB uses differential dataflow (via d2ts). Instead of re-running queries when data changes, it: + +1. Tracks what changed (insert, update, delete) +2. Propagates only the delta through the query pipeline +3. Updates results incrementally + +**Result:** Updating one row in a sorted 100k collection takes ~0.7ms on an M1 Pro. + +## Benchmarks + +| Operation | 100 items | 10k items | 100k items | +| -------------- | --------- | --------- | ---------- | +| Single update | <0.1ms | ~0.3ms | ~0.7ms | +| Filter change | <0.1ms | ~1ms | ~5ms | +| Full re-render | ~1ms | ~50ms | ~500ms | + +## Optimization Strategies + +### 1. Use Expressions Over Functional Variants + +```tsx +// ✅ Optimizable +.where(({ user }) => eq(user.active, true)) + +// ❌ Not optimizable +.fn.where((row) => row.user.active === true) +``` + +### 2. Filter Early + +Reduce data before expensive operations: + +```tsx +// ✅ Better: filter first +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .where(({ order }) => eq(order.status, 'completed')) + .join({ user: usersCollection }, ...) +) + +// ❌ Worse: join everything then filter +const { data } = useLiveQuery((q) => + q + .from({ order: ordersCollection }) + .join({ user: usersCollection }, ...) + .where(({ order }) => eq(order.status, 'completed')) +) +``` + +### 3. Use Derived Collections for Reuse + +Cache intermediate results: + +```tsx +// Create once, reuse everywhere +const activeUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) +) + +// Fast: uses cached result +const userPosts = useLiveQuery((q) => + q.from({ user: activeUsers }).join(...) +) + +// Fast: same cached result +const userComments = useLiveQuery((q) => + q.from({ user: activeUsers }).join(...) +) +``` + +### 4. Limit Result Sets + +Don't load more than needed: + +```tsx +// ✅ Paginated +const { data } = useLiveQuery((q) => + q + .from({ item: itemsCollection }) + .orderBy(({ item }) => item.createdAt, 'desc') + .limit(50), +) + +// ❌ Loading everything +const { data } = useLiveQuery((q) => q.from({ item: itemsCollection })) +``` + +### 5. Use On-Demand Sync Mode + +For large datasets, let queries drive loading: + +```tsx +const collection = createCollection( + queryCollectionOptions({ + syncMode: 'on-demand', // Only load what queries need + queryFn: async (ctx) => { + const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + return api.getItems(params) + }, + }), +) +``` + +### 6. Selective Field Loading + +Only select fields you need: + +```tsx +// ✅ Only needed fields +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), +) + +// ❌ All fields (includes large blobs, etc.) +const { data } = useLiveQuery((q) => q.from({ user: usersCollection })) +``` + +## Memory Considerations + +### Collection Size + +- **< 10k rows**: Eager sync works well +- **10k - 50k rows**: Consider progressive sync +- **> 50k rows**: Use on-demand sync + +### Query Result Caching + +Live query results are cached and updated incrementally. Multiple components using the same query share the cache: + +```tsx +// These share the same underlying query cache +function ComponentA() { + const { data } = useLiveQuery((q) => q.from({ user: usersCollection })) +} + +function ComponentB() { + const { data } = useLiveQuery((q) => q.from({ user: usersCollection })) +} +``` + +### Garbage Collection + +Unused queries are garbage collected after `gcTime` (default 5 seconds): + +```tsx +const collection = createCollection( + liveQueryCollectionOptions({ + query: ..., + gcTime: 30000, // Keep for 30 seconds after unmount + }) +) +``` + +## Debugging Performance + +### Enable Debug Logging + +```javascript +localStorage.debug = 'ts/db:*' +``` + +### Measure Query Time + +```tsx +const start = performance.now() +const { data } = useLiveQuery(...) +console.log(`Query took ${performance.now() - start}ms`) +``` + +### Profile with React DevTools + +Use React DevTools Profiler to identify re-render causes. + +## Common Pitfalls + +| Issue | Cause | Fix | +| ---------------------- | ------------------------------- | --------------------------------- | +| Slow initial load | Too much data | Use on-demand sync | +| Slow updates | Functional variants | Use expression functions | +| Memory growth | Too many active queries | Consolidate queries, check gcTime | +| Unnecessary re-renders | New query reference each render | Use dependency array correctly | diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/query-builder.md b/packages/playbook/skills/tanstack-db/live-queries/references/query-builder.md new file mode 100644 index 000000000..e67a584b3 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/query-builder.md @@ -0,0 +1,192 @@ +# Query Builder API + +Complete reference for the TanStack DB query builder. + +## Query Methods + +### from + +Start a query by specifying collections: + +```tsx +q.from({ user: usersCollection }) +q.from({ user: usersCollection, post: postsCollection }) +``` + +### where + +Filter results with conditions: + +```tsx +.where(({ user }) => eq(user.active, true)) + +// Multiple where clauses are ANDed +.where(({ user }) => eq(user.active, true)) +.where(({ user }) => gt(user.age, 18)) + +// Complex conditions +.where(({ user }) => + and( + eq(user.active, true), + or(gt(user.age, 25), eq(user.role, 'admin')) + ) +) +``` + +### select + +Transform and pick fields: + +```tsx +// Pick specific fields +.select(({ user }) => ({ + id: user.id, + name: user.name, +})) + +// Computed fields +.select(({ user }) => ({ + ...user, + fullName: concat(user.firstName, ' ', user.lastName), + isAdult: gt(user.age, 18), +})) +``` + +### join / leftJoin / innerJoin + +Combine data from multiple collections: + +```tsx +.join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.userId), + 'left' // 'left' | 'right' | 'inner' | 'full' +) + +// Convenience methods +.leftJoin({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId)) +.innerJoin({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId)) +``` + +### groupBy + +Group results for aggregation: + +```tsx +.groupBy(({ order }) => order.customerId) +.groupBy(({ order }) => [order.customerId, order.year]) // Multiple columns +``` + +### having + +Filter groups after aggregation: + +```tsx +.groupBy(({ order }) => order.customerId) +.select(({ order }) => ({ + customerId: order.customerId, + total: sum(order.amount), +})) +.having(({ $selected }) => gt($selected.total, 1000)) +``` + +### orderBy + +Sort results: + +```tsx +.orderBy(({ user }) => user.name, 'asc') +.orderBy(({ user }) => user.createdAt, 'desc') + +// Multiple columns +.orderBy(({ user }) => user.lastName, 'asc') +.orderBy(({ user }) => user.firstName, 'asc') +``` + +### limit / offset + +Paginate results: + +```tsx +.limit(20) +.offset(40) // Skip first 40, return next 20 +``` + +### distinct + +Remove duplicates: + +```tsx +.distinct() +``` + +### findOne + +Return single item instead of array: + +```tsx +.findOne() // Returns T | undefined instead of T[] +``` + +## Hook API + +### useLiveQuery + +```tsx +const { + data, // Query results (T[] or T | undefined for findOne) + isLoading, // True during initial load + isEnabled, // False when query returns undefined + error, // Any error that occurred +} = useLiveQuery( + (q) => q.from({ user: usersCollection }), + [dep1, dep2], // Optional dependency array +) +``` + +### useLiveSuspenseQuery + +```tsx +// data is always defined (suspends until ready) +const { data } = useLiveSuspenseQuery( + (q) => q.from({ user: usersCollection }), + [dep1, dep2], +) +``` + +### useLiveInfiniteQuery + +```tsx +const { + data, // All loaded pages flattened + hasNextPage, + fetchNextPage, + isFetchingNextPage, +} = useLiveInfiniteQuery( + (q, { pageParam }) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.id, 'asc') + .limit(20) + .offset(pageParam * 20), + { + getNextPageParam: (lastPage, allPages) => + lastPage.length === 20 ? allPages.length : undefined, + }, +) +``` + +## Type Inference + +Query results are fully typed based on your select: + +```tsx +const { data } = useLiveQuery((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + })), +) + +// data is typed as { id: string; name: string }[] +``` diff --git a/packages/playbook/skills/tanstack-db/live-queries/references/subqueries.md b/packages/playbook/skills/tanstack-db/live-queries/references/subqueries.md new file mode 100644 index 000000000..4bcab59b3 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/live-queries/references/subqueries.md @@ -0,0 +1,199 @@ +# Subqueries + +Embed queries within queries for complex data transformations. + +## Subqueries vs Derived Collections + +| Approach | Materialized? | Use Case | +| ------------------ | ------------- | --------------------------------------------------- | +| Subquery | No | Internal to parent query, not accessible separately | +| Derived Collection | Yes | Reusable, cacheable intermediate result | + +## Subquery in From + +Use a query as the source: + +```tsx +const { data } = useLiveQuery((q) => { + // Build subquery + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + // Use in main query + return q.from({ activeUser: activeUsers }).select(({ activeUser }) => ({ + id: activeUser.id, + name: activeUser.name, + })) +}) +``` + +## Subquery in Join + +Join against a filtered/transformed subset: + +```tsx +const { data } = useLiveQuery((q) => { + const recentPosts = q + .from({ post: postsCollection }) + .where(({ post }) => gt(post.createdAt, lastWeek)) + .orderBy(({ post }) => post.createdAt, 'desc') + + return q + .from({ user: usersCollection }) + .leftJoin({ recentPost: recentPosts }, ({ user, recentPost }) => + eq(user.id, recentPost.userId), + ) +}) +``` + +## Nested Subqueries + +Build complex queries with multiple levels: + +```tsx +const { data } = useLiveQuery((q) => { + // First level: count posts per user + const postCounts = q + .from({ post: postsCollection }) + .groupBy(({ post }) => post.userId) + .select(({ post }) => ({ + userId: post.userId, + postCount: count(post.id), + })) + + // Second level: join with users + const userStats = q + .from({ user: usersCollection }) + .leftJoin({ stats: postCounts }, ({ user, stats }) => + eq(user.id, stats.userId), + ) + .select(({ user, stats }) => ({ + id: user.id, + name: user.name, + postCount: stats?.postCount ?? 0, + })) + + // Final: top users + return q + .from({ userStat: userStats }) + .orderBy(({ userStat }) => userStat.postCount, 'desc') + .limit(10) +}) +``` + +## Subquery Deduplication + +Same subquery used multiple times is executed once: + +```tsx +const { data } = useLiveQuery((q) => { + // This subquery is defined once + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + + // Used in multiple places - only computed once + return q + .from({ activeUser: activeUsers }) + .join({ post: postsCollection }, ({ activeUser, post }) => + eq(activeUser.id, post.userId), + ) + .join({ comment: commentsCollection }, ({ activeUser, comment }) => + eq(activeUser.id, comment.userId), + ) +}) +``` + +## Derived Collections (Alternative) + +For reusable intermediate results, use derived collections: + +```tsx +// Create reusable derived collection +const activeUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) +) + +// Use in multiple queries +const activeUserPosts = useLiveQuery((q) => + q + .from({ user: activeUsers }) // Uses the derived collection + .join({ post: postsCollection }, ...) +) + +const activeUserComments = useLiveQuery((q) => + q + .from({ user: activeUsers }) // Same derived collection + .join({ comment: commentsCollection }, ...) +) +``` + +## When to Use Subqueries + +**Use subqueries when:** + +- Query is only needed within a single parent query +- You don't need to access intermediate results +- Building complex multi-step transformations + +**Use derived collections when:** + +- Same query is used in multiple places +- You want to cache intermediate results +- You need to access the intermediate collection directly + +## Subquery Patterns + +### Filter Before Join + +```tsx +const { data } = useLiveQuery((q) => { + const premiumUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.tier, 'premium')) + + return q + .from({ user: premiumUsers }) + .join({ order: ordersCollection }, ...) +}) +``` + +### Aggregate Then Join + +```tsx +const { data } = useLiveQuery((q) => { + const orderTotals = q + .from({ order: ordersCollection }) + .groupBy(({ order }) => order.userId) + .select(({ order }) => ({ + userId: order.userId, + total: sum(order.amount), + })) + + return q + .from({ user: usersCollection }) + .leftJoin({ totals: orderTotals }, ({ user, totals }) => + eq(user.id, totals.userId), + ) +}) +``` + +### Top N Per Group + +```tsx +const { data } = useLiveQuery((q) => { + const rankedPosts = q + .from({ post: postsCollection }) + .orderBy(({ post }) => post.likes, 'desc') + + return q + .from({ user: usersCollection }) + .leftJoin({ topPost: rankedPosts }, ({ user, topPost }) => + eq(user.id, topPost.userId), + ) + // Further processing... +}) +``` diff --git a/packages/playbook/skills/tanstack-db/mutations/SKILL.md b/packages/playbook/skills/tanstack-db/mutations/SKILL.md new file mode 100644 index 000000000..3af07e32e --- /dev/null +++ b/packages/playbook/skills/tanstack-db/mutations/SKILL.md @@ -0,0 +1,346 @@ +--- +name: tanstack-db-mutations +description: | + Mutation patterns in TanStack DB. + Use for insert/update/delete, optimistic updates, transactions, paced mutations, and error handling. +--- + +# Mutations + +TanStack DB mutations follow optimistic update → server persist → sync back flow. Changes appear instantly, then confirm or rollback based on server response. + +## Common Patterns + +### Collection-Level Mutations + +```tsx +// Insert +todoCollection.insert({ + id: crypto.randomUUID(), + text: 'Buy groceries', + completed: false, +}) + +// Insert multiple +todoCollection.insert([ + { id: '1', text: 'Task 1', completed: false }, + { id: '2', text: 'Task 2', completed: false }, +]) + +// Update (Immer-style draft) +todoCollection.update(todoId, (draft) => { + draft.completed = true + draft.completedAt = new Date() +}) + +// Update multiple +todoCollection.update([id1, id2], (drafts) => { + drafts.forEach((draft) => { + draft.completed = true + }) +}) + +// Delete +todoCollection.delete(todoId) + +// Delete multiple +todoCollection.delete([id1, id2]) +``` + +### Mutation Handlers + +Define handlers when creating collections to persist mutations: + +```tsx +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + + onInsert: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => api.todos.create(m.modified)), + ) + }, + + onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + api.todos.update(m.original.id, m.changes), + ), + ) + }, + + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => api.todos.delete(m.original.id)), + ) + }, + }), +) +``` + +### Custom Actions with createOptimisticAction + +For intent-based mutations or multi-collection changes: + +```tsx +import { createOptimisticAction } from '@tanstack/react-db' + +const likePost = createOptimisticAction({ + onMutate: (postId) => { + // Optimistic update (guess at change) + postCollection.update(postId, (draft) => { + draft.likeCount += 1 + draft.likedByMe = true + }) + }, + mutationFn: async (postId) => { + // Send intent to server + await api.posts.like(postId) + // Wait for sync back + await postCollection.utils.refetch() + }, +}) + +// Use it +likePost(postId) +``` + +### Multi-Collection Actions + +```tsx +const createProject = createOptimisticAction<{ name: string; ownerId: string }>( + { + onMutate: ({ name, ownerId }) => { + const projectId = crypto.randomUUID() + + projectCollection.insert({ + id: projectId, + name, + ownerId, + createdAt: new Date(), + }) + + userCollection.update(ownerId, (draft) => { + draft.projectCount += 1 + }) + }, + mutationFn: async ({ name, ownerId }) => { + await api.projects.create({ name, ownerId }) + await Promise.all([ + projectCollection.utils.refetch(), + userCollection.utils.refetch(), + ]) + }, + }, +) +``` + +### Manual Transactions + +For batch mutations with explicit commit control: + +```tsx +import { createTransaction } from '@tanstack/react-db' + +const reviewTx = createTransaction({ + autoCommit: false, // Wait for explicit commit + mutationFn: async ({ transaction }) => { + await api.batchUpdate(transaction.mutations) + }, +}) + +// Accumulate changes +reviewTx.mutate(() => { + todoCollection.update(id1, (d) => { + d.status = 'reviewed' + }) + todoCollection.update(id2, (d) => { + d.status = 'reviewed' + }) +}) + +// User reviews changes... + +// Add more changes +reviewTx.mutate(() => { + todoCollection.update(id3, (d) => { + d.status = 'reviewed' + }) +}) + +// Commit all at once +await reviewTx.commit() +// Or rollback +// reviewTx.rollback() +``` + +### Paced Mutations (Debounce/Throttle/Queue) + +```tsx +import { + usePacedMutations, + debounceStrategy, + throttleStrategy, + queueStrategy, +} from '@tanstack/react-db' + +// Debounce: Wait for inactivity (auto-save forms) +const mutate = usePacedMutations<{ field: string; value: string }>({ + onMutate: ({ field, value }) => { + formCollection.update(formId, (draft) => { + draft[field] = value + }) + }, + mutationFn: async ({ transaction }) => { + await api.forms.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), +}) + +// Throttle: Minimum spacing (sliders) +const mutate = usePacedMutations({ + onMutate: (volume) => { + settingsCollection.update('volume', (d) => { + d.value = volume + }) + }, + mutationFn: async ({ transaction }) => { + await api.settings.updateVolume(transaction.mutations) + }, + strategy: throttleStrategy({ wait: 200, leading: true, trailing: true }), +}) + +// Queue: Sequential processing (file uploads) +const mutate = usePacedMutations({ + onMutate: (file) => { + uploadCollection.insert({ + id: crypto.randomUUID(), + file, + status: 'pending', + }) + }, + mutationFn: async ({ transaction }) => { + await api.files.upload(transaction.mutations[0].modified) + }, + strategy: queueStrategy({ wait: 500 }), +}) +``` + +### Non-Optimistic Mutations + +Wait for server confirmation before showing change: + +```tsx +// Insert without optimistic update +const tx = todoCollection.insert( + { id: '1', text: 'Server-validated', completed: false }, + { optimistic: false }, +) + +// Wait for persistence +try { + await tx.isPersisted.promise + navigate('/success') +} catch (error) { + toast.error('Failed to create') +} +``` + +### Mutation with Metadata + +Annotate mutations for custom handler behavior: + +```tsx +todoCollection.update(todoId, { metadata: { intent: 'complete' } }, (draft) => { + draft.completed = true +}) + +// In handler +onUpdate: async ({ transaction }) => { + const mutation = transaction.mutations[0] + if (mutation.metadata?.intent === 'complete') { + await api.todos.complete(mutation.original.id) + } else { + await api.todos.update(mutation.original.id, mutation.changes) + } +} +``` + +### Waiting for Persistence + +```tsx +const tx = todoCollection.update(todoId, (draft) => { + draft.completed = true +}) + +// Check state +console.log(tx.state) // 'pending' | 'persisting' | 'completed' | 'failed' + +// Wait for completion +try { + await tx.isPersisted.promise + console.log('Saved!') +} catch (error) { + console.log('Failed:', error) +} +``` + +### Handling Temporary IDs + +When server generates the real ID: + +```tsx +// Option 1: Use client-generated UUIDs (recommended) +todoCollection.insert({ + id: crypto.randomUUID(), // Stable ID, no flicker + text: 'New todo', +}) + +// Option 2: Wait for persistence before enabling delete +const tx = todoCollection.insert({ id: tempId, text: 'New todo' }) +await tx.isPersisted.promise +// Now safe to delete with real ID +``` + +## Transaction Handler API + +```tsx +interface OperationHandler { + ({ transaction, collection }): Promise +} + +// transaction.mutations array: +interface PendingMutation { + collection: Collection + type: 'insert' | 'update' | 'delete' + key: string | number + original: TData // Original item (update/delete) + modified: TData // New item (insert/update) + changes: Partial // Only changed fields (update) + metadata?: Record +} +``` + +## Mutation Merging + +Multiple mutations on same item within a transaction merge: + +| Existing → New | Result | Description | +| --------------- | --------- | ------------------------------ | +| insert + update | `insert` | Merged into single insert | +| insert + delete | _removed_ | Cancel out | +| update + delete | `delete` | Delete wins | +| update + update | `update` | Changes merged, first original | + +## Detailed References + +| Reference | When to Use | +| ------------------------------- | ---------------------------------------------- | +| `references/handlers.md` | Handler patterns, collection-specific behavior | +| `references/transactions.md` | Manual transactions, autoCommit, lifecycle | +| `references/paced-mutations.md` | Debounce, throttle, queue strategies | +| `references/error-handling.md` | Rollback, retry patterns, error recovery | +| `references/temporary-ids.md` | Server-generated IDs, view key mapping | diff --git a/packages/playbook/skills/tanstack-db/mutations/references/error-handling.md b/packages/playbook/skills/tanstack-db/mutations/references/error-handling.md new file mode 100644 index 000000000..3c1c7a47d --- /dev/null +++ b/packages/playbook/skills/tanstack-db/mutations/references/error-handling.md @@ -0,0 +1,246 @@ +# Error Handling + +Handle mutation failures gracefully with automatic rollback and recovery patterns. + +## Automatic Rollback + +When a mutation handler throws, optimistic state rolls back automatically: + +```tsx +const todoCollection = createCollection({ + onUpdate: async ({ transaction }) => { + const response = await api.update(...) + if (!response.ok) { + throw new Error('Update failed') // Triggers rollback + } + }, +}) + +// User sees optimistic update immediately +todoCollection.update(id, (d) => { d.completed = true }) + +// If handler throws, UI reverts to previous state +``` + +## Catching Mutation Errors + +### Using Transaction Promise + +```tsx +const tx = todoCollection.update(id, (draft) => { + draft.completed = true +}) + +try { + await tx.isPersisted.promise + toast.success('Saved!') +} catch (error) { + toast.error(`Failed: ${error.message}`) +} +``` + +### With Custom Actions + +```tsx +const updateTodo = createOptimisticAction<{ + id: string + changes: Partial +}>({ + onMutate: ({ id, changes }) => { + todoCollection.update(id, (d) => Object.assign(d, changes)) + }, + mutationFn: async ({ id, changes }, { signal }) => { + const response = await api.update(id, changes, { signal }) + if (!response.ok) { + throw new Error('Update failed') + } + await todoCollection.utils.refetch() + }, +}) + +// Handle at call site +try { + await updateTodo({ id: '1', changes: { text: 'New text' } }) +} catch (error) { + console.error('Update failed:', error) +} +``` + +## Retry Patterns + +TanStack DB does not auto-retry. Implement retry logic in handlers: + +### Simple Retry + +```tsx +async function withRetry( + fn: () => Promise, + maxRetries = 3, + delay = 1000, +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (error) { + if (attempt === maxRetries - 1) throw error + await new Promise((r) => setTimeout(r, delay * (attempt + 1))) + } + } + throw new Error('Unreachable') +} + +const todoCollection = createCollection({ + onUpdate: async ({ transaction }) => { + await withRetry(() => + api.update( + transaction.mutations[0].original.id, + transaction.mutations[0].changes, + ), + ) + }, +}) +``` + +### With p-retry Library + +```tsx +import pRetry from 'p-retry' + +onUpdate: async ({ transaction }) => { + await pRetry( + () => api.update(...), + { + retries: 3, + onFailedAttempt: (error) => { + console.log(`Attempt ${error.attemptNumber} failed`) + }, + } + ) +} +``` + +## Transaction State Monitoring + +```tsx +const tx = todoCollection.update(id, (d) => { + d.done = true +}) + +// Poll state +const interval = setInterval(() => { + console.log('State:', tx.state) + if (tx.state === 'completed' || tx.state === 'failed') { + clearInterval(interval) + } +}, 100) + +// Or use promise +tx.isPersisted.promise + .then(() => console.log('Completed')) + .catch((e) => console.log('Failed:', e)) +``` + +## Schema Validation Errors + +Schema validation happens before handlers run: + +```tsx +import { SchemaValidationError } from '@tanstack/db' + +try { + todoCollection.insert({ + id: '1', + text: '', // Invalid: min length 1 + }) +} catch (error) { + if (error instanceof SchemaValidationError) { + console.log(error.type) // 'insert' + console.log(error.issues) // [{ path: ['text'], message: '...' }] + } +} +``` + +See [schemas/references/error-handling.md](../../schemas/references/error-handling.md) for more. + +## Network Error Handling + +```tsx +onUpdate: async ({ transaction }) => { + try { + const response = await fetch('/api/todos', { + method: 'PATCH', + body: JSON.stringify(transaction.mutations[0].changes), + }) + + if (!response.ok) { + if (response.status === 409) { + throw new Error('Conflict: data was modified by another user') + } + if (response.status === 403) { + throw new Error('Permission denied') + } + throw new Error(`Server error: ${response.status}`) + } + } catch (error) { + if (error.name === 'TypeError') { + throw new Error('Network error: please check your connection') + } + throw error + } +} +``` + +## Partial Failure Handling + +When batching, handle partial failures: + +```tsx +onUpdate: async ({ transaction }) => { + const results = await Promise.allSettled( + transaction.mutations.map((m) => api.update(m.original.id, m.changes)), + ) + + const failures = results.filter((r) => r.status === 'rejected') + if (failures.length > 0) { + throw new Error(`${failures.length} of ${results.length} updates failed`) + } +} +``` + +## User-Facing Error Messages + +```tsx +function TodoItem({ todo }) { + const [error, setError] = useState(null) + + const toggleComplete = async () => { + setError(null) + const tx = todoCollection.update(todo.id, (d) => { + d.completed = !d.completed + }) + + try { + await tx.isPersisted.promise + } catch (e) { + setError(e.message) + } + } + + return ( +
+ {todo.text} + + {error && {error}} +
+ ) +} +``` + +## Rollback vs Recovery + +| Scenario | What Happens | User Experience | +| ---------------- | ------------- | ------------------------ | +| Handler throws | Auto-rollback | UI reverts, show error | +| Network timeout | Auto-rollback | UI reverts, retry option | +| Validation error | Never applied | Show validation message | +| Conflict | Auto-rollback | Refresh and retry | diff --git a/packages/playbook/skills/tanstack-db/mutations/references/handlers.md b/packages/playbook/skills/tanstack-db/mutations/references/handlers.md new file mode 100644 index 000000000..f050f5294 --- /dev/null +++ b/packages/playbook/skills/tanstack-db/mutations/references/handlers.md @@ -0,0 +1,248 @@ +# Mutation Handlers + +Define how mutations persist to your backend. + +## Handler Types + +| Handler | Triggered By | Use Case | +| ---------- | --------------------- | ----------------------- | +| `onInsert` | `collection.insert()` | Create new records | +| `onUpdate` | `collection.update()` | Modify existing records | +| `onDelete` | `collection.delete()` | Remove records | + +## Handler Signature + +```tsx +type MutationHandler = (params: { + transaction: Transaction + collection: Collection +}) => Promise | any + +interface Transaction { + mutations: PendingMutation[] +} + +interface PendingMutation { + collection: Collection + type: 'insert' | 'update' | 'delete' + key: string | number + original: TData // Original item (update/delete only) + modified: TData // New/modified item (insert/update) + changes: Partial // Changed fields only (update only) + metadata?: Record +} +``` + +## Basic Handlers + +```tsx +const todoCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => api.todos.getAll(), + getKey: (item) => item.id, + + onInsert: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => api.todos.create(m.modified)), + ) + }, + + onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + api.todos.update(m.original.id, m.changes), + ), + ) + }, + + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => api.todos.delete(m.original.id)), + ) + }, + }), +) +``` + +## Collection-Specific Patterns + +### QueryCollection + +Automatic refetch after handler completes: + +```tsx +onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((m) => + api.todos.update(m.original.id, m.changes), + ), + ) + // QueryCollection automatically refetches after this returns +} +``` + +### ElectricCollection + +Return txid to wait for sync: + +```tsx +onUpdate: async ({ transaction }) => { + const txids = await Promise.all( + transaction.mutations.map(async (m) => { + const response = await api.todos.update(m.original.id, m.changes) + return response.txid + }), + ) + return { txid: txids } +} +``` + +### LocalCollection + +No handler needed (or use for side effects): + +```tsx +// LocalStorage and LocalOnly don't require handlers +// But you can add them for side effects: +onUpdate: async ({ transaction }) => { + analytics.track('settings_changed', { + fields: Object.keys(transaction.mutations[0].changes), + }) +} +``` + +## Using Metadata + +Customize behavior based on mutation metadata: + +```tsx +// When mutating +todoCollection.update(todoId, { metadata: { intent: 'complete' } }, (draft) => { + draft.completed = true +}) + +// In handler +onUpdate: async ({ transaction }) => { + const mutation = transaction.mutations[0] + + if (mutation.metadata?.intent === 'complete') { + // Use specialized endpoint + await api.todos.complete(mutation.original.id) + } else { + // Generic update + await api.todos.update(mutation.original.id, mutation.changes) + } +} +``` + +## Batch Mutations + +Handle multiple mutations efficiently: + +```tsx +onUpdate: async ({ transaction }) => { + // Single batch request instead of N requests + await api.todos.batchUpdate( + transaction.mutations.map((m) => ({ + id: m.original.id, + changes: m.changes, + })), + ) +} +``` + +## Shared Handler + +Use the same handler for all operations: + +```tsx +const mutationFn: MutationFn = async ({ transaction }) => { + const response = await api.mutations.batch( + transaction.mutations.map((m) => ({ + type: m.type, + table: 'todos', + key: m.key, + data: m.type === 'delete' ? undefined : m.modified, + })), + ) + + if (!response.ok) { + throw new Error(`Mutation failed: ${response.status}`) + } +} + +const todoCollection = createCollection({ + onInsert: mutationFn, + onUpdate: mutationFn, + onDelete: mutationFn, +}) +``` + +## Schema Transforms in Handlers + +Handlers receive transformed data (TOutput): + +```tsx +const schema = z.object({ + id: z.string(), + created_at: z + .union([z.string(), z.date()]) + .transform((val) => (typeof val === 'string' ? new Date(val) : val)), +}) + +onInsert: async ({ transaction }) => { + const item = transaction.mutations[0].modified + // item.created_at is already a Date! + + // Serialize for API if needed + await api.create({ + ...item, + created_at: item.created_at.toISOString(), + }) +} +``` + +## Error Handling + +Throw errors to trigger rollback: + +```tsx +onUpdate: async ({ transaction }) => { + const response = await api.todos.update(...) + + if (!response.ok) { + // This triggers optimistic state rollback + throw new Error(`Update failed: ${response.status}`) + } +} +``` + +## Handler Must Wait for Sync + +**Critical:** Handlers must not resolve until server changes have synced back. + +```tsx +// ✅ Correct: waits for refetch +onUpdate: async ({ transaction }) => { + await api.update(...) + await collection.utils.refetch() +} + +// ✅ Correct: QueryCollection auto-refetches +onUpdate: async ({ transaction }) => { + await api.update(...) + // Auto-refetch happens after return +} + +// ✅ Correct: Electric waits for txid +onUpdate: async ({ transaction }) => { + const { txid } = await api.update(...) + return { txid } +} + +// ❌ Wrong: doesn't wait for sync +onUpdate: async ({ transaction }) => { + api.update(...) // Fire and forget - will cause UI glitches +} +``` diff --git a/packages/playbook/skills/tanstack-db/mutations/references/paced-mutations.md b/packages/playbook/skills/tanstack-db/mutations/references/paced-mutations.md new file mode 100644 index 000000000..0534f2cbc --- /dev/null +++ b/packages/playbook/skills/tanstack-db/mutations/references/paced-mutations.md @@ -0,0 +1,200 @@ +# Paced Mutations + +Control when and how mutations persist to your backend using timing strategies. + +## Strategies + +### debounceStrategy + +Wait for inactivity before persisting. Only final state is saved. + +```tsx +import { usePacedMutations, debounceStrategy } from '@tanstack/react-db' + +const mutate = usePacedMutations<{ field: string; value: string }>({ + onMutate: ({ field, value }) => { + formCollection.update(formId, (draft) => { + draft[field] = value + }) + }, + mutationFn: async ({ transaction }) => { + await api.forms.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), +}) +``` + +**Use for:** Auto-save forms, search-as-you-type, settings panels + +### throttleStrategy + +Ensure minimum spacing between executions. Mutations between executions merge. + +```tsx +import { usePacedMutations, throttleStrategy } from '@tanstack/react-db' + +const mutate = usePacedMutations({ + onMutate: (volume) => { + settingsCollection.update('volume', (d) => { + d.value = volume + }) + }, + mutationFn: async ({ transaction }) => { + await api.settings.updateVolume(transaction.mutations) + }, + strategy: throttleStrategy({ + wait: 200, + leading: true, // Execute immediately on first call + trailing: true, // Execute after wait if there were mutations + }), +}) +``` + +**Use for:** Sliders, progress bars, analytics, live cursor position + +### queueStrategy + +Each mutation creates a separate transaction, processed sequentially. + +```tsx +import { usePacedMutations, queueStrategy } from '@tanstack/react-db' + +const mutate = usePacedMutations({ + onMutate: (file) => { + uploadCollection.insert({ + id: crypto.randomUUID(), + file, + status: 'pending', + }) + }, + mutationFn: async ({ transaction }) => { + await api.files.upload(transaction.mutations[0].modified) + }, + strategy: queueStrategy({ + wait: 500, + addItemsTo: 'back', // FIFO: add to back + getItemsFrom: 'front', // FIFO: process from front + }), +}) +``` + +**Use for:** File uploads, batch operations, audit trails, rate-limited APIs + +## Choosing a Strategy + +| Scenario | Strategy | Reason | +| ---------------- | -------- | ------------------------------- | +| Auto-save form | debounce | Wait for user to stop typing | +| Volume slider | throttle | Smooth updates without flooding | +| File uploads | queue | Every file must upload in order | +| Search input | debounce | Only search after user pauses | +| Real-time cursor | throttle | Consistent update rate | +| Chat messages | queue | Order matters, all must send | + +## Shared Queues + +Each hook call creates its own queue. To share across components: + +```tsx +// Create single shared instance +export const mutateDraft = createPacedMutations<{ id: string; text: string }>({ + onMutate: ({ id, text }) => { + draftCollection.update(id, (d) => { + d.text = text + }) + }, + mutationFn: async ({ transaction }) => { + await api.saveDraft(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }), +}) + +// Use everywhere - same debounce timer +function Editor1() { + return ( +