@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.
Files changed (48) hide show
  1. package/dist/esm/blob-range.d.ts +51 -0
  2. package/dist/esm/blob-range.js +84 -0
  3. package/dist/esm/blob-range.js.map +1 -0
  4. package/dist/esm/capabilities.d.ts +5 -0
  5. package/dist/esm/capabilities.js +16 -0
  6. package/dist/esm/capabilities.js.map +1 -0
  7. package/dist/esm/index.d.ts +13 -0
  8. package/dist/esm/index.js +9 -0
  9. package/dist/esm/memory.d.ts +19 -0
  10. package/dist/esm/memory.js +319 -0
  11. package/dist/esm/memory.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +252 -0
  13. package/dist/esm/middleware.js +872 -0
  14. package/dist/esm/middleware.js.map +1 -0
  15. package/dist/esm/reconstruct-generation.d.ts +129 -0
  16. package/dist/esm/reconstruct-generation.js +148 -0
  17. package/dist/esm/reconstruct-generation.js.map +1 -0
  18. package/dist/esm/reconstruct.d.ts +79 -0
  19. package/dist/esm/reconstruct.js +75 -0
  20. package/dist/esm/reconstruct.js.map +1 -0
  21. package/dist/esm/retrieve.d.ts +40 -0
  22. package/dist/esm/retrieve.js +54 -0
  23. package/dist/esm/retrieve.js.map +1 -0
  24. package/dist/esm/testkit/conformance.d.ts +33 -0
  25. package/dist/esm/testkit/conformance.js +997 -0
  26. package/dist/esm/testkit/conformance.js.map +1 -0
  27. package/dist/esm/types.d.ts +554 -0
  28. package/dist/esm/types.js +103 -0
  29. package/dist/esm/types.js.map +1 -0
  30. package/package.json +71 -0
  31. package/skills/ai-persistence/SKILL.md +218 -0
  32. package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
  33. package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
  34. package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
  35. package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
  36. package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
  37. package/skills/ai-persistence/server/SKILL.md +210 -0
  38. package/skills/ai-persistence/stores/SKILL.md +485 -0
  39. package/src/blob-range.ts +101 -0
  40. package/src/capabilities.ts +18 -0
  41. package/src/index.ts +114 -0
  42. package/src/memory.ts +491 -0
  43. package/src/middleware.ts +1795 -0
  44. package/src/reconstruct-generation.ts +244 -0
  45. package/src/reconstruct.ts +149 -0
  46. package/src/retrieve.ts +77 -0
  47. package/src/testkit/conformance.ts +1288 -0
  48. package/src/types.ts +878 -0
