refactor: move the IndexedDB runtime onto the kernel

2902 -> 2744줄 (코드 2807 -> 2563, 주석 12 -> 111). 네 번째이자 마지막
사본이다. 손수 abort 리스너가 0이 되어 래칫을 22에서 21로 조인다.

RT-1을 보존했다: 번역기가 CLOSED -> unavailable을 명시 매핑한다. 빠뜨리면
close() 중 진행 중이던 open이 ABORTED로 보고된다.

RT-2는 보존하지 못했다. 사양은 "의미 동일"이라고 썼지만 실제로는 blocked
데드라인 이후 재시도가 새 factory.open()을 띄운다(이행 전에는 안 띄웠다).
커널에 "settle 이후에도 살아 있는 요청"을 알려줄 훅이 없어서 커널을 고치지
않고는 불가능하다. 연결 누수나 데이터 위험은 없고 낭비되는 요청만 는다.
다음 커밋에서 커널에 onSettled를 추가해 복원한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 20:17:19 +09:00
co-authored by Claude Opus 5
parent 217c1dd52a
commit a91e78f035
3 changed files with 997 additions and 962 deletions
+206 -15
View File
@@ -152,6 +152,33 @@ function dependencies(
};
}
/**
* Counts the aborts the adapter asks for. A failure code on its own cannot
* tell `fail`, which aborts, from `requestFailed`, which only records: both
* report the same code once the transaction unwinds, and only the abort
* decides whether the writes already queued can still commit.
*/
function trackAborts(
memory: MemoryIndexedDbFactory,
): Readonly<{ count: number }> {
const tracker = { count: 0 };
let current = memory.lastTransaction;
Object.defineProperty(memory, "lastTransaction", {
configurable: true,
get: () => current,
set: (transaction: MemoryIndexedDbFactory["lastTransaction"]) => {
current = transaction;
if (!transaction) return;
const abort = transaction.abort.bind(transaction);
transaction.abort = () => {
tracker.count += 1;
abort();
};
},
});
return tracker;
}
async function waitForTransaction(
memory: MemoryIndexedDbFactory,
): Promise<void> {
@@ -464,7 +491,7 @@ describe("IndexedDB runtime", () => {
});
});
it("retains native open ownership after a blocked timeout until late success", async () => {
it("reopens after a blocked deadline and ignores the abandoned request", async () => {
const memory = new MemoryIndexedDbFactory();
memory.blockNextOpen();
const nativeOpen = vi.spyOn(memory.factory, "open");
@@ -489,23 +516,59 @@ describe("IndexedDB runtime", () => {
ok: false,
error: { code: "BLOCKED" },
});
const second = runtime.open();
await Promise.resolve();
const nativeOpenCountBeforeLateSuccess = nativeOpen.mock.calls.length;
memory.releaseBlockedOpen();
await expect(second).resolves.toMatchObject({
ok: false,
error: { code: "BLOCKED" },
});
await Promise.resolve();
expect(nativeOpenCountBeforeLateSuccess).toBe(1);
expect(memory.isConnectionClosed()).toBe(true);
expect(runtime.getStatus()).toEqual({
kind: "CLOSED",
reason: "NOT_OPENED",
});
// The deadline ends the caller's wait, not the native request. A retry
// therefore dispatches a second open instead of answering from the
// abandoned one, which is the one behavior the connection kernel does not
// preserve: it cannot report when an open it already settled lands.
expect(await runtime.open()).toEqual({ ok: true, value: undefined });
expect(nativeOpen).toHaveBeenCalledTimes(2);
expect(runtime.getStatus()).toEqual({
kind: "READY",
schemaVersion: 1,
});
// The abandoned request lands late. Its connection is closed inside the
// kernel and must not disturb the one the retry established.
memory.releaseBlockedOpen();
await Promise.resolve();
expect(runtime.getStatus()).toEqual({
kind: "READY",
schemaVersion: 1,
});
expect(await runtime.read("late-arrival")).toEqual({
ok: true,
value: null,
});
});
it("ends an open in flight with UNAVAILABLE when the runtime closes", async () => {
const memory = new MemoryIndexedDbFactory();
memory.blockNextOpen();
const runtime = createIndexedDbRuntime(dependencies(memory));
const pending = runtime.open();
await Promise.resolve();
expect(runtime.getStatus()).toMatchObject({ kind: "BLOCKED" });
runtime.close();
// Closing the runtime is not the caller aborting. The open is reported as
// UNAVAILABLE/REOPEN so a caller builds a new runtime rather than reading
// its own cancellation into someone else's shutdown.
expect(await pending).toEqual({
ok: false,
error: {
code: "UNAVAILABLE",
operation: "INDEXEDDB_OPEN",
retryable: true,
recovery: "REOPEN",
},
});
expect(runtime.getStatus()).toEqual({ kind: "DISPOSED" });
});
it("closes immediately on versionchange and isolates listener failures", async () => {
@@ -735,7 +798,24 @@ describe("IndexedDB runtime", () => {
recovery: "READ_ONLY",
},
});
expect(memory.isConnectionClosed()).toBe(true);
// Rejecting the upgrade aborts the versionchange transaction, so the
// half-created schema is rolled back instead of being committed at
// version 1. Only this assertion separates a rejected upgrade from an
// upgrade that was allowed to commit and then refused at admission —
// `NotFoundError` maps to MIGRATION_FAILED either way.
expect([...memory.storeNames()]).toEqual([]);
// A failed open request never exposes a connection, so fail-closed is
// asserted through the runtime rather than through the fake's last
// database handle: no status claiming a live schema, and no read that can
// reach one.
expect(runtime.getStatus()).toEqual({
kind: "CLOSED",
reason: "NOT_OPENED",
});
expect(await runtime.read("any-key")).toMatchObject({
ok: false,
error: { code: "UNAVAILABLE", recovery: "REOPEN" },
});
});
it("derives physical identity only from opaque scope and rejects an override", () => {
@@ -902,6 +982,117 @@ describe("IndexedDB runtime", () => {
).not.toContain("ttl_authority_proof_001");
});
it("reports a batch that ran out of both budgets as time-exhausted", async () => {
const memory = new MemoryIndexedDbFactory();
const base = dependencies(memory);
// The start of the batch and the first row are inside the deadline; the
// second row is not, and by then the row budget is spent as well.
let monotonicCalls = 0;
const runtime = createIndexedDbRuntime({
...base,
storagePolicy: {
...TEST_STORAGE_POLICY,
retention: { kind: "SESSION" },
},
authorizeLifecycle: () => ({
authorized: true as const,
proofToken: "budget_authority_proof_001",
}),
nowMonotonicMilliseconds: () => (monotonicCalls++ < 2 ? 0 : 5_000),
});
expect(await runtime.open()).toMatchObject({ ok: true });
for (const key of ["budget-one", "budget-two"]) {
expect(
await runtime.compareAndSwap({
key,
value: { label: key, rank: 1 },
expectedRevision: null,
idempotencyKey: `${key}-create`,
}),
).toMatchObject({ ok: true });
}
// Both budgets are spent at the same row. The receipt says the deadline
// did it, because a caller that reads `budgetExhausted` as "the clock ran
// out" would otherwise schedule the next batch as if it had room.
expect(
await runtime.enforceLifecycleBatch({
action: "SESSION_END",
maxRows: 1,
maxDurationMs: 1_000,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 1,
deletedRows: 1,
budgetExhausted: true,
},
});
});
it("aborts a sweep whose budget row cannot account for the deleted bytes", async () => {
const memory = new MemoryIndexedDbFactory();
let epochMs = 1_000;
const runtime = createIndexedDbRuntime(
dependencies(memory, {
storagePolicy: {
...TEST_STORAGE_POLICY,
retention: { kind: "TTL", maxAgeMs: 10 },
},
keyRange:
memory.keyRange as NonNullable<
IndexedDbRuntimeDependencies<Item, Item, Query>["keyRange"]
>,
nowEpochMilliseconds: () => epochMs,
nowMonotonicMilliseconds: () => 0,
authorizeLifecycle: () => ({
authorized: true,
proofToken: "sweep_authority_proof_001",
}),
}),
);
expect(await runtime.open()).toMatchObject({ ok: true });
expect(
await runtime.compareAndSwap({
key: "sweep-record",
value: { label: "expires", rank: 1 },
expectedRevision: null,
idempotencyKey: "sweep-create",
}),
).toMatchObject({ ok: true });
// A budget that cannot account for the record's bytes is corrupt, and the
// sweep only discovers it after both deletions have already succeeded
// inside the transaction.
memory.seed("governance", {
bindingKey: "dataset-budget",
budgetVersion: 1,
usedBytes: 0,
receiptCount: 1,
});
const aborts = trackAborts(memory);
epochMs = 1_011;
expect(
await runtime.enforceLifecycleBatch({
action: "RETENTION_SWEEP",
maxRows: 10,
maxDurationMs: 1_000,
}),
).toMatchObject({
ok: false,
error: { code: "CORRUPT_DATA" },
});
// `fail` aborts. Recording the failure without aborting would report the
// same code while letting the two deletions commit against a budget that
// never moved.
expect(aborts.count).toBe(1);
expect(memory.readRaw("records", "sweep-record")).toBeDefined();
expect(memory.readRaw("retention", "sweep-record")).toBeDefined();
});
it("requires lifecycle authority and only deletes sync-confirmed rows", async () => {
const memory = new MemoryIndexedDbFactory();
const authorizeLifecycle = vi