mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(durable): IndexedDbStorage, a browser storage backed by IndexedDB (@earendil-works/pi-durable/storage/indexeddb)
This commit is contained in:
parent
bdd9f0399d
commit
16f5f0af4d
6 changed files with 1514 additions and 0 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -3615,6 +3615,16 @@
|
||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fake-indexeddb": {
|
||||||
|
"version": "6.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.6.tgz",
|
||||||
|
"integrity": "sha512-ihhmr0KoET51jT8R4YhbUYuLLvr1qqO6INISygtN16i8loR8kAxtAUr4+EKHTSMjL+lggaAn4OC7DRqzrNgnuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -6363,6 +6373,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-plugin": "1.4.4",
|
"@cloudflare/vitest-plugin": "1.4.4",
|
||||||
|
"fake-indexeddb": "6.2.6",
|
||||||
"shx": "0.3.4",
|
"shx": "0.3.4",
|
||||||
"vitest": "4.1.11"
|
"vitest": "4.1.11"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added `IndexedDbStorage` (`@earendil-works/pi-durable/storage/indexeddb`), a browser storage backed by IndexedDB.
|
||||||
- `HarnessOptions.telemetry` emits the content-free `DURABLE_TELEMETRY_SCHEMA`: public submission admission spans, task phase spans (including extension tasks), committed entry and submission events, running phase-change checkpoints, and nested-tool replay events. Optional propagation stores opaque carriers on submissions and tasks so queued inputs and reopened tasks retain their trace; `TaskOptions.trace` accepts a carrier override or `"root"` (absence inherits the creator's carrier); creation-time `links` connect inputs sharing a run, while steer admissions link to the running trace. Internal admissions emit events without a submit span. Optional metrics record phase duration and live task counts. Failed phases record fixed error-status categories (`task_faulted`, `task_failed`, `task_orphaned`) and diagnostics capped at 500 characters; aborted phases keep status `ok`. Every `onReport` report also emits a fixed-field `pi.durable.report` event on its current span (dropped by the NOOP span when there is none), plus a `pi.durable.reports` counter by type; report telemetry contains no error payload and leaves the callback unchanged. Clock failures report without a phase context. Generation and compaction forward their phase context to pi-ai requests.
|
- `HarnessOptions.telemetry` emits the content-free `DURABLE_TELEMETRY_SCHEMA`: public submission admission spans, task phase spans (including extension tasks), committed entry and submission events, running phase-change checkpoints, and nested-tool replay events. Optional propagation stores opaque carriers on submissions and tasks so queued inputs and reopened tasks retain their trace; `TaskOptions.trace` accepts a carrier override or `"root"` (absence inherits the creator's carrier); creation-time `links` connect inputs sharing a run, while steer admissions link to the running trace. Internal admissions emit events without a submit span. Optional metrics record phase duration and live task counts. Failed phases record fixed error-status categories (`task_faulted`, `task_failed`, `task_orphaned`) and diagnostics capped at 500 characters; aborted phases keep status `ok`. Every `onReport` report also emits a fixed-field `pi.durable.report` event on its current span (dropped by the NOOP span when there is none), plus a `pi.durable.reports` counter by type; report telemetry contains no error payload and leaves the callback unchanged. Clock failures report without a phase context. Generation and compaction forward their phase context to pi-ai requests.
|
||||||
- `settings.compaction.modelOverrides` sets compaction thresholds per model, keyed by exact `"provider/modelId"`, so one harness can serve small- and large-window models; the threshold check, overflow recovery, and the compaction task use the entry of the model they act for ([#10942](https://github.com/earendil-works/pi/issues/10942)).
|
- `settings.compaction.modelOverrides` sets compaction thresholds per model, keyed by exact `"provider/modelId"`, so one harness can serve small- and large-window models; the threshold check, overflow recovery, and the compaction task use the entry of the model they act for ([#10942](https://github.com/earendil-works/pi/issues/10942)).
|
||||||
- Durable sleeps: a sleeping task holds no invocation, survives restarts, and resumes at its checkpoint once the Harness clock reaches `until`.
|
- Durable sleeps: a sleeping task holds no invocation, survives restarts, and resumes at its checkpoint once the Harness clock reaches `until`.
|
||||||
|
|
|
||||||
|
|
@ -759,10 +759,15 @@ const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": U
|
||||||
| Backend | Import | Notes |
|
| Backend | Import | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Memory | `MemoryStorage` from the package root | Nothing is persisted. |
|
| Memory | `MemoryStorage` from the package root | Nothing is persisted. |
|
||||||
|
| IndexedDB | `IndexedDbStorage.open(name)` from `@earendil-works/pi-durable/storage/indexeddb` | Browser-only storage in one IndexedDB database. |
|
||||||
| SQLite | `openNodeSqliteStorage(file)` from `@earendil-works/pi-durable/storage/sqlite/node` | One database file. WAL mode with `synchronous = NORMAL`: commits survive process crashes; the newest may be lost on power or host failure. |
|
| SQLite | `openNodeSqliteStorage(file)` from `@earendil-works/pi-durable/storage/sqlite/node` | One database file. WAL mode with `synchronous = NORMAL`: commits survive process crashes; the newest may be lost on power or host failure. |
|
||||||
| JSONL | `openNodeJsonlStorage(directory, context)` from `@earendil-works/pi-durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents. |
|
| JSONL | `openNodeJsonlStorage(directory, context)` from `@earendil-works/pi-durable/storage/jsonl/node` | Append-only files in one directory. Pass `{ fsync: true }` to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents. |
|
||||||
| Cloudflare Durable Object | `openDurableObjectSqliteStorage(ctx.storage)` from `@earendil-works/pi-durable/storage/sqlite/cloudflare` | The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions. |
|
| Cloudflare Durable Object | `openDurableObjectSqliteStorage(ctx.storage)` from `@earendil-works/pi-durable/storage/sqlite/cloudflare` | The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions. |
|
||||||
|
|
||||||
|
In a browser, open IndexedDB storage with `await IndexedDbStorage.open(name)`. One owner must hold the database at a
|
||||||
|
time; use a [Web Lock](https://developer.mozilla.org/docs/Web/API/Web_Locks_API) around its lifetime rather than opening
|
||||||
|
it in competing tabs or workers. Call `await navigator.storage.persist()` when durable retention matters.
|
||||||
|
|
||||||
SQLite storage can namespace its tables and indexes when sharing a database: pass `{ prefix: "pi_" }` to
|
SQLite storage can namespace its tables and indexes when sharing a database: pass `{ prefix: "pi_" }` to
|
||||||
`SqliteStorage.open(database, options)`, `openNodeSqliteStorage(path, options)`, or
|
`SqliteStorage.open(database, options)`, `openNodeSqliteStorage(path, options)`, or
|
||||||
`openDurableObjectSqliteStorage(ctx.storage, options)`. Storage renders the prefixed names into its SQL, so adapters
|
`openDurableObjectSqliteStorage(ctx.storage, options)`. Storage renders the prefixed names into its SQL, so adapters
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@
|
||||||
"import": "./dist/storage/memory.js",
|
"import": "./dist/storage/memory.js",
|
||||||
"default": "./dist/storage/memory.js"
|
"default": "./dist/storage/memory.js"
|
||||||
},
|
},
|
||||||
|
"./storage/indexeddb": {
|
||||||
|
"source": "./src/storage/indexeddb.ts",
|
||||||
|
"types": "./dist/storage/indexeddb.d.ts",
|
||||||
|
"import": "./dist/storage/indexeddb.js",
|
||||||
|
"default": "./dist/storage/indexeddb.js"
|
||||||
|
},
|
||||||
"./storage/jsonl": {
|
"./storage/jsonl": {
|
||||||
"source": "./src/storage/jsonl/index.ts",
|
"source": "./src/storage/jsonl/index.ts",
|
||||||
"types": "./dist/storage/jsonl/index.d.ts",
|
"types": "./dist/storage/jsonl/index.d.ts",
|
||||||
|
|
@ -154,6 +160,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-plugin": "1.4.4",
|
"@cloudflare/vitest-plugin": "1.4.4",
|
||||||
|
"fake-indexeddb": "6.2.6",
|
||||||
"shx": "0.3.4",
|
"shx": "0.3.4",
|
||||||
"vitest": "4.1.11"
|
"vitest": "4.1.11"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1208
packages/durable/src/storage/indexeddb.ts
Normal file
1208
packages/durable/src/storage/indexeddb.ts
Normal file
File diff suppressed because it is too large
Load diff
282
packages/durable/test/indexeddb-storage.test.ts
Normal file
282
packages/durable/test/indexeddb-storage.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
|
import type { Context, JsonValue } from "@earendil-works/chord";
|
||||||
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||||
|
import { registerStorageConformance } from "@earendil-works/pi-durable/testing";
|
||||||
|
import { IDBKeyRange as FakeIDBKeyRange, IDBFactory } from "fake-indexeddb";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { idFromNumber } from "../src/ids.ts";
|
||||||
|
import { IndexedDbStorage } from "../src/storage/indexeddb.ts";
|
||||||
|
import type {
|
||||||
|
ConversationId,
|
||||||
|
DocumentCreate,
|
||||||
|
DocumentId,
|
||||||
|
EntryId,
|
||||||
|
Id,
|
||||||
|
Seq,
|
||||||
|
Storage,
|
||||||
|
StorageWrite,
|
||||||
|
SubmissionId,
|
||||||
|
TaskId,
|
||||||
|
TaskRecord,
|
||||||
|
} from "../src/types.ts";
|
||||||
|
import { ROOT_CONVERSATION_ID } from "../src/types.ts";
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "IDBKeyRange", { configurable: true, value: FakeIDBKeyRange });
|
||||||
|
|
||||||
|
const context = BACKGROUND_CONTEXT;
|
||||||
|
type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||||
|
|
||||||
|
class ReopeningStorage implements Storage {
|
||||||
|
private current: IndexedDbStorage;
|
||||||
|
private readonly name: string;
|
||||||
|
private readonly indexedDB: IDBFactory;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(current: IndexedDbStorage, name: string, indexedDB: IDBFactory) {
|
||||||
|
this.current = current;
|
||||||
|
this.name = name;
|
||||||
|
this.indexedDB = indexedDB;
|
||||||
|
}
|
||||||
|
|
||||||
|
async commit(writes: readonly StorageWrite[], commitContext: Context): Promise<Seq> {
|
||||||
|
if (this.closed) return this.current.commit(writes, commitContext);
|
||||||
|
try {
|
||||||
|
return await this.current.commit(writes, commitContext);
|
||||||
|
} finally {
|
||||||
|
await this.current.close(context);
|
||||||
|
this.current = await IndexedDbStorage.open(this.name, { indexedDB: this.indexedDB });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mintId<I extends Id<string>>(): Promise<I> {
|
||||||
|
return this.current.mintId<I>();
|
||||||
|
}
|
||||||
|
conversation: Storage["conversation"] = (id, readContext) => this.current.conversation(id, readContext);
|
||||||
|
scanConversations: Storage["scanConversations"] = (query, limit, cursor, readContext) =>
|
||||||
|
this.current.scanConversations(query, limit, cursor, readContext);
|
||||||
|
entry(id: EntryId, readContext: Context): ReturnType<Storage["entry"]>;
|
||||||
|
entry(conversationId: ConversationId, id: EntryId, readContext: Context): ReturnType<Storage["entry"]>;
|
||||||
|
entry(idOrConversationId: EntryId | ConversationId, idOrContext: EntryId | Context, readContext?: Context) {
|
||||||
|
if (readContext === undefined) {
|
||||||
|
return this.current.entry(idFromNumber<EntryId>(idOrConversationId), idOrContext as Context);
|
||||||
|
}
|
||||||
|
if (typeof idOrContext !== "number") throw new TypeError("Storage.entry() requires an entry ID");
|
||||||
|
return this.current.entry(
|
||||||
|
idFromNumber<ConversationId>(idOrConversationId),
|
||||||
|
idFromNumber<EntryId>(idOrContext),
|
||||||
|
readContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
findLatestHeadMarker: Storage["findLatestHeadMarker"] = (conversationId, at, readContext) =>
|
||||||
|
this.current.findLatestHeadMarker(conversationId, at, readContext);
|
||||||
|
scanEntries: Storage["scanEntries"] = (query, limit, cursor, readContext) =>
|
||||||
|
this.current.scanEntries(query, limit, cursor, readContext);
|
||||||
|
task: Storage["task"] = (id, readContext) => this.current.task(id, readContext);
|
||||||
|
scanTasks: Storage["scanTasks"] = (query, limit, cursor, readContext) =>
|
||||||
|
this.current.scanTasks(query, limit, cursor, readContext);
|
||||||
|
submission: Storage["submission"] = (id, readContext) => this.current.submission(id, readContext);
|
||||||
|
scanSubmissions: Storage["scanSubmissions"] = (query, limit, cursor, readContext) =>
|
||||||
|
this.current.scanSubmissions(query, limit, cursor, readContext);
|
||||||
|
submissionByRequest: Storage["submissionByRequest"] = (conversationId, requestId, readContext) =>
|
||||||
|
this.current.submissionByRequest(conversationId, requestId, readContext);
|
||||||
|
findDocument: Storage["findDocument"] = (address, at, readContext) =>
|
||||||
|
this.current.findDocument(address, at, readContext);
|
||||||
|
document: Storage["document"] = (id, at, readContext) => this.current.document(id, at, readContext);
|
||||||
|
scanDocuments: Storage["scanDocuments"] = (query, limit, cursor, readContext) =>
|
||||||
|
this.current.scanDocuments(query, limit, cursor, readContext);
|
||||||
|
|
||||||
|
async close(closeContext: Context): Promise<void> {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
await this.current.close(closeContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStorage(use: (storage: Storage) => Promise<void>): Promise<void> {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const storage = await IndexedDbStorage.open("conformance", { indexedDB });
|
||||||
|
try {
|
||||||
|
await use(storage);
|
||||||
|
} finally {
|
||||||
|
await storage.close(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerStorageConformance({ describe, expect, it }, "IndexedDbStorage", withStorage);
|
||||||
|
|
||||||
|
registerStorageConformance({ describe, expect, it }, "IndexedDbStorage across reopen", async (use) => {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const name = "reopening-conformance";
|
||||||
|
const created = await IndexedDbStorage.open(name, { indexedDB });
|
||||||
|
await created.close(context);
|
||||||
|
const storage = new ReopeningStorage(await IndexedDbStorage.open(name, { indexedDB }), name, indexedDB);
|
||||||
|
try {
|
||||||
|
await use(storage);
|
||||||
|
} finally {
|
||||||
|
await storage.close(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function pendingTask(id: TaskId<JsonValue>, conversationId = ROOT_CONVERSATION_ID): StoredTask {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
conversationId,
|
||||||
|
kind: "test.task",
|
||||||
|
version: 1,
|
||||||
|
input: null,
|
||||||
|
state: { status: "pending", checkpoint: { phase: "ready" } },
|
||||||
|
background: false,
|
||||||
|
abortRequested: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoot(storage: Storage): Promise<void> {
|
||||||
|
await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("persists minted IDs across reopen", async () => {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const storage = await IndexedDbStorage.open("mint-reopen", { indexedDB });
|
||||||
|
expect(await storage.mintId<EntryId>()).toBe(2);
|
||||||
|
await storage.close(context);
|
||||||
|
const reopened = await IndexedDbStorage.open("mint-reopen", { indexedDB });
|
||||||
|
try {
|
||||||
|
expect(await reopened.mintId<EntryId>()).toBe(3);
|
||||||
|
} finally {
|
||||||
|
await reopened.close(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back table writes when a later document copy fails", async () => {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const storage = await IndexedDbStorage.open("atomic-copy", { indexedDB });
|
||||||
|
try {
|
||||||
|
await createRoot(storage);
|
||||||
|
const sourceId = await storage.mintId<DocumentId>();
|
||||||
|
const source = {
|
||||||
|
id: sourceId,
|
||||||
|
kind: "source",
|
||||||
|
scope: { kind: "conversation", conversationId: ROOT_CONVERSATION_ID },
|
||||||
|
history: "rewindable",
|
||||||
|
fork: "asOf",
|
||||||
|
} satisfies DocumentCreate;
|
||||||
|
await storage.commit(
|
||||||
|
[{ type: "document.create", record: source, content: { kind: "base", version: 1, value: { count: 1 } } }],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const taskId = await storage.mintId<TaskId<JsonValue>>();
|
||||||
|
const copyId = await storage.mintId<DocumentId>();
|
||||||
|
await expect(
|
||||||
|
storage.commit(
|
||||||
|
[
|
||||||
|
{ type: "task", value: pendingTask(taskId) },
|
||||||
|
{
|
||||||
|
type: "document.copy",
|
||||||
|
record: { ...source, id: copyId, kind: "mismatch" },
|
||||||
|
source: { id: sourceId, at: "current" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("was rejected");
|
||||||
|
expect(await storage.task(taskId, context)).toBeUndefined();
|
||||||
|
expect(await storage.document(copyId, "current", context)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await storage.close(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scans entries and head markers through fork ancestry", async () => {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const storage = await IndexedDbStorage.open("fork-ancestry", { indexedDB });
|
||||||
|
try {
|
||||||
|
await createRoot(storage);
|
||||||
|
const rootFirst = await storage.mintId<EntryId>();
|
||||||
|
const rootMarker = await storage.mintId<EntryId>();
|
||||||
|
await storage.commit(
|
||||||
|
[
|
||||||
|
{ type: "entry", value: { id: rootFirst, conversationId: ROOT_CONVERSATION_ID, kind: "first" } },
|
||||||
|
{
|
||||||
|
type: "entry",
|
||||||
|
value: {
|
||||||
|
id: rootMarker,
|
||||||
|
conversationId: ROOT_CONVERSATION_ID,
|
||||||
|
kind: "marker",
|
||||||
|
head: rootFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const childId = await storage.mintId<ConversationId>();
|
||||||
|
await storage.commit(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "conversation",
|
||||||
|
value: { id: childId, parent: { conversationId: ROOT_CONVERSATION_ID, at: rootMarker } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const childEntry = await storage.mintId<EntryId>();
|
||||||
|
await storage.commit(
|
||||||
|
[{ type: "entry", value: { id: childEntry, conversationId: childId, kind: "child" } }],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await storage.scanEntries({ conversationId: childId }, 10, undefined, context)).items.map(({ id }) => id),
|
||||||
|
).toEqual([childEntry, rootMarker, rootFirst]);
|
||||||
|
expect((await storage.findLatestHeadMarker(childId, undefined, context))?.id).toBe(rootMarker);
|
||||||
|
} finally {
|
||||||
|
await storage.close(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one readwrite transaction active through many mixed writes", async () => {
|
||||||
|
const indexedDB = new IDBFactory();
|
||||||
|
const storage = await IndexedDbStorage.open("large-commit", { indexedDB });
|
||||||
|
try {
|
||||||
|
await createRoot(storage);
|
||||||
|
const writes: StorageWrite[] = [];
|
||||||
|
let lastEntryId: EntryId | undefined;
|
||||||
|
let lastTaskId: TaskId<JsonValue> | undefined;
|
||||||
|
let lastSubmissionId: SubmissionId | undefined;
|
||||||
|
let lastDocumentId: DocumentId | undefined;
|
||||||
|
for (let index = 0; index < 40; index++) {
|
||||||
|
lastEntryId = await storage.mintId<EntryId>();
|
||||||
|
lastTaskId = await storage.mintId<TaskId<JsonValue>>();
|
||||||
|
lastSubmissionId = await storage.mintId<SubmissionId>();
|
||||||
|
lastDocumentId = await storage.mintId<DocumentId>();
|
||||||
|
writes.push(
|
||||||
|
{
|
||||||
|
type: "entry",
|
||||||
|
value: { id: lastEntryId, conversationId: ROOT_CONVERSATION_ID, kind: "bulk", data: { index } },
|
||||||
|
},
|
||||||
|
{ type: "task", value: pendingTask(lastTaskId) },
|
||||||
|
{
|
||||||
|
type: "submission",
|
||||||
|
value: {
|
||||||
|
id: lastSubmissionId,
|
||||||
|
conversationId: ROOT_CONVERSATION_ID,
|
||||||
|
requestId: `bulk-${index}`,
|
||||||
|
type: "input",
|
||||||
|
status: "queued",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "document.create",
|
||||||
|
record: { id: lastDocumentId, kind: "bulk", key: String(index), scope: { kind: "session" } },
|
||||||
|
content: { kind: "base", version: 1, value: { index } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await storage.commit(writes, context);
|
||||||
|
expect((await storage.entry(lastEntryId!, context))?.entry.data).toEqual({ index: 39 });
|
||||||
|
expect(await storage.task(lastTaskId!, context)).toEqual(pendingTask(lastTaskId!));
|
||||||
|
expect((await storage.submission(lastSubmissionId!, context))?.requestId).toBe("bulk-39");
|
||||||
|
expect((await storage.document(lastDocumentId!, "current", context))?.value).toEqual({ index: 39 });
|
||||||
|
} finally {
|
||||||
|
await storage.close(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue