CtrlK
BlogDocsLog inGet started
Tessl Logo

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, 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.

SKILL.md
Quality
Evals
Security

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

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
  },
})
ShapeContents
ChatTranscriptPersistencemessages (+ optional runs/interrupts/metadata)
ChatWithInterruptsPersistencemessages + runs + interrupts
ChatPersistenceall 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 keysmessages, 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

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

interface RunStore {
  createOrResume(input: {
    runId: string
    threadId: string
    status?: RunStatus
    startedAt: number
  }): Promise<RunRecord>
  update(
    runId: string,
    patch: Partial<
      Pick<RunRecord, 'status' | 'finishedAt' | 'error' | 'usage'>
    >,
  ): Promise<void>
  get(runId: string): Promise<RunRecord | null>
  findActiveRun(threadId: string): Promise<RunRecord | null>
}
  • createOrResume: if runId exists, return it unchanged (ignore new fields). Idempotent retries / resume depend on this.
  • update: missing runId is a no-op (do not throw, do not insert).
  • findActiveRun: latest 'running' for threadId (max startedAt); this is what reconstructChat uses to reconnect a reloading client 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.

Every method is required. Capability tiers live at the store level (omit runs and declare ChatTranscriptStores), never at the method level.

InterruptStore

interface InterruptStore {
  create(record: Omit<InterruptRecord, 'status' | 'resolvedAt'>): Promise<void>
  resolve(interruptId: string, response?: unknown): Promise<void>
  cancel(interruptId: string): Promise<void>
  get(interruptId: string): Promise<InterruptRecord | null>
  list(threadId: string): Promise<Array<InterruptRecord>>
  listPending(threadId: string): Promise<Array<InterruptRecord>>
  listByRun(runId: string): Promise<Array<InterruptRecord>>
  listPendingByRun(runId: string): Promise<Array<InterruptRecord>>
}
  • create always births 'pending'; insert-if-absent on interruptId (never clobber resolved back to pending).
  • All list* ordered by requestedAt ascending.
  • Requires a runs store when used with chat persistence.

MetadataStore

interface MetadataStore {
  get(namespace: string, key: string): Promise<unknown | null>
  set(namespace: string, key: string, value: unknown): Promise<void>
  delete(namespace: string, key: string): Promise<void>
}
  • The first argument is an app-defined namespace string, not the Scope identity type — despite SQL backends conventionally naming the column scope.
  • Identity is two fields (namespace, key) — do not join with : (('a:b','c') and ('a','b:c') must stay distinct).
  • Stored null is type-indistinguishable from absence; wrap if you must persist real null ({ value: null }).
  • SQL backends usually reject nullish set (NOT NULL JSON columns) with a clear TypeError — match that or document your semantics.

Timestamp convention

Store records (RunRecord, InterruptRecord) speak epoch milliseconds (number). Wire/result references that leave the persistence layer speak ISO-8601 strings; the middleware converts at the boundary. Do not mix the two on one field.

Minimal message store example

Type each store with its define*Store helper (defineMessageStore, defineRunStore, defineInterruptStore, defineMetadataStore): pass the object literal and get autocomplete + contract checking inline, with no : MessageStore annotation. The result composes into defineAIPersistence with exact presence.

import { defineMessageStore } from '@tanstack/ai-persistence'
import type { ModelMessage } from '@tanstack/ai'

const threads = new Map<string, Array<ModelMessage>>()

export const messages = defineMessageStore({
  async loadThread(threadId) {
    return [...(threads.get(threadId) ?? [])]
  },
  async saveThread(threadId, next) {
    threads.set(threadId, [...next])
  },
})

For durable DBs, preserve the same semantics with upserts / full-row replace.

Adopt part of it

You rarely need all four stores in the same system. Implement the ones you own and fill the rest from another base with composePersistence:

import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence'
import { messages, runs } from './my-postgres-stores'

export const persistence = composePersistence(memoryPersistence(), {
  overrides: { messages, runs },
})

Only listed keys move; others stay on the base. Pass false to drop a store. There is no cross-store transaction — if messages lives in Postgres and interrupts in Redis, a write touching both is two writes. The store invariants (idempotent createOrResume, insert-if-absent create) are exactly what make those retries safe.

composePersistence accepts the four state keys. Locks and sandbox instance maps are not composable here.

Map onto an existing schema

  • Your column names, your types. Name columns anything; use jsonb, timestamptz, whatever — convert in the row mapper. The record shape the methods return is fixed; how you store it is not.
  • Extra columns are fine. Add user_id, audit columns, a tenant id. Keep them nullable or defaulted so the store's inserts still succeed. The stores never read or write columns they do not know about.
  • Omit absent optionals in row mappers (...(row.error != null ? { error: row.error } : {})) so records compare cleanly.

Authorization

Store methods take bare threadIds. Authorize at the route before loadThread / saveThread / reconstructChat({ authorize }). Derive user identity from session, not the client body alone.

Conformance tests (required)

import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { myPersistence } from '../src/persistence'

runPersistenceConformance('my-backend', () => myPersistence())

// Declare intentional omissions — only the four state stores are valid keys:
// runPersistenceConformance('msgs-only', () => p, {
//   skip: ['runs', 'interrupts', 'metadata'],
// })

The testkit is the compatibility gate: round-trips, rich message shapes, empty-thread [], createOrResume idempotency, interrupt insert-if-absent, list ordering, composite-key non-aliasing. A missing store that is not listed in skip fails loudly.

skip accepts only 'messages' | 'runs' | 'interrupts' | 'metadata'. Do not pass 'locks' — it is not a state store and the suite does not cover it.

Reference implementation: memoryPersistence() in @tanstack/ai-persistence.

Common mistakes

CRITICAL: Append-only saveThread

Breaks the authoritative-history contract.

CRITICAL: createOrResume overwriting existing runs

Breaks safe resume / double-submit.

CRITICAL: Interrupt create upserting to pending

Can resurrect a resolved approval.

HIGH: Returning bare AIPersistence from the factory

withPersistence rejects it. Annotate a named shape.

HIGH: list* without stable requestedAt order

Middleware and tests assume ascending order.

HIGH: Skipping the testkit

Silent semantic drift shows up as stuck approvals or wiped history in prod.

Cross-references

  • ai-persistence/server — when middleware calls each store
  • ai-persistence/build-drizzle-adapter / -prisma- / -cloudflare- / -custom- — per-stack recipes
  • ai-core/locks — not a state store
Repository
TanStack/ai
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.