feat(durable): IndexedDbStorage, a browser storage backed by IndexedDB (@earendil-works/pi-durable/storage/indexeddb)

This commit is contained in:
Mario Zechner 2026-10-21 03:02:55 +02:00
parent bdd9f0399d
commit 16f5f0af4d
6 changed files with 1514 additions and 0 deletions

View file

@ -13,6 +13,7 @@
### 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.
- `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`.

View file

@ -759,10 +759,15 @@ const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": U
| Backend | Import | Notes |
|---|---|---|
| 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. |
| 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. |
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
`SqliteStorage.open(database, options)`, `openNodeSqliteStorage(path, options)`, or
`openDurableObjectSqliteStorage(ctx.storage, options)`. Storage renders the prefixed names into its SQL, so adapters

View file

@ -66,6 +66,12 @@
"import": "./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": {
"source": "./src/storage/jsonl/index.ts",
"types": "./dist/storage/jsonl/index.d.ts",
@ -154,6 +160,7 @@
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.4.4",
"fake-indexeddb": "6.2.6",
"shx": "0.3.4",
"vitest": "4.1.11"
}

File diff suppressed because it is too large Load diff

View 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);
}
});