@@ -0,0 +1,485 @@
1
+ ---
2
+ name: ai-persistence/stores
3
+ description: >
4
+ Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts
5
+ for @tanstack/ai-persistence against any database. defineAIPersistence,
6
+ composePersistence overrides, critical invariants (full-replace saveThread,
7
+ insert-if-absent createOrResume and interrupt create), authorize thread
8
+ access, runPersistenceConformance testkit. Use whenever you need server
9
+ persistence — the package ships contracts, not a backend for your database.
10
+ type: sub-skill
11
+ library: tanstack-ai
12
+ library_version: '0.0.0'
13
+ sources:
14
+ - 'TanStack/ai:docs/persistence/store-reference.md'
15
+ - 'TanStack/ai:docs/persistence/controls.md'
16
+ - 'TanStack/ai:packages/ai-persistence/src/types.ts'
17
+ ---
18
+
19
+ # Persistence Stores
20
+
21
+ > Builds on **ai-persistence** and **ai-persistence/server**.
22
+
23
+ `@tanstack/ai-persistence` ships **contracts**, not a backend for your
24
+ database. An adapter is an object with a `stores` map; implement the stores you
25
+ need against whatever you already run and hand the result to
26
+ `withPersistence`. The core never inspects your tables, so the schema is yours.
27
+
28
+ Use `memoryPersistence()` for dev and tests. Everything durable is an adapter
29
+ you write. This skill is the contract reference; the per-stack recipes that
30
+ write a `chat-persistence.ts` into an app are
31
+ `ai-persistence/build-{drizzle,prisma,cloudflare,custom}-adapter`, and
32
+ a complete `node:sqlite` implementation lives in
33
+ `examples/ts-react-chat/src/lib/sqlite-persistence.ts`.
34
+
35
+ ## Choose a shape
36
+
37
+ ```ts
38
+ import { defineAIPersistence } from '@tanstack/ai-persistence'
39
+ import type { ChatWithInterruptsPersistence } from '@tanstack/ai-persistence'
40
+
41
+ // Sparse is fine — only implement what you need.
42
+ export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({
43
+ stores: {
44
+ messages, // required for withPersistence / reconstructChat
45
+ runs, // required if you have interrupts
46
+ interrupts,
47
+ // metadata optional
48
+ },
49
+ })
50
+ ```
51
+
52
+ | Shape | Contents |
53
+ | ------------------------------- | ------------------------------------------------ |
54
+ | `ChatTranscriptPersistence` | `messages` (+ optional runs/interrupts/metadata) |
55
+ | `ChatWithInterruptsPersistence` | `messages` + `runs` + `interrupts` |
56
+ | `ChatPersistence` | all four chat stores |
57
+
58
+ `defineAIPersistence` preserves exact keys and rejects unknown keys at runtime.
59
+
60
+ **Annotate your factory with a named shape.** Bare `AIPersistence` is the
61
+ all-optional sparse bag, so `withPersistence` and `reconstructChat` reject it
62
+ (`stores.messages` is possibly `undefined`). This is the single most common
63
+ mistake when writing an adapter.
64
+
65
+ **`stores` accepts exactly four keys** — `messages`, `runs`, `interrupts`,
66
+ `metadata`. Anything else (notably `locks` or sandbox instance maps) throws
67
+ `Unknown AIPersistence store key` at runtime and fails to type-check. Locks:
68
+ **ai-core/locks** / `@tanstack/ai/locks`. Sandbox instance resume:
69
+ `@tanstack/ai-sandbox`.
70
+
71
+ ## Contracts and invariants
72
+
73
+ ### `MessageStore`
74
+
75
+ ```ts
76
+ interface MessageStore {
77
+ loadThread(threadId: string): Promise<Array<ModelMessage>>
78
+ saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
79
+ }
80
+ ```
81
+
82
+ - `loadThread` → `[]` for unknown threads (never `null`).
83
+ - `saveThread` is a **full overwrite**, not append. A one-message payload wipes history.
84
+
85
+ ### `RunStore`
86
+
87
+ `RunStatus`, `TerminalRunStatus`, `RunRecord`, `RunStore`, `defineRunStore`, and
88
+ `isTerminalRunStatus` are defined in `@tanstack/ai` and re-exported from
89
+ `@tanstack/ai-persistence`. Import those from either; the recipes in this skill
90
+ import from `@tanstack/ai-persistence` so an adapter author needs only one
91
+ package name.
92
+
93
+ **`RunError` is the exception — it is NOT re-exported.** Import it from
94
+ `@tanstack/ai` directly (`import type { RunError } from '@tanstack/ai'`); the
95
+ `@tanstack/ai-persistence` barrel has no such export and the import fails to
96
+ resolve.
97
+
98
+ Four methods are required (`createOrResume` / `update` / `get` /
99
+ `findActiveRun`). Two are optional: implement only the ones your backend needs,
100
+ and leave the rest off the object entirely (not `undefined`, just absent). A
101
+ four-method `RunStore` is a fully valid backend.
102
+
103
+ `withPersistence` itself calls **none** of the three non-`createOrResume`/`update`
104
+ query methods, so leaving both optional ones off costs nothing in the middleware.
105
+ Their consumers are elsewhere, and each absence disables exactly one feature:
106
+
107
+ | method | consumer | absent ⇒ |
108
+ | ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
109
+ | `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required — cannot be absent; stubbing it to `null` silently kills rejoin |
110
+ | `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all |
111
+ | `listByThread` | application code — nothing in the framework calls it | nothing framework-side breaks |
112
+
113
+ Consumers of the two OPTIONAL methods feature-detect with `store.method?.(...)`
114
+ and degrade rather than throwing. `findActiveRun` is required, so nothing
115
+ feature-detects it.
116
+
117
+ The conformance testkit does not feature-detect. An optional method that is
118
+ missing and not declared in `skipMethods` fails the suite, so an omission is
119
+ always a choice you made on purpose rather than a check that quietly did not
120
+ run. Declare yours and the suite reports them as skipped with a reason:
121
+
122
+ ```ts
123
+ // The shipped sqlite example implements findActiveRun and listReclaimable and
124
+ // declares only the one it omits.
125
+ runPersistenceConformance('sqlite', () => persistence, {
126
+ skipMethods: ['runs.listByThread'],
127
+ })
128
+ ```
129
+
130
+ ```ts
131
+ interface RunStore {
132
+ // Required
133
+ createOrResume(
134
+ input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
135
+ status?: RunStatus
136
+ },
137
+ ): Promise<RunRecord>
138
+ update(
139
+ runId: string,
140
+ patch: Partial<
141
+ Pick<
142
+ RunRecord,
143
+ | 'status'
144
+ | 'finishedAt'
145
+ | 'error'
146
+ | 'usage'
147
+ | 'sandboxKey'
148
+ | 'detachedSince'
149
+ | 'cancelRequested'
150
+ | 'driverEpoch'
151
+ >
152
+ >,
153
+ ): Promise<void>
154
+ get(runId: string): Promise<RunRecord | null>
155
+ findActiveRun(threadId: string): Promise<RunRecord | null>
156
+
157
+ // Optional
158
+ listByThread?(threadId: string): Promise<Array<RunRecord>>
159
+ listReclaimable?(opts: {
160
+ now: number
161
+ ttlMs: number
162
+ }): Promise<Array<RunRecord>>
163
+ }
164
+ ```
165
+
166
+ `RunStatus` is `'running' | 'interrupted' | 'completed' | 'failed' | 'aborted'`.
167
+ `'interrupted'` is a human-in-the-loop pause, not terminal: it is what
168
+ interrupt-resume continues from, and must never be conflated with `'aborted'`
169
+ (an explicit cancellation). `TerminalRunStatus` narrows to
170
+ `'completed' | 'failed' | 'aborted'`. `isTerminalRunStatus(status)` is a type
171
+ predicate: `(status: RunStatus) => status is TerminalRunStatus`, so calling it
172
+ inside a guard narrows `status` to `TerminalRunStatus` for the rest of that
173
+ branch, with no cast needed.
174
+
175
+ `RunRecord.error` is a structured `RunError`, not a bare string:
176
+
177
+ ```ts
178
+ interface RunError {
179
+ message: string
180
+ code?: string
181
+ }
182
+ ```
183
+
184
+ `message` is the provider's prose (it changes between model versions and
185
+ cannot be branched on); `code` is the stable, machine-branchable
186
+ classification a consumer switches over to retry, escalate, or show specific
187
+ UI. Store both, and omit `code` from a mapped record when its column is
188
+ `null` rather than writing `code: undefined` (`...(row.errorCode != null ? { code: row.errorCode } : {})`).
189
+
190
+ `defineRunStore<const T extends RunStore>(store: T): T` returns the passed
191
+ object's own type, so an optional method your store implements (say,
192
+ `listByThread`) stays known-present on the returned value instead of widening
193
+ back to `RunStore`'s `| undefined`. You get autocomplete and contract checking
194
+ without a separate `: RunStore` annotation, and without a feature-detection
195
+ guard on your own return value.
196
+
197
+ #### The durable-agent-runs fields: `sandboxKey`, `detachedSince`, `cancelRequested`, `driverEpoch`
198
+
199
+ These four `RunRecord` fields exist for the sandbox/durable-run layer to
200
+ reattach a run a client disconnected from, and for out-of-band cancellation.
201
+ A `RunStore` you write must round-trip all four through `update` → `get`, even
202
+ if your app does not use sandboxes yet — the conformance testkit checks this
203
+ unconditionally (it is not behind `skipMethods`, because `update`/`get` are
204
+ REQUIRED methods).
205
+
206
+ - **`sandboxKey`** — compound key identifying the sandbox this run is bound
207
+ to, so a reclaimer can find it to tear down.
208
+ - **`detachedSince`** — epoch ms when the last viewer detached; absent while
209
+ someone is attached. Read by `listReclaimable`.
210
+ - **`cancelRequested`** — set by an explicit out-of-band cancel (see below),
211
+ distinct from a mere disconnect.
212
+ - **`driverEpoch`** — monotonic fencing token, bumped by each host that
213
+ claims the run, so a superseded host can discover it lost by comparing the
214
+ stored value against the one it holds.
215
+
216
+ **Fresh-run reads must be `undefined`, not a coerced falsy default.** A
217
+ backend that reads a `NULL`/absent column back as `cancelRequested: false` or
218
+ `driverEpoch: 0` is claiming knowledge it does not have ("explicitly not
219
+ cancelled") — that is a different fact from "never set". Omit the field from
220
+ the mapped record instead
221
+ (`...(row.cancel_requested != null ? { cancelRequested: row.cancel_requested !== 0 } : {})`).
222
+
223
+ **`update` must use `'field' in patch`, not `patch.field !== undefined`, for
224
+ these four.** A caller clears `detachedSince` on reattach by passing it
225
+ explicitly as `undefined` — `store.update(runId, { detachedSince: undefined })`
226
+ — and that must write `NULL`, not be filtered out of the write. Checking
227
+ `!== undefined` cannot tell "clear this field" apart from "I didn't mention
228
+ this field", so it silently drops the clear and the run looks permanently
229
+ detached to the reaper forever after. `'detachedSince' in patch` is `true` for
230
+ an explicit `undefined` and `false` when the caller omitted the key entirely —
231
+ that is the distinction you need. The same applies to `cancelRequested`
232
+ (`false` is a real, meaningful value, not "unset") and to `sandboxKey` /
233
+ `driverEpoch`. See `examples/ts-react-chat/src/lib/sqlite-persistence.ts` for
234
+ a worked implementation of exactly this pattern.
235
+
236
+ #### Out-of-band cancellation
237
+
238
+ Cancel intent is **never inferred from a disconnect** — a user pressing Stop
239
+ and a user closing the tab produce an identical connection close, so the two
240
+ are indistinguishable from the abort alone. `@tanstack/ai` exports the actual
241
+ primitives:
242
+
243
+ - **`requestRunCancel`** — records durable cancel intent (writes
244
+ `cancelRequested: true` through a `RunStore`), for a run being driven on a
245
+ host other than the one handling the cancel request.
246
+ - **`wasCancelRequested`** — reads that intent back.
247
+ - **`RUN_CANCEL_REASON`** — the well-known abort reason string used for the
248
+ in-process case (the same host aborting its own signal), paired with
249
+ `isCancelRequestedReason` to check for it.
250
+
251
+ A `RunStore` you write does not call these directly — they operate on your
252
+ store through `update`/`get` — but `cancelRequested` must round-trip
253
+ faithfully (previous section) for the durable path to work at all.
254
+
255
+ - **`createOrResume`** (required): if `runId` exists, return it **unchanged**,
256
+ ignoring the passed `threadId` / `startedAt` / `status`. Resuming a run does
257
+ not reset `startedAt` or overwrite its current status. Idempotent retries and
258
+ double-submit depend on this. `status` defaults to `'running'` on first
259
+ creation.
260
+ - **`update`** (required): missing `runId` is a **no-op** (do not throw, do not
261
+ insert).
262
+ - **`get`** (required): current record, or `null` when unknown.
263
+ - **`listByThread`** (optional): every run for `threadId`, ascending by
264
+ `startedAt`. Only needed to render a thread's past agent activity.
265
+ - **`listReclaimable`** (optional): runs where `status === 'running'` AND
266
+ `detachedSince` is set AND `detachedSince <= now - ttlMs`. The cutoff is
267
+ inclusive: a run detached exactly at the cutoff qualifies. This is a query, not
268
+ automatic behavior — the consumer is `reapDetachedRuns` from
269
+ `@tanstack/ai-sandbox`, which the application schedules itself (cron, queue,
270
+ `alarm()`, `waitUntil`), so returning this list has no side effect until that
271
+ sweep runs. `detachedSince` is written for you by `withSandbox`'s detach path
272
+ (alongside `sandboxKey`) and cleared by the takeover path; drop either field and
273
+ nothing can reclaim the sandbox. `cancelRequested` is written by
274
+ `requestRunCancel` and read by `wasCancelRequested`, and the reaper's expiry
275
+ path goes through `requestRunCancel` to stop a run past its TTL.
276
+ - **`findActiveRun`** (**required**): the most recent `'running'` run for
277
+ `threadId` (max `startedAt`), or `null` if none is active. Enables reconnect
278
+ from a stable thread id without a client-held run id. Stub it out and
279
+ reconnect silently stops working — `null` is also the correct answer for an
280
+ idle thread, so nothing can detect the difference. It was optional for exactly
281
+ one release cycle and cost precisely that, which is why it is required now.
282
+
283
+ Capability tiers belong at the STORE level (omit `runs` entirely and declare
284
+ `ChatTranscriptStores`), not the method level — never ship a `RunStore` with a
285
+ stubbed method. The two list queries above are the only method-level options,
286
+ and each must be declared via `skipMethods` when absent.
287
+
288
+ ### `InterruptStore`
289
+
290
+ ```ts
291
+ interface InterruptStore {
292
+ create(record: Omit<InterruptRecord, 'status' | 'resolvedAt'>): Promise<void>
293
+ resolve(interruptId: string, response?: unknown): Promise<void>
294
+ cancel(interruptId: string): Promise<void>
295
+ get(interruptId: string): Promise<InterruptRecord | null>
296
+ list(threadId: string): Promise<Array<InterruptRecord>>
297
+ listPending(threadId: string): Promise<Array<InterruptRecord>>
298
+ listByRun(runId: string): Promise<Array<InterruptRecord>>
299
+ listPendingByRun(runId: string): Promise<Array<InterruptRecord>>
300
+ }
301
+ ```
302
+
303
+ - `create` always births `'pending'`; **insert-if-absent** on `interruptId`
304
+ (never clobber resolved back to pending).
305
+ - All `list*` ordered by `requestedAt` ascending.
306
+ - Requires a `runs` store when used with chat persistence.
307
+
308
+ ### `MetadataStore`
309
+
310
+ ```ts
311
+ interface MetadataStore {
312
+ get(namespace: string, key: string): Promise<unknown | null>
313
+ set(namespace: string, key: string, value: unknown): Promise<void>
314
+ delete(namespace: string, key: string): Promise<void>
315
+ }
316
+ ```
317
+
318
+ - The first argument is an **app-defined namespace string**, not the `Scope`
319
+ identity type — despite SQL backends conventionally naming the column
320
+ `scope`.
321
+ - Identity is **two fields** `(namespace, key)` — do not join with `:`
322
+ (`('a:b','c')` and `('a','b:c')` must stay distinct).
323
+ - Stored `null` is type-indistinguishable from absence; wrap if you must
324
+ persist real null (`{ value: null }`).
325
+ - SQL backends usually reject nullish `set` (NOT NULL JSON columns) with a
326
+ clear `TypeError` — match that or document your semantics.
327
+
328
+ ## Timestamp convention
329
+
330
+ Store _records_ (`RunRecord`, `InterruptRecord`) speak **epoch milliseconds**
331
+ (`number`). Wire/result references that leave the persistence layer speak
332
+ **ISO-8601 strings**; the middleware converts at the boundary. Do not mix the
333
+ two on one field.
334
+
335
+ ## Minimal message store example
336
+
337
+ Type each store with its `define*Store` helper (`defineMessageStore`,
338
+ `defineRunStore`, `defineInterruptStore`, `defineMetadataStore`): pass the object
339
+ literal and get autocomplete + contract checking inline, with no `: MessageStore`
340
+ annotation. The result composes into `defineAIPersistence` with exact presence.
341
+
342
+ ```ts
343
+ import { defineMessageStore } from '@tanstack/ai-persistence'
344
+ import type { ModelMessage } from '@tanstack/ai'
345
+
346
+ const threads = new Map<string, Array<ModelMessage>>()
347
+
348
+ export const messages = defineMessageStore({
349
+ async loadThread(threadId) {
350
+ return [...(threads.get(threadId) ?? [])]
351
+ },
352
+ async saveThread(threadId, next) {
353
+ threads.set(threadId, [...next])
354
+ },
355
+ })
356
+ ```
357
+
358
+ For durable DBs, preserve the same semantics with upserts / full-row replace.
359
+
360
+ ## Adopt part of it
361
+
362
+ You rarely need all four stores in the same system. Implement the ones you own
363
+ and fill the rest from another base with `composePersistence`:
364
+
365
+ ```ts
366
+ import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence'
367
+ import { messages, runs } from './my-postgres-stores'
368
+
369
+ export const persistence = composePersistence(memoryPersistence(), {
370
+ overrides: { messages, runs },
371
+ })
372
+ ```
373
+
374
+ Only listed keys move; others stay on the base. Pass `false` to drop a store.
375
+ There is **no cross-store transaction** — if `messages` lives in Postgres and
376
+ `interrupts` in Redis, a write touching both is two writes. The store
377
+ invariants (idempotent `createOrResume`, insert-if-absent `create`) are exactly
378
+ what make those retries safe.
379
+
380
+ `composePersistence` accepts the four state keys. Locks and sandbox instance
381
+ maps are not composable here.
382
+
383
+ ## Map onto an existing schema
384
+
385
+ - **Your column names, your types.** Name columns anything; use `jsonb`,
386
+ `timestamptz`, whatever — convert in the row mapper. The record shape the
387
+ methods return is fixed; how you store it is not.
388
+ - **Extra columns are fine.** Add `user_id`, audit columns, a tenant id. Keep
389
+ them nullable or defaulted so the store's inserts still succeed. The stores
390
+ never read or write columns they do not know about.
391
+ - **Omit absent optionals** in row mappers (`...(row.error != null ? { error: row.error } : {})`)
392
+ so records compare cleanly.
393
+
394
+ ## Authorization
395
+
396
+ Store methods take bare `threadId`s. **Authorize at the route** before
397
+ `loadThread` / `saveThread` / `reconstructChat({ authorize })`. Derive user
398
+ identity from session, not the client body alone.
399
+
400
+ ## Conformance tests (required)
401
+
402
+ ```ts
403
+ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
404
+ import { myPersistence } from '../src/persistence'
405
+
406
+ runPersistenceConformance('my-backend', () => myPersistence())
407
+
408
+ // Declare intentional omissions. The suite covers all seven stores, so a
409
+ // chat-only backend skips the generation half:
410
+ // runPersistenceConformance('chat-only', () => p, {
411
+ // skip: ['generationRuns', 'artifacts', 'blobs'],
412
+ // })
413
+ // `skip` never accepts 'locks' — locks are not a store.
414
+
415
+ // Declare an intentionally-unimplemented OPTIONAL RunStore method with
416
+ // skipMethods, so vitest reports it as a real SKIPPED case:
417
+ // runPersistenceConformance('my-backend', () => myPersistence(), {
418
+ // skipMethods: ['runs.listByThread', 'runs.listReclaimable'],
419
+ // })
420
+ ```
421
+
422
+ The testkit is the compatibility gate: round-trips, rich message shapes,
423
+ empty-thread `[]`, `createOrResume` idempotency, interrupt insert-if-absent,
424
+ list ordering, composite-key non-aliasing. A missing store that is not listed
425
+ in `skip` fails loudly.
426
+
427
+ `skip` accepts only `'messages' | 'runs' | 'interrupts' | 'metadata'`. **Do not
428
+ pass `'locks'`** — it is not a state store and the suite does not cover it.
429
+
430
+ **`skipMethods` (declare-or-fail for optional `RunStore` methods).** A backend
431
+ that omits an OPTIONAL `RunStore` method (`listByThread`, `listReclaimable` —
432
+ `findActiveRun` is required and cannot be declared away) must declare it in
433
+ `skipMethods` as `'runs.<method>'`, e.g.
434
+ `skipMethods: ['runs.listByThread', 'runs.listReclaimable']`. An omitted
435
+ method that is NOT declared throws with an actionable message instead of
436
+ silently reporting a pass; a declared one is reported as a SKIPPED vitest
437
+ case, never as a pass. A case that did not run must never be
438
+ indistinguishable from one that did. See
439
+ `examples/ts-react-chat/src/lib/sqlite-persistence.test.ts` for a worked
440
+ example: it declares `skipMethods: ['runs.listByThread']` only, keeping both
441
+ `findActiveRun` and `listReclaimable` under test.
442
+
443
+ Reference implementation: `memoryPersistence()` in `@tanstack/ai-persistence`.
444
+
445
+ ## Common mistakes
446
+
447
+ ### CRITICAL: Append-only `saveThread`
448
+
449
+ Breaks the authoritative-history contract.
450
+
451
+ ### CRITICAL: `createOrResume` overwriting existing runs
452
+
453
+ Breaks safe resume / double-submit.
454
+
455
+ ### CRITICAL: Interrupt `create` upserting to pending
456
+
457
+ Can resurrect a resolved approval.
458
+
459
+ ### HIGH: Returning bare `AIPersistence` from the factory
460
+
461
+ `withPersistence` rejects it. Annotate a named shape.
462
+
463
+ ### HIGH: `list*` without stable `requestedAt` order
464
+
465
+ Middleware and tests assume ascending order.
466
+
467
+ ### HIGH: Skipping the testkit
468
+
469
+ Silent semantic drift shows up as stuck approvals or wiped history in prod.
470
+
471
+ ### HIGH: `listReclaimable` cutoff off by one
472
+
473
+ The cutoff is inclusive (`detachedSince <= now - ttlMs`). Using a strict `<`
474
+ drops runs detached exactly at the boundary.
475
+
476
+ ### HIGH: Treating `listReclaimable` as automatic reclamation
477
+
478
+ It is a query a caller runs, not something the package acts on by itself.
479
+ Nothing reaps a returned run for you.
480
+
481
+ ## Cross-references
482
+
483
+ - **ai-persistence/server** — when middleware calls each store
484
+ - **ai-persistence/build-drizzle-adapter** / **-prisma-** / **-cloudflare-** / **-custom-** — per-stack recipes
485
+ - **ai-core/locks** — not a state store
@@ -0,0 +1,101 @@
1
+ import type { BlobRange } from './types'
2
+
3
+ /**
4
+ * Resolve a requested {@link BlobRange} against an object's real size, the way
5
+ * every byte-storing blob store has to before it slices.
6
+ *
7
+ * Clamps `length` to the end of the object and treats an absent `length` as
8
+ * "to the end", so the result is always the slice actually served — which is
9
+ * what `BlobObject.range` reports and what a `206` response's `Content-Range`
10
+ * is built from.
11
+ *
12
+ * Throws on an `offset` outside the object: that is a caller error, not a
13
+ * store error. A serve route knows the size (it is on the artifact record) and
14
+ * answers `416` from it, so a store only ever sees a satisfiable range unless
15
+ * something upstream is wrong — and silently returning an empty body there
16
+ * would serve a `206` that claims bytes it does not carry.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const { offset, length } = resolveBlobRange(bytes.byteLength, range)
21
+ * const slice = bytes.subarray(offset, offset + length)
22
+ * ```
23
+ */
24
+ export function resolveBlobRange(
25
+ size: number,
26
+ range: BlobRange,
27
+ ): { offset: number; length: number } {
28
+ const { offset } = range
29
+ if (!Number.isInteger(offset) || offset < 0 || offset >= size) {
30
+ throw new RangeError(
31
+ `Blob range offset ${offset} is outside the object (size ${size}).`,
32
+ )
33
+ }
34
+ const remaining = size - offset
35
+ if (range.length === undefined) return { offset, length: remaining }
36
+ if (!Number.isInteger(range.length) || range.length < 0) {
37
+ throw new RangeError(`Blob range length ${range.length} is not valid.`)
38
+ }
39
+ return { offset, length: Math.min(range.length, remaining) }
40
+ }
41
+
42
+ /**
43
+ * Resolve an HTTP `Range` header against a known object size, for a route that
44
+ * serves artifact bytes.
45
+ *
46
+ * Returns the {@link BlobRange} to pass to `retrieveBlob` / `BlobStore.get`,
47
+ * `'unsatisfiable'` when the request names bytes the object does not have — answer
48
+ * `416`, whose `content-range` is the literal `bytes` `*` then a slash then the
49
+ * size — or `undefined` when there is no range to honour and the whole object
50
+ * should be served: an absent header, an invalid byte-range-spec (`bytes=100-50`,
51
+ * which RFC 9110 says to ignore rather than reject), and the forms this does not
52
+ * implement (multiple ranges, units other than `bytes`), which a server is
53
+ * always free to answer in full.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const range = parseRangeHeader(request.headers.get('range'), record.size)
58
+ * if (range === 'unsatisfiable') return new Response(null, { status: 416 })
59
+ * const blob = await retrieveBlob(
60
+ * persistence,
61
+ * record,
62
+ * range ? { range } : undefined,
63
+ * )
64
+ * ```
65
+ */
66
+ export function parseRangeHeader(
67
+ header: string | null | undefined,
68
+ size: number,
69
+ ): BlobRange | 'unsatisfiable' | undefined {
70
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header?.trim() ?? '')
71
+ if (!match) return undefined
72
+ const [, rawStart, rawEnd] = match
73
+ // `bytes=-` names nothing at all.
74
+ if (rawStart === '' && rawEnd === '') return undefined
75
+
76
+ // `bytes=-500` is the LAST 500 bytes, not "from 0 to 500" — the one form
77
+ // that is easy to read backwards, and reading it backwards serves the wrong
78
+ // bytes with a 206 that claims they are the right ones.
79
+ if (rawStart === '') {
80
+ const suffix = Number(rawEnd)
81
+ // A zero-length suffix names no bytes, and NO range is satisfiable against
82
+ // a zero-byte object — without the size check, `bytes=-1` on an empty
83
+ // artifact resolves to `{ offset: 0 }` and throws out of the store instead
84
+ // of answering 416.
85
+ if (suffix === 0 || size === 0) return 'unsatisfiable'
86
+ return { offset: Math.max(0, size - suffix) }
87
+ }
88
+
89
+ const start = Number(rawStart)
90
+ const end = rawEnd === '' ? undefined : Number(rawEnd)
91
+ // `bytes=100-50` is an INVALID byte-range-spec, not an unsatisfiable one
92
+ // (RFC 9110 §14.1.1). An invalid spec is ignored and the whole
93
+ // representation is served — answering 416 would fail a request that is
94
+ // supposed to succeed. Checked before satisfiability so the size cannot turn
95
+ // an ignorable spec into a 416.
96
+ if (end !== undefined && end < start) return undefined
97
+ if (start >= size) return 'unsatisfiable'
98
+ if (end === undefined) return { offset: start }
99
+ // `end` is inclusive, and past the end of the object it simply clamps.
100
+ return { offset: start, length: Math.min(end, size - 1) - start + 1 }
101
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Persistence capability tokens.
3
+ *
4
+ * `withPersistence` PROVIDES persistence/interrupts so later middleware can
5
+ * read durable chat state. Locks live in `@tanstack/ai/locks` (`withLocks`).
6
+ */
7
+ import { createCapability } from '@tanstack/ai'
8
+ import type { AIPersistence, InterruptStore } from './types'
9
+
10
+ export const PersistenceCapability =
11
+ createCapability<AIPersistence>()('persistence')
12
+
13
+ export const InterruptsCapability = createCapability<InterruptStore>()(
14
+ 'persistence.interrupts',
15
+ )
16
+
17
+ export const [getPersistence, providePersistence] = PersistenceCapability
18
+ export const [getInterrupts, provideInterrupts] = InterruptsCapability