Skip to main content
ClaudeWave
Skill3.1k repo starsupdated 2d ago

ai-persistence/stores

>

Install in Claude Code
Copy
git clone --depth 1 https://github.com/TanStack/ai /tmp/ai-persistence-stores && cp -r /tmp/ai-persistence-stores/packages/ai-persistence/skills/ai-persistence/stores ~/.claude/skills/ai-persistence-stores
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# 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'

// 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
interface MessageStore {
  loadThread(threadId: string): Promise<Array<ModelMessage>>
  saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
}
```

- `loadThread` → `[]` for unknown threads (never `null`).
- `saveThread` is a **full overwrite**, not append. A one-message payload wipes history.

### `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`). Two 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` itself calls **none** of the three non-`createOrResume`/`update`
query methods, so leaving both optional ones off costs nothing in the middleware.
Their consumers are elsewhere, and each absence disables exactly one feature:

| method            | consumer                                                  | absent ⇒                                                                 |
| ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
| `findActiveRun`   | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required — cannot be absent; stubbing it to `null` silently kills rejoin |
| `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox`              | the store cannot be reaped at all                                        |
| `listByThread`    | application code — nothing in the framework calls it      | nothing framework-side breaks                                            |

Consumers of the two 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. Declare yours and the suite reports them as skipped with a reason:

```ts
// The shipped sqlite example implements findActiveRun and listReclaimable and
// declares only the one it omits.
runPersistenceConformance('sqlite', () => persistence, {
  skipMethods: ['runs.listByThread'],
})
```

```ts
interface RunStore {
  // Required
  createOrResume(
    input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
      status?: RunStatus
    },
  ): 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>>
  listReclaimable?(opts: {
    now: number
    ttlMs: number