@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,313 @@
1
+ ---
2
+ name: ai-persistence/build-cloudflare-adapter
3
+ description: Use when a Cloudflare Worker needs TanStack AI chat persistence — writes a chat-persistence.ts into the app against its D1 binding (raw or via Drizzle), plus a Durable Object LockStore. Covers per-request bindings, wrangler config, D1 migrations, and lease-based locks.
4
+ ---
5
+
6
+ # Cloudflare Chat Persistence
7
+
8
+ The deliverable is **one file in the Worker** — `src/lib/chat-persistence.ts` —
9
+ exporting a factory that builds a `ChatPersistence` from the request's D1
10
+ binding, plus (when the app needs coordination) a Durable Object lock store.
11
+ Tables go into the app's existing `migrations/` directory and are applied with
12
+ `wrangler d1 migrations apply`.
13
+
14
+ Do not create a package or a migration runner. Wrangler already tracks applied
15
+ migrations; a second bookkeeping table only creates drift.
16
+
17
+ Read the **Store Reference**
18
+ (`docs/persistence/store-reference.md`) for the store contracts, and
19
+ **ai-persistence/stores** for the shape rules. This skill covers only
20
+ the Cloudflare-specific parts.
21
+
22
+ ## 1. Read the app before writing anything
23
+
24
+ | Find | Where to look | What it decides |
25
+ | ---------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
26
+ | D1 binding name | `wrangler.jsonc` `d1_databases[].binding` | `env.DB` vs `env.AI_STATE` in the factory |
27
+ | How `env` reaches code | the Worker `fetch(request, env)`, or an async-local helper (`getDb()`, `getCloudflareContext()`) | Whether the factory takes `env` or reads a helper |
28
+ | Drizzle or raw D1 | `drizzle-orm` in `package.json`, a `src/db/schema.ts` | Which recipe below to follow |
29
+ | Migrations dir | `wrangler.jsonc` `migrations_dir`, default `migrations/` | Where the new `.sql` file goes |
30
+ | Existing table names | the current migrations / schema | Prefix (`chat_*`) so nothing collides |
31
+
32
+ ## 2. Two independent pieces
33
+
34
+ ```
35
+ D1 database -> messages, runs, interrupts, metadata (AIPersistence.stores)
36
+ Durable Object -> LockStore (withLocks — NOT a store)
37
+ ```
38
+
39
+ These do not compose into one object. `AIPersistence.stores` accepts exactly
40
+ four keys. Putting `locks` in the map throws
41
+ `Unknown AIPersistence store key: locks`; putting it in a `composePersistence`
42
+ override throws `Unknown AIPersistence override key: locks`. Both also fail to
43
+ type-check. Return
44
+ the state persistence from one factory and the lock store from another, then
45
+ wire them as two middlewares.
46
+
47
+ Most apps need only the first piece. Add the Durable Object when other
48
+ middleware genuinely needs mutual exclusion across isolates —
49
+ `InMemoryLockStore` gives none, because a Worker runs on many isolates at once.
50
+
51
+ ## 3. Bindings are per-request
52
+
53
+ This is the one rule that separates Cloudflare from every other backend. A D1
54
+ binding does not exist at module scope, so `chat-persistence.ts` **must export a
55
+ factory**, not a const:
56
+
57
+ ```ts ignore
58
+ import { defineAIPersistence } from '@tanstack/ai-persistence'
59
+ import type { ChatPersistence } from '@tanstack/ai-persistence'
60
+
61
+ /** Call inside a request handler — `env` is not available at module scope. */
62
+ export function chatPersistence(d1: D1Database): ChatPersistence {
63
+ return defineAIPersistence({
64
+ stores: {
65
+ messages: createMessageStore(d1),
66
+ runs: createRunStore(d1),
67
+ interrupts: createInterruptStore(d1),
68
+ metadata: createMetadataStore(d1),
69
+ },
70
+ })
71
+ }
72
+ ```
73
+
74
+ Annotate `ChatPersistence` — bare `AIPersistence` is the all-optional bag and
75
+ `withPersistence` rejects it. Building it per request is cheap: the stores hold
76
+ no state beyond the binding.
77
+
78
+ ## 4. The stores
79
+
80
+ Two routes, same invariants:
81
+
82
+ - **Drizzle over D1** — if the app already runs Drizzle, wrap the binding with
83
+ `drizzle(env.DB, { schema })` and follow
84
+ **ai-persistence/build-drizzle-adapter** verbatim (its "if `db` is
85
+ per-request" section is exactly this case). Stop reading here.
86
+ - **Raw D1** — implement the four stores against `d1.prepare(sql).bind(...)`:
87
+ `.first()` for `get`, `.all()` for `list*`, `.run()` for writes. D1 speaks
88
+ SQLite, so this mirrors the `node:sqlite` walkthrough in the guide one-for-one;
89
+ everything is already async, so no `Promise.resolve` wrapping.
90
+
91
+ The invariants are the whole game, whichever route you take:
92
+
93
+ | Store | Rule |
94
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | `messages` | `saveThread` is a full replace (`INSERT … ON CONFLICT(thread_id) DO UPDATE`) |
96
+ | `runs` | `createOrResume` reads first, else `INSERT … ON CONFLICT DO NOTHING`, then re-reads |
97
+ | `runs` | `update` on an unknown id is a silent no-op — never throws, never inserts |
98
+ | `runs` | `findActiveRun` (required) returns the latest `'running'` run for the thread, else null |
99
+ | `runs` | `listByThread` (optional) returns every run for the thread `ORDER BY started_at ASC` |
100
+ | `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
+ | `interrupts` | `create` is insert-if-absent; never clobber a resolved interrupt back to pending |
102
+ | `interrupts` | every `list*` ends `ORDER BY requested_at ASC` |
103
+ | `metadata` | reject nullish `set` with a clear `TypeError`; tell callers to use `delete` |
104
+
105
+ On `runs`, `findActiveRun` is required; `listByThread` and `listReclaimable` are
106
+ optional, so implement those two only if the app needs them. `withPersistence`
107
+ calls **none** of the three — the consumers are `reconstruct.ts`
108
+ (`findActiveRun`, for rejoin-by-thread) and `@tanstack/ai-sandbox`'s `reapDetachedRuns`
109
+ (`listReclaimable`, without which the store cannot be reaped); nothing in the
110
+ framework calls `listByThread`. Consumers of the two OPTIONAL methods
111
+ feature-detect with `store.method?.(...)` and degrade to "not supported" when one
112
+ is absent. The conformance testkit does not: either of those you leave out must
113
+ be listed in `skipMethods` or the suite fails, so declare them and it reports the
114
+ omission as a skip.
115
+
116
+ `RunRecord.error` is a structured `RunError` (`{ message: string, code?: string }`),
117
+ so the table gets two columns rather than one JSON blob: `error` for the
118
+ provider's prose and `error_code` for the stable classification an operator
119
+ filters and groups by. Write both together in `update`, so a later code-less
120
+ failure can never leave a stale `error_code` from an earlier one behind, and
121
+ omit `code` from the mapped record when the column is `null`:
122
+ `...(row.error != null ? { error: { message: row.error, ...(row.error_code != null ? { code: row.error_code } : {}) } } : {})`.
123
+ Other row mappers omit absent optionals the same way
124
+ (`...(row.sandbox_key != null ? { sandboxKey: row.sandbox_key } : {})`) so
125
+ records compare cleanly against the reference in-memory backend. JSON columns
126
+ are `text`: `JSON.parse` on read, `JSON.stringify` on write. Timestamps are
127
+ `integer` epoch ms.
128
+
129
+ `sandbox_key`, `detached_since`, `cancel_requested`, and `driver_epoch` are the
130
+ durable-agent-runs columns. In `update`, check `'field' in patch` for all
131
+ four — never `patch.field !== undefined` — because a reattach clears
132
+ `detachedSince` by passing it explicitly as `undefined`, which must bind
133
+ `NULL` into the `SET` clause rather than being filtered out of it (a filtered
134
+ clear leaves the stale value and the run looks permanently detached to the
135
+ reaper). The same applies to `cancelRequested`: `false` written explicitly is
136
+ a real, meaningful value distinct from "never set", not something to coerce
137
+ away. See `examples/ts-react-chat/src/lib/sqlite-persistence.ts` (D1 speaks
138
+ the same SQLite dialect) for the worked `update` body.
139
+
140
+ ## 5. The migration
141
+
142
+ Write the tables into the app's `migrations/` directory as a new numbered file:
143
+
144
+ ```sql
145
+ CREATE TABLE IF NOT EXISTS chat_threads (
146
+ thread_id text PRIMARY KEY NOT NULL,
147
+ messages_json text NOT NULL,
148
+ updated_at integer NOT NULL
149
+ );
150
+ CREATE TABLE IF NOT EXISTS chat_runs (
151
+ run_id text PRIMARY KEY NOT NULL,
152
+ thread_id text NOT NULL,
153
+ status text NOT NULL,
154
+ started_at integer NOT NULL,
155
+ finished_at integer,
156
+ error text,
157
+ error_code text,
158
+ usage_json text,
159
+ sandbox_key text,
160
+ detached_since integer,
161
+ cancel_requested integer,
162
+ driver_epoch integer
163
+ );
164
+ CREATE INDEX IF NOT EXISTS chat_runs_thread_status ON chat_runs (thread_id, status);
165
+ CREATE INDEX IF NOT EXISTS chat_runs_thread_started ON chat_runs (thread_id, started_at);
166
+ -- Powers listReclaimable: status = 'running' AND detached_since <= cutoff.
167
+ CREATE INDEX IF NOT EXISTS chat_runs_status_detached ON chat_runs (status, detached_since);
168
+ CREATE TABLE IF NOT EXISTS chat_interrupts (
169
+ interrupt_id text PRIMARY KEY NOT NULL,
170
+ run_id text NOT NULL,
171
+ thread_id text NOT NULL,
172
+ status text NOT NULL,
173
+ requested_at integer NOT NULL,
174
+ resolved_at integer,
175
+ payload_json text NOT NULL,
176
+ response_json text
177
+ );
178
+ CREATE INDEX IF NOT EXISTS chat_interrupts_thread ON chat_interrupts (thread_id, requested_at);
179
+ CREATE TABLE IF NOT EXISTS chat_metadata (
180
+ namespace text NOT NULL,
181
+ key text NOT NULL,
182
+ value_json text NOT NULL,
183
+ PRIMARY KEY (namespace, key)
184
+ );
185
+ ```
186
+
187
+ Apply with `wrangler d1 migrations apply <database-name>` (`--local` first, then
188
+ `--remote`). If the app also uses Drizzle, generate this file with
189
+ `drizzle-kit generate` instead of hand-writing it — the SQL and the Drizzle
190
+ table definitions must agree, so let one of them own the other.
191
+
192
+ ## 6. Wire it into the chat route
193
+
194
+ ```ts ignore
195
+ import {
196
+ chat,
197
+ chatParamsFromRequest,
198
+ toServerSentEventsResponse,
199
+ } from '@tanstack/ai'
200
+ import { openaiText } from '@tanstack/ai-openai'
201
+ import { withPersistence } from '@tanstack/ai-persistence'
202
+ import { chatPersistence } from './lib/chat-persistence'
203
+
204
+ export default {
205
+ async fetch(request: Request, env: Env) {
206
+ const params = await chatParamsFromRequest(request)
207
+ const stream = chat({
208
+ adapter: openaiText('gpt-5.5'),
209
+ messages: params.messages,
210
+ threadId: params.threadId,
211
+ runId: params.runId,
212
+ ...(params.resume ? { resume: params.resume } : {}),
213
+ middleware: [withPersistence(chatPersistence(env.DB))],
214
+ })
215
+ return toServerSentEventsResponse(stream)
216
+ },
217
+ }
218
+ ```
219
+
220
+ `threadId` is a bare string to the stores. **Authorize thread access at the
221
+ route** — derive the user from the session, never trust a client-supplied id.
222
+
223
+ ## 7. Durable Object lock store (only if needed)
224
+
225
+ Implement `LockStore` from `@tanstack/ai/locks`. `withLock(key, fn)`
226
+ routes each key to a Durable Object instance (`idFromName(key)`) that serializes
227
+ owners. Use **leases** so a crashed owner cannot block forever: the DO grants a
228
+ lease with an expiry, an alarm reclaims it, and the lock passes the callback an
229
+ `AbortSignal` that fires when ownership can no longer be guaranteed. Callbacks
230
+ must stop starting external mutations once the signal aborts.
231
+
232
+ Export the DO class from the Worker entry so wrangler can bind it:
233
+
234
+ ```ts ignore
235
+ export { ChatLockDurableObject } from './locks'
236
+ ```
237
+
238
+ Then wire both middlewares:
239
+
240
+ ```ts ignore
241
+ import { withLocks } from '@tanstack/ai/locks'
242
+ import { withPersistence } from '@tanstack/ai-persistence'
243
+
244
+ const middleware = [
245
+ withPersistence(chatPersistence(env.AI_STATE)),
246
+ withLocks(createDurableObjectLockStore(env.AI_LOCKS)),
247
+ ]
248
+ ```
249
+
250
+ ## wrangler bindings
251
+
252
+ ```jsonc
253
+ {
254
+ "d1_databases": [
255
+ {
256
+ "binding": "AI_STATE",
257
+ "database_name": "tanstack-ai-state",
258
+ "database_id": "<id>",
259
+ "migrations_dir": "migrations",
260
+ },
261
+ ],
262
+ "durable_objects": {
263
+ "bindings": [{ "name": "AI_LOCKS", "class_name": "ChatLockDurableObject" }],
264
+ },
265
+ "migrations": [
266
+ { "tag": "v1", "new_sqlite_classes": ["ChatLockDurableObject"] },
267
+ ],
268
+ }
269
+ ```
270
+
271
+ Durable Object locks do not use the D1 table migration set; their state is
272
+ configured through the migration tags above.
273
+
274
+ ## Verify
275
+
276
+ ```ts ignore
277
+ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
278
+ import { env } from 'cloudflare:test'
279
+ import { chatPersistence } from '../src/lib/chat-persistence'
280
+
281
+ runPersistenceConformance('app-d1', () => chatPersistence(env.AI_STATE), {
282
+ skip: ['generationRuns', 'artifacts', 'blobs'],
283
+ })
284
+ ```
285
+
286
+ Run it against a Miniflare D1 binding with the migration applied, reset between
287
+ runs. All four state stores are provided; the suite also covers the three
288
+ generation stores, so a chat-only adapter declares them skipped (drop the `skip`
289
+ once you add the R2-backed set from
290
+ **ai-persistence/build-cloudflare-artifact-store**). `skip` never accepts
291
+ `'locks'`, which is not a store.
292
+
293
+ If your recipe leaves an optional `runs` method
294
+ (`listByThread`/`listReclaimable`) unimplemented, declare it
295
+ with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
296
+ omitted method that is not declared fails the suite instead of silently
297
+ passing.
298
+
299
+ The lock store needs its **own** tests, because nothing in the conformance suite
300
+ touches it. Cover at minimum: two concurrent `withLock` calls on the same key
301
+ serialize; different keys do not block each other; a lease that expires aborts
302
+ the signal handed to the critical section; and a callback that throws still
303
+ releases the lock.
304
+
305
+ ## Only if you are publishing this as a package
306
+
307
+ For a reusable npm adapter rather than a file in the app: peer-dep
308
+ `@cloudflare/workers-types >=4.x`, and prepend
309
+ `/// <reference types="@cloudflare/workers-types" />` to the generated
310
+ `index.d.ts` so consumers get the D1/DurableObject types. Emit the table SQL
311
+ into the consumer's `migrations/` directory rather than shipping a runner, and
312
+ if you offer both raw-D1 and Drizzle paths, guard with a test that the emitted
313
+ SQL and the Drizzle tables describe the same schema.