feat: add the shared IndexedDB connection and transaction kernel

네 어댑터가 각자 구현한 open/blocked/upgrade/트랜잭션 메커니즘을 커널로
모은다. 사본은 아직 이행하지 않았으므로 런타임 동작은 그대로다.

실패 분류는 커널에 넣지 않고 translate 콜백으로 주입한다. RT/MT/OP의
mapIndexedDbException과 CP의 mapBrowserDataException이 같은 에러에 다른
답을 내며, 그 차이를 통일하는 것은 별건이기 때문이다.

IndexedDbFailureCause는 translate의 입력과 호출자가 주는 admit FAIL에만
나오고 공개 반환 타입에는 나오지 않는다. 기존 커널 abortable-operation.ts는
AbortTerminalReason 3멤버를 반환 타입에 박아서 5종이 필요한 http v3가 아예
쓰지 못했는데, 그 함정을 피하기 위한 설계다. 사양 3.4의 네 호출부를 실제
코드로 써서 tsc --strict로 컴파일해 수용을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 19:11:35 +09:00
co-authored by Claude Opus 5
parent 0f157494cd
commit cb62bfb9a9
5 changed files with 2799 additions and 63 deletions
+886
View File
@@ -0,0 +1,886 @@
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<TestFailure> {
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<Readonly<{ callback: () => void; delayMs: number }>>;
cleared: unknown[];
}> {
const pending: Array<Readonly<{ callback: () => 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<void> {
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise<void>((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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<Readonly<{ oldVersion: number; newVersion: number | null }>> = [];
memory.blockNextOpen();
const result = await openIndexedDbDatabase<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
factory: {
open: () => {
throw new Error("boom");
},
} as unknown as IDBFactory,
databaseName: "kernel",
translate,
});
// UPGRADE_REJECTED
const rejecting = new MemoryIndexedDbFactory();
await openIndexedDbDatabase<TestFailure>({
factory: rejecting.factory,
databaseName: "kernel",
version: 1,
translate,
});
// BLOCKED
const blocking = new MemoryIndexedDbFactory();
blocking.blockNextOpen();
await openIndexedDbDatabase<TestFailure>({
factory: blocking.factory,
databaseName: "kernel",
version: 1,
translate,
});
// BLOCKED_DEADLINE
const deadline = new MemoryIndexedDbFactory();
const timers = manualTimers();
deadline.blockNextOpen();
const deadlinePending = openIndexedDbDatabase<TestFailure>({
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<TestFailure>({
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<TestFailure>({
factory: new MemoryIndexedDbFactory().factory,
databaseName: "kernel",
translate,
signal: aborted.signal,
});
// CLOSED
const handle = createIndexedDbConnection<TestFailure>({
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<Parameters<typeof createIndexedDbConnection<TestFailure>>[0]> = {},
) {
const opens = vi.fn((signal: AbortSignal | undefined) =>
openIndexedDbDatabase<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
open: () =>
new Promise((resolve) => {
release = resolve as typeof release;
}),
translate: recordingTranslate([]),
});
const pending = handle.acquire();
handle.close();
await pending;
const opened = await openIndexedDbDatabase<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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<TestFailure>({
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 } },
});
});
});