lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-custom-adapter
Claude Code and agent skill by lukasa1993/tanstack-skills-extracted
- community
Checking that the SKILL.md file is still in the repository.
Use when an app needs TanStack AI chat persistence on a database with no dedicated recipe — raw Postgres (pg/postgres.js), Kysely, node:sqlite, MongoDB, Supabase, Redis. Writes a chat-persistence.ts against the app's existing client, covering the four stores, the idempotency invariants, and the conformance gate. Route to the Drizzle, Prisma, or Cloudflare skills instead when one of those matches.
Install
npx vskill@latest install lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-custom-adapterSKILL.md
Custom Chat Persistence
The deliverable is one file in the app — src/lib/chat-persistence.ts — exporting a ChatPersistence built from the database client the app already has. Plus whatever DDL that database needs, added through the app's existing migration flow.
Do not create a package, a second client, or a migration runner.
Always write all four tables (or collections) and all four stores: thread, run, interrupt, metadata. The docs call interrupts and metadata optional for a hand-rolled adapter; this recipe is not that case. withPersistence needs all four to type-check as ChatPersistence, and the conformance suite fails without them. Do not stop at a thread table and a run table.
Route first. If the app already runs one of these, stop and use that skill — it has the driver-specific code:
| App runs | Use | |
|---|---|---|
| Drizzle ORM (any dialect) | ai-persistence/build-drizzle-adapter | |
| Prisma | ai-persistence/build-prisma-adapter | |
| Cloudflare Workers + D1 | ai-persistence/build-cloudflare-adapter |
Everything else lands here. The full contracts and their invariants are in ai-persistence/stores and docs/persistence/store-reference.md; the complete worked node:sqlite walkthrough is docs/persistence/build-your-own-chat-adapter.md and examples/ts-react-chat/src/lib/sqlite-persistence.ts.
1. Read the app before writing anything
| Find | Where to look | What it decides | |
|---|---|---|---|
| The client | src/db.ts, src/lib/db.ts, src/server/db.ts | What the file imports — never construct a second pool | |
| Client lifetime | module singleton vs per-request factory (getDb(), bindings) | export const chatPersistence vs export function | |
| Migration flow | migrations/, drizzle/, supabase/migrations/, an ORM CLI | How the DDL gets applied — use theirs, add nothing new | |
| Naming conventions | existing tables/collections | Prefix (chat_*) so nothing collides | |
| JSON support | jsonb (Postgres), json (MySQL 5.7+), text (SQLite) | Whether mappers stringify/parse | |
| Import alias | tsconfig.json paths | @/db, ~/db, #/db, or a relative path |
2. Shape the storage
Four logical records. Whatever the engine, keep these keys — the store methods look records up by exactly these:
| Record | Key | Fields | |
|---|---|---|---|
| thread | threadId | messages (array, full transcript) | |
| run | runId | threadId, status, startedAt, finishedAt?, error?, usage?, parentRunId?, subagentRunId?, name?, sandboxKey?, detachedSince?, cancelRequested?, driverEpoch? | |
| interrupt | interruptId | runId, threadId, status, requestedAt, resolvedAt?, payload, response? | |
| metadata | (namespace, key) | value |
- Timestamps are epoch milliseconds (
number) in records. Store them
however the engine prefers and convert in the mapper.
(namespace, key)is a composite key. Never join with a separator —
('a:b','c') and ('a','b:c') must stay distinct records, and the conformance suite checks it.
- Index
runs(threadId, status),runs(threadId, startedAt), and
interrupts(threadId, requestedAt) for the listing paths. If the backend implements listReclaimable, also index runs(status, detachedSince). If it implements listByParentRun, also index runs(parentRunId, startedAt).
run.erroris a structuredRunError({ message: string, code?: string }),
not a bare string. message is the provider's prose; code is the stable, machine-branchable classification an operator filters and groups by. In a SQL-backed table, store it as two columns (error, error_code) rather than one JSON blob, moved together in update so a later code-less failure can never leave a stale code from an earlier one behind. run.status is one of 'running' | 'interrupted' | 'completed' | 'failed' | 'aborted'; 'interrupted' is a pause, not terminal, and only 'completed' | 'failed' | 'aborted' are terminal.
- Extra app-owned columns are fine (a
userId, audit columns) as long as they
are nullable or defaulted. The stores never read columns they do not know about.
3. The invariants
Getting one of these wrong is the usual source of stuck approvals and wiped history. They are engine-independent:
- `saveThread` is a full overwrite, never an append. The argument is the
complete authoritative transcript.
- `loadThread` returns `[]` for an unknown thread, never
null. - `createOrResume` is insert-if-absent — an existing
runIdcomes back
_unchanged_, ignoring the new field values. Resume and double-submit depend on it. After a racy insert, re-read rather than trusting your own write.
- `runs.update` on an unknown id is a silent no-op — it must not throw and
must not insert. (Drivers that throw on zero rows affected need the updateMany-style call, not the update-one-or-throw call.)
- **
runs.updatedistinguishes "field omitted" from "field explicitly
cleared" for the durable-run fields** (sandboxKey, detachedSince, cancelRequested, driverEpoch). A reattach clears detachedSince by passing it explicitly as undefined — update(runId, { detachedSince: undefined }) — and that must write NULL, not be silently dropped. Check 'detachedSince' in patch, never patch.detachedSince !== undefined; the latter cannot tell a clear from an omission and leaves every reattached run looking permanently detached to the reaper. Same rule for cancelRequested (false is a real value, not "unset") and for sandboxKey / driverEpoch. See examples/ts-react-chat/src/lib/sqlite-persistence.ts for the pattern.
- `interrupts.create` is insert-if-absent — never clobber a resolved
interrupt back to pending. Every list* is ordered by requestedAt ascending.
- `runs.listReclaimable` uses an inclusive cutoff (if implemented):
status === 'running' AND detachedSince <= now - ttlMs. It is a query, not automatic reclamation: reapDetachedRuns from @tanstack/ai-sandbox is the sweep that consumes it, and the application schedules that sweep. A store without this method cannot be reaped. runs.findActiveRun is required; runs.listByThread, runs.listByParentRun, and runs.listReclaimable are optional: implement only what the app needs and leave the rest off the object. listByParentRun returns the child runs for one parentRunId, oldest startedAt first. reconstructChat uses that list to put subagent cards back. createOrResume copies parentRunId, subagentRunId, and name on the first insert and leaves them unchanged on resume.
Row mappers omit absent optionals (...(row.sandbox_key != null ? { sandboxKey: row.sandbox_key } : {})) so records compare cleanly against the reference in-memory backend. For a two-column error/error_code layout, the mapper is ...(row.error != null ? { error: { message: row.error, ...(row.error_code != null ? { code: row.error_code } : {}) } } : {}).
4. Write src/lib/chat-persistence.ts
Four factories and one assembly. Postgres via pg shown here; the shape is the same for any driver.
<!-- fence: custom-chat-persistence -->
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 100 of 100Pattern checks on the skill's files.
- Model reviewNot runA model review is scheduled for the next rescan.
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/tanstack-ai-persistence-build-custom-adapter/SKILL.md
Popularity
- 7-day trend score
- 0
Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-persistence-build-custom-adapter)