import { describe, expect, it, vi } from "vitest"; import type { IndexedDbFailureCause, IndexedDbTranslate, } from "../../src/adapters/platform/indexeddb-connection.ts"; import { openIndexedDbDatabase } from "../../src/adapters/platform/indexeddb-connection.ts"; import { onIndexedDbRequest, openIndexedDbTransaction, runIndexedDbTransaction, walkIndexedDbCursor, type IndexedDbCursorStep, type IndexedDbRequestSink, type IndexedDbWalkSummary, } from "../../src/adapters/platform/indexeddb-transaction.ts"; import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts"; /** * IDB-X-02 golden tests. Four hand-written copies of this state machine already * disagree about request errors, value-less completions and durability. Every * one of those choices has to remain expressible at the call site, so the tests * assert the mechanics and never a taxonomy. */ type TestFailure = Readonly<{ cause: IndexedDbFailureCause["kind"]; detail?: unknown }>; function recordingTranslate( seen: IndexedDbFailureCause["kind"][] = [], ): IndexedDbTranslate { return (cause) => { seen.push(cause.kind); switch (cause.kind) { case "NATIVE_EXCEPTION": return { cause: cause.kind, detail: cause.error }; case "BLOCKED": return { cause: cause.kind }; case "BLOCKED_DEADLINE": case "CALLER_ABORT": case "CLOSED": case "NO_VALUE_PRODUCED": case "UNSUPPORTED": return { cause: cause.kind }; case "UPGRADE_REJECTED": case "ADMISSION_REJECTED": return { cause: cause.kind, detail: cause.detail }; default: { const exhaustive: never = cause; return exhaustive; } } }; } type Row = Readonly<{ id: string; rank: number }>; const ROWS: readonly Row[] = Object.freeze([ Object.freeze({ id: "a", rank: 1 }), Object.freeze({ id: "b", rank: 2 }), Object.freeze({ id: "c", rank: 3 }), Object.freeze({ id: "d", rank: 4 }), ]); async function seededDatabase( rows: readonly Row[] = ROWS, ): Promise> { const memory = new MemoryIndexedDbFactory(); const opened = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate(), upgrade: ({ database }) => { database.createObjectStore("rows", { keyPath: "id" }); return { kind: "APPLIED" }; }, }); if (!opened.ok) throw new Error("fixture database did not open"); for (const row of rows) memory.seed("rows", row); return { memory, database: opened.value }; } type StubTransaction = { error: DOMException | null; abort: () => void; oncomplete: ((event: Event) => unknown) | null; onerror: ((event: Event) => unknown) | null; onabort: ((event: Event) => unknown) | null; objectStore: (name: string) => IDBObjectStore; }; /** * A transaction the test drives by hand. The memory fake aborts on every * request error, so "recorded but not aborted" and "abort() itself throws" — * the two behaviors the four copies disagree about — are only observable here. */ function stubDatabase( options: Readonly<{ abortThrows?: boolean; rejectOptionsBag?: boolean }> = {}, ): Readonly<{ database: IDBDatabase; transactions: StubTransaction[]; calls: unknown[][]; }> { const transactions: StubTransaction[] = []; const calls: unknown[][] = []; const database = { transaction: (...args: unknown[]) => { calls.push(args); if (options.rejectOptionsBag === true && args.length > 2) { throw new TypeError("This engine has no durability option."); } const transaction: StubTransaction = { error: null, abort: vi.fn(() => { if (options.abortThrows === true) { throw new DOMException("already finished", "InvalidStateError"); } }), oncomplete: null, onerror: null, onabort: null, objectStore: () => ({}) as IDBObjectStore, }; transactions.push(transaction); return transaction as unknown as IDBTransaction; }, } as unknown as IDBDatabase; return { database, transactions, calls }; } function stubSink(): Readonly<{ sink: IndexedDbRequestSink; failures: TestFailure[]; requestErrors: unknown[]; }> { const failures: TestFailure[] = []; const requestErrors: unknown[] = []; return { failures, requestErrors, sink: { fail: (failure) => failures.push(failure), requestFailed: (error) => requestErrors.push(error), }, }; } describe("shared IndexedDB transaction mechanics", () => { it("returns the value the queue produced once the transaction commits", async () => { const { database, memory } = await seededDatabase(); const result = await runIndexedDbTransaction({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction, context) => { const store = transaction.objectStore("rows"); onIndexedDbRequest(store.put({ id: "e", rank: 5 }), context, () => { context.succeed("written"); }); }, }); expect(result).toEqual({ ok: true, value: "written" }); expect(memory.readRaw("rows", "e")).toEqual({ id: "e", rank: 5 }); }); it("reports NO_VALUE_PRODUCED when a transaction completes without succeed", async () => { const { database } = await seededDatabase(); const seen: IndexedDbFailureCause["kind"][] = []; const result = await runIndexedDbTransaction({ database, stores: ["rows"], mode: "readonly", translate: recordingTranslate(seen), queue: (transaction, context) => { onIndexedDbRequest( transaction.objectStore("rows").get("a"), context, () => undefined, ); }, }); expect(result).toEqual({ ok: false, error: { cause: "NO_VALUE_PRODUCED" }, }); expect(seen).toEqual(["NO_VALUE_PRODUCED"]); }); it("aborts on fail() and reports the recorded failure", async () => { const { database, memory } = await seededDatabase(); const result = await runIndexedDbTransaction({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction, context) => { const store = transaction.objectStore("rows"); onIndexedDbRequest(store.put({ id: "e", rank: 5 }), context, () => { context.fail({ cause: "ADMISSION_REJECTED", detail: "policy" }); }); }, }); expect(result).toEqual({ ok: false, error: { cause: "ADMISSION_REJECTED", detail: "policy" }, }); // The aborted transaction must not have committed the write. expect(memory.readRaw("rows", "e")).toBeUndefined(); }); it("routes a request-level error into the sink", async () => { const { database } = await seededDatabase(); const result = await runIndexedDbTransaction({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction, context) => { const store = transaction.objectStore("rows"); // `add` on an existing key is a request-level ConstraintError, the // exact case `indexeddb-opfs-journal.ts` never wires today. onIndexedDbRequest(store.add({ id: "a", rank: 9 }), context, () => { context.succeed("unreachable"); }); }, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.cause).toBe("NATIVE_EXCEPTION"); expect((result.error.detail as DOMException).name).toBe("ConstraintError"); }); it("records a request error without aborting, while fail() aborts", () => { const stub = stubDatabase(); void runIndexedDbTransaction({ database: stub.database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (_transaction, context) => { context.requestFailed(new Error("recorded only")); }, }); const transaction = stub.transactions[0]; expect(transaction).toBeDefined(); expect(transaction?.abort).not.toHaveBeenCalled(); }); it("never claims a caller abort when transaction.abort() throws", async () => { // CP-4. A transaction can be durably committed while its completion event // is still queued. Reporting ABORTED for it would call a committed write a // failure, so the transaction's own events decide the outcome. const stub = stubDatabase({ abortThrows: true }); const controller = new AbortController(); const pending = runIndexedDbTransaction({ database: stub.database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), signal: controller.signal, queue: (_transaction, context) => { context.succeed("committed"); }, }); controller.abort(); stub.transactions[0]?.oncomplete?.(new Event("complete")); await expect(pending).resolves.toEqual({ ok: true, value: "committed" }); }); it("reports CALLER_ABORT when the signal aborts the transaction", async () => { const stub = stubDatabase(); const controller = new AbortController(); const pending = runIndexedDbTransaction({ database: stub.database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), signal: controller.signal, queue: () => undefined, }); controller.abort(); expect(stub.transactions[0]?.abort).toHaveBeenCalledTimes(1); stub.transactions[0]?.onabort?.(new Event("abort")); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" }, }); }); it("returns CALLER_ABORT without opening a transaction for an aborted signal", async () => { const stub = stubDatabase(); const controller = new AbortController(); controller.abort(); await expect( runIndexedDbTransaction({ database: stub.database, stores: ["rows"], mode: "readonly", translate: recordingTranslate(), signal: controller.signal, queue: () => undefined, }), ).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); expect(stub.calls).toHaveLength(0); }); it("maps a throw from transaction creation", async () => { const boom = new DOMException("closed", "InvalidStateError"); const database = { transaction: () => { throw boom; }, } as unknown as IDBDatabase; await expect( runIndexedDbTransaction({ database, stores: ["rows"], mode: "readonly", translate: recordingTranslate(), queue: () => undefined, }), ).resolves.toEqual({ ok: false, error: { cause: "NATIVE_EXCEPTION", detail: boom }, }); }); it("maps a throw from the queue callback and aborts", async () => { const { database, memory } = await seededDatabase(); const boom = new Error("queue defect"); const result = await runIndexedDbTransaction({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction) => { transaction.objectStore("rows").put({ id: "e", rank: 5 }); throw boom; }, }); expect(result).toEqual({ ok: false, error: { cause: "NATIVE_EXCEPTION", detail: boom }, }); expect(memory.readRaw("rows", "e")).toBeUndefined(); }); it("an abort after succeed reports a failure rather than the value", async () => { const stub = stubDatabase(); const pending = runIndexedDbTransaction({ database: stub.database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (_transaction, context) => { context.succeed("value"); context.fail({ cause: "CLOSED" }); }, }); const transaction = stub.transactions[0]; if (!transaction) throw new Error("no transaction"); transaction.error = new DOMException("aborted", "AbortError"); transaction.onabort?.(new Event("abort")); const result = await pending; expect(result.ok).toBe(false); if (result.ok) return; // The first candidate wins, so the later `fail` never becomes the value; // an aborted transaction still cannot report the success it never kept. expect(result.error.cause).toBe("NATIVE_EXCEPTION"); }); }); describe("IndexedDB transaction factory", () => { it("omits the options bag entirely for an undefined durability", () => { const stub = stubDatabase(); openIndexedDbTransaction(stub.database, ["rows"], "readonly"); expect(stub.calls).toEqual([[["rows"], "readonly"]]); }); it("passes a named durability as an options bag", () => { const stub = stubDatabase(); openIndexedDbTransaction(stub.database, ["rows"], "readwrite", "strict"); expect(stub.calls).toEqual([ [["rows"], "readwrite", { durability: "strict" }], ]); }); it("falls back to the no-options form when the engine rejects the bag", () => { const stub = stubDatabase({ rejectOptionsBag: true }); openIndexedDbTransaction(stub.database, ["rows"], "readwrite", "relaxed"); expect(stub.calls).toEqual([ [["rows"], "readwrite", { durability: "relaxed" }], [["rows"], "readwrite"], ]); }); it("rethrows a non-TypeError from transaction creation", () => { const boom = new DOMException("closed", "InvalidStateError"); const database = { transaction: () => { throw boom; }, } as unknown as IDBDatabase; expect(() => openIndexedDbTransaction(database, ["rows"], "readonly", "strict"), ).toThrow(boom); }); }); describe("IndexedDB cursor pump", () => { async function walk( input: Readonly<{ visit: ( visit: Readonly<{ cursor: IDBCursorWithValue; scannedRows: number; resume: (step: IndexedDbCursorStep) => void; }>, ) => IndexedDbCursorStep; budget?: Parameters>[0]["budget"]; signal?: AbortSignal; store?: IDBObjectStore | undefined; }>, ): Promise< Readonly<{ result: Awaited>>; }> > { const { database } = await seededDatabase(); const result = await runIndexedDbTransaction< IndexedDbWalkSummary, TestFailure >({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction, context) => { const store = transaction.objectStore("rows"); walkIndexedDbCursor({ request: store.openCursor(), sink: context, translate: recordingTranslate(), ...(input.budget ? { budget: input.budget } : {}), ...(input.signal ? { signal: input.signal } : {}), visit: input.visit, done: (summary) => context.succeed(summary), }); }, }); return { result }; } it("visits every row and reports EXHAUSTED", async () => { const visited: string[] = []; const { result } = await walk({ visit: ({ cursor }) => { visited.push((cursor.value as Row).id); return { kind: "CONTINUE" }; }, }); expect(visited).toEqual(["a", "b", "c", "d"]); expect(result).toEqual({ ok: true, value: { reason: "EXHAUSTED", scannedRows: 4 }, }); }); it("ends the walk where the visitor stops it", async () => { const { result } = await walk({ visit: ({ scannedRows }) => scannedRows === 2 ? { kind: "STOP" } : { kind: "CONTINUE" }, }); expect(result).toEqual({ ok: true, value: { reason: "STOPPED", scannedRows: 2 }, }); }); it("resumes a row after the visitor's own nested request chain", async () => { // Without SUSPEND the pump is unusable by runtime and maintenance: every // walk there issues nested requests before advancing. const order: string[] = []; const { database } = await seededDatabase(); const result = await runIndexedDbTransaction< IndexedDbWalkSummary, TestFailure >({ database, stores: ["rows"], mode: "readwrite", translate: recordingTranslate(), queue: (transaction, context) => { const store = transaction.objectStore("rows"); walkIndexedDbCursor({ request: store.openCursor(), sink: context, translate: recordingTranslate(), visit: ({ cursor, resume }) => { const id = (cursor.value as Row).id; order.push(`visit:${id}`); onIndexedDbRequest(store.get(id), context, (value) => { order.push(`nested:${(value as Row).id}`); resume({ kind: "CONTINUE" }); // Idempotent: a second resume cannot advance the cursor twice. resume({ kind: "CONTINUE" }); }); return { kind: "SUSPEND" }; }, done: (summary) => context.succeed(summary), }); }, }); expect(result).toEqual({ ok: true, value: { reason: "EXHAUSTED", scannedRows: 4 }, }); expect(order).toEqual([ "visit:a", "nested:a", "visit:b", "nested:b", "visit:c", "nested:c", "visit:d", "nested:d", ]); }); it("supports both keyed resume forms", async () => { const skipped: string[] = []; const { result } = await walk({ visit: ({ cursor, scannedRows }) => { skipped.push((cursor.value as Row).id); if (scannedRows === 1) return { kind: "CONTINUE_FROM", key: "c" }; if (scannedRows === 2) { return { kind: "CONTINUE_PRIMARY", key: "d", primaryKey: "d" }; } return { kind: "CONTINUE" }; }, }); expect(skipped).toEqual(["a", "c", "d"]); expect(result).toEqual({ ok: true, value: { reason: "EXHAUSTED", scannedRows: 3 }, }); }); it("stops on a row budget and on a time budget with distinct reasons", async () => { const rowBudget = await walk({ budget: { admit: (scannedRows) => scannedRows >= 2 ? { ok: true, value: "ROW_BUDGET" } : { ok: true, value: "CONTINUE" }, }, visit: () => ({ kind: "CONTINUE" }), }); expect(rowBudget.result).toEqual({ ok: true, value: { reason: "ROW_BUDGET", scannedRows: 2 }, }); const timeBudget = await walk({ budget: { admit: (scannedRows) => scannedRows >= 1 ? { ok: true, value: "TIME_BUDGET" } : { ok: true, value: "CONTINUE" }, }, visit: () => ({ kind: "CONTINUE" }), }); expect(timeBudget.result).toEqual({ ok: true, value: { reason: "TIME_BUDGET", scannedRows: 1 }, }); }); it("routes an unreadable budget clock into the sink instead of continuing", async () => { const { result } = await walk({ budget: { admit: () => ({ ok: false, error: { cause: "UNSUPPORTED" } }), }, visit: () => ({ kind: "CONTINUE" }), }); expect(result).toEqual({ ok: false, error: { cause: "UNSUPPORTED" } }); }); it("aborts the transaction and ends the walk when the caller signal fired", async () => { const controller = new AbortController(); const { result } = await walk({ signal: controller.signal, visit: ({ cursor }) => { if ((cursor.value as Row).id === "a") controller.abort(); return { kind: "CONTINUE" }; }, }); expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); }); it("routes a native advance failure through the sink", async () => { const { result } = await walk({ visit: ({ cursor }) => { // Advancing twice is an InvalidStateError in the engine; the pump must // report it rather than let it escape the event handler. cursor.continue(); return { kind: "CONTINUE" }; }, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.cause).toBe("NATIVE_EXCEPTION"); }); it("reports a cursor request error through requestFailed and stops", () => { const stub = stubSink(); const request = { result: null, error: new DOMException("gone", "UnknownError"), onsuccess: null as ((event: Event) => unknown) | null, onerror: null as ((event: Event) => unknown) | null, }; const done = vi.fn(); walkIndexedDbCursor({ request: request as unknown as IDBRequest, sink: stub.sink, translate: recordingTranslate(), visit: () => ({ kind: "CONTINUE" }), done, }); request.onerror?.(new Event("error")); expect(stub.requestErrors).toEqual([request.error]); // The transaction's own outcome decides the result; the walk has no value. expect(done).not.toHaveBeenCalled(); }); it("routes a throw from the visitor into the sink", () => { const stub = stubSink(); const boom = new Error("visitor defect"); const request = { result: { value: { id: "a" }, continue: vi.fn() }, error: null, onsuccess: null as ((event: Event) => unknown) | null, onerror: null as ((event: Event) => unknown) | null, }; walkIndexedDbCursor({ request: request as unknown as IDBRequest, sink: stub.sink, translate: recordingTranslate(), visit: () => { throw boom; }, done: vi.fn(), }); request.onsuccess?.(new Event("success")); // A defective visitor must abort rather than let a partial write commit. expect(stub.failures).toEqual([ { cause: "NATIVE_EXCEPTION", detail: boom }, ]); }); it("ignores resume when the visitor did not suspend", () => { const stub = stubSink(); const advance = vi.fn(); const request = { result: { value: { id: "a" }, continue: advance }, error: null, onsuccess: null as ((event: Event) => unknown) | null, onerror: null as ((event: Event) => unknown) | null, }; walkIndexedDbCursor({ request: request as unknown as IDBRequest, sink: stub.sink, translate: recordingTranslate(), visit: ({ resume }) => { resume({ kind: "CONTINUE" }); return { kind: "STOP" }; }, done: vi.fn(), }); request.onsuccess?.(new Event("success")); expect(advance).not.toHaveBeenCalled(); }); });