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:
co-authored by
Claude Opus 5
parent
0f157494cd
commit
cb62bfb9a9
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,716 @@
|
||||
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<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 };
|
||||
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<Readonly<{ memory: MemoryIndexedDbFactory; database: IDBDatabase }>> {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const opened = await openIndexedDbDatabase<TestFailure>({
|
||||
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<TestFailure>;
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<string, TestFailure>({
|
||||
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<typeof walkIndexedDbCursor<TestFailure>>[0]["budget"];
|
||||
signal?: AbortSignal;
|
||||
store?: IDBObjectStore | undefined;
|
||||
}>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
result: Awaited<ReturnType<typeof runIndexedDbTransaction<IndexedDbWalkSummary, TestFailure>>>;
|
||||
}>
|
||||
> {
|
||||
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<TestFailure>({
|
||||
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<TestFailure>({
|
||||
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<TestFailure>({
|
||||
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||
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<TestFailure>({
|
||||
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||
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<TestFailure>({
|
||||
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user