@tanstack/ai-persistence 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/blob-range.d.ts +51 -0
- package/dist/esm/blob-range.js +84 -0
- package/dist/esm/blob-range.js.map +1 -0
- package/dist/esm/capabilities.d.ts +5 -0
- package/dist/esm/capabilities.js +16 -0
- package/dist/esm/capabilities.js.map +1 -0
- package/dist/esm/index.d.ts +13 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/memory.d.ts +19 -0
- package/dist/esm/memory.js +319 -0
- package/dist/esm/memory.js.map +1 -0
- package/dist/esm/middleware.d.ts +252 -0
- package/dist/esm/middleware.js +872 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/reconstruct-generation.d.ts +129 -0
- package/dist/esm/reconstruct-generation.js +148 -0
- package/dist/esm/reconstruct-generation.js.map +1 -0
- package/dist/esm/reconstruct.d.ts +79 -0
- package/dist/esm/reconstruct.js +75 -0
- package/dist/esm/reconstruct.js.map +1 -0
- package/dist/esm/retrieve.d.ts +40 -0
- package/dist/esm/retrieve.js +54 -0
- package/dist/esm/retrieve.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +33 -0
- package/dist/esm/testkit/conformance.js +997 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/types.d.ts +554 -0
- package/dist/esm/types.js +103 -0
- package/dist/esm/types.js.map +1 -0
- package/package.json +71 -0
- package/skills/ai-persistence/SKILL.md +218 -0
- package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
- package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
- package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
- package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
- package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
- package/skills/ai-persistence/server/SKILL.md +210 -0
- package/skills/ai-persistence/stores/SKILL.md +485 -0
- package/src/blob-range.ts +101 -0
- package/src/capabilities.ts +18 -0
- package/src/index.ts +114 -0
- package/src/memory.ts +491 -0
- package/src/middleware.ts +1795 -0
- package/src/reconstruct-generation.ts +244 -0
- package/src/reconstruct.ts +149 -0
- package/src/retrieve.ts +77 -0
- package/src/testkit/conformance.ts +1288 -0
- package/src/types.ts +878 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-persistence/build-drizzle-adapter
|
|
3
|
+
description: Use when an app already runs Drizzle ORM and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing db handle, schema file, and drizzle-kit journal. Covers the four tables (SQLite/Postgres/MySQL), the onConflict idempotency rules, JSON columns, and per-request bindings like D1.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Drizzle Chat Persistence
|
|
7
|
+
|
|
8
|
+
The deliverable is **one file in the app** — `src/lib/chat-persistence.ts` —
|
|
9
|
+
exporting a `ChatPersistence` built from the app's existing Drizzle `db`. Plus
|
|
10
|
+
four tables added to the app's existing schema file and a migration generated
|
|
11
|
+
through the app's existing `drizzle-kit` setup.
|
|
12
|
+
|
|
13
|
+
Do not create a package, a second `db` instance, a migration runner, or a
|
|
14
|
+
`drizzle.config.ts`. The app has those.
|
|
15
|
+
|
|
16
|
+
Read the **Store Reference**
|
|
17
|
+
(`docs/persistence/store-reference.md`) for the store contracts and
|
|
18
|
+
invariants, and **ai-persistence/stores** for the shape rules. Every
|
|
19
|
+
store below mirrors the reference in-memory backend in
|
|
20
|
+
`@tanstack/ai-persistence` (`memory.ts`); the shared conformance testkit is the
|
|
21
|
+
proof.
|
|
22
|
+
|
|
23
|
+
## 1. Read the app before writing anything
|
|
24
|
+
|
|
25
|
+
| Find | Where to look | What it decides |
|
|
26
|
+
| ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
27
|
+
| Dialect | `drizzle.config.ts` `dialect:`, or the `drizzle-orm/*-core` import | `sqlite-core` vs `pg-core` vs `mysql-core` column builders |
|
|
28
|
+
| Schema file(s) | `drizzle.config.ts` `schema:` glob | Where the four tables go — append, never start a new file |
|
|
29
|
+
| The `db` handle | `src/db/index.ts`, `src/db.ts`, `src/server/db.ts` | Module singleton (`export const db`) vs factory (`getDb()`) |
|
|
30
|
+
| Migration flow | `drizzle.config.ts` `out:`, the `migrations/` or `drizzle/` journal | Which generate/apply commands to tell the user to run |
|
|
31
|
+
| Naming conventions | Existing tables in the schema file | Table prefix, var casing, `snake_case` column names |
|
|
32
|
+
| Import alias | `tsconfig.json` `paths` | `@/db`, `~/db`, `#/db/index`, or a relative path |
|
|
33
|
+
|
|
34
|
+
Match what is already there. If their tables are `chat_*`-prefixed and their
|
|
35
|
+
vars are camelCase, so are yours. If they already have a `messages` table for
|
|
36
|
+
something else, prefix — the store code reads database names off the table
|
|
37
|
+
objects, so any name works.
|
|
38
|
+
|
|
39
|
+
**Never invent a migration path.** Add the tables to their schema file, then
|
|
40
|
+
have them run their own commands (`npx drizzle-kit generate` then
|
|
41
|
+
`migrate`/`push`, or `wrangler d1 migrations apply` for D1). A parallel
|
|
42
|
+
migration table behind their back is how schemas drift.
|
|
43
|
+
|
|
44
|
+
## 2. Add the tables to their schema file
|
|
45
|
+
|
|
46
|
+
SQLite. JSON payloads use `text({ mode: 'json' })` so Drizzle round-trips
|
|
47
|
+
objects for you; timestamps are `integer` epoch ms.
|
|
48
|
+
|
|
49
|
+
```ts ignore
|
|
50
|
+
import {
|
|
51
|
+
index,
|
|
52
|
+
integer,
|
|
53
|
+
primaryKey,
|
|
54
|
+
sqliteTable,
|
|
55
|
+
text,
|
|
56
|
+
} from 'drizzle-orm/sqlite-core'
|
|
57
|
+
import type { ModelMessage, TokenUsage } from '@tanstack/ai'
|
|
58
|
+
import type { InterruptRecord, RunStatus } from '@tanstack/ai-persistence'
|
|
59
|
+
|
|
60
|
+
export const chatThreads = sqliteTable('chat_threads', {
|
|
61
|
+
threadId: text('thread_id').primaryKey(),
|
|
62
|
+
messagesJson: text('messages_json', { mode: 'json' })
|
|
63
|
+
.$type<Array<ModelMessage>>()
|
|
64
|
+
.notNull(),
|
|
65
|
+
updatedAt: integer('updated_at').notNull(),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const chatRuns = sqliteTable(
|
|
69
|
+
'chat_runs',
|
|
70
|
+
{
|
|
71
|
+
runId: text('run_id').primaryKey(),
|
|
72
|
+
threadId: text('thread_id').notNull(),
|
|
73
|
+
status: text('status').$type<RunStatus>().notNull(),
|
|
74
|
+
startedAt: integer('started_at').notNull(),
|
|
75
|
+
finishedAt: integer('finished_at'),
|
|
76
|
+
error: text('error'),
|
|
77
|
+
errorCode: text('error_code'),
|
|
78
|
+
usageJson: text('usage_json', { mode: 'json' }).$type<TokenUsage>(),
|
|
79
|
+
sandboxKey: text('sandbox_key'),
|
|
80
|
+
detachedSince: integer('detached_since'),
|
|
81
|
+
cancelRequested: integer('cancel_requested', { mode: 'boolean' }),
|
|
82
|
+
driverEpoch: integer('driver_epoch'),
|
|
83
|
+
},
|
|
84
|
+
(table) => [
|
|
85
|
+
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
|
|
86
|
+
index('chat_runs_status_detached').on(table.status, table.detachedSince),
|
|
87
|
+
// Powers listByThread and findActiveRun.
|
|
88
|
+
index('chat_runs_thread_started').on(table.threadId, table.startedAt),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
export const chatInterrupts = sqliteTable('chat_interrupts', {
|
|
93
|
+
interruptId: text('interrupt_id').primaryKey(),
|
|
94
|
+
runId: text('run_id').notNull(),
|
|
95
|
+
threadId: text('thread_id').notNull(),
|
|
96
|
+
status: text('status').$type<InterruptRecord['status']>().notNull(),
|
|
97
|
+
requestedAt: integer('requested_at').notNull(),
|
|
98
|
+
resolvedAt: integer('resolved_at'),
|
|
99
|
+
payloadJson: text('payload_json', { mode: 'json' })
|
|
100
|
+
.$type<Record<string, unknown>>()
|
|
101
|
+
.notNull(),
|
|
102
|
+
responseJson: text('response_json', { mode: 'json' }).$type<unknown>(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export const chatMetadata = sqliteTable(
|
|
106
|
+
'chat_metadata',
|
|
107
|
+
{
|
|
108
|
+
namespace: text('namespace').notNull(),
|
|
109
|
+
key: text('key').notNull(),
|
|
110
|
+
valueJson: text('value_json', { mode: 'json' }).$type<unknown>().notNull(),
|
|
111
|
+
},
|
|
112
|
+
(table) => [primaryKey({ columns: [table.namespace, table.key] })],
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`updatedAt` on threads is an app-owned extra, not part of any contract — the
|
|
117
|
+
stores never read columns they do not know about, so add `userId`, tenant ids,
|
|
118
|
+
or audit columns the same way (nullable or defaulted so inserts still succeed).
|
|
119
|
+
The `namespace` column is the `MetadataStore` first argument; the stock SQL in
|
|
120
|
+
the guide calls the same column `scope`.
|
|
121
|
+
|
|
122
|
+
`RunRecord.error` is a structured `RunError` (`{ message: string, code?: string }`),
|
|
123
|
+
so it gets two columns rather than one JSON blob: `error` for the provider's
|
|
124
|
+
prose and `errorCode` for the stable classification an operator filters and
|
|
125
|
+
groups by. `error` and `errorCode` always move together in `update`, so a
|
|
126
|
+
later code-less failure can never leave a stale `code` from an earlier one
|
|
127
|
+
behind.
|
|
128
|
+
|
|
129
|
+
**Postgres** (`drizzle-orm/pg-core`): `jsonb()` for the JSON payloads,
|
|
130
|
+
`bigint({ mode: 'number' })` for epoch-ms timestamps (including
|
|
131
|
+
`detachedSince`), `integer()` for `driverEpoch`, `boolean()` for
|
|
132
|
+
`cancelRequested`, `text()` elsewhere, composite `primaryKey` on
|
|
133
|
+
`(namespace, key)` unchanged. **MySQL**
|
|
134
|
+
(`drizzle-orm/mysql-core`): `json()`, `bigint({ mode: 'number' })`,
|
|
135
|
+
`boolean()` for `cancelRequested`, and `varchar(..., { length: 255 })` for the
|
|
136
|
+
primary-key columns. The store bodies below are identical across all three,
|
|
137
|
+
only `onConflictDoUpdate` becomes `onDuplicateKeyUpdate` on MySQL, and the
|
|
138
|
+
`(status, detachedSince)` / `(threadId, startedAt)` indexes carry over as is.
|
|
139
|
+
|
|
140
|
+
## 3. Write `src/lib/chat-persistence.ts`
|
|
141
|
+
|
|
142
|
+
The whole file. Idempotency is the entire game — the comments below mark the
|
|
143
|
+
rules the conformance suite checks.
|
|
144
|
+
|
|
145
|
+
```ts ignore
|
|
146
|
+
import { and, asc, desc, eq, isNotNull, lte } from 'drizzle-orm'
|
|
147
|
+
import { defineAIPersistence } from '@tanstack/ai-persistence'
|
|
148
|
+
import type { SQL } from 'drizzle-orm'
|
|
149
|
+
import type {
|
|
150
|
+
ChatPersistence,
|
|
151
|
+
InterruptRecord,
|
|
152
|
+
InterruptStore,
|
|
153
|
+
MessageStore,
|
|
154
|
+
MetadataStore,
|
|
155
|
+
RunRecord,
|
|
156
|
+
RunStore,
|
|
157
|
+
} from '@tanstack/ai-persistence'
|
|
158
|
+
|
|
159
|
+
import { db } from '@/db'
|
|
160
|
+
import {
|
|
161
|
+
chatInterrupts,
|
|
162
|
+
chatMetadata,
|
|
163
|
+
chatRuns,
|
|
164
|
+
chatThreads,
|
|
165
|
+
} from '@/db/schema'
|
|
166
|
+
|
|
167
|
+
type Db = typeof db
|
|
168
|
+
|
|
169
|
+
// Records omit absent optionals so they compare cleanly against the reference
|
|
170
|
+
// in-memory backend.
|
|
171
|
+
function mapRun(row: typeof chatRuns.$inferSelect): RunRecord {
|
|
172
|
+
return {
|
|
173
|
+
runId: row.runId,
|
|
174
|
+
threadId: row.threadId,
|
|
175
|
+
status: row.status,
|
|
176
|
+
startedAt: row.startedAt,
|
|
177
|
+
...(row.finishedAt != null ? { finishedAt: row.finishedAt } : {}),
|
|
178
|
+
...(row.error != null
|
|
179
|
+
? {
|
|
180
|
+
error: {
|
|
181
|
+
message: row.error,
|
|
182
|
+
...(row.errorCode != null ? { code: row.errorCode } : {}),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {}),
|
|
186
|
+
...(row.usageJson != null ? { usage: row.usageJson } : {}),
|
|
187
|
+
...(row.sandboxKey != null ? { sandboxKey: row.sandboxKey } : {}),
|
|
188
|
+
...(row.detachedSince != null ? { detachedSince: row.detachedSince } : {}),
|
|
189
|
+
...(row.cancelRequested != null
|
|
190
|
+
? { cancelRequested: row.cancelRequested }
|
|
191
|
+
: {}),
|
|
192
|
+
...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapInterrupt(
|
|
197
|
+
row: typeof chatInterrupts.$inferSelect,
|
|
198
|
+
): InterruptRecord {
|
|
199
|
+
return {
|
|
200
|
+
interruptId: row.interruptId,
|
|
201
|
+
runId: row.runId,
|
|
202
|
+
threadId: row.threadId,
|
|
203
|
+
status: row.status,
|
|
204
|
+
requestedAt: row.requestedAt,
|
|
205
|
+
payload: row.payloadJson,
|
|
206
|
+
...(row.resolvedAt != null ? { resolvedAt: row.resolvedAt } : {}),
|
|
207
|
+
...(row.responseJson != null ? { response: row.responseJson } : {}),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createMessageStore(db: Db): MessageStore {
|
|
212
|
+
return {
|
|
213
|
+
async loadThread(threadId) {
|
|
214
|
+
const rows = await db
|
|
215
|
+
.select({ messagesJson: chatThreads.messagesJson })
|
|
216
|
+
.from(chatThreads)
|
|
217
|
+
.where(eq(chatThreads.threadId, threadId))
|
|
218
|
+
.limit(1)
|
|
219
|
+
// Unknown thread is [], never null.
|
|
220
|
+
return rows[0]?.messagesJson ?? []
|
|
221
|
+
},
|
|
222
|
+
// Full overwrite — `messages` is the complete authoritative transcript.
|
|
223
|
+
async saveThread(threadId, messages) {
|
|
224
|
+
const updatedAt = Date.now()
|
|
225
|
+
await db
|
|
226
|
+
.insert(chatThreads)
|
|
227
|
+
.values({ threadId, messagesJson: messages, updatedAt })
|
|
228
|
+
.onConflictDoUpdate({
|
|
229
|
+
target: chatThreads.threadId,
|
|
230
|
+
set: { messagesJson: messages, updatedAt },
|
|
231
|
+
})
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createRunStore(db: Db): RunStore {
|
|
237
|
+
async function get(runId: string) {
|
|
238
|
+
const rows = await db
|
|
239
|
+
.select()
|
|
240
|
+
.from(chatRuns)
|
|
241
|
+
.where(eq(chatRuns.runId, runId))
|
|
242
|
+
.limit(1)
|
|
243
|
+
return rows[0] ? mapRun(rows[0]) : null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
get,
|
|
248
|
+
// Idempotent: an existing runId is returned untouched so resume and
|
|
249
|
+
// double-submit are safe.
|
|
250
|
+
async createOrResume({ runId, threadId, startedAt, status }) {
|
|
251
|
+
const existing = await get(runId)
|
|
252
|
+
if (existing) return existing
|
|
253
|
+
|
|
254
|
+
await db
|
|
255
|
+
.insert(chatRuns)
|
|
256
|
+
.values({ runId, threadId, status: status ?? 'running', startedAt })
|
|
257
|
+
.onConflictDoNothing({ target: chatRuns.runId })
|
|
258
|
+
|
|
259
|
+
// Re-read rather than trusting the insert: a concurrent createOrResume
|
|
260
|
+
// may have won the race, and that row is the authoritative one.
|
|
261
|
+
const stored = await get(runId)
|
|
262
|
+
return (
|
|
263
|
+
stored ?? { runId, threadId, status: status ?? 'running', startedAt }
|
|
264
|
+
)
|
|
265
|
+
},
|
|
266
|
+
// Patching an unknown runId is a no-op: never throws, never inserts.
|
|
267
|
+
async update(runId, patch) {
|
|
268
|
+
const set: Partial<typeof chatRuns.$inferInsert> = {}
|
|
269
|
+
if (patch.status !== undefined) set.status = patch.status
|
|
270
|
+
if (patch.finishedAt !== undefined) set.finishedAt = patch.finishedAt
|
|
271
|
+
// Both columns move together, so a later code-less failure cannot
|
|
272
|
+
// leave a stale errorCode from an earlier one behind.
|
|
273
|
+
if (patch.error !== undefined) {
|
|
274
|
+
set.error = patch.error.message
|
|
275
|
+
set.errorCode = patch.error.code ?? null
|
|
276
|
+
}
|
|
277
|
+
if (patch.usage !== undefined) set.usageJson = patch.usage
|
|
278
|
+
// The four durable-run fields use `'field' in patch`, NOT
|
|
279
|
+
// `!== undefined`: a reattach clears `detachedSince` by passing it
|
|
280
|
+
// explicitly as `undefined`, and that must still write NULL. Checking
|
|
281
|
+
// `!== undefined` cannot distinguish "clear this" from "didn't mention
|
|
282
|
+
// this", so it would silently drop the clear and leave the run looking
|
|
283
|
+
// permanently detached to the reaper. Same reasoning applies to
|
|
284
|
+
// `cancelRequested` (`false` is a meaningful value, not "unset").
|
|
285
|
+
if ('sandboxKey' in patch) set.sandboxKey = patch.sandboxKey ?? null
|
|
286
|
+
if ('detachedSince' in patch)
|
|
287
|
+
set.detachedSince = patch.detachedSince ?? null
|
|
288
|
+
if ('cancelRequested' in patch)
|
|
289
|
+
set.cancelRequested = patch.cancelRequested ?? null
|
|
290
|
+
if ('driverEpoch' in patch) set.driverEpoch = patch.driverEpoch ?? null
|
|
291
|
+
if (Object.keys(set).length === 0) return
|
|
292
|
+
|
|
293
|
+
await db.update(chatRuns).set(set).where(eq(chatRuns.runId, runId))
|
|
294
|
+
},
|
|
295
|
+
// Optional in the contract; enables reconnect without a client-held run id.
|
|
296
|
+
async findActiveRun(threadId) {
|
|
297
|
+
const rows = await db
|
|
298
|
+
.select()
|
|
299
|
+
.from(chatRuns)
|
|
300
|
+
.where(
|
|
301
|
+
and(eq(chatRuns.threadId, threadId), eq(chatRuns.status, 'running')),
|
|
302
|
+
)
|
|
303
|
+
.orderBy(desc(chatRuns.startedAt))
|
|
304
|
+
.limit(1)
|
|
305
|
+
return rows[0] ? mapRun(rows[0]) : null
|
|
306
|
+
},
|
|
307
|
+
// Optional; every run for the thread, ascending by startedAt. Uses the
|
|
308
|
+
// (threadId, startedAt) index.
|
|
309
|
+
async listByThread(threadId) {
|
|
310
|
+
const rows = await db
|
|
311
|
+
.select()
|
|
312
|
+
.from(chatRuns)
|
|
313
|
+
.where(eq(chatRuns.threadId, threadId))
|
|
314
|
+
.orderBy(asc(chatRuns.startedAt))
|
|
315
|
+
return rows.map(mapRun)
|
|
316
|
+
},
|
|
317
|
+
// Optional; still-running runs detached at or before the cutoff. Uses the
|
|
318
|
+
// (status, detachedSince) index. The cutoff is inclusive.
|
|
319
|
+
async listReclaimable({ now, ttlMs }) {
|
|
320
|
+
const cutoff = now - ttlMs
|
|
321
|
+
const rows = await db
|
|
322
|
+
.select()
|
|
323
|
+
.from(chatRuns)
|
|
324
|
+
.where(
|
|
325
|
+
and(
|
|
326
|
+
eq(chatRuns.status, 'running'),
|
|
327
|
+
isNotNull(chatRuns.detachedSince),
|
|
328
|
+
lte(chatRuns.detachedSince, cutoff),
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
return rows.map(mapRun)
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function createInterruptStore(db: Db): InterruptStore {
|
|
337
|
+
// Every listing is ordered by requestedAt ascending.
|
|
338
|
+
const listWhere = async (where: SQL | undefined) => {
|
|
339
|
+
const rows = await db
|
|
340
|
+
.select()
|
|
341
|
+
.from(chatInterrupts)
|
|
342
|
+
.where(where)
|
|
343
|
+
.orderBy(asc(chatInterrupts.requestedAt))
|
|
344
|
+
return rows.map(mapInterrupt)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
// Insert-if-absent: a duplicate create must never clobber a resolved
|
|
349
|
+
// interrupt back to pending.
|
|
350
|
+
async create(record) {
|
|
351
|
+
await db
|
|
352
|
+
.insert(chatInterrupts)
|
|
353
|
+
.values({
|
|
354
|
+
interruptId: record.interruptId,
|
|
355
|
+
runId: record.runId,
|
|
356
|
+
threadId: record.threadId,
|
|
357
|
+
status: 'pending',
|
|
358
|
+
requestedAt: record.requestedAt,
|
|
359
|
+
payloadJson: record.payload,
|
|
360
|
+
...(record.response !== undefined
|
|
361
|
+
? { responseJson: record.response }
|
|
362
|
+
: {}),
|
|
363
|
+
})
|
|
364
|
+
.onConflictDoNothing({ target: chatInterrupts.interruptId })
|
|
365
|
+
},
|
|
366
|
+
async resolve(interruptId, response) {
|
|
367
|
+
await db
|
|
368
|
+
.update(chatInterrupts)
|
|
369
|
+
.set({
|
|
370
|
+
status: 'resolved',
|
|
371
|
+
resolvedAt: Date.now(),
|
|
372
|
+
...(response !== undefined ? { responseJson: response } : {}),
|
|
373
|
+
})
|
|
374
|
+
.where(eq(chatInterrupts.interruptId, interruptId))
|
|
375
|
+
},
|
|
376
|
+
async cancel(interruptId) {
|
|
377
|
+
await db
|
|
378
|
+
.update(chatInterrupts)
|
|
379
|
+
.set({ status: 'cancelled', resolvedAt: Date.now() })
|
|
380
|
+
.where(eq(chatInterrupts.interruptId, interruptId))
|
|
381
|
+
},
|
|
382
|
+
async get(interruptId) {
|
|
383
|
+
const rows = await db
|
|
384
|
+
.select()
|
|
385
|
+
.from(chatInterrupts)
|
|
386
|
+
.where(eq(chatInterrupts.interruptId, interruptId))
|
|
387
|
+
.limit(1)
|
|
388
|
+
return rows[0] ? mapInterrupt(rows[0]) : null
|
|
389
|
+
},
|
|
390
|
+
list: (threadId) => listWhere(eq(chatInterrupts.threadId, threadId)),
|
|
391
|
+
listPending: (threadId) =>
|
|
392
|
+
listWhere(
|
|
393
|
+
and(
|
|
394
|
+
eq(chatInterrupts.threadId, threadId),
|
|
395
|
+
eq(chatInterrupts.status, 'pending'),
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
listByRun: (runId) => listWhere(eq(chatInterrupts.runId, runId)),
|
|
399
|
+
listPendingByRun: (runId) =>
|
|
400
|
+
listWhere(
|
|
401
|
+
and(
|
|
402
|
+
eq(chatInterrupts.runId, runId),
|
|
403
|
+
eq(chatInterrupts.status, 'pending'),
|
|
404
|
+
),
|
|
405
|
+
),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function createMetadataStore(db: Db): MetadataStore {
|
|
410
|
+
return {
|
|
411
|
+
async get(namespace, key) {
|
|
412
|
+
const rows = await db
|
|
413
|
+
.select({ valueJson: chatMetadata.valueJson })
|
|
414
|
+
.from(chatMetadata)
|
|
415
|
+
.where(
|
|
416
|
+
and(eq(chatMetadata.namespace, namespace), eq(chatMetadata.key, key)),
|
|
417
|
+
)
|
|
418
|
+
.limit(1)
|
|
419
|
+
return rows[0]?.valueJson ?? null
|
|
420
|
+
},
|
|
421
|
+
async set(namespace, key, value) {
|
|
422
|
+
// A JSON-mode column binds JS null as SQL NULL, which the NOT NULL
|
|
423
|
+
// column rejects with an opaque driver error. Fail clearly instead.
|
|
424
|
+
if (value == null) {
|
|
425
|
+
throw new TypeError(
|
|
426
|
+
`Cannot store ${value} for (${namespace}, ${key}) — use delete() to clear metadata.`,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
await db
|
|
430
|
+
.insert(chatMetadata)
|
|
431
|
+
.values({ namespace, key, valueJson: value })
|
|
432
|
+
.onConflictDoUpdate({
|
|
433
|
+
target: [chatMetadata.namespace, chatMetadata.key],
|
|
434
|
+
set: { valueJson: value },
|
|
435
|
+
})
|
|
436
|
+
},
|
|
437
|
+
async delete(namespace, key) {
|
|
438
|
+
await db
|
|
439
|
+
.delete(chatMetadata)
|
|
440
|
+
.where(
|
|
441
|
+
and(eq(chatMetadata.namespace, namespace), eq(chatMetadata.key, key)),
|
|
442
|
+
)
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The four chat state stores backed by the app's Drizzle database. */
|
|
448
|
+
export const chatPersistence: ChatPersistence = defineAIPersistence({
|
|
449
|
+
stores: {
|
|
450
|
+
messages: createMessageStore(db),
|
|
451
|
+
runs: createRunStore(db),
|
|
452
|
+
interrupts: createInterruptStore(db),
|
|
453
|
+
metadata: createMetadataStore(db),
|
|
454
|
+
},
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and
|
|
459
|
+
`withPersistence` rejects it. There is no `locks` store: `stores` accepts only
|
|
460
|
+
those four keys, and coordination is wired separately with `withLocks` (see
|
|
461
|
+
**ai-core/locks**).
|
|
462
|
+
|
|
463
|
+
### If `db` is per-request
|
|
464
|
+
|
|
465
|
+
Workers/D1 and any request-scoped client cannot read a binding at module scope.
|
|
466
|
+
Export a factory instead, and call it inside the handler:
|
|
467
|
+
|
|
468
|
+
```ts ignore
|
|
469
|
+
type Db = ReturnType<typeof getDb>
|
|
470
|
+
|
|
471
|
+
export function chatPersistence(): ChatPersistence {
|
|
472
|
+
const db = getDb()
|
|
473
|
+
return defineAIPersistence({
|
|
474
|
+
stores: {
|
|
475
|
+
messages: createMessageStore(db),
|
|
476
|
+
runs: createRunStore(db),
|
|
477
|
+
interrupts: createInterruptStore(db),
|
|
478
|
+
metadata: createMetadataStore(db),
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
The store factories are unchanged — only the export flips from a const to a
|
|
485
|
+
function. For D1 specifically, see
|
|
486
|
+
**ai-persistence/build-cloudflare-adapter**.
|
|
487
|
+
|
|
488
|
+
## 4. Wire it into the chat route
|
|
489
|
+
|
|
490
|
+
```ts ignore
|
|
491
|
+
import {
|
|
492
|
+
chat,
|
|
493
|
+
chatParamsFromRequest,
|
|
494
|
+
toServerSentEventsResponse,
|
|
495
|
+
} from '@tanstack/ai'
|
|
496
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
497
|
+
import { withPersistence } from '@tanstack/ai-persistence'
|
|
498
|
+
import { chatPersistence } from '@/lib/chat-persistence'
|
|
499
|
+
|
|
500
|
+
export async function POST(request: Request) {
|
|
501
|
+
const params = await chatParamsFromRequest(request)
|
|
502
|
+
const stream = chat({
|
|
503
|
+
adapter: openaiText('gpt-5.5'),
|
|
504
|
+
messages: params.messages,
|
|
505
|
+
threadId: params.threadId,
|
|
506
|
+
runId: params.runId,
|
|
507
|
+
...(params.resume ? { resume: params.resume } : {}),
|
|
508
|
+
middleware: [withPersistence(chatPersistence)],
|
|
509
|
+
})
|
|
510
|
+
return toServerSentEventsResponse(stream)
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`threadId` is a bare string to the stores. **Authorize thread access at the
|
|
515
|
+
route** — derive the user from the session, never trust a client-supplied id.
|
|
516
|
+
|
|
517
|
+
## 5. Verify
|
|
518
|
+
|
|
519
|
+
```ts ignore
|
|
520
|
+
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
|
|
521
|
+
import { chatPersistence } from '../src/lib/chat-persistence'
|
|
522
|
+
|
|
523
|
+
runPersistenceConformance('app-drizzle', () => chatPersistence, {
|
|
524
|
+
skip: ['generationRuns', 'artifacts', 'blobs'],
|
|
525
|
+
})
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Point it at a throwaway database (`:memory:` SQLite, a scratch schema, PGlite)
|
|
529
|
+
that has the migration applied, and reset between runs. The suite covers all
|
|
530
|
+
seven stores, so a chat adapter declares the generation half it omits; drop the
|
|
531
|
+
`skip` once you add those tables. `skip` never accepts `'locks'`, which is not a
|
|
532
|
+
store.
|
|
533
|
+
|
|
534
|
+
If your recipe leaves an optional `runs` method
|
|
535
|
+
(`listByThread`/`listReclaimable`) unimplemented, declare it
|
|
536
|
+
with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
|
|
537
|
+
omitted method that is not declared fails the suite instead of silently
|
|
538
|
+
passing.
|
|
539
|
+
|
|
540
|
+
## Only if you are publishing this as a package
|
|
541
|
+
|
|
542
|
+
Everything above assumes the file lives in the app. If instead you are shipping
|
|
543
|
+
a reusable `drizzle` adapter to npm, the same store bodies apply, plus:
|
|
544
|
+
|
|
545
|
+
- **Peer deps** `@tanstack/ai`, `@tanstack/ai-persistence`, `drizzle-orm >=0.44.0`;
|
|
546
|
+
dev dep `drizzle-kit`. Keep the module root free of Node built-ins so it is
|
|
547
|
+
edge-safe, and put any `node:sqlite` convenience factory behind a `/sqlite`
|
|
548
|
+
subpath.
|
|
549
|
+
- **Type `db` structurally** so a consumer's client is assignable:
|
|
550
|
+
`Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'select' | 'insert' | 'update' | 'delete'>`.
|
|
551
|
+
- **Multi-dialect**: take a `provider: 'sqlite' | 'pg'` option, declare
|
|
552
|
+
overloads so `db` and `schema` must agree, and add a runtime dialect check so
|
|
553
|
+
a mismatched pair fails at construction rather than on first query.
|
|
554
|
+
- **BYO schema**: accept `drizzlePersistence(db, { schema })`, validate the
|
|
555
|
+
tables/columns exist at construction, and pin the required column shapes with
|
|
556
|
+
a compile-time contract type.
|
|
557
|
+
- **Never bundle SQL migrations or a runner.** Either re-export the stock tables
|
|
558
|
+
from a `/sqlite-schema` subpath so the consumer's `drizzle-kit` picks them up,
|
|
559
|
+
or emit an owned starter schema file with a small CLI. An opt-in
|
|
560
|
+
`ensureTables(db)` issuing `CREATE TABLE IF NOT EXISTS` is fine for local dev,
|
|
561
|
+
kept clearly separate from their journal. Pick one DDL owner per database.
|
|
562
|
+
- Run `runPersistenceConformance` once per dialect.
|