From ef4eb8fb2c262921322801273752b51f703cdd44 Mon Sep 17 00:00:00 2001 From: LadyBluenotes Date: Fri, 20 Feb 2026 09:51:50 -0800 Subject: [PATCH 01/11] initialize @tanstack/playbook package with basic configuration and CLI --- README.md | 2 +- build-guide.md | 572 ++++++++++++ packages/agents/README.md | 10 - packages/agents/manifest.json | 21 - packages/agents/package.json | 8 - packages/agents/project.json | 32 - packages/agents/rules/safety.md | 11 - packages/agents/rules/staging-migration.md | 11 - packages/agents/rules/style.md | 11 - packages/agents/rules/tanstack-conventions.md | 16 - packages/agents/skills/tanstack/SKILL.md | 16 - packages/agents/subagents/tanstack-db.md | 20 - packages/agents/subagents/tanstack-form.md | 20 - packages/agents/subagents/tanstack-query.md | 20 - packages/agents/subagents/tanstack-router.md | 28 - packages/agents/subagents/tanstack-start.md | 28 - packages/agents/subagents/tanstack-table.md | 20 - packages/agents/subagents/tanstack-virtual.md | 20 - packages/db-playbook/package.json | 9 + packages/db-playbook/skills/registry.json | 64 ++ .../db-playbook/skills/tanstack-db/SKILL.md | 179 ++++ .../skills/tanstack-db/collections/SKILL.md | 280 ++++++ .../references/custom-collections.md | 281 ++++++ .../references/electric-collection.md | 237 +++++ .../references/local-collections.md | 222 +++++ .../references/query-collection.md | 254 ++++++ .../collections/references/sync-modes.md | 228 +++++ .../skills/tanstack-db/electric/SKILL.md | 274 ++++++ .../electric/references/debugging.md | 219 +++++ .../electric/references/proxy-setup.md | 259 ++++++ .../tanstack-db/electric/references/shapes.md | 213 +++++ .../electric/references/txid-matching.md | 185 ++++ .../skills/tanstack-db/live-queries/SKILL.md | 264 ++++++ .../live-queries/references/aggregations.md | 197 ++++ .../references/functional-variants.md | 186 ++++ .../live-queries/references/joins.md | 180 ++++ .../live-queries/references/performance.md | 193 ++++ .../live-queries/references/query-builder.md | 192 ++++ .../live-queries/references/subqueries.md | 199 ++++ .../skills/tanstack-db/mutations/SKILL.md | 346 +++++++ .../mutations/references/error-handling.md | 248 +++++ .../mutations/references/handlers.md | 248 +++++ .../mutations/references/paced-mutations.md | 200 ++++ .../mutations/references/temporary-ids.md | 225 +++++ .../mutations/references/transactions.md | 268 ++++++ .../skills/tanstack-db/powersync/SKILL.md | 335 +++++++ .../skills/tanstack-db/query/SKILL.md | 314 +++++++ .../skills/tanstack-db/rxdb/SKILL.md | 308 +++++++ .../skills/tanstack-db/schemas/SKILL.md | 304 +++++++ .../schemas/references/error-handling.md | 234 +++++ .../schemas/references/tinput-toutput.md | 184 ++++ .../schemas/references/transformations.md | 199 ++++ .../schemas/references/validation.md | 177 ++++ .../skills/tanstack-db/trailbase/SKILL.md | 288 ++++++ packages/playbook/package.json | 25 + packages/playbook/src/cli.ts | 99 ++ packages/playbook/src/registry.ts | 63 ++ packages/playbook/tsconfig.json | 15 + packages/router-skills/README.md | 5 - packages/router-skills/manifest.json | 53 -- packages/router-skills/package.json | 8 - packages/router-skills/project.json | 32 - packages/router-skills/skills/topics.json | 44 - .../skills/v1/authenticated-routes.md | 43 - .../router-skills/skills/v1/custom-links.md | 41 - .../skills/v1/custom-search-serialization.md | 45 - .../skills/v1/data-loading-advanced.md | 48 - .../router-skills/skills/v1/data-refresh.md | 40 - .../skills/v1/deferred-data-loading.md | 44 - .../skills/v1/document-head-management.md | 46 - .../skills/v1/error-boundaries.md | 46 - .../skills/v1/eslint-plugin-router.md | 38 - .../skills/v1/external-data-loading.md | 39 - .../skills/v1/file-based-routing.md | 45 - .../router-skills/skills/v1/history-types.md | 36 - packages/router-skills/skills/v1/index.md | 93 -- .../skills/v1/installation-guides.md | 39 - packages/router-skills/skills/v1/layouts.md | 49 - .../router-skills/skills/v1/link-options.md | 37 - packages/router-skills/skills/v1/links.md | 47 - packages/router-skills/skills/v1/loaders.md | 43 - .../skills/v1/matching-and-location.md | 44 - .../skills/v1/navigation-blocking.md | 38 - .../router-skills/skills/v1/navigation.md | 45 - .../skills/v1/not-found-boundaries.md | 42 - packages/router-skills/skills/v1/params.md | 41 - .../router-skills/skills/v1/preloading.md | 37 - .../skills/v1/query-integration.md | 36 - packages/router-skills/skills/v1/redirects.md | 37 - .../skills/v1/render-optimizations.md | 38 - .../router-skills/skills/v1/route-context.md | 41 - packages/router-skills/skills/v1/route-ids.md | 42 - .../skills/v1/route-lazy-loading.md | 44 - .../router-skills/skills/v1/route-masking.md | 44 - .../router-skills/skills/v1/route-trees.md | 47 - .../skills/v1/router-devtools.md | 36 - .../router-skills/skills/v1/router-setup.md | 44 - .../router-skills/skills/v1/router-state.md | 39 - .../skills/v1/routing-strategies.md | 47 - .../skills/v1/scroll-restoration.md | 36 - .../router-skills/skills/v1/search-params.md | 50 - .../router-skills/skills/v1/ssr-loaders.md | 45 - .../skills/v1/static-route-data.md | 41 - .../router-skills/skills/v1/type-safety.md | 37 - .../skills/v1/view-transitions.md | 37 - packages/start-skills/README.md | 5 - packages/start-skills/manifest.json | 31 - packages/start-skills/package.json | 8 - packages/start-skills/project.json | 32 - packages/start-skills/skills/topics.json | 22 - .../start-skills/skills/v1/authentication.md | 34 - packages/start-skills/skills/v1/databases.md | 33 - packages/start-skills/skills/v1/deployment.md | 25 - .../skills/v1/environment-config.md | 40 - .../skills/v1/error-boundaries.md | 26 - .../start-skills/skills/v1/execution-model.md | 38 - .../start-skills/skills/v1/file-structure.md | 42 - packages/start-skills/skills/v1/index.md | 62 -- packages/start-skills/skills/v1/middleware.md | 34 - .../start-skills/skills/v1/observability.md | 36 - .../start-skills/skills/v1/path-aliases.md | 38 - .../skills/v1/router-integration.md | 39 - packages/start-skills/skills/v1/routing.md | 34 - .../start-skills/skills/v1/selective-ssr.md | 26 - .../skills/v1/server-entry-point.md | 36 - .../skills/v1/server-functions.md | 35 - .../start-skills/skills/v1/server-routes.md | 37 - .../start-skills/skills/v1/start-setup.md | 41 - .../skills/v1/static-prerendering.md | 32 - .../start-skills/skills/v1/streaming-ssr.md | 25 - prompts/generate-skill.md | 86 ++ rfc.md | 858 ++++++++++++++++++ 132 files changed, 9912 insertions(+), 3053 deletions(-) create mode 100644 build-guide.md delete mode 100644 packages/agents/README.md delete mode 100644 packages/agents/manifest.json delete mode 100644 packages/agents/package.json delete mode 100644 packages/agents/project.json delete mode 100644 packages/agents/rules/safety.md delete mode 100644 packages/agents/rules/staging-migration.md delete mode 100644 packages/agents/rules/style.md delete mode 100644 packages/agents/rules/tanstack-conventions.md delete mode 100644 packages/agents/skills/tanstack/SKILL.md delete mode 100644 packages/agents/subagents/tanstack-db.md delete mode 100644 packages/agents/subagents/tanstack-form.md delete mode 100644 packages/agents/subagents/tanstack-query.md delete mode 100644 packages/agents/subagents/tanstack-router.md delete mode 100644 packages/agents/subagents/tanstack-start.md delete mode 100644 packages/agents/subagents/tanstack-table.md delete mode 100644 packages/agents/subagents/tanstack-virtual.md create mode 100644 packages/db-playbook/package.json create mode 100644 packages/db-playbook/skills/registry.json create mode 100644 packages/db-playbook/skills/tanstack-db/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/references/custom-collections.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/references/electric-collection.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/references/local-collections.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/references/query-collection.md create mode 100644 packages/db-playbook/skills/tanstack-db/collections/references/sync-modes.md create mode 100644 packages/db-playbook/skills/tanstack-db/electric/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/electric/references/debugging.md create mode 100644 packages/db-playbook/skills/tanstack-db/electric/references/proxy-setup.md create mode 100644 packages/db-playbook/skills/tanstack-db/electric/references/shapes.md create mode 100644 packages/db-playbook/skills/tanstack-db/electric/references/txid-matching.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/aggregations.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/functional-variants.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/joins.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/performance.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/query-builder.md create mode 100644 packages/db-playbook/skills/tanstack-db/live-queries/references/subqueries.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/references/error-handling.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/references/handlers.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/references/paced-mutations.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/references/temporary-ids.md create mode 100644 packages/db-playbook/skills/tanstack-db/mutations/references/transactions.md create mode 100644 packages/db-playbook/skills/tanstack-db/powersync/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/query/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/rxdb/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/schemas/SKILL.md create mode 100644 packages/db-playbook/skills/tanstack-db/schemas/references/error-handling.md create mode 100644 packages/db-playbook/skills/tanstack-db/schemas/references/tinput-toutput.md create mode 100644 packages/db-playbook/skills/tanstack-db/schemas/references/transformations.md create mode 100644 packages/db-playbook/skills/tanstack-db/schemas/references/validation.md create mode 100644 packages/db-playbook/skills/tanstack-db/trailbase/SKILL.md create mode 100644 packages/playbook/package.json create mode 100644 packages/playbook/src/cli.ts create mode 100644 packages/playbook/src/registry.ts create mode 100644 packages/playbook/tsconfig.json delete mode 100644 packages/router-skills/README.md delete mode 100644 packages/router-skills/manifest.json delete mode 100644 packages/router-skills/package.json delete mode 100644 packages/router-skills/project.json delete mode 100644 packages/router-skills/skills/topics.json delete mode 100644 packages/router-skills/skills/v1/authenticated-routes.md delete mode 100644 packages/router-skills/skills/v1/custom-links.md delete mode 100644 packages/router-skills/skills/v1/custom-search-serialization.md delete mode 100644 packages/router-skills/skills/v1/data-loading-advanced.md delete mode 100644 packages/router-skills/skills/v1/data-refresh.md delete mode 100644 packages/router-skills/skills/v1/deferred-data-loading.md delete mode 100644 packages/router-skills/skills/v1/document-head-management.md delete mode 100644 packages/router-skills/skills/v1/error-boundaries.md delete mode 100644 packages/router-skills/skills/v1/eslint-plugin-router.md delete mode 100644 packages/router-skills/skills/v1/external-data-loading.md delete mode 100644 packages/router-skills/skills/v1/file-based-routing.md delete mode 100644 packages/router-skills/skills/v1/history-types.md delete mode 100644 packages/router-skills/skills/v1/index.md delete mode 100644 packages/router-skills/skills/v1/installation-guides.md delete mode 100644 packages/router-skills/skills/v1/layouts.md delete mode 100644 packages/router-skills/skills/v1/link-options.md delete mode 100644 packages/router-skills/skills/v1/links.md delete mode 100644 packages/router-skills/skills/v1/loaders.md delete mode 100644 packages/router-skills/skills/v1/matching-and-location.md delete mode 100644 packages/router-skills/skills/v1/navigation-blocking.md delete mode 100644 packages/router-skills/skills/v1/navigation.md delete mode 100644 packages/router-skills/skills/v1/not-found-boundaries.md delete mode 100644 packages/router-skills/skills/v1/params.md delete mode 100644 packages/router-skills/skills/v1/preloading.md delete mode 100644 packages/router-skills/skills/v1/query-integration.md delete mode 100644 packages/router-skills/skills/v1/redirects.md delete mode 100644 packages/router-skills/skills/v1/render-optimizations.md delete mode 100644 packages/router-skills/skills/v1/route-context.md delete mode 100644 packages/router-skills/skills/v1/route-ids.md delete mode 100644 packages/router-skills/skills/v1/route-lazy-loading.md delete mode 100644 packages/router-skills/skills/v1/route-masking.md delete mode 100644 packages/router-skills/skills/v1/route-trees.md delete mode 100644 packages/router-skills/skills/v1/router-devtools.md delete mode 100644 packages/router-skills/skills/v1/router-setup.md delete mode 100644 packages/router-skills/skills/v1/router-state.md delete mode 100644 packages/router-skills/skills/v1/routing-strategies.md delete mode 100644 packages/router-skills/skills/v1/scroll-restoration.md delete mode 100644 packages/router-skills/skills/v1/search-params.md delete mode 100644 packages/router-skills/skills/v1/ssr-loaders.md delete mode 100644 packages/router-skills/skills/v1/static-route-data.md delete mode 100644 packages/router-skills/skills/v1/type-safety.md delete mode 100644 packages/router-skills/skills/v1/view-transitions.md delete mode 100644 packages/start-skills/README.md delete mode 100644 packages/start-skills/manifest.json delete mode 100644 packages/start-skills/package.json delete mode 100644 packages/start-skills/project.json delete mode 100644 packages/start-skills/skills/topics.json delete mode 100644 packages/start-skills/skills/v1/authentication.md delete mode 100644 packages/start-skills/skills/v1/databases.md delete mode 100644 packages/start-skills/skills/v1/deployment.md delete mode 100644 packages/start-skills/skills/v1/environment-config.md delete mode 100644 packages/start-skills/skills/v1/error-boundaries.md delete mode 100644 packages/start-skills/skills/v1/execution-model.md delete mode 100644 packages/start-skills/skills/v1/file-structure.md delete mode 100644 packages/start-skills/skills/v1/index.md delete mode 100644 packages/start-skills/skills/v1/middleware.md delete mode 100644 packages/start-skills/skills/v1/observability.md delete mode 100644 packages/start-skills/skills/v1/path-aliases.md delete mode 100644 packages/start-skills/skills/v1/router-integration.md delete mode 100644 packages/start-skills/skills/v1/routing.md delete mode 100644 packages/start-skills/skills/v1/selective-ssr.md delete mode 100644 packages/start-skills/skills/v1/server-entry-point.md delete mode 100644 packages/start-skills/skills/v1/server-functions.md delete mode 100644 packages/start-skills/skills/v1/server-routes.md delete mode 100644 packages/start-skills/skills/v1/start-setup.md delete mode 100644 packages/start-skills/skills/v1/static-prerendering.md delete mode 100644 packages/start-skills/skills/v1/streaming-ssr.md create mode 100644 prompts/generate-skill.md create mode 100644 rfc.md diff --git a/README.md b/README.md index 7cc3382..6815d77 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# TanStack Agents +# @tanstack/playbook diff --git a/build-guide.md b/build-guide.md new file mode 100644 index 0000000..2f268d2 --- /dev/null +++ b/build-guide.md @@ -0,0 +1,572 @@ +# @tanstack/playbook — Build Guide + +Step-by-step instructions for building the `@tanstack/playbook` repo from scratch. Each phase is a discrete, self-contained task that can be handed directly to a coding agent. Complete phases in order — each one depends on the previous. + +The RFC at `tanstack-playbook-rfc.md` is the source of truth for design decisions. When this guide and the RFC conflict, the RFC wins. + +--- + +## Phase 1: Repo Scaffold + +**What you're doing:** Creating the repo skeleton — directory structure, `package.json`, TypeScript config, and a `registry.json` that is the source of truth for all skills. No content yet, just the right shape. + +**Hand to agent:** + +> Set up the `@tanstack/playbook` repo from scratch. Do the following exactly: +> +> 1. `npm init -y`. Set `"name": "@tanstack/playbook"`, `"type": "module"`, `"version": "0.0.1"`. Add `"bin": { "tanstack-playbook": "./dist/cli.js" }`. Add `"files": ["dist", "skills"]`. Add scripts: `"build": "tsc"`, `"dev": "tsx src/cli.ts"`. +> +> 2. Install dev deps: `npm install -D typescript tsx @types/node`. +> +> 3. Create `tsconfig.json`: target `ESNext`, `moduleResolution: "bundler"`, `outDir: "./dist"`, `rootDir: "./src"`, `strict: true`. +> +> 4. Create `.gitignore` ignoring `node_modules` and `dist`. +> +> 5. Create the following directory tree. Every empty leaf directory gets a `.gitkeep`: +> +> ``` +> skills/ +> tanstack-query/ +> router/ +> core/ +> references/ +> caching/ +> references/ +> infinite/ +> suspense/ +> tanstack-router/ +> router/ +> core/ +> references/ +> search-params/ +> loaders/ +> with-query/ +> tanstack-db/ +> router/ +> core/ +> optimistic/ +> electric/ +> tanstack-form/ +> router/ +> core/ +> references/ +> async/ +> submission/ +> tanstack-table/ +> router/ +> core/ +> references/ +> features/ +> virtual/ +> internal/ +> skill-staleness-check/ +> prompts/ +> src/ +> .warp/ +> automations/ +> .github/ +> workflows/ +> ``` +> +> 6. Create `skills/registry.json`. This is the single source of truth for all skills — the CLI reads from it, the install command reads from it, and the Oz automation reads from it. Leave `description` empty for now; it gets filled in during skill generation. +> +> ```json +> { +> "skills": [ +> { "name": "tanstack-query/router", "package": "query", "internal": false, "description": "" }, +> { "name": "tanstack-query/core", "package": "query", "internal": false, "description": "" }, +> { "name": "tanstack-query/caching", "package": "query", "internal": false, "description": "" }, +> { "name": "tanstack-query/infinite", "package": "query", "internal": false, "description": "" }, +> { "name": "tanstack-query/suspense", "package": "query", "internal": false, "description": "" }, +> { "name": "tanstack-router/router", "package": "router", "internal": false, "description": "" }, +> { "name": "tanstack-router/core", "package": "router", "internal": false, "description": "" }, +> { "name": "tanstack-router/search-params", "package": "router", "internal": false, "description": "" }, +> { "name": "tanstack-router/loaders", "package": "router", "internal": false, "description": "" }, +> { "name": "tanstack-router/with-query", "package": "router", "internal": false, "description": "" }, +> { "name": "tanstack-db/router", "package": "db", "internal": false, "description": "" }, +> { "name": "tanstack-db/core", "package": "db", "internal": false, "description": "" }, +> { "name": "tanstack-db/optimistic", "package": "db", "internal": false, "description": "" }, +> { "name": "tanstack-db/electric", "package": "db", "internal": false, "description": "" }, +> { "name": "tanstack-form/router", "package": "form", "internal": false, "description": "" }, +> { "name": "tanstack-form/core", "package": "form", "internal": false, "description": "" }, +> { "name": "tanstack-form/async", "package": "form", "internal": false, "description": "" }, +> { "name": "tanstack-form/submission", "package": "form", "internal": false, "description": "" }, +> { "name": "tanstack-table/router", "package": "table", "internal": false, "description": "" }, +> { "name": "tanstack-table/core", "package": "table", "internal": false, "description": "" }, +> { "name": "tanstack-table/features", "package": "table", "internal": false, "description": "" }, +> { "name": "tanstack-table/virtual", "package": "table", "internal": false, "description": "" }, +> { "name": "internal/skill-staleness-check", "package": null, "internal": true, "description": "" } +> ] +> } +> ``` +> +> 7. Create `README.md` with one line: `# @tanstack/playbook`. Will be filled out in Phase 9. +> +> 8. `git init`, `git add .`, `git commit -m "chore: initial scaffold"`. + +**Done when:** Directory tree exists, `skills/registry.json` is in place, `npm run build` runs without errors. + +--- + +## Phase 2: CLI — list and show + +**What you're doing:** Building the CLI that powers `list` and `show`. These two commands work off `registry.json` and the SKILL.md files on disk. No install logic yet. + +**Hand to agent:** + +> Build the CLI for `@tanstack/playbook`. Entry point: `src/cli.ts`. Use Node built-ins (`fs`, `path`) only — no additional dependencies. +> +> **`src/registry.ts`** — shared module used by CLI and install command: +> - `getSkills(packageFilter?: string)` — returns all non-internal registry entries, optionally filtered by `package` field +> - `getSkill(name: string)` — returns a single entry or null +> - `getSkillPath(name: string)` — returns the absolute path to that skill's SKILL.md +> +> Skill name `/` maps to `skills///SKILL.md` relative to the package root. Resolve the package root using `import.meta.url`, not `process.cwd()`. +> +> **`list` command** — prints all non-internal skills, one per line, `name` padded then `description`. +> `--package ` filters to skills where the `package` field matches. +> +> Example output: +> ``` +> $ npx @tanstack/playbook list --package +> /router Entry point for TanStack work +> /core ... +> /... ... +> ``` +> +> **`show ` command** — reads and prints the full SKILL.md to stdout. +> If the skill is not in the registry or the file doesn't exist on disk, print a clear error and exit with code 1. +> +> Example: +> ``` +> $ npx @tanstack/playbook show /core +> [full SKILL.md content] +> +> $ npx @tanstack/playbook show /does-not-exist +> Error: skill "/does-not-exist" not found +> ``` +> +> Wire the binary in `package.json` `bin` field to `./dist/cli.js`. + +**Done when:** `npx @tanstack/playbook list` prints all 22 registry entries (descriptions empty for now). `npx @tanstack/playbook show ` fails clearly since no SKILL.md files exist yet. + +--- + +## Phase 3: Generation Prompt File + +**What you're doing:** Committing the skill generation prompt to the repo before generating any skills, so the prompt itself is version-controlled and reviewable. + +**Hand to agent:** + +> Create `prompts/generate-skill.md` with two parts. +> +> **Part 1 — the prompt itself:** +> +> ````markdown +> # Skill Generation Prompt +> +> You are generating a SKILL.md file for the `@tanstack/playbook` agent skills repo. +> Skills in this repo are written for coding agents (Claude Code, Cursor, Warp Oz), +> not for human readers. Your output will be loaded into an agent's context window +> and used to guide code generation. +> +> ## Your task +> +> Generate a complete SKILL.md for the skill named: **{SKILL_NAME}** +> Use the format `library-group/skill-name`, e.g. `tanstack-/core`. +> +> The skill covers: **{SKILL_DESCRIPTION}** +> +> Source documentation to distill from: +> {SOURCE_DOCS} +> +> ## Output requirements +> +> ### 1. Frontmatter +> +> ```yaml +> --- +> name: {SKILL_NAME} +> description: > +> One to three sentences. What this skill covers and exactly when an agent +> should load it. Written for the agent — include the keywords an agent would +> encounter when it needs this skill. +> triggers: +> - list +> - of +> - keywords +> metadata: +> sources: +> - 'repo:path/to/source/file' # format: repo-name:relative-path, globs ok +> skills: +> - other-skill/name # only if this skill references another skill +> --- +> ``` +> +> ### 2. A minimal setup example +> +> A complete, copy-pasteable code block showing the minimum viable usage. +> No placeholder comments. No unnecessary boilerplate. React adapter only. +> Import from the correct `@tanstack/react-*` package. +> +> ### 3. The primary patterns +> +> The one to three most important things an agent needs to know to use this correctly. +> Each pattern must include a working code example. +> Do not explain what the agent already knows (TypeScript, React hooks, etc). +> Only write what is specific to this TanStack library and skill. +> +> ### 4. Common Mistakes +> +> A "Common Mistakes" section with at least three entries. Each entry must show: +> - ❌ The wrong pattern with a code example +> - ✅ The correct pattern with a code example +> - A one-line explanation of why the wrong pattern fails +> +> Focus on mistakes that produce plausible-looking but broken or subtly incorrect code. +> +> ### 5. Resources (only if needed) +> +> If detailed API options or exhaustive type signatures are important but too long +> for the main skill, list them as references: +> +> ```markdown +> ## Resources +> - [Full option reference](references/api.md) +> ``` +> +> Omit this section entirely if the skill is complete without it. +> +> ## Constraints +> +> - Total SKILL.md must be under 500 lines +> - React adapter only — no Vue, Solid, Svelte, or Angular examples +> - All imports must use the real package name (e.g. `@tanstack/react-`) +> - No marketing copy, no motivational prose +> - No explanations of TypeScript or React concepts the agent already knows +> - Every code example must be complete enough to copy-paste without modification +> ```` +> +> **Part 2 — usage and review checklist:** +> +> ```markdown +> ## Usage +> +> 1. Open this prompt in your agent (Claude Code, Cursor, etc.) +> 2. Fill in the three placeholders: +> - `{SKILL_NAME}` — e.g. `tanstack-/core` (library/skill-name format) +> - `{SKILL_DESCRIPTION}` — one sentence describing the skill's scope +> - `{SOURCE_DOCS}` — paste the relevant documentation pages in full +> 3. Run the prompt. Save output to `skills///SKILL.md` +> 4. Update `skills/registry.json` with the `description` from the generated frontmatter +> 5. Verify the skill passes the review checklist below +> +> ## Review Checklist +> +> - [ ] Valid YAML frontmatter: name, description, triggers, metadata.sources, metadata.skills +> - [ ] `name` matches the directory path exactly +> - [ ] At least one complete, copy-pasteable code example (no `...` or `[placeholder]`) +> - [ ] Common Mistakes — at least 3 entries, each with ❌ wrong + ✅ correct + explanation +> - [ ] Under 500 lines total +> - [ ] React adapter only — all imports from `@tanstack/react-*` +> - [ ] No concept explanations the agent already knows +> - [ ] `metadata.sources` entries use `repo:path` format +> - [ ] `metadata.skills` lists any other playbook skills this skill references +> - [ ] `npx @tanstack/playbook show ` prints the skill correctly after adding it +> ``` + +**Done when:** `prompts/generate-skill.md` exists with both parts committed. + +--- + +## Phase 4: Generate All Skills + +**What you're doing:** Using the generation prompt to create every skill across all five libraries. The `router` skill for each library is always generated first — it needs to know all the other skills in its library to write the routing table. + +**How to run the prompt:** + +For each skill, use the prompt from `prompts/generate-skill.md`. Fill in `{SKILL_NAME}`, `{SKILL_DESCRIPTION}`, and `{SOURCE_DOCS}`. Save the output to the correct path and update `registry.json` with the description. + +**Source docs to fetch — get these before starting:** + +| Library | Docs pages to fetch | +|---|---| +| tanstack-query | tanstack.com/query/latest/docs/framework/react/{overview, guides/queries, guides/query-keys, guides/mutations, guides/query-invalidation, guides/caching, guides/infinite-queries, guides/suspense} | +| tanstack-router | tanstack.com/router/latest/docs/framework/react/{overview, guide/routing-concepts, guide/navigation, guide/search-params, guide/data-loading} | +| tanstack-db | tanstack.com/db/latest/docs/{overview, guide/collections, guide/queries, guide/mutations, guide/optimistic-mutations} | +| tanstack-form | tanstack.com/form/latest/docs/framework/react/{overview, guides/basic-concepts, guides/validation, guides/async-validation, guides/submission} | +| tanstack-table | tanstack.com/table/latest/docs/{introduction, guide/column-defs, guide/sorting, guide/filtering, guide/pagination, guide/virtualization} | + +**Order within each library:** always generate `router` first, then the remaining skills in any order. The router skill needs all other skills listed in its routing table. + +**Special case — `tanstack-router/with-query`:** This skill crosses two libraries. Its `metadata.skills` must include both `tanstack-query/core` and `tanstack-router/loaders`. Use docs from both libraries as source material. + +**Skills to generate (22 total):** + +| Library | Skills | +|---|---| +| tanstack-query | router, core, caching, infinite, suspense | +| tanstack-router | router, core, search-params, loaders, with-query | +| tanstack-db | router, core, optimistic, electric | +| tanstack-form | router, core, async, submission | +| tanstack-table | router, core, features, virtual | + +**Review checklist (apply to every skill before moving on):** + +- [ ] Valid YAML frontmatter: name, description, triggers, metadata.sources, metadata.skills +- [ ] `name` matches directory path exactly +- [ ] At least one complete, copy-pasteable code example +- [ ] Common Mistakes section with at least 3 entries +- [ ] Under 500 lines +- [ ] React adapter only — all imports from `@tanstack/react-*` +- [ ] Each library's `router` skill routing table covers all other skills in that library +- [ ] `registry.json` descriptions filled in for every skill + +**Done when:** All 22 SKILL.md files exist on disk, pass the review checklist, and `npx @tanstack/playbook list` shows all of them with descriptions. + +--- + +## Phase 5: CLI — install Command + +**What you're doing:** Adding the `install` command — writes thin skills to agent directories and emits the AGENTS.md snippet. + +**Hand to agent:** + +> Add an `install` command to `src/cli.ts`. +> +> **Which skills to install:** +> `--package ` (repeatable). Valid values: `query`, `router`, `db`, `form`, `table`. +> No `--package` flag means install all non-internal skills. +> Always include the `router` skill for each package being installed. +> +> **Where to install:** +> Primary target: `.agents/skills/` in `process.cwd()`. Always install here. +> Detect additional targets by checking if these dirs exist in `process.cwd()`: +> - `.claude/` → `.claude/skills/` +> - `.cursor/` → `.cursor/skills/` +> - `.codex/` → `.codex/skills/` +> - `.windsurf/` → `.windsurf/skills/` +> - `.github/` → `.github/skills/` +> +> If additional targets are detected, list them and ask: `Install to these as well? (Y/n)`. +> `--yes` skips the prompt. `--global` installs to `~/.agents/skills/` instead. +> +> **Thin skill content:** +> For each skill write a thin SKILL.md at `//SKILL.md`. +> Create parent directories as needed. +> +> Template: +> ``` +> --- +> name: +> description: +> --- +> +> # +> +> Full content is in the @tanstack/playbook npm package. +> +> ```bash +> npx @tanstack/playbook show +> ``` +> +> To list all skills for this library: +> +> ```bash +> npx @tanstack/playbook list --package +> ``` +> ``` +> +> **AGENTS.md snippet:** +> Print after installing, tailored to what was installed: +> +> ``` +> ✔ Installed X skills to .agents/skills/ +> +> Add the following to your AGENTS.md: +> +> ───────────────────────────────────────────────── +> ## Included Playbooks +> +> ### TanStack Playbook +> Skills are installed in `.agents/skills/`. +> Load each library's router skill first when working with that library. +> +> [list each installed skill with its one-line description] +> +> All skills cover the React adapter only. +> ───────────────────────────────────────────────── +> ``` +> +> Descriptions come from `registry.json`. Group the listing by library in the output. + +**Done when:** Running `npx @tanstack/playbook install --package ` in a test directory creates the correct `.agents/skills//{router,...}/SKILL.md` files and prints the correct snippet — for every valid `--package` value. + +--- + +## Phase 6: Internal Staleness-Check Skill + +**What you're doing:** Writing the `internal/skill-staleness-check` SKILL.md — the Oz agent's operating instructions for keeping skills current. Not installed to users. + +**Hand to agent:** + +> Create `skills/internal/skill-staleness-check/SKILL.md`. This skill is the Oz automation's step-by-step procedure. Write it for an autonomous agent, not a human. It must be unambiguous enough to follow without clarification. +> +> Cover all of the following in order: +> +> **Inputs:** Webhook payload — `package` (e.g. `@tanstack/`), `sha` (commit SHA), `changed_files` (array of relative file paths that changed). +> +> **Step 1 — Map files to skills:** +> Parse frontmatter from every SKILL.md in `skills/` excluding `skills/internal/`. +> Build a lookup: `metadata.sources` entries → skill name. +> Sources use `repo:path` format and support globs. +> Match `changed_files` from the payload against this lookup. +> +> **Step 2 — Evaluate each matched skill:** +> For each match: fetch current SKILL.md + the diff for the changed file from the source repo at the given SHA. +> Decide: does this diff change behavior, an API, a pattern, or an example that this skill documents? +> If YES: rewrite the skill. Preserve all sections. Stay under 500 lines. React adapter only. Keep all working examples and Common Mistakes intact. +> If NO: skip. Do not open a PR. +> +> **Step 3 — Cross-skill cascade (one level only):** +> For each skill rewritten in Step 2: scan all other skills for `metadata.skills` entries listing the updated skill. +> For each skill that references it: evaluate for staleness. Rewrite and open a PR if stale; skip silently if not. +> Do not recurse — only one level of cascade. +> +> **Step 4 — Open PRs:** +> For each rewritten skill: branch `skill-update/-<7-char-sha>`, commit updated SKILL.md, open PR against main. +> PR title: `skill: update (@)` +> PR body sections: Triggered by / What changed in source / What changed in skill / Cross-skill impact / Review checklist. +> +> **No-op rule:** If no files match any skill's sources, or if matched diffs don't affect documented behavior, exit silently. No PR, no output, no notification. + +**Done when:** `skills/internal/skill-staleness-check/SKILL.md` exists and covers all four steps and the no-op rule without ambiguity. + +--- + +## Phase 7: Oz Config + GitHub Actions Template + +**What you're doing:** Creating the Warp Oz automation config and the GitHub Actions template that each package repo will use to fire the webhook. + +**Hand to agent:** + +> Create two files: +> +> **`.warp/automations/skill-check.yml`:** +> ```yaml +> name: Skill Staleness Check +> trigger: webhook +> skill: skills/internal/skill-staleness-check/SKILL.md +> environments: +> - repo: YOUR_ORG/playbook # TODO: replace with actual org +> - repo: TanStack/query +> - repo: TanStack/router +> - repo: TanStack/db +> - repo: TanStack/form +> - repo: TanStack/table +> ``` +> +> **`.github/workflows/notify-playbook.template.yml`** — template to copy into each package repo: +> ```yaml +> name: Notify Playbook +> +> on: +> push: +> branches: [main] +> +> jobs: +> dispatch: +> runs-on: ubuntu-latest +> steps: +> - uses: actions/checkout@v4 +> with: +> fetch-depth: 2 +> +> - name: Get changed files +> id: diff +> run: | +> echo "files=$(git diff --name-only HEAD~1 HEAD | jq -R -s -c 'split("\n")[:-1]')" >> $GITHUB_OUTPUT +> +> - name: Trigger Oz skill check +> run: | +> curl -X POST "${{ secrets.OZ_WEBHOOK_URL }}" \ +> -H "Authorization: Bearer ${{ secrets.OZ_WEBHOOK_TOKEN }}" \ +> -H "Content-Type: application/json" \ +> -d '{ +> "package": "@tanstack/REPLACE_WITH_PACKAGE_NAME", +> "sha": "${{ github.sha }}", +> "changed_files": ${{ steps.diff.outputs.files }} +> }' +> ``` +> +> Then add a `## CI Setup` section to `README.md` explaining: +> - Copy the template into each package repo as `.github/workflows/notify-playbook.yml` +> - Replace `REPLACE_WITH_PACKAGE_NAME` with the package name +> - Add `OZ_WEBHOOK_URL` and `OZ_WEBHOOK_TOKEN` as secrets in each package repo (obtained from Oz after creating the automation) + +**Done when:** Both files exist and the README CI Setup section is clear. + +--- + +## Phase 8: Polish and Publish + +**What you're doing:** Final README, build verification, and publishing to npm. + +**Hand to agent:** + +> Complete the following before publishing: +> +> 1. **Write `README.md`** with these sections: + > - One paragraph describing what `@tanstack/playbook` is +> - Quick start: install command with `--package` examples for each library +> - CLI reference table (`list`, `list --package`, `show`, `install`, `install --package`, `install --global`) +> - Example AGENTS.md snippet output (use any library as the example — keep it generic) +> - Link to the RFC for full architecture details +> - CI Setup section (from Phase 7) +> +> 2. **Build:** `npm run build`. Fix any TypeScript errors. Confirm `dist/cli.js` exists. +> +> 3. **Smoke test every package:** + > - `node dist/cli.js list` — all 22 skills listed with descriptions +> - `node dist/cli.js list --package ` — correct subset each time +> - `node dist/cli.js show ` — prints correctly +> - `node dist/cli.js install --package ` in a tmp dir — creates correct `.agents/skills/` structure and prints snippet +> +> 4. **Set version to `0.1.0`**. Add `"description": "Agent skills for the TanStack ecosystem"`. Add `"keywords": ["tanstack", "agents", "skills", "ai", "playbook"]`. +> +> 5. `npm publish --access public` + +**Done when:** `npx @tanstack/playbook list` works for anyone. Package is live on npm. + +--- + +## Phase 9: Wire Package Repos + +**What you're doing:** Adding the `notify-playbook.yml` GitHub Action to each TanStack package repo. This requires PR access to repos you may not own — do this manually. + +**Steps (repeat for each of the five package repos):** + +1. Copy `.github/workflows/notify-playbook.template.yml` into the target repo as `.github/workflows/notify-playbook.yml` +2. Replace `REPLACE_WITH_PACKAGE_NAME` with the correct value (`query`, `router`, `db`, `form`, or `table`) +3. Add repository secrets: `OZ_WEBHOOK_URL` and `OZ_WEBHOOK_TOKEN` (get these from Oz after setting up the automation in Phase 7) +4. Open a PR and merge it + +**Verify end-to-end:** After all five repos are wired, make a trivial doc change in one package repo and confirm the webhook fires, Oz receives the payload, evaluates the affected skill, and either opens a PR or exits silently. Check the Oz dashboard for the run log. + +**Done when:** All five repos have the workflow and a test run confirms the full pipeline. + +--- + +## Summary + +| Phase | What | Who | +|---|---|---| +| 1 | Repo scaffold, directory structure, registry.json | Agent | +| 2 | CLI: list + show | Agent | +| 3 | Generation prompt file + review checklist | Agent | +| 4 | Generate all 22 skills | Agent with prompt | +| 5 | CLI: install + AGENTS.md snippet | Agent | +| 6 | Internal staleness-check skill | Agent | +| 7 | Oz config + GitHub Actions template | Agent | +| 8 | README, build, publish | Agent | +| 9 | Wire up package repos | Manual | diff --git a/packages/agents/README.md b/packages/agents/README.md deleted file mode 100644 index 5e27516..0000000 --- a/packages/agents/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# @tanstack/agents - -TanStack-wide agent infrastructure. - -Contents: - -- `rules/` always-on guidance -- `subagents/` library heuristics (e.g. Router) - -Note: Skill Markdown files are intentionally not added yet. diff --git a/packages/agents/manifest.json b/packages/agents/manifest.json deleted file mode 100644 index 30b810b..0000000 --- a/packages/agents/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "package": "@tanstack/agents", - "rules": { - "tanstack/safety": "rules/safety.md", - "tanstack/style": "rules/style.md", - "tanstack/conventions": "rules/tanstack-conventions.md", - "tanstack/staging-migration": "rules/staging-migration.md" - }, - "subagents": { - "tanstack-router": "subagents/tanstack-router.md", - "tanstack-query": "subagents/tanstack-query.md", - "tanstack-table": "subagents/tanstack-table.md", - "tanstack-form": "subagents/tanstack-form.md", - "tanstack-virtual": "subagents/tanstack-virtual.md", - "tanstack-start": "subagents/tanstack-start.md", - "tanstack-db": "subagents/tanstack-db.md" - }, - "skills": { - "tanstack": "skills/tanstack/SKILL.md" - } -} diff --git a/packages/agents/package.json b/packages/agents/package.json deleted file mode 100644 index 12d3e2f..0000000 --- a/packages/agents/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@tanstack/agents", - "version": "0.0.0", - "private": true, - "description": "TanStack-wide agent rules, subagents, and entrypoint skills", - "license": "MIT", - "type": "module" -} diff --git a/packages/agents/project.json b/packages/agents/project.json deleted file mode 100644 index f22ec05..0000000 --- a/packages/agents/project.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "agents", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "projectType": "library", - "sourceRoot": "packages/agents", - "targets": { - "build": { - "executor": "nx:run-commands", - "options": { - "command": "node -e \"process.stdout.write('build:noop\\n')\"" - } - }, - "test:eslint": { - "executor": "nx:run-commands", - "options": { - "command": "node -e \"process.stdout.write('test:eslint:noop\\n')\"" - } - }, - "test:types": { - "executor": "nx:run-commands", - "options": { - "command": "node -e \"process.stdout.write('test:types:noop\\n')\"" - } - }, - "test:lib": { - "executor": "nx:run-commands", - "options": { - "command": "node -e \"process.stdout.write('test:lib:noop\\n')\"" - } - } - } -} diff --git a/packages/agents/rules/safety.md b/packages/agents/rules/safety.md deleted file mode 100644 index ab853e6..0000000 --- a/packages/agents/rules/safety.md +++ /dev/null @@ -1,11 +0,0 @@ -# TanStack Safety - -Purpose: - -- - -Guidelines: - -- -- -- diff --git a/packages/agents/rules/staging-migration.md b/packages/agents/rules/staging-migration.md deleted file mode 100644 index fd5604d..0000000 --- a/packages/agents/rules/staging-migration.md +++ /dev/null @@ -1,11 +0,0 @@ -# Staging + Migration Rules (This Repo) - -Purpose: - -- - -Guidelines: - -- -- -- diff --git a/packages/agents/rules/style.md b/packages/agents/rules/style.md deleted file mode 100644 index ac3a391..0000000 --- a/packages/agents/rules/style.md +++ /dev/null @@ -1,11 +0,0 @@ -# TanStack Style - -Purpose: - -- - -Guidelines: - -- -- -- diff --git a/packages/agents/rules/tanstack-conventions.md b/packages/agents/rules/tanstack-conventions.md deleted file mode 100644 index 0b91757..0000000 --- a/packages/agents/rules/tanstack-conventions.md +++ /dev/null @@ -1,16 +0,0 @@ -# TanStack Conventions - -Purpose: - -- - -Guidelines: - -- -- -- - -IDs: - -- Skill IDs: `` -- Subagent IDs: `` diff --git a/packages/agents/skills/tanstack/SKILL.md b/packages/agents/skills/tanstack/SKILL.md deleted file mode 100644 index 76dd881..0000000 --- a/packages/agents/skills/tanstack/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ -# tanstack (Entrypoint Skill) - -Apply these rules first: - -- `packages/agents/rules/safety.md` -- `packages/agents/rules/style.md` -- `packages/agents/rules/tanstack-conventions.md` -- `packages/agents/rules/staging-migration.md` - -Select a library subagent based on the user task: - -- `packages/agents/subagents/.md` - -If a library skill bundle is installed, open: - -- `./.agents/skills//SKILL.md` diff --git a/packages/agents/subagents/tanstack-db.md b/packages/agents/subagents/tanstack-db.md deleted file mode 100644 index a75eaaa..0000000 --- a/packages/agents/subagents/tanstack-db.md +++ /dev/null @@ -1,20 +0,0 @@ -# tanstack-db (Subagent) - -Scope: - -- - -Optimize for: - -- -- -- - -Avoid: - -- -- - -When stuck: - -- diff --git a/packages/agents/subagents/tanstack-form.md b/packages/agents/subagents/tanstack-form.md deleted file mode 100644 index 27aed58..0000000 --- a/packages/agents/subagents/tanstack-form.md +++ /dev/null @@ -1,20 +0,0 @@ -# tanstack-form (Subagent) - -Scope: - -- - -Optimize for: - -- -- -- - -Avoid: - -- -- - -When stuck: - -- diff --git a/packages/agents/subagents/tanstack-query.md b/packages/agents/subagents/tanstack-query.md deleted file mode 100644 index f463a42..0000000 --- a/packages/agents/subagents/tanstack-query.md +++ /dev/null @@ -1,20 +0,0 @@ -# tanstack-query (Subagent) - -Scope: - -- - -Optimize for: - -- -- -- - -Avoid: - -- -- - -When stuck: - -- diff --git a/packages/agents/subagents/tanstack-router.md b/packages/agents/subagents/tanstack-router.md deleted file mode 100644 index ed1dea4..0000000 --- a/packages/agents/subagents/tanstack-router.md +++ /dev/null @@ -1,28 +0,0 @@ -# tanstack-router (Subagent) - -Scope: - -- Use for TanStack Router routing, data-loading, and error handling decisions. -- Apply when defining route trees, search params, loaders, or SSR constraints. -- Pull in Router skills for focused, API-level guidance. - -Optimize for: - -- End-to-end type safety across routes, params, search, and loader data. -- Loader-first data flows with predictable caching and prefetching. -- Clear nesting with localized error and not-found boundaries. - -Avoid: - -- Untyped search params or ad-hoc parsing outside validation. -- Fetching route-critical data in components instead of loaders. - -When stuck: - -- Which adapter and routing style (file-based vs code-based) are you using? -- What data must be loaded before render vs fetched after mount? -- Is this SSR/Start or client-only? - -Look up skills: - -- Use `@skills/router` to pick the right Router skill. diff --git a/packages/agents/subagents/tanstack-start.md b/packages/agents/subagents/tanstack-start.md deleted file mode 100644 index a5bfc4e..0000000 --- a/packages/agents/subagents/tanstack-start.md +++ /dev/null @@ -1,28 +0,0 @@ -# tanstack-start (Subagent) - -Scope: - -- Use for TanStack Start app architecture, SSR, and server function workflows. -- Apply when wiring Start entry points, adapters, middleware, and deployment targets. -- Reference `tanstack-router` for routing, loaders, and route tree structure. - -Optimize for: - -- Predictable SSR and streaming behavior with clear server/client boundaries. -- Secure server function usage with typed inputs and outputs. -- Cohesive Start setup that matches adapter and hosting constraints. - -Avoid: - -- Mixing server-only code into client bundles or routes. -- Duplicating router-level loader logic in client components. - -When stuck: - -- Which Start adapter and deployment target are you using? -- Is the work about routing/data loading (use `tanstack-router`)? -- What must run on the server vs the client for this flow? - -Look up skills: - -- Use `tanstack-router` guidance when routing is involved. diff --git a/packages/agents/subagents/tanstack-table.md b/packages/agents/subagents/tanstack-table.md deleted file mode 100644 index fa0b164..0000000 --- a/packages/agents/subagents/tanstack-table.md +++ /dev/null @@ -1,20 +0,0 @@ -# tanstack-table (Subagent) - -Scope: - -- - -Optimize for: - -- -- -- - -Avoid: - -- -- - -When stuck: - -- diff --git a/packages/agents/subagents/tanstack-virtual.md b/packages/agents/subagents/tanstack-virtual.md deleted file mode 100644 index 81d91b4..0000000 --- a/packages/agents/subagents/tanstack-virtual.md +++ /dev/null @@ -1,20 +0,0 @@ -# tanstack-virtual (Subagent) - -Scope: - -- - -Optimize for: - -- -- -- - -Avoid: - -- -- - -When stuck: - -- diff --git a/packages/db-playbook/package.json b/packages/db-playbook/package.json new file mode 100644 index 0000000..85d1c90 --- /dev/null +++ b/packages/db-playbook/package.json @@ -0,0 +1,9 @@ +{ + "name": "@tanstack/db-playbook", + "version": "0.0.1", + "type": "module", + "private": true, + "files": [ + "skills" + ] +} diff --git a/packages/db-playbook/skills/registry.json b/packages/db-playbook/skills/registry.json new file mode 100644 index 0000000..01c4062 --- /dev/null +++ b/packages/db-playbook/skills/registry.json @@ -0,0 +1,64 @@ +{ + "skills": [ + { + "name": "tanstack-db", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/collections", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/electric", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/live-queries", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/mutations", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/powersync", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/query", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/rxdb", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/schemas", + "package": "db", + "internal": false, + "description": "" + }, + { + "name": "tanstack-db/trailbase", + "package": "db", + "internal": false, + "description": "" + } + ] +} diff --git a/packages/db-playbook/skills/tanstack-db/SKILL.md b/packages/db-playbook/skills/tanstack-db/SKILL.md new file mode 100644 index 0000000..5ad8e7f --- /dev/null +++ b/packages/db-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 ( +
    + {todos?.map((todo) => ( +
  • toggleTodo(todo.id)}> + {todo.text} +
  • + ))} +
