@tanstack/ai-persistence 0.6.4 → 0.6.5
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/memory.js +8 -1
- package/dist/esm/memory.js.map +1 -1
- package/dist/esm/merge-stored.d.ts +2 -0
- package/dist/esm/merge-stored.js +47 -0
- package/dist/esm/merge-stored.js.map +1 -0
- package/dist/esm/middleware.d.ts +2 -1
- package/dist/esm/middleware.js +18 -44
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reconstruct.js +90 -1
- package/dist/esm/reconstruct.js.map +1 -1
- package/dist/esm/subagent-runs.d.ts +40 -0
- package/dist/esm/subagent-runs.js +366 -0
- package/dist/esm/subagent-runs.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -1
- package/dist/esm/testkit/conformance.js +94 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/package.json +3 -3
- package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +41 -16
- package/skills/ai-persistence/build-custom-adapter/SKILL.md +49 -20
- package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +54 -9
- package/skills/ai-persistence/build-prisma-adapter/SKILL.md +32 -6
- package/skills/ai-persistence/stores/SKILL.md +46 -29
- package/src/memory.ts +16 -0
- package/src/merge-stored.ts +67 -0
- package/src/middleware.ts +19 -67
- package/src/reconstruct.ts +155 -2
- package/src/subagent-runs.ts +581 -0
- package/src/testkit/conformance.ts +125 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-persistence",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"vitest": "^4.1.10",
|
|
46
|
-
"@tanstack/ai": "^0.
|
|
46
|
+
"@tanstack/ai": "^0.59.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"vitest": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@vitest/coverage-v8": "4.1.10",
|
|
55
55
|
"vitest": "^4.1.10",
|
|
56
|
-
"@tanstack/ai": "0.
|
|
56
|
+
"@tanstack/ai": "0.59.0"
|
|
57
57
|
},
|
|
58
58
|
"scripts": {
|
|
59
59
|
"build": "vite build",
|
|
@@ -97,21 +97,27 @@ The invariants are the whole game, whichever route you take:
|
|
|
97
97
|
| `runs` | `update` on an unknown id is a silent no-op — never throws, never inserts |
|
|
98
98
|
| `runs` | `findActiveRun` (required) returns the latest `'running'` run for the thread, else null |
|
|
99
99
|
| `runs` | `listByThread` (optional) returns every run for the thread `ORDER BY started_at ASC` |
|
|
100
|
+
| `runs` | `listByParentRun` (optional) returns child runs for one `parent_run_id`, `ORDER BY started_at ASC`. `reconstructChat` uses it to put subagent cards back |
|
|
100
101
|
| `runs` | `listReclaimable` (optional) returns runs where `status = 'running' AND detached_since IS NOT NULL AND detached_since <= now - ttlMs` (inclusive cutoff); it is a query, not automatic reclamation |
|
|
101
102
|
| `interrupts` | `create` is insert-if-absent; never clobber a resolved interrupt back to pending |
|
|
102
103
|
| `interrupts` | every `list*` ends `ORDER BY requested_at ASC` |
|
|
103
104
|
| `metadata` | reject nullish `set` with a clear `TypeError`; tell callers to use `delete` |
|
|
104
105
|
|
|
105
|
-
On `runs`, `findActiveRun` is required
|
|
106
|
-
optional, so implement those
|
|
107
|
-
calls
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
On `runs`, `findActiveRun` is required. `listByThread`, `listByParentRun`, and
|
|
107
|
+
`listReclaimable` are optional, so implement those only if the app needs them.
|
|
108
|
+
`withPersistence` calls `createOrResume`, `update`, and `get`. `reconstruct.ts` calls
|
|
109
|
+
`findActiveRun` for rejoin-by-thread and `listByParentRun` to put subagent
|
|
110
|
+
cards back. `@tanstack/ai-sandbox`'s `reapDetachedRuns` calls `listReclaimable`.
|
|
111
|
+
`reconstruct.ts` also calls `listByThread` to find the parent runs of children
|
|
112
|
+
that a tool call started. Consumers of the optional methods
|
|
113
|
+
feature-detect with `store.method?.(...)` and degrade when one is absent. The
|
|
114
|
+
conformance testkit does not: each optional method you leave out must be listed
|
|
115
|
+
in `skipMethods` or the suite fails. `listByParentRun` is the exception. When it
|
|
116
|
+
is absent, the subagent checks skip on their own.
|
|
117
|
+
|
|
118
|
+
`createOrResume` copies `parentRunId`, `subagentRunId`, and `name` on the first
|
|
119
|
+
insert. A later call for the same `runId` leaves them unchanged. If the caller
|
|
120
|
+
omits a field, omit it on the mapped record.
|
|
115
121
|
|
|
116
122
|
`RunRecord.error` is a structured `RunError` (`{ message: string, code?: string }`),
|
|
117
123
|
so the table gets two columns rather than one JSON blob: `error` for the
|
|
@@ -159,10 +165,14 @@ CREATE TABLE IF NOT EXISTS chat_runs (
|
|
|
159
165
|
sandbox_key text,
|
|
160
166
|
detached_since integer,
|
|
161
167
|
cancel_requested integer,
|
|
162
|
-
driver_epoch integer
|
|
168
|
+
driver_epoch integer,
|
|
169
|
+
parent_run_id text,
|
|
170
|
+
subagent_run_id text,
|
|
171
|
+
name text
|
|
163
172
|
);
|
|
164
173
|
CREATE INDEX IF NOT EXISTS chat_runs_thread_status ON chat_runs (thread_id, status);
|
|
165
174
|
CREATE INDEX IF NOT EXISTS chat_runs_thread_started ON chat_runs (thread_id, started_at);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS chat_runs_parent_started ON chat_runs (parent_run_id, started_at);
|
|
166
176
|
-- Powers listReclaimable: status = 'running' AND detached_since <= cutoff.
|
|
167
177
|
CREATE INDEX IF NOT EXISTS chat_runs_status_detached ON chat_runs (status, detached_since);
|
|
168
178
|
CREATE TABLE IF NOT EXISTS chat_interrupts (
|
|
@@ -189,6 +199,20 @@ Apply with `wrangler d1 migrations apply <database-name>` (`--local` first, then
|
|
|
189
199
|
`drizzle-kit generate` instead of hand-writing it — the SQL and the Drizzle
|
|
190
200
|
table definitions must agree, so let one of them own the other.
|
|
191
201
|
|
|
202
|
+
An existing `chat_runs` table does not get the three subagent columns from
|
|
203
|
+
`CREATE TABLE IF NOT EXISTS`, and the `chat_runs_parent_started` index then
|
|
204
|
+
fails. To add subagent support to an existing database, apply a separate
|
|
205
|
+
migration first:
|
|
206
|
+
|
|
207
|
+
```sql
|
|
208
|
+
ALTER TABLE chat_runs ADD COLUMN parent_run_id text;
|
|
209
|
+
ALTER TABLE chat_runs ADD COLUMN subagent_run_id text;
|
|
210
|
+
ALTER TABLE chat_runs ADD COLUMN name text;
|
|
211
|
+
CREATE INDEX IF NOT EXISTS chat_runs_parent_started ON chat_runs (parent_run_id, started_at);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
A store without subagent support can skip the columns and the index.
|
|
215
|
+
|
|
192
216
|
## 6. Wire it into the chat route
|
|
193
217
|
|
|
194
218
|
```ts ignore
|
|
@@ -290,11 +314,12 @@ once you add the R2-backed set from
|
|
|
290
314
|
**ai-persistence/build-cloudflare-artifact-store**). `skip` never accepts
|
|
291
315
|
`'locks'`, which is not a store.
|
|
292
316
|
|
|
293
|
-
If your recipe leaves
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
317
|
+
If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
|
|
318
|
+
declare it with `skipMethods`, for example
|
|
319
|
+
`{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
|
|
320
|
+
fails the suite instead of silently passing. Subagent support is optional: when
|
|
321
|
+
`listByParentRun` is absent, the subagent checks skip on their own and need no
|
|
322
|
+
entry.
|
|
298
323
|
|
|
299
324
|
The lock store needs its **own** tests, because nothing in the conformance suite
|
|
300
325
|
touches it. Cover at minimum: two concurrent `withLock` calls on the same key
|
|
@@ -43,12 +43,12 @@ complete worked `node:sqlite` walkthrough is
|
|
|
43
43
|
Four logical records. Whatever the engine, keep these keys — the store methods
|
|
44
44
|
look records up by exactly these:
|
|
45
45
|
|
|
46
|
-
| Record | Key | Fields
|
|
47
|
-
| --------- | ------------------ |
|
|
48
|
-
| thread | `threadId` | `messages` (array, full transcript)
|
|
49
|
-
| run | `runId` | `threadId`, `status`, `startedAt`, `finishedAt?`, `error?`, `usage?`, `sandboxKey?`, `detachedSince?`, `cancelRequested?`, `driverEpoch?` |
|
|
50
|
-
| interrupt | `interruptId` | `runId`, `threadId`, `status`, `requestedAt`, `resolvedAt?`, `payload`, `response?`
|
|
51
|
-
| metadata | `(namespace, key)` | `value`
|
|
46
|
+
| Record | Key | Fields |
|
|
47
|
+
| --------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
48
|
+
| thread | `threadId` | `messages` (array, full transcript) |
|
|
49
|
+
| run | `runId` | `threadId`, `status`, `startedAt`, `finishedAt?`, `error?`, `usage?`, `parentRunId?`, `subagentRunId?`, `name?`, `sandboxKey?`, `detachedSince?`, `cancelRequested?`, `driverEpoch?` |
|
|
50
|
+
| interrupt | `interruptId` | `runId`, `threadId`, `status`, `requestedAt`, `resolvedAt?`, `payload`, `response?` |
|
|
51
|
+
| metadata | `(namespace, key)` | `value` |
|
|
52
52
|
|
|
53
53
|
- Timestamps are **epoch milliseconds** (`number`) in records. Store them
|
|
54
54
|
however the engine prefers and convert in the mapper.
|
|
@@ -57,8 +57,8 @@ look records up by exactly these:
|
|
|
57
57
|
conformance suite checks it.
|
|
58
58
|
- Index `runs(threadId, status)`, `runs(threadId, startedAt)`, and
|
|
59
59
|
`interrupts(threadId, requestedAt)` for the listing paths. If the backend
|
|
60
|
-
implements `listReclaimable`, also index `runs(status, detachedSince)
|
|
61
|
-
|
|
60
|
+
implements `listReclaimable`, also index `runs(status, detachedSince)`. If it
|
|
61
|
+
implements `listByParentRun`, also index `runs(parentRunId, startedAt)`.
|
|
62
62
|
- `run.error` is a structured `RunError` (`{ message: string, code?: string }`),
|
|
63
63
|
not a bare string. `message` is the provider's prose; `code` is the stable,
|
|
64
64
|
machine-branchable classification an operator filters and groups by. In a
|
|
@@ -105,8 +105,12 @@ history. They are engine-independent:
|
|
|
105
105
|
automatic reclamation: `reapDetachedRuns` from `@tanstack/ai-sandbox` is the
|
|
106
106
|
sweep that consumes it, and the application schedules that sweep. A store
|
|
107
107
|
without this method cannot be reaped. `runs.findActiveRun` is required;
|
|
108
|
-
`runs.listByThread`
|
|
109
|
-
what the app needs and leave the rest off the object.
|
|
108
|
+
`runs.listByThread`, `runs.listByParentRun`, and `runs.listReclaimable` are
|
|
109
|
+
optional: implement only what the app needs and leave the rest off the object.
|
|
110
|
+
`listByParentRun` returns the child runs for one `parentRunId`, oldest
|
|
111
|
+
`startedAt` first. `reconstructChat` uses that list to put subagent cards
|
|
112
|
+
back. `createOrResume` copies `parentRunId`, `subagentRunId`, and `name` on
|
|
113
|
+
the first insert and leaves them unchanged on resume.
|
|
110
114
|
|
|
111
115
|
Row mappers omit absent optionals
|
|
112
116
|
(`...(row.sandbox_key != null ? { sandboxKey: row.sandbox_key } : {})`) so
|
|
@@ -165,21 +169,44 @@ function createRunStore(db: Pool): RunStore {
|
|
|
165
169
|
return {
|
|
166
170
|
get,
|
|
167
171
|
// Idempotent: an existing runId is returned untouched.
|
|
168
|
-
async createOrResume(
|
|
172
|
+
async createOrResume(input) {
|
|
173
|
+
const { runId, threadId, startedAt, status } = input
|
|
169
174
|
const existing = await get(runId)
|
|
170
175
|
if (existing) return existing
|
|
171
176
|
|
|
172
177
|
await db.query(
|
|
173
|
-
`INSERT INTO chat_runs (
|
|
174
|
-
|
|
178
|
+
`INSERT INTO chat_runs (
|
|
179
|
+
run_id, thread_id, status, started_at,
|
|
180
|
+
parent_run_id, subagent_run_id, name
|
|
181
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
175
182
|
ON CONFLICT (run_id) DO NOTHING`,
|
|
176
|
-
[
|
|
183
|
+
[
|
|
184
|
+
runId,
|
|
185
|
+
threadId,
|
|
186
|
+
status ?? 'running',
|
|
187
|
+
startedAt,
|
|
188
|
+
input.parentRunId ?? null,
|
|
189
|
+
input.subagentRunId ?? null,
|
|
190
|
+
input.name ?? null,
|
|
191
|
+
],
|
|
177
192
|
)
|
|
178
193
|
// Re-read: a concurrent createOrResume may have won the race, and that
|
|
179
194
|
// row is the authoritative one.
|
|
180
195
|
const stored = await get(runId)
|
|
181
196
|
return (
|
|
182
|
-
stored ?? {
|
|
197
|
+
stored ?? {
|
|
198
|
+
runId,
|
|
199
|
+
threadId,
|
|
200
|
+
status: status ?? 'running',
|
|
201
|
+
startedAt,
|
|
202
|
+
...(input.parentRunId !== undefined
|
|
203
|
+
? { parentRunId: input.parentRunId }
|
|
204
|
+
: {}),
|
|
205
|
+
...(input.subagentRunId !== undefined
|
|
206
|
+
? { subagentRunId: input.subagentRunId }
|
|
207
|
+
: {}),
|
|
208
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
209
|
+
}
|
|
183
210
|
)
|
|
184
211
|
},
|
|
185
212
|
// ... update (no-op on unknown id; sandboxKey/detachedSince/
|
|
@@ -191,8 +218,9 @@ function createRunStore(db: Pool): RunStore {
|
|
|
191
218
|
// error = patch.error.message and error_code = patch.error.code ?? null,
|
|
192
219
|
// together in the same call),
|
|
193
220
|
// findActiveRun (latest 'running', required), listByThread (ascending
|
|
194
|
-
// by startedAt, optional),
|
|
195
|
-
//
|
|
221
|
+
// by startedAt, optional), listByParentRun (children of parentRunId,
|
|
222
|
+
// ascending by startedAt, optional), listReclaimable (status = 'running'
|
|
223
|
+
// AND detachedSince <= now - ttlMs, inclusive cutoff, optional)
|
|
196
224
|
}
|
|
197
225
|
}
|
|
198
226
|
|
|
@@ -321,8 +349,9 @@ seven stores, so declare every intentional omission — a chat adapter skips the
|
|
|
321
349
|
generation half above, and adds e.g. `'metadata'` if it drops that too. `skip`
|
|
322
350
|
never accepts `'locks'`, which is not a store.
|
|
323
351
|
|
|
324
|
-
If your recipe leaves
|
|
325
|
-
|
|
352
|
+
If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
|
|
353
|
+
declare it separately with `skipMethods`, for example
|
|
326
354
|
`{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
|
|
327
|
-
fails the suite instead of silently passing.
|
|
355
|
+
fails the suite instead of silently passing. Subagent support is optional: when
|
|
356
|
+
`listByParentRun` is absent, the subagent checks skip on their own. `findActiveRun` is **not** in that
|
|
328
357
|
set — it is required, so there is nothing to declare.
|
|
@@ -80,12 +80,17 @@ export const chatRuns = sqliteTable(
|
|
|
80
80
|
detachedSince: integer('detached_since'),
|
|
81
81
|
cancelRequested: integer('cancel_requested', { mode: 'boolean' }),
|
|
82
82
|
driverEpoch: integer('driver_epoch'),
|
|
83
|
+
parentRunId: text('parent_run_id'),
|
|
84
|
+
subagentRunId: text('subagent_run_id'),
|
|
85
|
+
name: text('name'),
|
|
83
86
|
},
|
|
84
87
|
(table) => [
|
|
85
88
|
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
|
|
86
89
|
index('chat_runs_status_detached').on(table.status, table.detachedSince),
|
|
87
90
|
// Powers listByThread and findActiveRun.
|
|
88
91
|
index('chat_runs_thread_started').on(table.threadId, table.startedAt),
|
|
92
|
+
// Powers listByParentRun: children of one parent, oldest startedAt first.
|
|
93
|
+
index('chat_runs_parent_started').on(table.parentRunId, table.startedAt),
|
|
89
94
|
],
|
|
90
95
|
)
|
|
91
96
|
|
|
@@ -135,7 +140,8 @@ behind.
|
|
|
135
140
|
`boolean()` for `cancelRequested`, and `varchar(..., { length: 255 })` for the
|
|
136
141
|
primary-key columns. The store bodies below are identical across all three,
|
|
137
142
|
only `onConflictDoUpdate` becomes `onDuplicateKeyUpdate` on MySQL, and the
|
|
138
|
-
`(status, detachedSince)
|
|
143
|
+
`(status, detachedSince)`, `(threadId, startedAt)`, and
|
|
144
|
+
`(parentRunId, startedAt)` indexes carry over as is.
|
|
139
145
|
|
|
140
146
|
## 3. Write `src/lib/chat-persistence.ts`
|
|
141
147
|
|
|
@@ -190,6 +196,9 @@ function mapRun(row: typeof chatRuns.$inferSelect): RunRecord {
|
|
|
190
196
|
? { cancelRequested: row.cancelRequested }
|
|
191
197
|
: {}),
|
|
192
198
|
...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
|
|
199
|
+
...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}),
|
|
200
|
+
...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}),
|
|
201
|
+
...(row.name != null ? { name: row.name } : {}),
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
204
|
|
|
@@ -247,20 +256,45 @@ function createRunStore(db: Db): RunStore {
|
|
|
247
256
|
get,
|
|
248
257
|
// Idempotent: an existing runId is returned untouched so resume and
|
|
249
258
|
// double-submit are safe.
|
|
250
|
-
async createOrResume(
|
|
259
|
+
async createOrResume(input) {
|
|
260
|
+
const { runId, threadId, startedAt, status } = input
|
|
251
261
|
const existing = await get(runId)
|
|
252
262
|
if (existing) return existing
|
|
253
263
|
|
|
254
264
|
await db
|
|
255
265
|
.insert(chatRuns)
|
|
256
|
-
.values({
|
|
266
|
+
.values({
|
|
267
|
+
runId,
|
|
268
|
+
threadId,
|
|
269
|
+
status: status ?? 'running',
|
|
270
|
+
startedAt,
|
|
271
|
+
...(input.parentRunId !== undefined
|
|
272
|
+
? { parentRunId: input.parentRunId }
|
|
273
|
+
: {}),
|
|
274
|
+
...(input.subagentRunId !== undefined
|
|
275
|
+
? { subagentRunId: input.subagentRunId }
|
|
276
|
+
: {}),
|
|
277
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
278
|
+
})
|
|
257
279
|
.onConflictDoNothing({ target: chatRuns.runId })
|
|
258
280
|
|
|
259
281
|
// Re-read rather than trusting the insert: a concurrent createOrResume
|
|
260
282
|
// may have won the race, and that row is the authoritative one.
|
|
261
283
|
const stored = await get(runId)
|
|
262
284
|
return (
|
|
263
|
-
stored ?? {
|
|
285
|
+
stored ?? {
|
|
286
|
+
runId,
|
|
287
|
+
threadId,
|
|
288
|
+
status: status ?? 'running',
|
|
289
|
+
startedAt,
|
|
290
|
+
...(input.parentRunId !== undefined
|
|
291
|
+
? { parentRunId: input.parentRunId }
|
|
292
|
+
: {}),
|
|
293
|
+
...(input.subagentRunId !== undefined
|
|
294
|
+
? { subagentRunId: input.subagentRunId }
|
|
295
|
+
: {}),
|
|
296
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
297
|
+
}
|
|
264
298
|
)
|
|
265
299
|
},
|
|
266
300
|
// Patching an unknown runId is a no-op: never throws, never inserts.
|
|
@@ -314,6 +348,16 @@ function createRunStore(db: Db): RunStore {
|
|
|
314
348
|
.orderBy(asc(chatRuns.startedAt))
|
|
315
349
|
return rows.map(mapRun)
|
|
316
350
|
},
|
|
351
|
+
// Optional. Child runs for one parent, oldest startedAt first.
|
|
352
|
+
// reconstructChat uses this list to put subagent cards back.
|
|
353
|
+
async listByParentRun(parentRunId) {
|
|
354
|
+
const rows = await db
|
|
355
|
+
.select()
|
|
356
|
+
.from(chatRuns)
|
|
357
|
+
.where(eq(chatRuns.parentRunId, parentRunId))
|
|
358
|
+
.orderBy(asc(chatRuns.startedAt))
|
|
359
|
+
return rows.map(mapRun)
|
|
360
|
+
},
|
|
317
361
|
// Optional; still-running runs detached at or before the cutoff. Uses the
|
|
318
362
|
// (status, detachedSince) index. The cutoff is inclusive.
|
|
319
363
|
async listReclaimable({ now, ttlMs }) {
|
|
@@ -531,11 +575,12 @@ seven stores, so a chat adapter declares the generation half it omits; drop the
|
|
|
531
575
|
`skip` once you add those tables. `skip` never accepts `'locks'`, which is not a
|
|
532
576
|
store.
|
|
533
577
|
|
|
534
|
-
If your recipe leaves
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
578
|
+
If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
|
|
579
|
+
declare it with `skipMethods`, for example
|
|
580
|
+
`{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
|
|
581
|
+
fails the suite instead of silently passing. Subagent support is optional: when
|
|
582
|
+
`listByParentRun` is absent, the subagent checks skip on their own and need no
|
|
583
|
+
entry.
|
|
539
584
|
|
|
540
585
|
## Only if you are publishing this as a package
|
|
541
586
|
|
|
@@ -67,9 +67,14 @@ model ChatRun {
|
|
|
67
67
|
detachedSince BigInt? @map("detached_since")
|
|
68
68
|
cancelRequested Boolean? @map("cancel_requested")
|
|
69
69
|
driverEpoch Int? @map("driver_epoch")
|
|
70
|
+
parentRunId String? @map("parent_run_id")
|
|
71
|
+
subagentRunId String? @map("subagent_run_id")
|
|
72
|
+
name String?
|
|
70
73
|
|
|
71
74
|
@@index([threadId, status])
|
|
72
75
|
@@index([threadId, startedAt])
|
|
76
|
+
// Powers listByParentRun: children of one parent, oldest startedAt first.
|
|
77
|
+
@@index([parentRunId, startedAt])
|
|
73
78
|
// Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
|
|
74
79
|
@@index([status, detachedSince])
|
|
75
80
|
@@map("chat_runs")
|
|
@@ -204,6 +209,9 @@ function mapRun(row: ChatRun): RunRecord {
|
|
|
204
209
|
? { cancelRequested: row.cancelRequested }
|
|
205
210
|
: {}),
|
|
206
211
|
...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
|
|
212
|
+
...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}),
|
|
213
|
+
...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}),
|
|
214
|
+
...(row.name != null ? { name: row.name } : {}),
|
|
207
215
|
}
|
|
208
216
|
}
|
|
209
217
|
|
|
@@ -250,7 +258,8 @@ function createRunStore(db: PrismaClient): RunStore {
|
|
|
250
258
|
},
|
|
251
259
|
// An empty `update` is Prisma's ON CONFLICT DO NOTHING: an existing runId
|
|
252
260
|
// comes back untouched, so resume and double-submit are safe.
|
|
253
|
-
async createOrResume(
|
|
261
|
+
async createOrResume(input) {
|
|
262
|
+
const { runId, threadId, startedAt, status } = input
|
|
254
263
|
const row = await db.chatRun.upsert({
|
|
255
264
|
where: { runId },
|
|
256
265
|
create: {
|
|
@@ -258,6 +267,13 @@ function createRunStore(db: PrismaClient): RunStore {
|
|
|
258
267
|
threadId,
|
|
259
268
|
status: status ?? 'running',
|
|
260
269
|
startedAt: BigInt(startedAt),
|
|
270
|
+
...(input.parentRunId !== undefined
|
|
271
|
+
? { parentRunId: input.parentRunId }
|
|
272
|
+
: {}),
|
|
273
|
+
...(input.subagentRunId !== undefined
|
|
274
|
+
? { subagentRunId: input.subagentRunId }
|
|
275
|
+
: {}),
|
|
276
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
261
277
|
},
|
|
262
278
|
update: {},
|
|
263
279
|
})
|
|
@@ -314,6 +330,15 @@ function createRunStore(db: PrismaClient): RunStore {
|
|
|
314
330
|
})
|
|
315
331
|
return rows.map(mapRun)
|
|
316
332
|
},
|
|
333
|
+
// Optional. Child runs for one parent, oldest startedAt first.
|
|
334
|
+
// reconstructChat uses this list to put subagent cards back.
|
|
335
|
+
async listByParentRun(parentRunId) {
|
|
336
|
+
const rows = await db.chatRun.findMany({
|
|
337
|
+
where: { parentRunId },
|
|
338
|
+
orderBy: { startedAt: 'asc' },
|
|
339
|
+
})
|
|
340
|
+
return rows.map(mapRun)
|
|
341
|
+
},
|
|
317
342
|
// Optional; still-running runs detached at or before the cutoff. Uses
|
|
318
343
|
// the (status, detachedSince) index. The cutoff is inclusive.
|
|
319
344
|
async listReclaimable({ now, ttlMs }) {
|
|
@@ -489,11 +514,12 @@ provided; the suite also covers the three generation stores, so declare those as
|
|
|
489
514
|
skipped until you add them. `skip` never accepts `'locks'`, which is not a
|
|
490
515
|
store.
|
|
491
516
|
|
|
492
|
-
If your recipe leaves
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
517
|
+
If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
|
|
518
|
+
declare it with `skipMethods`, for example
|
|
519
|
+
`{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
|
|
520
|
+
fails the suite instead of silently passing. Subagent support is optional: when
|
|
521
|
+
`listByParentRun` is absent, the subagent checks skip on their own and need no
|
|
522
|
+
entry.
|
|
497
523
|
|
|
498
524
|
## Only if you are publishing this as a package
|
|
499
525
|
|
|
@@ -113,28 +113,30 @@ package name.
|
|
|
113
113
|
resolve.
|
|
114
114
|
|
|
115
115
|
Four methods are required (`createOrResume` / `update` / `get` /
|
|
116
|
-
`findActiveRun`).
|
|
116
|
+
`findActiveRun`). Three are optional: implement only the ones your backend needs,
|
|
117
117
|
and leave the rest off the object entirely (not `undefined`, just absent). A
|
|
118
118
|
four-method `RunStore` is a fully valid backend.
|
|
119
119
|
|
|
120
|
-
`withPersistence`
|
|
121
|
-
|
|
122
|
-
Their consumers are elsewhere, and each absence disables exactly one feature:
|
|
120
|
+
`withPersistence` calls `createOrResume` and `update`. The query methods have
|
|
121
|
+
other consumers. Each missing optional method disables one feature:
|
|
123
122
|
|
|
124
|
-
| method | consumer | absent
|
|
125
|
-
| ----------------- | --------------------------------------------------------- |
|
|
126
|
-
| `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required
|
|
127
|
-
| `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all
|
|
128
|
-
| `listByThread` |
|
|
123
|
+
| method | consumer | absent means |
|
|
124
|
+
| ----------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
|
|
125
|
+
| `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required. A `null` stub hides a live run |
|
|
126
|
+
| `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all |
|
|
127
|
+
| `listByThread` | `reconstructChat`, when the transcript has tool calls | the cards of children that a tool call started stay absent |
|
|
128
|
+
| `listByParentRun` | `reconstructChat` | a reload shows the saved text, and the cards stay absent |
|
|
129
129
|
|
|
130
|
-
Consumers of the
|
|
130
|
+
Consumers of the optional methods feature-detect with `store.method?.(...)`
|
|
131
131
|
and degrade rather than throwing. `findActiveRun` is required, so nothing
|
|
132
132
|
feature-detects it.
|
|
133
133
|
|
|
134
134
|
The conformance testkit does not feature-detect. An optional method that is
|
|
135
135
|
missing and not declared in `skipMethods` fails the suite, so an omission is
|
|
136
136
|
always a choice you made on purpose rather than a check that quietly did not
|
|
137
|
-
run.
|
|
137
|
+
run. The one exception is `listByParentRun`: subagent support is optional, so
|
|
138
|
+
those checks skip on their own. Declare yours and the suite reports them as
|
|
139
|
+
skipped with a reason:
|
|
138
140
|
|
|
139
141
|
```ts
|
|
140
142
|
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
|
|
@@ -155,6 +157,9 @@ interface RunStore {
|
|
|
155
157
|
createOrResume: (
|
|
156
158
|
input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
|
|
157
159
|
status?: RunStatus
|
|
160
|
+
parentRunId?: string
|
|
161
|
+
subagentRunId?: string
|
|
162
|
+
name?: string
|
|
158
163
|
},
|
|
159
164
|
) => Promise<RunRecord>
|
|
160
165
|
update: (
|
|
@@ -178,6 +183,7 @@ interface RunStore {
|
|
|
178
183
|
|
|
179
184
|
// Optional
|
|
180
185
|
listByThread?: (threadId: string) => Promise<Array<RunRecord>>
|
|
186
|
+
listByParentRun?: (parentRunId: string) => Promise<Array<RunRecord>>
|
|
181
187
|
listReclaimable?: (opts: {
|
|
182
188
|
now: number
|
|
183
189
|
ttlMs: number
|
|
@@ -280,15 +286,20 @@ store through `update`/`get` — but `cancelRequested` must round-trip
|
|
|
280
286
|
faithfully (previous section) for the durable path to work at all.
|
|
281
287
|
|
|
282
288
|
- **`createOrResume`** (required): if `runId` exists, return it **unchanged**,
|
|
283
|
-
including its stored `usage`, and ignore the passed `threadId
|
|
284
|
-
`status`. Resuming a run does not
|
|
285
|
-
|
|
286
|
-
to `'running'` on first
|
|
289
|
+
including its stored `usage`, and ignore the passed `threadId`, `startedAt`,
|
|
290
|
+
`status`, `parentRunId`, `subagentRunId`, and `name`. Resuming a run does not
|
|
291
|
+
reset `startedAt` or overwrite its current status. Idempotent retries and
|
|
292
|
+
double-submit depend on this. `status` defaults to `'running'` on first
|
|
293
|
+
creation. The three link fields are copied only on the first insert.
|
|
287
294
|
- **`update`** (required): missing `runId` is a **no-op** (do not throw, do not
|
|
288
295
|
insert).
|
|
289
296
|
- **`get`** (required): current record, or `null` when unknown.
|
|
290
297
|
- **`listByThread`** (optional): every run for `threadId`, ascending by
|
|
291
|
-
`startedAt`.
|
|
298
|
+
`startedAt`. `reconstructChat` calls it to find the parent runs of children
|
|
299
|
+
that a tool call started.
|
|
300
|
+
- **`listByParentRun`** (optional): child runs for `parentRunId`, ascending by
|
|
301
|
+
`startedAt`. `reconstructChat` uses this list to put subagent cards back.
|
|
302
|
+
Omit the method and a reload shows the saved text. The cards stay absent.
|
|
292
303
|
- **`listReclaimable`** (optional): runs where `status === 'running'` AND
|
|
293
304
|
`detachedSince` is set AND `detachedSince <= now - ttlMs`. The cutoff is
|
|
294
305
|
inclusive: a run detached exactly at the cutoff qualifies. This is a query, not
|
|
@@ -308,9 +319,15 @@ faithfully (previous section) for the durable path to work at all.
|
|
|
308
319
|
one release cycle and cost precisely that, which is why it is required now.
|
|
309
320
|
|
|
310
321
|
Capability tiers belong at the STORE level (omit `runs` entirely and declare
|
|
311
|
-
`ChatTranscriptStores`), not the method level
|
|
312
|
-
stubbed method. The
|
|
313
|
-
and each must be declared via `skipMethods` when absent
|
|
322
|
+
`ChatTranscriptStores`), not the method level. Never ship a `RunStore` with a
|
|
323
|
+
stubbed method. The list queries above are the only method-level options,
|
|
324
|
+
and each must be declared via `skipMethods` when absent, except
|
|
325
|
+
`listByParentRun`: without it the subagent checks skip on their own.
|
|
326
|
+
|
|
327
|
+
A subagent child run also stores `parentRunId`, `subagentRunId`, and `name`.
|
|
328
|
+
`createOrResume` writes them on the first insert. A later call for the same
|
|
329
|
+
`runId` leaves them unchanged. If the caller omits a field, omit it on the
|
|
330
|
+
record. Do not store `''` for a missing field.
|
|
314
331
|
|
|
315
332
|
### `InterruptStore`
|
|
316
333
|
|
|
@@ -468,17 +485,17 @@ in `skip` fails loudly.
|
|
|
468
485
|
pass `'locks'`** — it is not a state store and the suite does not cover it.
|
|
469
486
|
|
|
470
487
|
**`skipMethods` (declare-or-fail for optional `RunStore` methods).** A backend
|
|
471
|
-
that omits
|
|
472
|
-
|
|
473
|
-
`skipMethods
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
488
|
+
that omits `listByThread` or `listReclaimable` must declare it. `findActiveRun`
|
|
489
|
+
is required. Declare the omission as `'runs.<method>'`, for example
|
|
490
|
+
`skipMethods: ['runs.listByThread', 'runs.listReclaimable']`. Subagent support is
|
|
491
|
+
optional: when `listByParentRun` is absent, the subagent checks (the link fields
|
|
492
|
+
and the child listing) skip on their own and need no entry.
|
|
493
|
+
An omitted method that is NOT declared throws with an actionable message
|
|
494
|
+
instead of silently reporting a pass. A declared one is reported as a SKIPPED
|
|
495
|
+
vitest case, never as a pass. A case that did not run must never be
|
|
478
496
|
indistinguishable from one that did. See
|
|
479
|
-
`examples/ts-react-chat/src/lib/sqlite-persistence.test.ts
|
|
480
|
-
|
|
481
|
-
`findActiveRun` and `listReclaimable` under test.
|
|
497
|
+
`examples/ts-react-chat/src/lib/sqlite-persistence.test.ts`. It implements every
|
|
498
|
+
run method, so it declares no `skipMethods`.
|
|
482
499
|
|
|
483
500
|
Reference implementation: `memoryPersistence()` in `@tanstack/ai-persistence`.
|
|
484
501
|
|
package/src/memory.ts
CHANGED
|
@@ -60,6 +60,9 @@ class MemoryRunStore implements RunStore {
|
|
|
60
60
|
threadId: string
|
|
61
61
|
status?: RunRecord['status']
|
|
62
62
|
startedAt: number
|
|
63
|
+
parentRunId?: string
|
|
64
|
+
subagentRunId?: string
|
|
65
|
+
name?: string
|
|
63
66
|
}): Promise<RunRecord> {
|
|
64
67
|
const existing = this.runs.get(input.runId)
|
|
65
68
|
if (existing) return Promise.resolve(existing)
|
|
@@ -68,6 +71,13 @@ class MemoryRunStore implements RunStore {
|
|
|
68
71
|
threadId: input.threadId,
|
|
69
72
|
status: input.status ?? 'running',
|
|
70
73
|
startedAt: input.startedAt,
|
|
74
|
+
...(input.parentRunId !== undefined
|
|
75
|
+
? { parentRunId: input.parentRunId }
|
|
76
|
+
: {}),
|
|
77
|
+
...(input.subagentRunId !== undefined
|
|
78
|
+
? { subagentRunId: input.subagentRunId }
|
|
79
|
+
: {}),
|
|
80
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
71
81
|
}
|
|
72
82
|
this.runs.set(record.runId, record)
|
|
73
83
|
return Promise.resolve(record)
|
|
@@ -107,6 +117,12 @@ class MemoryRunStore implements RunStore {
|
|
|
107
117
|
.sort((a, b) => a.startedAt - b.startedAt)
|
|
108
118
|
return Promise.resolve(matching)
|
|
109
119
|
}
|
|
120
|
+
listByParentRun(parentRunId: string): Promise<Array<RunRecord>> {
|
|
121
|
+
const matching = [...this.runs.values()]
|
|
122
|
+
.filter((run) => run.parentRunId === parentRunId)
|
|
123
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
124
|
+
return Promise.resolve(matching)
|
|
125
|
+
}
|
|
110
126
|
listReclaimable(opts: {
|
|
111
127
|
now: number
|
|
112
128
|
ttlMs: number
|