@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,518 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-persistence/build-prisma-adapter
|
|
3
|
+
description: Use when an app already runs Prisma and needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its existing PrismaClient and schema.prisma. Covers the four models, BigInt timestamps, JSON-as-string columns, upsert-with-empty-update idempotency, and model renaming.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Prisma 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 `PrismaClient`. Plus
|
|
10
|
+
four models added to the app's existing `schema.prisma` and a migration created
|
|
11
|
+
with the app's own `prisma migrate`.
|
|
12
|
+
|
|
13
|
+
Do not create a package, a second client, a datasource block, a generator, or a
|
|
14
|
+
hand-written SQL migration. 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
|
+
| Schema location | `prisma/schema.prisma`, or a multi-file `prisma/schema/` dir | Append to the existing file, or add one new `.prisma` file |
|
|
28
|
+
| Provider | the `datasource` block | Whether `Json` is available; nothing else changes |
|
|
29
|
+
| Client singleton | `src/lib/prisma.ts`, `src/db.ts`, `globalThis` dev cache | What `chat-persistence.ts` imports — never `new PrismaClient()` |
|
|
30
|
+
| Generated client | the `generator client` block (`output`, `prisma-client-js` vs `prisma-client`) | Where `ChatRun`/`ChatInterrupt` row types come from |
|
|
31
|
+
| Existing model names | the schema | Whether `Message`/`Run` are taken — prefix if so |
|
|
32
|
+
| Migration flow | `prisma/migrations/`, or `db push` in scripts | `prisma migrate dev` vs `prisma db push` |
|
|
33
|
+
|
|
34
|
+
Prisma 6 and 7 both work: the delegate query API (`findUnique`, `upsert`,
|
|
35
|
+
`update`, `findMany`, `delete`) is unchanged, so it does not matter which
|
|
36
|
+
client the app generated.
|
|
37
|
+
|
|
38
|
+
**Never invent a migration path.** Add the models, then have the user run their
|
|
39
|
+
own `npx prisma migrate dev --name chat-persistence` (or `db push`) and
|
|
40
|
+
`prisma generate`.
|
|
41
|
+
|
|
42
|
+
## 2. Add the models to their schema
|
|
43
|
+
|
|
44
|
+
IDs are `String`, timestamps are `BigInt` (portable epoch ms — `Int` overflows
|
|
45
|
+
in 2038, `DateTime` forces a conversion at every boundary), JSON payloads are
|
|
46
|
+
`String`. Use `@map`/`@@map` to match the app's database naming.
|
|
47
|
+
|
|
48
|
+
```prisma
|
|
49
|
+
model ChatThread {
|
|
50
|
+
threadId String @id @map("thread_id")
|
|
51
|
+
messagesJson String @map("messages_json")
|
|
52
|
+
updatedAt BigInt @map("updated_at")
|
|
53
|
+
|
|
54
|
+
@@map("chat_threads")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
model ChatRun {
|
|
58
|
+
runId String @id @map("run_id")
|
|
59
|
+
threadId String @map("thread_id")
|
|
60
|
+
status String
|
|
61
|
+
startedAt BigInt @map("started_at")
|
|
62
|
+
finishedAt BigInt? @map("finished_at")
|
|
63
|
+
error String?
|
|
64
|
+
errorCode String? @map("error_code")
|
|
65
|
+
usageJson String? @map("usage_json")
|
|
66
|
+
sandboxKey String? @map("sandbox_key")
|
|
67
|
+
detachedSince BigInt? @map("detached_since")
|
|
68
|
+
cancelRequested Boolean? @map("cancel_requested")
|
|
69
|
+
driverEpoch Int? @map("driver_epoch")
|
|
70
|
+
|
|
71
|
+
@@index([threadId, status])
|
|
72
|
+
@@index([threadId, startedAt])
|
|
73
|
+
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
|
|
74
|
+
@@index([status, detachedSince])
|
|
75
|
+
@@map("chat_runs")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
model ChatInterrupt {
|
|
79
|
+
interruptId String @id @map("interrupt_id")
|
|
80
|
+
runId String @map("run_id")
|
|
81
|
+
threadId String @map("thread_id")
|
|
82
|
+
status String
|
|
83
|
+
requestedAt BigInt @map("requested_at")
|
|
84
|
+
resolvedAt BigInt? @map("resolved_at")
|
|
85
|
+
payloadJson String @map("payload_json")
|
|
86
|
+
responseJson String? @map("response_json")
|
|
87
|
+
|
|
88
|
+
@@index([threadId, requestedAt])
|
|
89
|
+
@@map("chat_interrupts")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
model ChatMetadata {
|
|
93
|
+
namespace String
|
|
94
|
+
key String
|
|
95
|
+
valueJson String @map("value_json")
|
|
96
|
+
|
|
97
|
+
@@id([namespace, key])
|
|
98
|
+
@@map("chat_metadata")
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Rename models freely to fit the app — the store code below is the only thing
|
|
103
|
+
that references them. Extra app-owned fields (a `userId`, audit columns) are
|
|
104
|
+
fine as long as they are optional or defaulted, so the stores' creates still
|
|
105
|
+
succeed. `namespace` is the `MetadataStore` first argument; the stock SQL in
|
|
106
|
+
the guide calls the same column `scope`.
|
|
107
|
+
|
|
108
|
+
`RunRecord.error` is a structured `RunError` (`{ message: string, code?: string }`),
|
|
109
|
+
so it gets two columns rather than one JSON blob: `error` for the provider's
|
|
110
|
+
prose and `errorCode` for the stable classification an operator filters and
|
|
111
|
+
groups by. `error` and `errorCode` always move together in `update`, so a
|
|
112
|
+
later code-less failure can never leave a stale `code` from an earlier one
|
|
113
|
+
behind.
|
|
114
|
+
|
|
115
|
+
On **Postgres or MySQL** you can switch the `*Json` fields to Prisma's `Json`
|
|
116
|
+
type and drop the `JSON.stringify`/`parse` in the mappers below. Keep `String`
|
|
117
|
+
if the app targets SQLite or if it is multi-provider.
|
|
118
|
+
|
|
119
|
+
## 3. Write `src/lib/chat-persistence.ts`
|
|
120
|
+
|
|
121
|
+
Two conversions the SQL backends do not need: `BigInt` timestamps in and out,
|
|
122
|
+
and JSON as strings. Everything else is the shared invariant set.
|
|
123
|
+
|
|
124
|
+
```ts ignore
|
|
125
|
+
import { defineAIPersistence } from '@tanstack/ai-persistence'
|
|
126
|
+
import type {
|
|
127
|
+
ChatInterrupt,
|
|
128
|
+
ChatRun,
|
|
129
|
+
Prisma,
|
|
130
|
+
PrismaClient,
|
|
131
|
+
} from '@prisma/client'
|
|
132
|
+
import type { ModelMessage, TokenUsage } from '@tanstack/ai'
|
|
133
|
+
import type {
|
|
134
|
+
ChatPersistence,
|
|
135
|
+
InterruptRecord,
|
|
136
|
+
InterruptStatus,
|
|
137
|
+
InterruptStore,
|
|
138
|
+
MessageStore,
|
|
139
|
+
MetadataStore,
|
|
140
|
+
RunRecord,
|
|
141
|
+
RunStatus,
|
|
142
|
+
RunStore,
|
|
143
|
+
} from '@tanstack/ai-persistence'
|
|
144
|
+
|
|
145
|
+
import { prisma } from '@/lib/prisma'
|
|
146
|
+
|
|
147
|
+
// Trusts the shape the stores themselves wrote — nothing else writes these
|
|
148
|
+
// columns.
|
|
149
|
+
function parseJson<T>(raw: string): T {
|
|
150
|
+
return JSON.parse(raw)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const RUN_STATUSES: ReadonlyArray<RunStatus> = [
|
|
154
|
+
'running',
|
|
155
|
+
'interrupted',
|
|
156
|
+
'completed',
|
|
157
|
+
'failed',
|
|
158
|
+
'aborted',
|
|
159
|
+
]
|
|
160
|
+
const INTERRUPT_STATUSES: ReadonlyArray<InterruptStatus> = [
|
|
161
|
+
'pending',
|
|
162
|
+
'resolved',
|
|
163
|
+
'cancelled',
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
// The column is a plain String, so narrow instead of trusting it.
|
|
167
|
+
function toRunStatus(value: string): RunStatus {
|
|
168
|
+
const status = RUN_STATUSES.find((candidate) => candidate === value)
|
|
169
|
+
if (!status) throw new Error(`Unknown run status: ${value}`)
|
|
170
|
+
return status
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toInterruptStatus(value: string): InterruptStatus {
|
|
174
|
+
const status = INTERRUPT_STATUSES.find((candidate) => candidate === value)
|
|
175
|
+
if (!status) throw new Error(`Unknown interrupt status: ${value}`)
|
|
176
|
+
return status
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Records omit absent optionals so they compare cleanly against the reference
|
|
180
|
+
// in-memory backend.
|
|
181
|
+
function mapRun(row: ChatRun): RunRecord {
|
|
182
|
+
return {
|
|
183
|
+
runId: row.runId,
|
|
184
|
+
threadId: row.threadId,
|
|
185
|
+
status: toRunStatus(row.status),
|
|
186
|
+
startedAt: Number(row.startedAt),
|
|
187
|
+
...(row.finishedAt != null ? { finishedAt: Number(row.finishedAt) } : {}),
|
|
188
|
+
...(row.error != null
|
|
189
|
+
? {
|
|
190
|
+
error: {
|
|
191
|
+
message: row.error,
|
|
192
|
+
...(row.errorCode != null ? { code: row.errorCode } : {}),
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
...(row.usageJson != null
|
|
197
|
+
? { usage: parseJson<TokenUsage>(row.usageJson) }
|
|
198
|
+
: {}),
|
|
199
|
+
...(row.sandboxKey != null ? { sandboxKey: row.sandboxKey } : {}),
|
|
200
|
+
...(row.detachedSince != null
|
|
201
|
+
? { detachedSince: Number(row.detachedSince) }
|
|
202
|
+
: {}),
|
|
203
|
+
...(row.cancelRequested != null
|
|
204
|
+
? { cancelRequested: row.cancelRequested }
|
|
205
|
+
: {}),
|
|
206
|
+
...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function mapInterrupt(row: ChatInterrupt): InterruptRecord {
|
|
211
|
+
return {
|
|
212
|
+
interruptId: row.interruptId,
|
|
213
|
+
runId: row.runId,
|
|
214
|
+
threadId: row.threadId,
|
|
215
|
+
status: toInterruptStatus(row.status),
|
|
216
|
+
requestedAt: Number(row.requestedAt),
|
|
217
|
+
payload: parseJson<Record<string, unknown>>(row.payloadJson),
|
|
218
|
+
...(row.resolvedAt != null ? { resolvedAt: Number(row.resolvedAt) } : {}),
|
|
219
|
+
...(row.responseJson != null
|
|
220
|
+
? { response: parseJson<unknown>(row.responseJson) }
|
|
221
|
+
: {}),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createMessageStore(db: PrismaClient): MessageStore {
|
|
226
|
+
return {
|
|
227
|
+
async loadThread(threadId) {
|
|
228
|
+
const row = await db.chatThread.findUnique({ where: { threadId } })
|
|
229
|
+
// Unknown thread is [], never null.
|
|
230
|
+
return row ? parseJson<Array<ModelMessage>>(row.messagesJson) : []
|
|
231
|
+
},
|
|
232
|
+
// Full overwrite — `messages` is the complete authoritative transcript.
|
|
233
|
+
async saveThread(threadId, messages) {
|
|
234
|
+
const messagesJson = JSON.stringify(messages)
|
|
235
|
+
const updatedAt = BigInt(Date.now())
|
|
236
|
+
await db.chatThread.upsert({
|
|
237
|
+
where: { threadId },
|
|
238
|
+
create: { threadId, messagesJson, updatedAt },
|
|
239
|
+
update: { messagesJson, updatedAt },
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createRunStore(db: PrismaClient): RunStore {
|
|
246
|
+
return {
|
|
247
|
+
async get(runId) {
|
|
248
|
+
const row = await db.chatRun.findUnique({ where: { runId } })
|
|
249
|
+
return row ? mapRun(row) : null
|
|
250
|
+
},
|
|
251
|
+
// An empty `update` is Prisma's ON CONFLICT DO NOTHING: an existing runId
|
|
252
|
+
// comes back untouched, so resume and double-submit are safe.
|
|
253
|
+
async createOrResume({ runId, threadId, startedAt, status }) {
|
|
254
|
+
const row = await db.chatRun.upsert({
|
|
255
|
+
where: { runId },
|
|
256
|
+
create: {
|
|
257
|
+
runId,
|
|
258
|
+
threadId,
|
|
259
|
+
status: status ?? 'running',
|
|
260
|
+
startedAt: BigInt(startedAt),
|
|
261
|
+
},
|
|
262
|
+
update: {},
|
|
263
|
+
})
|
|
264
|
+
return mapRun(row)
|
|
265
|
+
},
|
|
266
|
+
// Patching an unknown runId is a no-op: never throws, never inserts.
|
|
267
|
+
async update(runId, patch) {
|
|
268
|
+
const data: Prisma.ChatRunUpdateManyMutationInput = {}
|
|
269
|
+
if (patch.status !== undefined) data.status = patch.status
|
|
270
|
+
if (patch.finishedAt !== undefined) {
|
|
271
|
+
data.finishedAt = BigInt(patch.finishedAt)
|
|
272
|
+
}
|
|
273
|
+
// Both columns move together, so a later code-less failure cannot
|
|
274
|
+
// leave a stale errorCode from an earlier one behind.
|
|
275
|
+
if (patch.error !== undefined) {
|
|
276
|
+
data.error = patch.error.message
|
|
277
|
+
data.errorCode = patch.error.code ?? null
|
|
278
|
+
}
|
|
279
|
+
if (patch.usage !== undefined)
|
|
280
|
+
data.usageJson = JSON.stringify(patch.usage)
|
|
281
|
+
// The four durable-run fields use `'field' in patch`, NOT
|
|
282
|
+
// `!== undefined`: a reattach clears `detachedSince` by passing it
|
|
283
|
+
// explicitly as `undefined`, and that must still write NULL, not be
|
|
284
|
+
// silently dropped from the update. Checking `!== undefined` cannot
|
|
285
|
+
// tell "clear this" from "didn't mention this", and would leave every
|
|
286
|
+
// reattached run looking permanently detached to the reaper. Same
|
|
287
|
+
// reasoning for `cancelRequested` (`false` is a meaningful value).
|
|
288
|
+
if ('sandboxKey' in patch) data.sandboxKey = patch.sandboxKey ?? null
|
|
289
|
+
if ('detachedSince' in patch) {
|
|
290
|
+
data.detachedSince =
|
|
291
|
+
patch.detachedSince === undefined ? null : BigInt(patch.detachedSince)
|
|
292
|
+
}
|
|
293
|
+
if ('cancelRequested' in patch)
|
|
294
|
+
data.cancelRequested = patch.cancelRequested ?? null
|
|
295
|
+
if ('driverEpoch' in patch) data.driverEpoch = patch.driverEpoch ?? null
|
|
296
|
+
if (Object.keys(data).length === 0) return
|
|
297
|
+
|
|
298
|
+
await db.chatRun.updateMany({ where: { runId }, data })
|
|
299
|
+
},
|
|
300
|
+
// Optional in the contract; enables reconnect without a client-held run id.
|
|
301
|
+
async findActiveRun(threadId) {
|
|
302
|
+
const row = await db.chatRun.findFirst({
|
|
303
|
+
where: { threadId, status: 'running' },
|
|
304
|
+
orderBy: { startedAt: 'desc' },
|
|
305
|
+
})
|
|
306
|
+
return row ? mapRun(row) : null
|
|
307
|
+
},
|
|
308
|
+
// Optional; every run for the thread, ascending by startedAt. Uses the
|
|
309
|
+
// (threadId, startedAt) index.
|
|
310
|
+
async listByThread(threadId) {
|
|
311
|
+
const rows = await db.chatRun.findMany({
|
|
312
|
+
where: { threadId },
|
|
313
|
+
orderBy: { startedAt: 'asc' },
|
|
314
|
+
})
|
|
315
|
+
return rows.map(mapRun)
|
|
316
|
+
},
|
|
317
|
+
// Optional; still-running runs detached at or before the cutoff. Uses
|
|
318
|
+
// the (status, detachedSince) index. The cutoff is inclusive.
|
|
319
|
+
async listReclaimable({ now, ttlMs }) {
|
|
320
|
+
const cutoff = BigInt(now - ttlMs)
|
|
321
|
+
const rows = await db.chatRun.findMany({
|
|
322
|
+
where: {
|
|
323
|
+
status: 'running',
|
|
324
|
+
detachedSince: { not: null, lte: cutoff },
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
return rows.map(mapRun)
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function createInterruptStore(db: PrismaClient): InterruptStore {
|
|
333
|
+
// Every listing is ordered by requestedAt ascending.
|
|
334
|
+
const listWhere = async (where: Prisma.ChatInterruptWhereInput) => {
|
|
335
|
+
const rows = await db.chatInterrupt.findMany({
|
|
336
|
+
where,
|
|
337
|
+
orderBy: { requestedAt: 'asc' },
|
|
338
|
+
})
|
|
339
|
+
return rows.map(mapInterrupt)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
// Insert-if-absent: a duplicate create must never clobber a resolved
|
|
344
|
+
// interrupt back to pending.
|
|
345
|
+
async create(record) {
|
|
346
|
+
await db.chatInterrupt.upsert({
|
|
347
|
+
where: { interruptId: record.interruptId },
|
|
348
|
+
create: {
|
|
349
|
+
interruptId: record.interruptId,
|
|
350
|
+
runId: record.runId,
|
|
351
|
+
threadId: record.threadId,
|
|
352
|
+
status: 'pending',
|
|
353
|
+
requestedAt: BigInt(record.requestedAt),
|
|
354
|
+
payloadJson: JSON.stringify(record.payload),
|
|
355
|
+
...(record.response !== undefined
|
|
356
|
+
? { responseJson: JSON.stringify(record.response) }
|
|
357
|
+
: {}),
|
|
358
|
+
},
|
|
359
|
+
update: {},
|
|
360
|
+
})
|
|
361
|
+
},
|
|
362
|
+
async resolve(interruptId, response) {
|
|
363
|
+
await db.chatInterrupt.updateMany({
|
|
364
|
+
where: { interruptId },
|
|
365
|
+
data: {
|
|
366
|
+
status: 'resolved',
|
|
367
|
+
resolvedAt: BigInt(Date.now()),
|
|
368
|
+
...(response !== undefined
|
|
369
|
+
? { responseJson: JSON.stringify(response) }
|
|
370
|
+
: {}),
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
},
|
|
374
|
+
async cancel(interruptId) {
|
|
375
|
+
await db.chatInterrupt.updateMany({
|
|
376
|
+
where: { interruptId },
|
|
377
|
+
data: { status: 'cancelled', resolvedAt: BigInt(Date.now()) },
|
|
378
|
+
})
|
|
379
|
+
},
|
|
380
|
+
async get(interruptId) {
|
|
381
|
+
const row = await db.chatInterrupt.findUnique({ where: { interruptId } })
|
|
382
|
+
return row ? mapInterrupt(row) : null
|
|
383
|
+
},
|
|
384
|
+
list: (threadId) => listWhere({ threadId }),
|
|
385
|
+
listPending: (threadId) => listWhere({ threadId, status: 'pending' }),
|
|
386
|
+
listByRun: (runId) => listWhere({ runId }),
|
|
387
|
+
listPendingByRun: (runId) => listWhere({ runId, status: 'pending' }),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function createMetadataStore(db: PrismaClient): MetadataStore {
|
|
392
|
+
return {
|
|
393
|
+
async get(namespace, key) {
|
|
394
|
+
const row = await db.chatMetadata.findUnique({
|
|
395
|
+
where: { namespace_key: { namespace, key } },
|
|
396
|
+
})
|
|
397
|
+
return row ? parseJson<unknown>(row.valueJson) : null
|
|
398
|
+
},
|
|
399
|
+
async set(namespace, key, value) {
|
|
400
|
+
// JSON.stringify(undefined) is undefined, which Prisma rejects against a
|
|
401
|
+
// required column with an opaque error. Fail clearly instead.
|
|
402
|
+
if (value == null) {
|
|
403
|
+
throw new TypeError(
|
|
404
|
+
`Cannot store ${value} for (${namespace}, ${key}) — use delete() to clear metadata.`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
const valueJson = JSON.stringify(value)
|
|
408
|
+
await db.chatMetadata.upsert({
|
|
409
|
+
where: { namespace_key: { namespace, key } },
|
|
410
|
+
create: { namespace, key, valueJson },
|
|
411
|
+
update: { valueJson },
|
|
412
|
+
})
|
|
413
|
+
},
|
|
414
|
+
async delete(namespace, key) {
|
|
415
|
+
await db.chatMetadata.deleteMany({ where: { namespace, key } })
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The four chat state stores backed by the app's Prisma client. */
|
|
421
|
+
export const chatPersistence: ChatPersistence = defineAIPersistence({
|
|
422
|
+
stores: {
|
|
423
|
+
messages: createMessageStore(prisma),
|
|
424
|
+
runs: createRunStore(prisma),
|
|
425
|
+
interrupts: createInterruptStore(prisma),
|
|
426
|
+
metadata: createMetadataStore(prisma),
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Notes that bite:
|
|
432
|
+
|
|
433
|
+
- **`updateMany`, not `update`, for patches.** `update` throws
|
|
434
|
+
`P2025` on a missing row; the contract says a patch to an unknown id is a
|
|
435
|
+
silent no-op.
|
|
436
|
+
- **`namespace_key`** is Prisma's generated alias for the `@@id([namespace, key])`
|
|
437
|
+
composite. If you rename the fields, the alias name changes with them.
|
|
438
|
+
- Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and
|
|
439
|
+
`withPersistence` rejects it. There is no `locks` store: `stores` accepts only
|
|
440
|
+
those four keys, and coordination is wired separately with `withLocks` (see
|
|
441
|
+
**ai-core/locks**).
|
|
442
|
+
- If the app renamed the models, the delegate accessors are **camelCase**
|
|
443
|
+
(`prisma.chatThread` for `model ChatThread`), and the row types imported from
|
|
444
|
+
the client are PascalCase.
|
|
445
|
+
|
|
446
|
+
## 4. Wire it into the chat route
|
|
447
|
+
|
|
448
|
+
```ts ignore
|
|
449
|
+
import {
|
|
450
|
+
chat,
|
|
451
|
+
chatParamsFromRequest,
|
|
452
|
+
toServerSentEventsResponse,
|
|
453
|
+
} from '@tanstack/ai'
|
|
454
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
455
|
+
import { withPersistence } from '@tanstack/ai-persistence'
|
|
456
|
+
import { chatPersistence } from '@/lib/chat-persistence'
|
|
457
|
+
|
|
458
|
+
export async function POST(request: Request) {
|
|
459
|
+
const params = await chatParamsFromRequest(request)
|
|
460
|
+
const stream = chat({
|
|
461
|
+
adapter: openaiText('gpt-5.5'),
|
|
462
|
+
messages: params.messages,
|
|
463
|
+
threadId: params.threadId,
|
|
464
|
+
runId: params.runId,
|
|
465
|
+
...(params.resume ? { resume: params.resume } : {}),
|
|
466
|
+
middleware: [withPersistence(chatPersistence)],
|
|
467
|
+
})
|
|
468
|
+
return toServerSentEventsResponse(stream)
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`threadId` is a bare string to the stores. **Authorize thread access at the
|
|
473
|
+
route** — derive the user from the session, never trust a client-supplied id.
|
|
474
|
+
|
|
475
|
+
## 5. Verify
|
|
476
|
+
|
|
477
|
+
```ts ignore
|
|
478
|
+
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
|
|
479
|
+
import { chatPersistence } from '../src/lib/chat-persistence'
|
|
480
|
+
|
|
481
|
+
runPersistenceConformance('app-prisma', () => chatPersistence, {
|
|
482
|
+
skip: ['generationRuns', 'artifacts', 'blobs'],
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Point the client at a throwaway database with the migration applied (a scratch
|
|
487
|
+
SQLite file is enough) and reset it between runs. All four state stores are
|
|
488
|
+
provided; the suite also covers the three generation stores, so declare those as
|
|
489
|
+
skipped until you add them. `skip` never accepts `'locks'`, which is not a
|
|
490
|
+
store.
|
|
491
|
+
|
|
492
|
+
If your recipe leaves an optional `runs` method
|
|
493
|
+
(`listByThread`/`listReclaimable`) unimplemented, declare it
|
|
494
|
+
with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
|
|
495
|
+
omitted method that is not declared fails the suite instead of silently
|
|
496
|
+
passing.
|
|
497
|
+
|
|
498
|
+
## Only if you are publishing this as a package
|
|
499
|
+
|
|
500
|
+
Everything above assumes the file lives in the app. For a reusable npm adapter,
|
|
501
|
+
the same store bodies apply, plus:
|
|
502
|
+
|
|
503
|
+
- **Peer dep** `@prisma/client >=6.7.0`. Ship no datasource, generator,
|
|
504
|
+
connection URL, or prebuilt SQL migration — those stay in the consumer's
|
|
505
|
+
schema.
|
|
506
|
+
- **Type the client structurally** (a `PrismaClientLike` shape) and read model
|
|
507
|
+
delegates off it at runtime, so Prisma 6 and 7 clients both satisfy it
|
|
508
|
+
regardless of where they were generated.
|
|
509
|
+
- **Ship the models as a raw string asset** plus a CLI
|
|
510
|
+
(`tanstack-ai-prisma-models`) that copies a provider-neutral fragment into the
|
|
511
|
+
consumer's multi-file schema directory. They then run `prisma migrate`.
|
|
512
|
+
- **Let consumers rename**: `prismaPersistence(prisma, { models: { messages: 'chatMessage' } })`,
|
|
513
|
+
where map values are the camelCase client accessors. Throw a
|
|
514
|
+
`PrismaModelError` naming every store whose delegate cannot be found. Keep the
|
|
515
|
+
field surface and the composite-id alias fixed; database names and extra
|
|
516
|
+
app-owned fields are theirs.
|
|
517
|
+
- Run `runPersistenceConformance` over a temporary SQLite database generated
|
|
518
|
+
from the fragment.
|