@tanstack/ai-persistence 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/blob-range.d.ts +51 -0
- package/dist/esm/blob-range.js +84 -0
- package/dist/esm/blob-range.js.map +1 -0
- package/dist/esm/capabilities.d.ts +5 -0
- package/dist/esm/capabilities.js +16 -0
- package/dist/esm/capabilities.js.map +1 -0
- package/dist/esm/index.d.ts +13 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/memory.d.ts +19 -0
- package/dist/esm/memory.js +319 -0
- package/dist/esm/memory.js.map +1 -0
- package/dist/esm/middleware.d.ts +252 -0
- package/dist/esm/middleware.js +872 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/reconstruct-generation.d.ts +129 -0
- package/dist/esm/reconstruct-generation.js +148 -0
- package/dist/esm/reconstruct-generation.js.map +1 -0
- package/dist/esm/reconstruct.d.ts +79 -0
- package/dist/esm/reconstruct.js +75 -0
- package/dist/esm/reconstruct.js.map +1 -0
- package/dist/esm/retrieve.d.ts +40 -0
- package/dist/esm/retrieve.js +54 -0
- package/dist/esm/retrieve.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +33 -0
- package/dist/esm/testkit/conformance.js +997 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/types.d.ts +554 -0
- package/dist/esm/types.js +103 -0
- package/dist/esm/types.js.map +1 -0
- package/package.json +71 -0
- package/skills/ai-persistence/SKILL.md +218 -0
- package/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +313 -0
- package/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +693 -0
- package/skills/ai-persistence/build-custom-adapter/SKILL.md +328 -0
- package/skills/ai-persistence/build-drizzle-adapter/SKILL.md +562 -0
- package/skills/ai-persistence/build-prisma-adapter/SKILL.md +518 -0
- package/skills/ai-persistence/server/SKILL.md +210 -0
- package/skills/ai-persistence/stores/SKILL.md +485 -0
- package/src/blob-range.ts +101 -0
- package/src/capabilities.ts +18 -0
- package/src/index.ts +114 -0
- package/src/memory.ts +491 -0
- package/src/middleware.ts +1795 -0
- package/src/reconstruct-generation.ts +244 -0
- package/src/reconstruct.ts +149 -0
- package/src/retrieve.ts +77 -0
- package/src/testkit/conformance.ts +1288 -0
- package/src/types.ts +878 -0
|
@@ -0,0 +1,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
|