import { describe, expect, it, vi } from "vitest"; import { createIndexedDbConnection, deleteIndexedDbDatabase, openIndexedDbDatabase, type IndexedDbFailureCause, type IndexedDbTranslate, } from "../../src/adapters/platform/indexeddb-connection.ts"; import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts"; /** * IDB-X-01 golden tests. The kernel owns connection mechanics only: it must * never name an operation label, a failure code or a schema. Every assertion * below reads the cause the kernel reported, not a taxonomy it chose. */ type TestFailure = Readonly<{ cause: IndexedDbFailureCause["kind"]; detail?: unknown }>; /** * A `translate` written as a total switch over the union. This is the extension * point `abortable-operation.ts:11` lacked: adding a cause member breaks every * translate at compile time instead of silently changing behavior, and the * union never appears in a return type. */ 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, detail: { oldVersion: cause.oldVersion, newVersion: cause.newVersion }, }; case "BLOCKED_DEADLINE": case "CALLER_ABORT": case "CLOSED": case "NO_VALUE_PRODUCED": case "UNSUPPORTED": return { cause: cause.kind }; case "UPGRADE_REJECTED": return { cause: cause.kind, detail: cause.detail }; case "ADMISSION_REJECTED": return { cause: cause.kind, detail: cause.detail }; default: { const exhaustive: never = cause; return exhaustive; } } }; } function manualTimers(): Readonly<{ snapshot: Readonly<{ setTimer: (callback: () => void, delayMs: number) => unknown; clearTimer: (handle: unknown) => void; }>; pending: Array void; delayMs: number }>>; cleared: unknown[]; }> { const pending: Array void; delayMs: number }>> = []; const cleared: unknown[] = []; return { pending, cleared, snapshot: { setTimer: (callback, delayMs) => { pending.push({ callback, delayMs }); return pending.length; }, clearTimer: (handle) => { cleared.push(handle); }, }, }; } async function flush(): Promise { for (let attempt = 0; attempt < 30; attempt += 1) { await new Promise((resolve) => { globalThis.setTimeout(resolve, 0); }); } } type StubOpenRequest = { result: IDBDatabase; error: DOMException | null; transaction: IDBTransaction | null; onsuccess: ((event: Event) => unknown) | null; onerror: ((event: Event) => unknown) | null; onblocked: ((event: IDBVersionChangeEvent) => unknown) | null; onupgradeneeded: ((event: IDBVersionChangeEvent) => unknown) | null; }; type StubFactory = Readonly<{ factory: IDBFactory; opened: StubOpenRequest[]; deleted: StubOpenRequest[]; }>; /** * The memory fake models a real database. These stubs model only the request * object, so paths the fake cannot reach — a null `newVersion`, a cancellable * upgrade transaction, `deleteDatabase` — are still exercised. */ function stubFactory(): StubFactory { const opened: StubOpenRequest[] = []; const deleted: StubOpenRequest[] = []; const request = (): StubOpenRequest => ({ result: { close: vi.fn() } as unknown as IDBDatabase, error: null, transaction: null, onsuccess: null, onerror: null, onblocked: null, onupgradeneeded: null, }); return { opened, deleted, factory: { open: () => { const next = request(); opened.push(next); return next as unknown as IDBOpenDBRequest; }, deleteDatabase: () => { const next = request(); deleted.push(next); return next as unknown as IDBOpenDBRequest; }, } as unknown as IDBFactory, }; } function versionChangeEvent( oldVersion: number, newVersion: number | null, ): IDBVersionChangeEvent { return { oldVersion, newVersion } as IDBVersionChangeEvent; } describe("shared IndexedDB connection mechanics", () => { it("opens a database and hands the caller the live connection", async () => { const memory = new MemoryIndexedDbFactory(); const seen: IndexedDbFailureCause["kind"][] = []; const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate(seen), upgrade: ({ database }) => { database.createObjectStore("rows", { keyPath: "id" }); return { kind: "APPLIED" }; }, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.version).toBe(1); expect(memory.hasStore("rows")).toBe(true); expect(seen).toEqual([]); }); it("reports a native throw from the factory as NATIVE_EXCEPTION", async () => { const boom = new Error("no factory"); const result = await openIndexedDbDatabase({ factory: { open: () => { throw boom; }, } as unknown as IDBFactory, databaseName: "kernel", translate: recordingTranslate([]), }); expect(result).toEqual({ ok: false, error: { cause: "NATIVE_EXCEPTION", detail: boom }, }); }); it("routes a request-level error through translate", async () => { const memory = new MemoryIndexedDbFactory(); await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 2, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), }); // Opening an older version makes the fake fill `request.error` and fire // `onerror`; the kernel must report that error rather than a generic cause. const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.cause).toBe("NATIVE_EXCEPTION"); expect((result.error.detail as DOMException).name).toBe("VersionError"); }); it("settles with BLOCKED when onblocked fires and no deadline is configured", async () => { const memory = new MemoryIndexedDbFactory(); const observed: Array> = []; memory.blockNextOpen(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 3, translate: recordingTranslate([]), onBlocked: (event) => { observed.push(event); throw new Error("an observer cannot change the outcome"); }, }); expect(observed).toEqual([{ oldVersion: 0, newVersion: 3 }]); expect(result).toEqual({ ok: false, error: { cause: "BLOCKED", detail: { oldVersion: 0, newVersion: 3 }, }, }); }); it("waits the configured deadline before settling with BLOCKED_DEADLINE", async () => { const memory = new MemoryIndexedDbFactory(); const timers = manualTimers(); memory.blockNextOpen(); let settled = false; const pending = openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 3, translate: recordingTranslate([]), blockedTimeoutMs: 10_000, timers: timers.snapshot, }); void pending.then(() => { settled = true; }); await flush(); expect(settled).toBe(false); expect(timers.pending).toHaveLength(1); expect(timers.pending[0]?.delayMs).toBe(10_000); timers.pending[0]?.callback(); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "BLOCKED_DEADLINE" }, }); }); it("rejects a positive blocked deadline that has no timer snapshot", () => { const memory = new MemoryIndexedDbFactory(); expect(() => openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", translate: recordingTranslate([]), blockedTimeoutMs: 10_000, }), ).toThrow(TypeError); }); it("aborts the versionchange transaction when upgrade rejects, so no schema commits", async () => { const memory = new MemoryIndexedDbFactory(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: ({ database }) => { database.createObjectStore("rows", { keyPath: "id" }); return { kind: "REJECTED", detail: "POLICY" }; }, }); expect(result).toEqual({ ok: false, error: { cause: "UPGRADE_REJECTED", detail: "POLICY" }, }); // The store the rejected upgrade created must not be visible afterwards. expect(memory.hasStore("rows")).toBe(false); const oldVersions: number[] = []; await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: ({ oldVersion }) => { oldVersions.push(oldVersion); return { kind: "APPLIED" }; }, }); expect(oldVersions).toEqual([0]); }); it("treats a throw from upgrade as a rejection and carries the thrown value", async () => { const memory = new MemoryIndexedDbFactory(); const boom = new Error("migration defect"); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => { throw boom; }, }); expect(result).toEqual({ ok: false, error: { cause: "UPGRADE_REJECTED", detail: boom }, }); expect(memory.hasStore("rows")).toBe(false); }); it("treats any upgrade as unexpected when no upgrade callback is given", async () => { const memory = new MemoryIndexedDbFactory(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.cause).toBe("UPGRADE_REJECTED"); }); it("reports UPGRADE_REJECTED before running upgrade when newVersion is null", async () => { const stub = stubFactory(); const upgrade = vi.fn(() => ({ kind: "APPLIED" as const })); const pending = openIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), upgrade, }); const request = stub.opened[0]; expect(request).toBeDefined(); if (!request) return; const abort = vi.fn(); request.transaction = { abort } as unknown as IDBTransaction; request.onupgradeneeded?.(versionChangeEvent(4, null)); request.error = new DOMException("aborted", "AbortError"); request.onerror?.(new Event("error")); expect(upgrade).not.toHaveBeenCalled(); expect(abort).toHaveBeenCalledTimes(1); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "UPGRADE_REJECTED", detail: undefined }, }); }); it("admits a successful open and lets an async admission run", async () => { const memory = new MemoryIndexedDbFactory(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), admit: async (database) => { await Promise.resolve(); return database.objectStoreNames.contains("rows") ? { kind: "ADMIT" } : { kind: "REJECT", detail: "STORE" }; }, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error).toEqual({ cause: "ADMISSION_REJECTED", detail: "STORE" }); // A rejected admission must close the connection the caller never saw. expect(memory.isConnectionClosed()).toBe(true); }); it("closes the connection when admission fails with an explicit cause", async () => { const memory = new MemoryIndexedDbFactory(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), admit: () => ({ kind: "FAIL", cause: { kind: "CALLER_ABORT" } }), }); expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); expect(memory.isConnectionClosed()).toBe(true); }); it("admits every successful open when no admit callback is given", async () => { const memory = new MemoryIndexedDbFactory(); const result = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), }); expect(result.ok).toBe(true); expect(memory.isConnectionClosed()).toBe(false); }); it("returns CALLER_ABORT without touching the factory for an already aborted signal", async () => { const stub = stubFactory(); const controller = new AbortController(); controller.abort(); const result = await openIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), signal: controller.signal, }); expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); expect(stub.opened).toHaveLength(0); }); it("aborts a pending upgrade transaction when the caller signal fires", async () => { const stub = stubFactory(); const controller = new AbortController(); const pending = openIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), signal: controller.signal, }); const request = stub.opened[0]; expect(request).toBeDefined(); if (!request) return; const abort = vi.fn(); request.transaction = { abort } as unknown as IDBTransaction; controller.abort(); expect(abort).toHaveBeenCalledTimes(1); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" }, }); }); it("closes a connection that arrives after the caller gave up", async () => { const memory = new MemoryIndexedDbFactory(); const controller = new AbortController(); let release: ((admission: { kind: "ADMIT" }) => void) | undefined; const pending = openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), signal: controller.signal, upgrade: () => ({ kind: "APPLIED" }), admit: () => new Promise<{ kind: "ADMIT" }>((resolve) => { release = resolve; }), }); await flush(); controller.abort(); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" }, }); release?.({ kind: "ADMIT" }); await flush(); expect(memory.isConnectionClosed()).toBe(true); }); it("calls translate for every cause the kernel can report", async () => { const seen: IndexedDbFailureCause["kind"][] = []; const translate = recordingTranslate(seen); // NATIVE_EXCEPTION await openIndexedDbDatabase({ factory: { open: () => { throw new Error("boom"); }, } as unknown as IDBFactory, databaseName: "kernel", translate, }); // UPGRADE_REJECTED const rejecting = new MemoryIndexedDbFactory(); await openIndexedDbDatabase({ factory: rejecting.factory, databaseName: "kernel", version: 1, translate, }); // BLOCKED const blocking = new MemoryIndexedDbFactory(); blocking.blockNextOpen(); await openIndexedDbDatabase({ factory: blocking.factory, databaseName: "kernel", version: 1, translate, }); // BLOCKED_DEADLINE const deadline = new MemoryIndexedDbFactory(); const timers = manualTimers(); deadline.blockNextOpen(); const deadlinePending = openIndexedDbDatabase({ factory: deadline.factory, databaseName: "kernel", version: 1, translate, blockedTimeoutMs: 5, timers: timers.snapshot, }); await flush(); timers.pending[0]?.callback(); await deadlinePending; // ADMISSION_REJECTED const admitting = new MemoryIndexedDbFactory(); await openIndexedDbDatabase({ factory: admitting.factory, databaseName: "kernel", version: 1, translate, upgrade: () => ({ kind: "APPLIED" }), admit: () => ({ kind: "REJECT", detail: "POLICY" }), }); // CALLER_ABORT const aborted = new AbortController(); aborted.abort(); await openIndexedDbDatabase({ factory: new MemoryIndexedDbFactory().factory, databaseName: "kernel", translate, signal: aborted.signal, }); // CLOSED const handle = createIndexedDbConnection({ open: () => new Promise(() => undefined), translate, }); const closing = handle.acquire(); handle.close(); await closing; expect(new Set(seen)).toEqual( new Set([ "NATIVE_EXCEPTION", "UPGRADE_REJECTED", "BLOCKED", "BLOCKED_DEADLINE", "ADMISSION_REJECTED", "CALLER_ABORT", "CLOSED", ]), ); }); }); describe("single-flight IndexedDB connection handle", () => { function handleFor( memory: MemoryIndexedDbFactory, overrides: Partial>[0]> = {}, ) { const opens = vi.fn((signal: AbortSignal | undefined) => openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), signal, upgrade: ({ database }) => { if (!database.objectStoreNames.contains("rows")) { database.createObjectStore("rows", { keyPath: "id" }); } return { kind: "APPLIED" }; }, }), ); return { opens, handle: createIndexedDbConnection({ open: opens, translate: recordingTranslate([]), ...overrides, }), }; } it("shares one in-flight open and then reuses the cached connection", async () => { const memory = new MemoryIndexedDbFactory(); const { handle, opens } = handleFor(memory); const [first, second] = await Promise.all([ handle.acquire(), handle.acquire(), ]); const third = await handle.acquire(); expect(opens).toHaveBeenCalledTimes(1); expect(first.ok && second.ok && third.ok).toBe(true); expect(handle.current()).not.toBeNull(); }); it("drops and closes the cached connection on versionchange, then reopens", async () => { const memory = new MemoryIndexedDbFactory(); const events: string[] = []; const { handle, opens } = handleFor(memory, { onVersionChange: () => { events.push("VERSION_CHANGE"); throw new Error("a notification defect cannot keep the connection"); }, }); await handle.acquire(); memory.triggerVersionChange(2); expect(events).toEqual(["VERSION_CHANGE"]); expect(handle.current()).toBeNull(); expect(memory.isConnectionClosed()).toBe(true); await handle.acquire(); expect(opens).toHaveBeenCalledTimes(2); }); it("drops the cached connection when the browser forces it closed", async () => { const memory = new MemoryIndexedDbFactory(); const events: string[] = []; const { handle } = handleFor(memory, { onForcedClose: () => events.push("FORCED"), }); await handle.acquire(); memory.triggerForcedClose(); expect(events).toEqual(["FORCED"]); expect(handle.current()).toBeNull(); expect(handle.isClosed()).toBe(false); }); it("settles an in-flight open with CLOSED rather than CALLER_ABORT", async () => { const seen: IndexedDbFailureCause["kind"][] = []; let openSignal: AbortSignal | undefined; const handle = createIndexedDbConnection({ open: (signal) => { openSignal = signal; return new Promise(() => undefined); }, translate: recordingTranslate(seen), }); const pending = handle.acquire(); handle.close(); handle.close(); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "CLOSED" }, }); expect(seen).toEqual(["CLOSED"]); expect(handle.isClosed()).toBe(true); // The in-flight native request is still cancelled, so a pending upgrade // transaction does not outlive the handle. expect(openSignal?.aborted).toBe(true); await expect(handle.acquire()).resolves.toEqual({ ok: false, error: { cause: "CLOSED" }, }); }); it("closes a connection that arrives after the handle was closed", async () => { const memory = new MemoryIndexedDbFactory(); let release: ((result: { ok: true; value: IDBDatabase }) => void) | undefined; const handle = createIndexedDbConnection({ open: () => new Promise((resolve) => { release = resolve as typeof release; }), translate: recordingTranslate([]), }); const pending = handle.acquire(); handle.close(); await pending; const opened = await openIndexedDbDatabase({ factory: memory.factory, databaseName: "kernel", version: 1, translate: recordingTranslate([]), upgrade: () => ({ kind: "APPLIED" }), }); expect(opened.ok).toBe(true); if (!opened.ok) return; release?.({ ok: true, value: opened.value }); await flush(); expect(memory.isConnectionClosed()).toBe(true); expect(handle.current()).toBeNull(); }); it("aborts one caller without cancelling the shared open", async () => { const memory = new MemoryIndexedDbFactory(); const { handle, opens } = handleFor(memory); const controller = new AbortController(); const abandoned = handle.acquire(controller.signal); const kept = handle.acquire(); controller.abort(); await expect(abandoned).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" }, }); const survivor = await kept; expect(survivor.ok).toBe(true); expect(opens).toHaveBeenCalledTimes(1); expect(handle.current()).not.toBeNull(); }); it("reports CALLER_ABORT for an already aborted acquire without opening", async () => { const memory = new MemoryIndexedDbFactory(); const { handle, opens } = handleFor(memory); const controller = new AbortController(); controller.abort(); await expect(handle.acquire(controller.signal)).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" }, }); expect(opens).not.toHaveBeenCalled(); }); it("closes the cached connection and reports it through current()", async () => { const memory = new MemoryIndexedDbFactory(); const { handle } = handleFor(memory); await handle.acquire(); expect(handle.current()).not.toBeNull(); handle.close(); expect(handle.current()).toBeNull(); expect(memory.isConnectionClosed()).toBe(true); }); it("retries after a failed open instead of caching the failure", async () => { const attempts: number[] = []; const handle = createIndexedDbConnection({ open: () => { attempts.push(attempts.length); return Promise.resolve({ ok: false as const, error: { cause: "NATIVE_EXCEPTION" as const }, }); }, translate: recordingTranslate([]), }); await handle.acquire(); await handle.acquire(); expect(attempts).toHaveLength(2); }); }); describe("IndexedDB database deletion", () => { it("reports DELETED and settles the caller's registration exactly once", async () => { const stub = stubFactory(); const settled = vi.fn(); const pending = deleteIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), onSettled: settled, }); const request = stub.deleted[0]; expect(request).toBeDefined(); request?.onsuccess?.(new Event("success")); request?.onsuccess?.(new Event("success")); await expect(pending).resolves.toEqual({ ok: true, value: { kind: "DELETED" } }); expect(settled).toHaveBeenCalledTimes(1); }); it("routes a delete error through translate and still settles the registration", async () => { const stub = stubFactory(); const settled = vi.fn(); const pending = deleteIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), onSettled: settled, }); const request = stub.deleted[0]; if (!request) throw new Error("no delete request"); request.error = new DOMException("nope", "UnknownError"); request.onerror?.(new Event("error")); const result = await pending; expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.cause).toBe("NATIVE_EXCEPTION"); expect(settled).toHaveBeenCalledTimes(1); }); it("reports BLOCKED_DEADLINE without settling, then settles when the request lands", async () => { const stub = stubFactory(); const timers = manualTimers(); const settled = vi.fn(); const pending = deleteIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), blockedTimeoutMs: 5_000, timers: timers.snapshot, onSettled: settled, }); const request = stub.deleted[0]; if (!request) throw new Error("no delete request"); request.onblocked?.(versionChangeEvent(1, null)); expect(timers.pending).toHaveLength(1); timers.pending[0]?.callback(); await expect(pending).resolves.toEqual({ ok: true, value: { kind: "BLOCKED_DEADLINE" }, }); // A dispatched deleteDatabase cannot be cancelled, so the deadline is not // the end of the request: the registration is released only when it lands. expect(settled).not.toHaveBeenCalled(); request.onsuccess?.(new Event("success")); expect(settled).toHaveBeenCalledTimes(1); }); it("settles with BLOCKED when no deadline is configured", async () => { const stub = stubFactory(); const pending = deleteIndexedDbDatabase({ factory: stub.factory, databaseName: "kernel", translate: recordingTranslate([]), }); stub.deleted[0]?.onblocked?.(versionChangeEvent(2, null)); await expect(pending).resolves.toEqual({ ok: false, error: { cause: "BLOCKED", detail: { oldVersion: 2, newVersion: null } }, }); }); });