Skip to main content

ai-persistence-stores

Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts for @tanstack/ai-persistence against any database. defineAIPersistence, composePersistence overrides, critical invariants (full-replace saveThread, optional loadThread limit/before hint, insert-if-absent createOrResume and interrupt create), authorize thread access, runPersistenceConformance testkit. Use whenever you need server persistence — the package ships contracts, not a backend for your database.

Quellinformationen

Repository
TanStack/ai
Letzte Quellaktivität
24. September 2026 um 09:20
Erkannte Sprache von SKILL.md
Englisch
Sterne
3.199
Forks
366

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
ai-persistence/stores
description
Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts for @tanstack/ai-persistence against any database. defineAIPersistence, composePersistence overrides, critical invariants (full-replace saveThread, optional loadThread limit/before hint, insert-if-absent createOrResume and interrupt create), authorize thread access, runPersistenceConformance testkit. Use whenever you need server persistence — the package ships contracts, not a backend for your database.
type
sub-skill
library
tanstack-ai
library_version
0.0.0
sources
["TanStack/ai:docs/persistence/store-reference.md","TanStack/ai:docs/persistence/controls.md","TanStack/ai:packages/ai-persistence/src/types.ts"]
# Persistence Stores > Builds on **ai-persistence** and **ai-persistence/server**. `@tanstack/ai-persistence` ships **contracts**, not a backend for your database. An adapter is an object with a `stores` map; implement the stores you need against whatever you already run and hand the result to `withPersistence`. The core never inspects your tables, so the schema is yours. Use `memoryPersistence()` for dev and tests. Everything durable is an adapter you write. This skill is the contract reference; the per-stack recipes that write a `chat-persistence.ts` into an app are `ai-persistence/build-{drizzle,prisma,cloudflare,custom}-adapter`, and a complete `node:sqlite` implementation lives in `examples/ts-react-chat/src/lib/sqlite-persistence.ts`. ## Choose a shape ```ts import { defineAIPersistence } from '@tanstack/ai-persistence' import type { ChatWithInterruptsPersistence } from '@tanstack/ai-persistence' import { messages, runs, interrupts } from './stores' // Sparse is fine — only implement what you need. export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({ stores: { messages, // required for withPersistence / reconstructChat runs, // required if you have interrupts interrupts, // metadata optional }, }) ``` | Shape | Contents | | ------------------------------- | ------------------------------------------------ | | `ChatTranscriptPersistence` | `messages` (+ optional runs/interrupts/metadata) | | `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` | | `ChatPersistence` | all four chat stores | `defineAIPersistence` preserves exact keys and rejects unknown keys at runtime. **Annotate your factory with a named shape.** Bare `AIPersistence` is the all-optional sparse bag, so `withPersistence` and `reconstructChat` reject it (`stores.messages` is possibly `undefined`). This is the single most common mistake when writing an adapter. **`stores` accepts exactly four keys** — `messages`, `runs`, `interrupts`, `metadata`. Anything else (notably `locks` or sandbox instance maps) throws `Unknown AIPersistence store key` at runtime and fails to type-check. Locks: **ai-core/locks** / `@tanstack/ai/locks`. Sandbox instance resume: `@tanstack/ai-sandbox`. ## Contracts and invariants ### `MessageStore` ```ts import type { ModelMessage } from '@tanstack/ai' interface MessagePage { messages: Array<ModelMessage> truncated: boolean cursor?: string } interface MessageStore { loadThread: ( threadId: string, options?: { limit?: number; before?: string }, ) => Promise<Array<ModelMessage> | MessagePage> saveThread: (threadId: string, messages: Array<ModelMessage>) => Promise<void> } ``` - Call `loadThread` with only `threadId` and return the full array (`[]` for unknown threads, never `null`). Never a `MessagePage`. - `limit` and `before` are an optional hydrate hint. Ignore them and return the full array, or return a `MessagePage`. `before` is opaque. You mint the cursor. - `saveThread` is a **full replace** of the merged list, not append. Merge by id is `withPersistence`, not this store. ### `RunStore` `RunStatus`, `TerminalRunStatus`, `RunRecord`, `RunStore`, `defineRunStore`, and `isTerminalRunStatus` are defined in `@tanstack/ai` and re-exported from `@tanstack/ai-persistence`. Import those from either; the recipes in this skill import from `@tanstack/ai-persistence` so an adapter author needs only one package name. **`RunError` is the exception — it is NOT re-exported.** Import it from `@tanstack/ai` directly (`import type { RunError } from '@tanstack/ai'`); the `@tanstack/ai-persistence` barrel has no such export and the import fails to resolve. Four methods are required (`createOrResume` / `update` / `get` / `findActiveRun`). Three are optional: implement only the ones your backend needs, and leave the rest off the object entirely (not `undefined`, just absent). A four-method `RunStore` is a fully valid backend. `withPersistence` calls `createOrResume` and `update`. The query methods have other consumers. Each missing optional method disables one feature: | method | consumer | absent means | | ----------------- | --------------------------------------------------------- | ---------------------------------------------------------- | | `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required. A `null` stub hides a live run | | `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all | | `listByThread` | `reconstructChat`, when the transcript has tool calls | the cards of children that a tool call started stay absent | | `listByParentRun` | `reconstructChat` | a reload shows the saved text, and the cards stay absent | Consumers of the optional methods feature-detect with `store.method?.(...)` and degrade rather than throwing. `findActiveRun` is required, so nothing feature-detects it. The conformance testkit does not feature-detect. An optional method that is missing and not declared in `skipMethods` fails the suite, so an omission is always a choice you made on purpose rather than a check that quietly did not run. The one exception is `listByParentRun`: subagent support is optional, so those checks skip on their own. Declare yours and the suite reports them as skipped with a reason: ```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { persistence } from './persistence' // The shipped sqlite example implements findActiveRun and listReclaimable and // declares only the one it omits. runPersistenceConformance('sqlite', () => persistence, { skipMethods: ['runs.listByThread'], }) ``` ```ts import type { RunRecord, RunStatus } from '@tanstack/ai-persistence' interface RunStore { // Required createOrResume: ( input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & { status?: RunStatus parentRunId?: string subagentRunId?: string name?: string }, ) => Promise<RunRecord> update: ( runId: string, patch: Partial< Pick< RunRecord, | 'status' | 'finishedAt' | 'error' | 'usage' | 'sandboxKey' | 'detachedSince' | 'cancelRequested' | 'driverEpoch' > >, ) => Promise<void> get: (runId: string) => Promise<RunRecord | null> findActiveRun: (threadId: string) => Promise<RunRecord | null> // Optional listByThread?: (threadId: string) => Promise<Array<RunRecord>> listByParentRun?: (parentRunId: string) => Promise<Array<RunRecord>> listReclaimable?: (opts: { now: number ttlMs: number }) => Promise<Array<RunRecord>> } ``` `RunStatus` is `'running' | 'interrupted' | 'completed' | 'failed' | 'aborted'`. `'interrupted'` is a human-in-the-loop pause, not terminal: it is what interrupt-resume continues from, and must never be conflated with `'aborted'` (an explicit cancellation). `TerminalRunStatus` narrows to `'completed' | 'failed' | 'aborted'`. `isTerminalRunStatus(status)` is a type predicate: `(status: RunStatus) => status is TerminalRunStatus`, so calling it inside a guard narrows `status` to `TerminalRunStatus` for the rest of that branch, with no cast needed. `RunRecord.usage` is optional. `withPersistence` sums reported numeric fields across provider calls for that `runId`, while opaque `providerUsageDetails` retains the latest reported bag. Known usage is persisted on interruption and every terminal status. `RunRecord.error` is a structured `RunError`, not a bare string: ```ts interface RunError { message: string code?: string } ``` `message` is the provider's prose (it changes between model versions and cannot be branched on); `code` is the stable, machine-branchable classification a consumer switches over to retry, escalate, or show specific UI. Store both, and omit `code` from a mapped record when its column is `null` rather than writing `code: undefined` (`...(row.errorCode != null ? { code: row.errorCode } : {})`). `defineRunStore<const T extends RunStore>(store: T): T` returns the passed object's own type, so an optional method your store implements (say, `listByThread`) stays known-present on the returned value instead of widening back to `RunStore`'s `| undefined`. You get autocomplete and contract checking without a separate `: RunStore` annotation, and without a feature-detection guard on your own return value. #### The durable-agent-runs fields: `sandboxKey`, `detachedSince`, `cancelRequested`, `driverEpoch` These four `RunRecord` fields exist for the sandbox/durable-run layer to reattach a run a client disconnected from, and for out-of-band cancellation. A `RunStore` you write must round-trip all four through `update` → `get`, even if your app does not use sandboxes yet — the conformance testkit checks this unconditionally (it is not behind `skipMethods`, because `update`/`get` are REQUIRED methods). - **`sandboxKey`** — compound key identifying the sandbox this run is bound to, so a reclaimer can find it to tear down. - **`detachedSince`** — epoch ms when the last viewer detached; absent while someone is attached. Read by `listReclaimable`. - **`cancelRequested`** — set by an explicit out-of-band cancel (see below), distinct from a mere disconnect. - **`driverEpoch`** — monotonic fencing token, bumped by each host that claims the run, so a superseded host can discover it lost by comparing the stored value against the one it holds. **Fresh-run reads must be `undefined`, not a coerced falsy default.** A backend that reads a `NULL`/absent column back as `cancelRequested: false` or `driverEpoch: 0` is claiming knowledge it does not have ("explicitly not cancelled") — that is a different fact from "never set". Omit the field from the mapped record instead (`...(row.cancel_requested != null ? { cancelRequested: row.cancel_requested !== 0 } : {})`). **`update` must use `'field' in patch`, not `patch.field !== undefined`, for these four.** A caller clears `detachedSince` on reattach by passing it explicitly as `undefined` — `store.update(runId, { detachedSince: undefined })` — and that must write `NULL`, not be filtered out of the write. Checking `!== undefined` cannot tell "clear this field" apart from "I didn't mention this field", so it silently drops the clear and the run looks permanently detached to the reaper forever after. `'detachedSince' in patch` is `true` for an explicit `undefined` and `false` when the caller omitted the key entirely — that is the distinction you need. The same applies to `cancelRequested` (`false` is a real, meaningful value, not "unset") and to `sandboxKey` / `driverEpoch`. See `examples/ts-react-chat/src/lib/sqlite-persistence.ts` for a worked implementation of exactly this pattern. #### Out-of-band cancellation Cancel intent is **never inferred from a disconnect** — a user pressing Stop and a user closing the tab produce an identical connection close, so the two are indistinguishable from the abort alone. `@tanstack/ai` exports the actual primitives: - **`requestRunCancel`** — records durable cancel intent (writes `cancelRequested: true` through a `RunStore`), for a run being driven on a host other than the one handling the cancel request. - **`wasCancelRequested`** — reads that intent back. - **`RUN_CANCEL_REASON`** — the well-known abort reason string used for the in-process case (the same host aborting its own signal), paired with `isCancelRequestedReason` to check for it. A `RunStore` you write does not call these directly — they operate on your store through `update`/`get` — but `cancelRequested` must round-trip faithfully (previous section) for the durable path to work at all. - **`createOrResume`** (required): if `runId` exists, return it **unchanged**, including its stored `usage`, and ignore the passed `threadId`, `startedAt`, `status`, `parentRunId`, `subagentRunId`, and `name`. Resuming a run does not reset `startedAt` or overwrite its current status. Idempotent retries and double-submit depend on this. `status` defaults to `'running'` on first creation. The three link fields are copied only on the first insert. - **`update`** (required): missing `runId` is a **no-op** (do not throw, do not insert). - **`get`** (required): current record, or `null` when unknown. - **`listByThread`** (optional): every run for `threadId`, ascending by `startedAt`. `reconstructChat` calls it to find the parent runs of children that a tool call started. - **`listByParentRun`** (optional): child runs for `parentRunId`, ascending by `startedAt`. `reconstructChat` uses this list to put subagent cards back. Omit the method and a reload shows the saved text. The cards stay absent. - **`listReclaimable`** (optional): runs where `status === 'running'` AND `detachedSince` is set AND `detachedSince <= now - ttlMs`. The cutoff is inclusive: a run detached exactly at the cutoff qualifies. This is a query, not automatic behavior — the consumer is `reapDetachedRuns` from `@tanstack/ai-sandbox`, which the application schedules itself (cron, queue, `alarm()`, `waitUntil`), so returning this list has no side effect until that sweep runs. `detachedSince` is written for you by `withSandbox`'s detach path (alongside `sandboxKey`) and cleared by the takeover path; drop either field and nothing can reclaim the sandbox. `cancelRequested` is written by `requestRunCancel` and read by `wasCancelRequested`, and the reaper's expiry path goes through `requestRunCancel` to stop a run past its TTL. - **`findActiveRun`** (**required**): the most recent `'running'` run for `threadId` (max `startedAt`), or `null` if none is active. Enables reconnect from a stable thread id without a client-held run id. Stub it out and reconnect silently stops working — `null` is also the correct answer for an idle thread, so nothing can detect the difference. It was optional for exactly one release cycle and cost precisely that, which is why it is required now. Capability tiers belong at the STORE level (omit `runs` entirely and declare `ChatTranscriptStores`), not the method level. Never ship a `RunStore` with a stubbed method. The list queries above are the only method-level options, and each must be declared via `skipMethods` when absent, except `listByParentRun`: without it the subagent checks skip on their own. A subagent child run also stores `parentRunId`, `subagentRunId`, and `name`. `createOrResume` writes them on the first insert. A later call for the same `runId` leaves them unchanged. If the caller omits a field, omit it on the record. Do not store `''` for a missing field. ### `InterruptStore`
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen