@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,997 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ //#region src/testkit/conformance.ts
3
+ /**
4
+ * Shared conformance suite for the `AIPersistence` store contract.
5
+ *
6
+ * Every backend runs this identical suite — the in-memory reference store and
7
+ * every adapter you write against your own database — so that schema drift or
8
+ * an implementation gap fails immediately. It exercises every method of every
9
+ * store the persistence exposes and is the authoritative compatibility gate for
10
+ * the store interfaces in `../types.ts`.
11
+ *
12
+ * Covers all seven stores: the four chat state stores (`messages`, `runs`,
13
+ * `interrupts`, `metadata`) and the three generation stores (`generationRuns`,
14
+ * `artifacts`, `blobs`). Locks are not part of this suite — they are a separate
15
+ * coordination concern (`LockStore` + `withLocks`), not a store.
16
+ *
17
+ * SKIPPING (declare or fail): a backend that deliberately omits a store must
18
+ * declare it in `options.skip`, and one that omits an OPTIONAL store method
19
+ * must declare it in `options.skipMethods`. Anything absent and not declared
20
+ * fails the suite loudly, and anything declared absent is reported by vitest as
21
+ * a SKIPPED case, never as a pass. Silent gaps are not allowed: a case that did
22
+ * not run must never be indistinguishable from one that did. A chat-only
23
+ * adapter therefore passes `skip: ['generationRuns', 'artifacts', 'blobs']`,
24
+ * and a generation-only one skips the four state stores.
25
+ *
26
+ * NOT COVERED HERE: the four durable-run fields on `RunRecord` (`sandboxKey`,
27
+ * `detachedSince`, `cancelRequested`, `driverEpoch`). They exist for durable
28
+ * sandboxed runs, a chat app never writes them, and requiring them here made every
29
+ * backend implement four columns and an omitted-vs-explicit-undefined rule it had no
30
+ * use for. They are proven by `runDurableRunFieldsConformance` from
31
+ * `@tanstack/ai-sandbox/testkit`, next to the takeover and reaper suites that consume
32
+ * them. A backend that never runs sandboxes can leave the columns out.
33
+ *
34
+ * RESERVED RUN-ID PREFIX: `rc-` belongs to the `listReclaimable` case, which
35
+ * filters the method's (not thread-scoped) result down to `rc-` ids before an
36
+ * exact-set assertion. A new case in `describe('runs')` must NOT seed a run id
37
+ * starting with `rc-`, or it silently changes that expected set.
38
+ */
39
+ /**
40
+ * Unwrap a value the store contract says must be present. Fails the test with a
41
+ * readable message instead of a non-null assertion (banned in this package) or
42
+ * an early `return` that would pass silently.
43
+ */
44
+ function required(value, what) {
45
+ if (value == null) throw new Error(`AIPersistence conformance: expected ${what} to exist`);
46
+ return value;
47
+ }
48
+ /** Read a `BlobObject.body` to completion as one contiguous buffer. */
49
+ async function drainStream(stream) {
50
+ const chunks = [];
51
+ let total = 0;
52
+ const reader = stream.getReader();
53
+ for (;;) {
54
+ const { value, done } = await reader.read();
55
+ if (done) break;
56
+ chunks.push(value);
57
+ total += value.byteLength;
58
+ }
59
+ const bytes = new Uint8Array(total);
60
+ let offset = 0;
61
+ for (const chunk of chunks) {
62
+ bytes.set(chunk, offset);
63
+ offset += chunk.byteLength;
64
+ }
65
+ return bytes;
66
+ }
67
+ /**
68
+ * Register a Vitest suite that validates `makePersistence()` against the full
69
+ * `AIPersistence` contract — every store it provides, and none it declares
70
+ * skipped.
71
+ */
72
+ function runPersistenceConformance(name, makePersistence, options) {
73
+ const skip = new Set(options?.skip ?? []);
74
+ const skipMethods = new Set(options?.skipMethods ?? []);
75
+ describe(`AIPersistence conformance: ${name}`, () => {
76
+ let persistence;
77
+ beforeAll(async () => {
78
+ persistence = await makePersistence();
79
+ });
80
+ /**
81
+ * Return the store for `key`, or `null` when the backend intentionally
82
+ * skips it. Throws (failing the test) when a store is missing but was not
83
+ * declared in `options.skip`.
84
+ */
85
+ function resolveStore(key) {
86
+ const store = persistence.stores[key];
87
+ if (store) return store;
88
+ if (skip.has(key)) return null;
89
+ throw new Error(`AIPersistence conformance: store '${key}' is missing. Provide it, or pass { skip: ['${key}'] } if the omission is intentional.`);
90
+ }
91
+ /**
92
+ * Narrow `runs` to a store that definitely implements the optional method
93
+ * `methodName`, so the case can call it without a non-null assertion.
94
+ *
95
+ * Returns `false` only when the omission was declared in
96
+ * `options.skipMethods` (the caller then reports a skip). An undeclared
97
+ * omission throws, mirroring `resolveStore`: a case that cannot run must
98
+ * never be reported as a pass.
99
+ */
100
+ function hasRunsMethod(runs, methodName) {
101
+ if (runs[methodName]) return true;
102
+ const key = `runs.${methodName}`;
103
+ if (skipMethods.has(key)) return false;
104
+ throw new Error(`AIPersistence conformance: optional method '${key}' is not implemented. Implement it, or pass { skipMethods: ['${key}'] } if the omission is intentional.`);
105
+ }
106
+ describe("messages", () => {
107
+ it("round-trips a thread and returns [] for unknown threads", async (ctx) => {
108
+ const store = resolveStore("messages");
109
+ if (!store) return ctx.skip("store not provided");
110
+ expect(await store.loadThread("thread-unknown")).toEqual([]);
111
+ await store.saveThread("thread-msg", [{
112
+ role: "user",
113
+ content: "hi"
114
+ }, {
115
+ role: "assistant",
116
+ content: "hello"
117
+ }]);
118
+ expect(await store.loadThread("thread-msg")).toEqual([{
119
+ role: "user",
120
+ content: "hi"
121
+ }, {
122
+ role: "assistant",
123
+ content: "hello"
124
+ }]);
125
+ await store.saveThread("thread-msg", [{
126
+ role: "user",
127
+ content: "redo"
128
+ }]);
129
+ expect(await store.loadThread("thread-msg")).toEqual([{
130
+ role: "user",
131
+ content: "redo"
132
+ }]);
133
+ });
134
+ it("round-trips rich message shapes with deep equality", async (ctx) => {
135
+ const store = resolveStore("messages");
136
+ if (!store) return ctx.skip("store not provided");
137
+ const rich = [
138
+ {
139
+ role: "user",
140
+ content: "plain string"
141
+ },
142
+ {
143
+ role: "assistant",
144
+ content: "",
145
+ toolCalls: [{
146
+ id: "call-1",
147
+ type: "function",
148
+ function: {
149
+ name: "search",
150
+ arguments: "{\"query\":\"weather in Paris\"}"
151
+ }
152
+ }]
153
+ },
154
+ {
155
+ role: "tool",
156
+ content: "{\"temperature\":21,\"unit\":\"C\"}",
157
+ toolCallId: "call-1"
158
+ },
159
+ {
160
+ role: "user",
161
+ content: [{
162
+ type: "text",
163
+ content: "What is in this image?"
164
+ }, {
165
+ type: "image",
166
+ source: {
167
+ type: "url",
168
+ value: "https://example.com/cat.png",
169
+ mimeType: "image/png"
170
+ }
171
+ }]
172
+ },
173
+ {
174
+ role: "assistant",
175
+ content: "Here is my answer.",
176
+ thinking: [{
177
+ content: "The user is asking about the image.",
178
+ signature: "sig-1"
179
+ }]
180
+ }
181
+ ];
182
+ await store.saveThread("thread-rich", rich);
183
+ expect(await store.loadThread("thread-rich")).toEqual(rich);
184
+ });
185
+ });
186
+ describe("runs", () => {
187
+ it("creates, resumes idempotently, updates, and gets", async (ctx) => {
188
+ const store = resolveStore("runs");
189
+ if (!store) return ctx.skip("store not provided");
190
+ expect(await store.get("run-missing")).toBeNull();
191
+ expect(await store.createOrResume({
192
+ runId: "run-1",
193
+ threadId: "thread-1",
194
+ startedAt: 1e3
195
+ })).toMatchObject({
196
+ runId: "run-1",
197
+ threadId: "thread-1",
198
+ status: "running",
199
+ startedAt: 1e3
200
+ });
201
+ expect(await store.createOrResume({
202
+ runId: "run-1",
203
+ threadId: "thread-different",
204
+ startedAt: 9999
205
+ })).toMatchObject({
206
+ runId: "run-1",
207
+ threadId: "thread-1",
208
+ startedAt: 1e3
209
+ });
210
+ await store.update("run-1", {
211
+ status: "completed",
212
+ finishedAt: 2e3,
213
+ usage: {
214
+ promptTokens: 3,
215
+ completionTokens: 4,
216
+ totalTokens: 7
217
+ }
218
+ });
219
+ expect(await store.get("run-1")).toMatchObject({
220
+ runId: "run-1",
221
+ status: "completed",
222
+ finishedAt: 2e3,
223
+ usage: {
224
+ promptTokens: 3,
225
+ completionTokens: 4,
226
+ totalTokens: 7
227
+ }
228
+ });
229
+ await store.update("run-1", {
230
+ status: "failed",
231
+ error: {
232
+ message: "boom",
233
+ code: "provider_overloaded"
234
+ }
235
+ });
236
+ const failed = await store.get("run-1");
237
+ expect(failed?.status).toBe("failed");
238
+ expect(failed?.error).toEqual({
239
+ message: "boom",
240
+ code: "provider_overloaded"
241
+ });
242
+ await store.update("run-absent", { status: "completed" });
243
+ expect(await store.get("run-absent")).toBeNull();
244
+ });
245
+ it("createOrResume never resurrects a finished run", async (ctx) => {
246
+ const store = resolveStore("runs");
247
+ if (!store) return ctx.skip("store not provided");
248
+ await store.createOrResume({
249
+ runId: "nc-1",
250
+ threadId: "nc-t",
251
+ startedAt: 10
252
+ });
253
+ await store.update("nc-1", {
254
+ status: "completed",
255
+ finishedAt: 20
256
+ });
257
+ expect(await store.createOrResume({
258
+ runId: "nc-1",
259
+ threadId: "nc-t",
260
+ startedAt: 999,
261
+ status: "running"
262
+ })).toMatchObject({
263
+ runId: "nc-1",
264
+ status: "completed",
265
+ startedAt: 10,
266
+ finishedAt: 20
267
+ });
268
+ expect(await store.get("nc-1")).toMatchObject({
269
+ status: "completed",
270
+ startedAt: 10,
271
+ finishedAt: 20
272
+ });
273
+ });
274
+ it("findActiveRun returns the most recent running run for a thread", async (ctx) => {
275
+ const store = resolveStore("runs");
276
+ if (!store) return ctx.skip("store not provided");
277
+ const thread = "thread-active";
278
+ expect(await store.findActiveRun(thread)).toBeNull();
279
+ await store.createOrResume({
280
+ runId: "active-1",
281
+ threadId: thread,
282
+ startedAt: 1e3
283
+ });
284
+ await store.createOrResume({
285
+ runId: "active-2",
286
+ threadId: thread,
287
+ startedAt: 2e3
288
+ });
289
+ expect(await store.findActiveRun(thread)).toMatchObject({
290
+ runId: "active-2",
291
+ status: "running"
292
+ });
293
+ await store.createOrResume({
294
+ runId: "other-1",
295
+ threadId: "thread-other",
296
+ startedAt: 3e3
297
+ });
298
+ expect(await store.findActiveRun(thread)).toMatchObject({ runId: "active-2" });
299
+ await store.update("active-2", {
300
+ status: "completed",
301
+ finishedAt: 2500
302
+ });
303
+ expect(await store.findActiveRun(thread)).toMatchObject({
304
+ runId: "active-1",
305
+ status: "running"
306
+ });
307
+ await store.update("active-1", {
308
+ status: "completed",
309
+ finishedAt: 1500
310
+ });
311
+ expect(await store.findActiveRun(thread)).toBeNull();
312
+ });
313
+ it("lists runs by thread when supported", async (ctx) => {
314
+ const runs = resolveStore("runs");
315
+ if (!runs) return ctx.skip("store not provided");
316
+ if (!hasRunsMethod(runs, "listByThread")) return ctx.skip("runs.listByThread not implemented");
317
+ await runs.createOrResume({
318
+ runId: "lt-b",
319
+ threadId: "lt",
320
+ startedAt: 2
321
+ });
322
+ await runs.createOrResume({
323
+ runId: "lt-a",
324
+ threadId: "lt",
325
+ startedAt: 1
326
+ });
327
+ expect((await runs.listByThread("lt")).map((r) => r.runId)).toEqual(["lt-a", "lt-b"]);
328
+ });
329
+ it("lists reclaimable detached runs when supported", async (ctx) => {
330
+ const runs = resolveStore("runs");
331
+ if (!runs) return ctx.skip("store not provided");
332
+ if (!hasRunsMethod(runs, "listReclaimable")) return ctx.skip("runs.listReclaimable not implemented");
333
+ const now = 1e4;
334
+ const ttlMs = 5e3;
335
+ const cutoff = now - ttlMs;
336
+ await runs.createOrResume({
337
+ runId: "rc-included",
338
+ threadId: "rc-t",
339
+ startedAt: 1
340
+ });
341
+ await runs.update("rc-included", { detachedSince: 1e3 });
342
+ await runs.createOrResume({
343
+ runId: "rc-boundary",
344
+ threadId: "rc-t",
345
+ startedAt: 1
346
+ });
347
+ await runs.update("rc-boundary", { detachedSince: cutoff });
348
+ await runs.createOrResume({
349
+ runId: "rc-too-recent",
350
+ threadId: "rc-t",
351
+ startedAt: 1
352
+ });
353
+ await runs.update("rc-too-recent", { detachedSince: 5001 });
354
+ await runs.createOrResume({
355
+ runId: "rc-completed",
356
+ threadId: "rc-t",
357
+ startedAt: 1
358
+ });
359
+ await runs.update("rc-completed", {
360
+ detachedSince: 1e3,
361
+ status: "completed",
362
+ finishedAt: 2e3
363
+ });
364
+ await runs.createOrResume({
365
+ runId: "rc-never-detached",
366
+ threadId: "rc-t",
367
+ startedAt: 1
368
+ });
369
+ expect((await runs.listReclaimable({
370
+ now,
371
+ ttlMs
372
+ })).map((r) => r.runId).filter((id) => id.startsWith("rc-")).sort()).toEqual(["rc-boundary", "rc-included"]);
373
+ const zeroTtlRunId = `rc-${crypto.randomUUID()}`;
374
+ await runs.createOrResume({
375
+ runId: zeroTtlRunId,
376
+ threadId: "rc-t",
377
+ startedAt: 1
378
+ });
379
+ await runs.update(zeroTtlRunId, { detachedSince: now });
380
+ expect((await runs.listReclaimable({
381
+ now,
382
+ ttlMs: 0
383
+ })).find((r) => r.runId === zeroTtlRunId)?.detachedSince).toBe(now);
384
+ const reattachedRunId = `rc-${crypto.randomUUID()}`;
385
+ await runs.createOrResume({
386
+ runId: reattachedRunId,
387
+ threadId: "rc-t",
388
+ startedAt: 1
389
+ });
390
+ await runs.update(reattachedRunId, { detachedSince: 1e3 });
391
+ await runs.update(reattachedRunId, { detachedSince: void 0 });
392
+ expect((await runs.listReclaimable({
393
+ now,
394
+ ttlMs
395
+ })).some((r) => r.runId === reattachedRunId)).toBe(false);
396
+ const terminalRunIds = await Promise.all([
397
+ "completed",
398
+ "failed",
399
+ "aborted"
400
+ ].map(async (status) => {
401
+ const runId = `rc-${crypto.randomUUID()}`;
402
+ await runs.createOrResume({
403
+ runId,
404
+ threadId: "rc-t",
405
+ startedAt: 1
406
+ });
407
+ await runs.update(runId, {
408
+ status,
409
+ detachedSince: 1e3,
410
+ finishedAt: 2e3
411
+ });
412
+ return runId;
413
+ }));
414
+ expect((await runs.listReclaimable({
415
+ now,
416
+ ttlMs
417
+ })).some((r) => terminalRunIds.includes(r.runId))).toBe(false);
418
+ const interruptedRunId = `rc-${crypto.randomUUID()}`;
419
+ await runs.createOrResume({
420
+ runId: interruptedRunId,
421
+ threadId: "rc-t",
422
+ startedAt: 1
423
+ });
424
+ await runs.update(interruptedRunId, {
425
+ status: "interrupted",
426
+ detachedSince: 1e3
427
+ });
428
+ expect((await runs.listReclaimable({
429
+ now,
430
+ ttlMs
431
+ })).some((r) => r.runId === interruptedRunId)).toBe(false);
432
+ });
433
+ });
434
+ describe("interrupts", () => {
435
+ it("creates, resolves, cancels, and lists by thread and run", async (ctx) => {
436
+ const store = resolveStore("interrupts");
437
+ if (!store) return ctx.skip("store not provided");
438
+ expect(await store.get("int-missing")).toBeNull();
439
+ await store.create({
440
+ interruptId: "int-1",
441
+ runId: "run-i",
442
+ threadId: "thread-i",
443
+ requestedAt: 10,
444
+ payload: {
445
+ tool: "search",
446
+ args: { q: "x" }
447
+ }
448
+ });
449
+ await store.create({
450
+ interruptId: "int-2",
451
+ runId: "run-i",
452
+ threadId: "thread-i",
453
+ requestedAt: 20,
454
+ payload: { tool: "write" }
455
+ });
456
+ await store.create({
457
+ interruptId: "int-3",
458
+ runId: "run-other",
459
+ threadId: "thread-i",
460
+ requestedAt: 30,
461
+ payload: {}
462
+ });
463
+ expect(await store.get("int-1")).toMatchObject({
464
+ interruptId: "int-1",
465
+ runId: "run-i",
466
+ threadId: "thread-i",
467
+ status: "pending",
468
+ requestedAt: 10,
469
+ payload: {
470
+ tool: "search",
471
+ args: { q: "x" }
472
+ }
473
+ });
474
+ expect((await store.list("thread-i")).map((r) => r.interruptId)).toEqual([
475
+ "int-1",
476
+ "int-2",
477
+ "int-3"
478
+ ]);
479
+ expect((await store.listByRun("run-i")).map((r) => r.interruptId)).toEqual(["int-1", "int-2"]);
480
+ expect((await store.listPending("thread-i")).map((r) => r.interruptId)).toEqual([
481
+ "int-1",
482
+ "int-2",
483
+ "int-3"
484
+ ]);
485
+ await store.resolve("int-1", { ok: true });
486
+ const resolved = await store.get("int-1");
487
+ expect(resolved?.status).toBe("resolved");
488
+ expect(resolved?.response).toEqual({ ok: true });
489
+ expect(typeof resolved?.resolvedAt).toBe("number");
490
+ await store.cancel("int-2");
491
+ const cancelled = await store.get("int-2");
492
+ expect(cancelled?.status).toBe("cancelled");
493
+ expect(typeof cancelled?.resolvedAt).toBe("number");
494
+ expect((await store.listPending("thread-i")).map((r) => r.interruptId)).toEqual(["int-3"]);
495
+ expect((await store.listPendingByRun("run-i")).map((r) => r.interruptId)).toEqual([]);
496
+ });
497
+ it("create is insert-if-absent: a duplicate id never clobbers a resolved interrupt", async (ctx) => {
498
+ const store = resolveStore("interrupts");
499
+ if (!store) return ctx.skip("store not provided");
500
+ await store.create({
501
+ interruptId: "int-dup",
502
+ runId: "run-dup",
503
+ threadId: "thread-dup",
504
+ requestedAt: 100,
505
+ payload: { attempt: 1 }
506
+ });
507
+ await store.resolve("int-dup", { answer: 42 });
508
+ await store.create({
509
+ interruptId: "int-dup",
510
+ runId: "run-dup",
511
+ threadId: "thread-dup",
512
+ requestedAt: 200,
513
+ payload: { attempt: 2 }
514
+ });
515
+ const after = await store.get("int-dup");
516
+ expect(after?.status).toBe("resolved");
517
+ expect(after?.response).toEqual({ answer: 42 });
518
+ expect(after?.payload).toEqual({ attempt: 1 });
519
+ expect(after?.requestedAt).toBe(100);
520
+ });
521
+ it("lists ordered by requestedAt ascending even when inserts are out of order", async (ctx) => {
522
+ const store = resolveStore("interrupts");
523
+ if (!store) return ctx.skip("store not provided");
524
+ await store.create({
525
+ interruptId: "int-late",
526
+ runId: "run-order",
527
+ threadId: "thread-order",
528
+ requestedAt: 300,
529
+ payload: {}
530
+ });
531
+ await store.create({
532
+ interruptId: "int-early",
533
+ runId: "run-order",
534
+ threadId: "thread-order",
535
+ requestedAt: 100,
536
+ payload: {}
537
+ });
538
+ await store.create({
539
+ interruptId: "int-mid",
540
+ runId: "run-order",
541
+ threadId: "thread-order",
542
+ requestedAt: 200,
543
+ payload: {}
544
+ });
545
+ expect((await store.list("thread-order")).map((r) => r.interruptId)).toEqual([
546
+ "int-early",
547
+ "int-mid",
548
+ "int-late"
549
+ ]);
550
+ expect((await store.listPending("thread-order")).map((r) => r.interruptId)).toEqual([
551
+ "int-early",
552
+ "int-mid",
553
+ "int-late"
554
+ ]);
555
+ expect((await store.listByRun("run-order")).map((r) => r.interruptId)).toEqual([
556
+ "int-early",
557
+ "int-mid",
558
+ "int-late"
559
+ ]);
560
+ });
561
+ });
562
+ describe("generationRuns", () => {
563
+ it("creates, resumes idempotently, updates, and gets", async () => {
564
+ const store = resolveStore("generationRuns");
565
+ if (!store) return;
566
+ expect(await store.get("gen-missing")).toBeNull();
567
+ const created = await store.createOrResume({
568
+ runId: "gen-1",
569
+ threadId: "gen-thread-1",
570
+ activity: "image",
571
+ provider: "openai",
572
+ model: "gpt-image-1",
573
+ startedAt: 1e3
574
+ });
575
+ expect(created).toMatchObject({
576
+ runId: "gen-1",
577
+ activity: "image",
578
+ provider: "openai",
579
+ model: "gpt-image-1",
580
+ status: "running",
581
+ startedAt: 1e3
582
+ });
583
+ expect(created.threadId).toBe("gen-thread-1");
584
+ const resumed = await store.createOrResume({
585
+ runId: "gen-1",
586
+ activity: "video",
587
+ provider: "google",
588
+ model: "veo-3",
589
+ startedAt: 9999,
590
+ threadId: "thread-late"
591
+ });
592
+ expect(resumed).toMatchObject({
593
+ runId: "gen-1",
594
+ activity: "image",
595
+ provider: "openai",
596
+ model: "gpt-image-1",
597
+ startedAt: 1e3
598
+ });
599
+ expect(resumed.threadId).toBe("gen-thread-1");
600
+ await store.update("gen-1", {
601
+ status: "completed",
602
+ finishedAt: 2e3,
603
+ result: { images: [{ url: "https://example.com/a.png" }] },
604
+ artifacts: [{
605
+ role: "output",
606
+ artifactId: "art-1",
607
+ runId: "gen-1",
608
+ threadId: "thread-gen",
609
+ name: "a.png",
610
+ mimeType: "image/png",
611
+ size: 3,
612
+ createdAt: "2024-01-01T00:00:00.000Z",
613
+ source: {
614
+ activity: "image",
615
+ path: "images.0",
616
+ provider: "openai",
617
+ model: "gpt-image-1"
618
+ }
619
+ }],
620
+ usage: {
621
+ promptTokens: 1,
622
+ completionTokens: 2,
623
+ totalTokens: 3
624
+ }
625
+ });
626
+ const done = await store.get("gen-1");
627
+ expect(done).toMatchObject({
628
+ status: "completed",
629
+ finishedAt: 2e3,
630
+ result: { images: [{ url: "https://example.com/a.png" }] },
631
+ usage: {
632
+ promptTokens: 1,
633
+ completionTokens: 2,
634
+ totalTokens: 3
635
+ }
636
+ });
637
+ expect(done?.artifacts).toHaveLength(1);
638
+ expect(done?.artifacts?.[0]).toMatchObject({
639
+ artifactId: "art-1",
640
+ mimeType: "image/png"
641
+ });
642
+ await store.update("gen-1", {
643
+ status: "failed",
644
+ error: {
645
+ message: "boom",
646
+ code: "provider_error"
647
+ }
648
+ });
649
+ const failed = await store.get("gen-1");
650
+ expect(failed?.status).toBe("failed");
651
+ expect(failed?.error).toEqual({
652
+ message: "boom",
653
+ code: "provider_error"
654
+ });
655
+ await store.update("gen-absent", { status: "completed" });
656
+ expect(await store.get("gen-absent")).toBeNull();
657
+ });
658
+ it("findLatestForThread returns the most recently started linked run", async () => {
659
+ const store = resolveStore("generationRuns");
660
+ if (!store) return;
661
+ const thread = "thread-gen-latest";
662
+ expect(await store.findLatestForThread(thread)).toBeNull();
663
+ await store.createOrResume({
664
+ runId: "gen-late",
665
+ activity: "image",
666
+ provider: "openai",
667
+ model: "gpt-image-1",
668
+ startedAt: 3e3,
669
+ threadId: thread
670
+ });
671
+ await store.createOrResume({
672
+ runId: "gen-early",
673
+ activity: "image",
674
+ provider: "openai",
675
+ model: "gpt-image-1",
676
+ startedAt: 1e3,
677
+ threadId: thread
678
+ });
679
+ expect(await store.findLatestForThread(thread)).toMatchObject({ runId: "gen-late" });
680
+ await store.createOrResume({
681
+ runId: "gen-other",
682
+ activity: "image",
683
+ provider: "openai",
684
+ model: "gpt-image-1",
685
+ startedAt: 9e3,
686
+ threadId: "thread-gen-other"
687
+ });
688
+ await store.update("gen-late", {
689
+ status: "completed",
690
+ finishedAt: 3500
691
+ });
692
+ expect(await store.findLatestForThread(thread)).toMatchObject({
693
+ runId: "gen-late",
694
+ status: "completed"
695
+ });
696
+ expect(await store.findLatestForThread("thread-unlinked")).toBeNull();
697
+ });
698
+ });
699
+ describe("artifacts", () => {
700
+ const artifact = (overrides) => ({
701
+ runId: "run-art",
702
+ threadId: "thread-art",
703
+ name: "image.png",
704
+ mimeType: "image/png",
705
+ size: 3,
706
+ createdAt: 100,
707
+ ...overrides
708
+ });
709
+ it("saves as an upsert, gets, and lists by run", async () => {
710
+ const store = resolveStore("artifacts");
711
+ if (!store) return;
712
+ expect(await store.get("art-missing")).toBeNull();
713
+ expect(await store.list("run-unknown")).toEqual([]);
714
+ await store.save(artifact({
715
+ artifactId: "art-a",
716
+ blobKey: "artifacts/run-art/art-a",
717
+ createdAt: 100
718
+ }));
719
+ await store.save(artifact({
720
+ artifactId: "art-b",
721
+ sourceUrl: "https://provider.example/expiring.png",
722
+ createdAt: 200
723
+ }));
724
+ await store.save(artifact({
725
+ artifactId: "art-c",
726
+ runId: "run-art-other"
727
+ }));
728
+ expect(await store.get("art-a")).toMatchObject({
729
+ artifactId: "art-a",
730
+ runId: "run-art",
731
+ threadId: "thread-art",
732
+ blobKey: "artifacts/run-art/art-a",
733
+ name: "image.png",
734
+ mimeType: "image/png",
735
+ size: 3,
736
+ createdAt: 100
737
+ });
738
+ expect(await store.get("art-b")).toMatchObject({ sourceUrl: "https://provider.example/expiring.png" });
739
+ expect((await store.list("run-art")).map((r) => r.artifactId)).toEqual(["art-a", "art-b"]);
740
+ await store.save(artifact({
741
+ artifactId: "art-a",
742
+ name: "renamed.png",
743
+ size: 9
744
+ }));
745
+ const updated = await store.get("art-a");
746
+ expect(updated).toMatchObject({
747
+ name: "renamed.png",
748
+ size: 9
749
+ });
750
+ expect(updated?.blobKey).toBeUndefined();
751
+ expect((await store.list("run-art")).map((r) => r.artifactId)).toEqual(["art-a", "art-b"]);
752
+ });
753
+ it("deletes one artifact and every artifact for a run", async () => {
754
+ const store = resolveStore("artifacts");
755
+ if (!store) return;
756
+ await store.save(artifact({
757
+ artifactId: "art-d1",
758
+ runId: "run-del"
759
+ }));
760
+ await store.save(artifact({
761
+ artifactId: "art-d2",
762
+ runId: "run-del"
763
+ }));
764
+ await store.save(artifact({
765
+ artifactId: "art-keep",
766
+ runId: "run-keep"
767
+ }));
768
+ await store.delete("art-d1");
769
+ expect(await store.get("art-d1")).toBeNull();
770
+ expect((await store.list("run-del")).map((r) => r.artifactId)).toEqual(["art-d2"]);
771
+ await store.delete("art-d1");
772
+ await store.deleteForRun("run-del");
773
+ expect(await store.list("run-del")).toEqual([]);
774
+ expect(await store.get("art-d2")).toBeNull();
775
+ expect((await store.list("run-keep")).map((r) => r.artifactId)).toEqual(["art-keep"]);
776
+ await store.deleteForRun("run-del");
777
+ });
778
+ });
779
+ describe("blobs", () => {
780
+ it("round-trips bytes and metadata through put/get/head", async () => {
781
+ const store = resolveStore("blobs");
782
+ if (!store) return;
783
+ expect(await store.get("blob-missing")).toBeNull();
784
+ expect(await store.head("blob-missing")).toBeNull();
785
+ const bytes = new Uint8Array([
786
+ 1,
787
+ 2,
788
+ 3,
789
+ 4
790
+ ]);
791
+ expect(await store.put("blob/a", bytes, {
792
+ contentType: "image/png",
793
+ customMetadata: { runId: "run-blob" }
794
+ })).toMatchObject({
795
+ key: "blob/a",
796
+ size: 4,
797
+ contentType: "image/png",
798
+ customMetadata: { runId: "run-blob" }
799
+ });
800
+ const object = required(await store.get("blob/a"), "blob/a");
801
+ expect(new Uint8Array(await object.arrayBuffer())).toEqual(bytes);
802
+ expect(object).toMatchObject({
803
+ key: "blob/a",
804
+ size: 4,
805
+ contentType: "image/png",
806
+ customMetadata: { runId: "run-blob" }
807
+ });
808
+ expect(await store.head("blob/a")).toMatchObject({
809
+ key: "blob/a",
810
+ size: 4
811
+ });
812
+ await store.put("blob/text", "héllo");
813
+ expect(await required(await store.get("blob/text"), "blob/text").text()).toBe("héllo");
814
+ await store.put("blob/buffer", new Uint8Array([9, 9]).buffer);
815
+ const buffered = required(await store.get("blob/buffer"), "blob/buffer");
816
+ expect(new Uint8Array(await buffered.arrayBuffer())).toEqual(new Uint8Array([9, 9]));
817
+ });
818
+ it("accepts a stream body with no declared length", async () => {
819
+ const store = resolveStore("blobs");
820
+ if (!store) return;
821
+ const bytes = new Uint8Array(64 * 1024).map((_, i) => i % 251);
822
+ const lengthless = required(new Response(bytes).body, "response body").pipeThrough(new TransformStream());
823
+ expect((await store.put("blob/stream", lengthless, { contentType: "application/octet-stream" })).size).toBe(bytes.byteLength);
824
+ const object = required(await store.get("blob/stream"), "blob/stream");
825
+ expect(new Uint8Array(await object.arrayBuffer())).toEqual(bytes);
826
+ expect(object.size).toBe(bytes.byteLength);
827
+ });
828
+ it("accepts expectedLength as an advisory hint for a stream body", async () => {
829
+ const store = resolveStore("blobs");
830
+ if (!store) return;
831
+ const bytes = new Uint8Array(9 * 1024).map((_, i) => i % 251);
832
+ const lengthless = required(new Response(bytes).body, "response body").pipeThrough(new TransformStream());
833
+ expect((await store.put("blob/stream-hint", lengthless, { expectedLength: bytes.byteLength })).size).toBe(bytes.byteLength);
834
+ const object = required(await store.get("blob/stream-hint"), "blob/stream-hint");
835
+ expect(new Uint8Array(await object.arrayBuffer())).toEqual(bytes);
836
+ expect(object.size).toBe(bytes.byteLength);
837
+ });
838
+ it("serves a byte range and reports the slice it served", async () => {
839
+ const store = resolveStore("blobs");
840
+ if (!store) return;
841
+ const bytes = (/* @__PURE__ */ new Uint8Array(1024)).map((_, i) => i % 251);
842
+ await store.put("blob/range", bytes);
843
+ const middle = required(await store.get("blob/range", { range: {
844
+ offset: 100,
845
+ length: 50
846
+ } }), "blob/range");
847
+ expect(new Uint8Array(await middle.arrayBuffer())).toEqual(bytes.slice(100, 150));
848
+ expect(middle.size).toBe(bytes.byteLength);
849
+ expect(middle.range).toEqual({
850
+ offset: 100,
851
+ length: 50
852
+ });
853
+ const tail = required(await store.get("blob/range", { range: { offset: 1e3 } }), "blob/range");
854
+ expect(new Uint8Array(await tail.arrayBuffer())).toEqual(bytes.slice(1e3));
855
+ expect(tail.range).toEqual({
856
+ offset: 1e3,
857
+ length: 24
858
+ });
859
+ const clamped = required(await store.get("blob/range", { range: {
860
+ offset: 1e3,
861
+ length: 1e3
862
+ } }), "blob/range");
863
+ expect(clamped.range).toEqual({
864
+ offset: 1e3,
865
+ length: 24
866
+ });
867
+ expect((await clamped.arrayBuffer()).byteLength).toBe(24);
868
+ const body = required(await store.get("blob/range", { range: {
869
+ offset: 10,
870
+ length: 5
871
+ } }), "blob/range").body;
872
+ if (body) expect(await drainStream(body)).toEqual(bytes.slice(10, 15));
873
+ expect(required(await store.get("blob/range"), "blob/range").range).toBeUndefined();
874
+ });
875
+ it("overwrites an existing key and deletes silently", async () => {
876
+ const store = resolveStore("blobs");
877
+ if (!store) return;
878
+ const first = await store.put("blob/over", new Uint8Array([1]), {
879
+ contentType: "text/plain",
880
+ customMetadata: { v: "1" }
881
+ });
882
+ const second = await store.put("blob/over", new Uint8Array([
883
+ 2,
884
+ 2,
885
+ 2
886
+ ]), {
887
+ contentType: "application/octet-stream",
888
+ customMetadata: { v: "2" }
889
+ });
890
+ expect(second).toMatchObject({
891
+ size: 3,
892
+ contentType: "application/octet-stream",
893
+ customMetadata: { v: "2" }
894
+ });
895
+ if (first.etag !== void 0 && second.etag !== void 0) expect(second.etag).not.toBe(first.etag);
896
+ const after = required(await store.get("blob/over"), "blob/over");
897
+ expect(new Uint8Array(await after.arrayBuffer())).toEqual(new Uint8Array([
898
+ 2,
899
+ 2,
900
+ 2
901
+ ]));
902
+ await store.delete("blob/over");
903
+ expect(await store.get("blob/over")).toBeNull();
904
+ expect(await store.head("blob/over")).toBeNull();
905
+ await store.delete("blob/over");
906
+ });
907
+ it("lists by literal prefix in ascending key order", async () => {
908
+ const store = resolveStore("blobs");
909
+ if (!store) return;
910
+ await store.put("list_/b", new Uint8Array([2]));
911
+ await store.put("list_/a", new Uint8Array([1]));
912
+ await store.put("list_/c", new Uint8Array([3]));
913
+ await store.put("list-x/d", new Uint8Array([4]));
914
+ await store.put("LIST_/e", new Uint8Array([5]));
915
+ const page = await store.list({ prefix: "list_/" });
916
+ expect(page.objects.map((o) => o.key)).toEqual([
917
+ "list_/a",
918
+ "list_/b",
919
+ "list_/c"
920
+ ]);
921
+ expect(page.truncated).toBeFalsy();
922
+ expect((await store.list({ prefix: "list_/nothing-here" })).objects).toEqual([]);
923
+ });
924
+ it("pages with a cursor and returns an empty page for limit 0", async () => {
925
+ const store = resolveStore("blobs");
926
+ if (!store) return;
927
+ for (const key of [
928
+ "page/a",
929
+ "page/b",
930
+ "page/c",
931
+ "page/d",
932
+ "page/e"
933
+ ]) await store.put(key, new Uint8Array([1]));
934
+ const empty = await store.list({
935
+ prefix: "page/",
936
+ limit: 0
937
+ });
938
+ expect(empty.objects).toEqual([]);
939
+ expect(empty.truncated).toBeFalsy();
940
+ expect(empty.cursor).toBeUndefined();
941
+ const seen = [];
942
+ let cursor;
943
+ for (let guard = 0; guard < 10; guard++) {
944
+ const result = await store.list({
945
+ prefix: "page/",
946
+ limit: 2,
947
+ ...cursor !== void 0 ? { cursor } : {}
948
+ });
949
+ seen.push(...result.objects.map((o) => o.key));
950
+ if (!result.truncated) break;
951
+ expect(result.cursor).toBe(result.objects.at(-1)?.key);
952
+ cursor = result.cursor;
953
+ }
954
+ expect(seen).toEqual([
955
+ "page/a",
956
+ "page/b",
957
+ "page/c",
958
+ "page/d",
959
+ "page/e"
960
+ ]);
961
+ const exact = await store.list({
962
+ prefix: "page/",
963
+ limit: 5
964
+ });
965
+ expect(exact.objects.map((o) => o.key)).toEqual(seen);
966
+ expect(exact.truncated).toBeFalsy();
967
+ });
968
+ });
969
+ describe("metadata", () => {
970
+ it("sets, gets, namespaces, and deletes without composite-key collisions", async (ctx) => {
971
+ const store = resolveStore("metadata");
972
+ if (!store) return ctx.skip("store not provided");
973
+ expect(await store.get("scope-a", "k")).toBeNull();
974
+ await store.set("scope-a", "k", { n: 1 });
975
+ await store.set("scope-b", "k", { n: 2 });
976
+ expect(await store.get("scope-a", "k")).toEqual({ n: 1 });
977
+ expect(await store.get("scope-b", "k")).toEqual({ n: 2 });
978
+ await store.set("scope-a", "k", { n: 3 });
979
+ expect(await store.get("scope-a", "k")).toEqual({ n: 3 });
980
+ await store.delete("scope-a", "k");
981
+ expect(await store.get("scope-a", "k")).toBeNull();
982
+ expect(await store.get("scope-b", "k")).toEqual({ n: 2 });
983
+ await store.set("a:b", "c", "left");
984
+ await store.set("a", "b:c", "right");
985
+ expect(await store.get("a:b", "c")).toBe("left");
986
+ expect(await store.get("a", "b:c")).toBe("right");
987
+ await store.delete("a:b", "c");
988
+ expect(await store.get("a:b", "c")).toBeNull();
989
+ expect(await store.get("a", "b:c")).toBe("right");
990
+ });
991
+ });
992
+ });
993
+ }
994
+ //#endregion
995
+ export { runPersistenceConformance };
996
+
997
+ //# sourceMappingURL=conformance.js.map