+ ) +} +``` + +## 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/db-playbook/skills/tanstack-db/collections/SKILL.md b/packages/db-playbook/skills/tanstack-db/collections/SKILL.md new file mode 100644 index 0000000..3c23a39 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/collections/references/custom-collections.md b/packages/db-playbook/skills/tanstack-db/collections/references/custom-collections.md new file mode 100644 index 0000000..92f7753 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/collections/references/electric-collection.md b/packages/db-playbook/skills/tanstack-db/collections/references/electric-collection.md new file mode 100644 index 0000000..fb7ee4a --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/collections/references/local-collections.md b/packages/db-playbook/skills/tanstack-db/collections/references/local-collections.md new file mode 100644 index 0000000..3ae69bc --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/collections/references/query-collection.md b/packages/db-playbook/skills/tanstack-db/collections/references/query-collection.md new file mode 100644 index 0000000..8cbeaba --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/collections/references/sync-modes.md b/packages/db-playbook/skills/tanstack-db/collections/references/sync-modes.md new file mode 100644 index 0000000..ef34a07 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/electric/SKILL.md b/packages/db-playbook/skills/tanstack-db/electric/SKILL.md new file mode 100644 index 0000000..9436f15 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/electric/references/debugging.md b/packages/db-playbook/skills/tanstack-db/electric/references/debugging.md new file mode 100644 index 0000000..dbaf399 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/electric/references/proxy-setup.md b/packages/db-playbook/skills/tanstack-db/electric/references/proxy-setup.md new file mode 100644 index 0000000..7e99d4b --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/electric/references/shapes.md b/packages/db-playbook/skills/tanstack-db/electric/references/shapes.md new file mode 100644 index 0000000..851e7f4 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/electric/references/txid-matching.md b/packages/db-playbook/skills/tanstack-db/electric/references/txid-matching.md new file mode 100644 index 0000000..02e2830 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/SKILL.md b/packages/db-playbook/skills/tanstack-db/live-queries/SKILL.md new file mode 100644 index 0000000..0713b55 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/aggregations.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/aggregations.md new file mode 100644 index 0000000..db1a865 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/functional-variants.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/functional-variants.md new file mode 100644 index 0000000..d801f85 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/joins.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/joins.md new file mode 100644 index 0000000..eb09eea --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/performance.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/performance.md new file mode 100644 index 0000000..90d10d9 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/query-builder.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/query-builder.md new file mode 100644 index 0000000..e67a584 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/live-queries/references/subqueries.md b/packages/db-playbook/skills/tanstack-db/live-queries/references/subqueries.md new file mode 100644 index 0000000..4bcab59 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/mutations/SKILL.md b/packages/db-playbook/skills/tanstack-db/mutations/SKILL.md new file mode 100644 index 0000000..3af07e3 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/mutations/references/error-handling.md b/packages/db-playbook/skills/tanstack-db/mutations/references/error-handling.md new file mode 100644 index 0000000..5af9863 --- /dev/null +++ b/packages/db-playbook/skills/tanstack-db/mutations/references/error-handling.md @@ -0,0 +1,248 @@ +# 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/db-playbook/skills/tanstack-db/mutations/references/handlers.md b/packages/db-playbook/skills/tanstack-db/mutations/references/handlers.md new file mode 100644 index 0000000..f050f52 --- /dev/null +++ b/packages/db-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/db-playbook/skills/tanstack-db/mutations/references/paced-mutations.md b/packages/db-playbook/skills/tanstack-db/mutations/references/paced-mutations.md new file mode 100644 index 0000000..ee5e2a3 --- /dev/null +++ b/packages/db-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 ( +