ai-persistence/stores
GitHub定义AI持久化存储契约,支持消息、运行记录、中断及元数据存储。提供形状选择与关键不变量说明,指导开发者实现自定义数据库适配器以替代内置后端。
触发场景
安装
npx skills add TanStack/ai --skill ai-persistence/stores -g -y
SKILL.md
Frontmatter
{
"name": "ai-persistence\/stores",
"type": "sub-skill",
"library": "tanstack-ai",
"sources": [
"TanStack\/ai:docs\/persistence\/build-your-own-adapter.md",
"TanStack\/ai:docs\/persistence\/controls.md",
"TanStack\/ai:packages\/ai-persistence\/src\/types.ts"
],
"description": "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.\n",
"library_version": "0.0.0"
}
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
},
})
| 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
interface MessageStore {
loadThread(threadId: string): Promise<Array<ModelMessage>>
saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
}
loadThread→[]for unknown threads (nevernull).saveThreadis 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: ifrunIdexists, return it unchanged (ignore new fields). Idempotent retries / resume depend on this.update: missingrunIdis a no-op (do not throw, do not insert).findActiveRun: latest'running'forthreadId(maxstartedAt); this is whatreconstructChatuses to reconnect a reloading client without a client-held run id. Stub it out and reconnect silently stops working —nullis 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>>
}
createalways births'pending'; insert-if-absent oninterruptId(never clobber resolved back to pending).- All
list*ordered byrequestedAtascending. - Requires a
runsstore 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
Scopeidentity type — despite SQL backends conventionally naming the columnscope. - Identity is two fields
(namespace, key)— do not join with:(('a:b','c')and('a','b:c')must stay distinct). - Stored
nullis 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 clearTypeError— 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. The suite covers all seven stores, so a
// chat-only backend skips the generation half:
// runPersistenceConformance('chat-only', () => p, {
// skip: ['generationRuns', 'artifacts', 'blobs'],
// })
// `skip` never accepts 'locks' — locks are not a store.
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
版本历史
-
1cb04d5
当前 2026-07-31 17:39
新增客户端生成持久化功能,支持媒体生成的只读恢复快照;重构API以复用共享存储契约,优化Web存储适配器的泛型推断与默认值处理。
- 05280a5 2026-07-30 23:52


