Files
clean-architecture-frontend…/tests/unit/indexeddb-maintenance.test.ts
T
DongHyeonkaandClaude Opus 5 3366a81f0f refactor: move IndexedDB maintenance onto the kernel
1558 -> 1430줄. 손수 abort 리스너가 0이 되어 래칫을 23에서 22로 조인다.

보존한 동작 셋:
- blockedTimeoutMs를 넘기지 않는다. blocked 이벤트는 유지보수에게 종단이다
  — 배치는 opt-in 백그라운드 패스라 다른 컨텍스트가 사라지길 기대하며
  데드라인만큼 매다는 비용이 BLOCKED를 보고하고 재시도시키는 것보다 크다
- upgrade 콜백을 생략한다. 커널은 생략을 "어떤 upgrade든 예상 밖"으로 읽고
  거절하는데, 그게 유지보수가 늘 해온 동작이다. 빈 APPLIED를 넣으면 잘못된
  스키마로 열린다
- onversionchange는 admit 안, 바인딩 검증 앞에 등록한다

계획이 blocked 회귀 테스트로 지목한 indexeddb-maintenance.test.ts:289-290은
실제로는 onblocked 경로가 아니라 drain 검사였다. MT의 진짜 blocked 경로에는
테스트가 없었으므로 새로 썼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:35:22 +09:00

1011 lines
29 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createIndexedDbMaintenance } from "../../src/adapters/storage/indexeddb/indexeddb-maintenance.ts";
import { createIndexedDbRuntime } from "../../src/adapters/storage/indexeddb/indexeddb-runtime.ts";
import type {
IndexedDbDataMigrationPolicy,
IndexedDbObservation,
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
type CurrentPayload = Readonly<{
label: string;
}>;
const TEST_SCOPE = Object.freeze({
authorityToken: "authoritytoken_002",
namespaceToken: "namespacetoken_002",
partitionToken: "partitiontoken_002",
accountScope: "ORIGIN_SHARED" as const,
});
const TEST_STORAGE_POLICY = Object.freeze({
owner: "platform-storage",
namespace: "indexeddb-maintenance-test",
classification: "INTERNAL" as const,
authority: "SERVER" as const,
accountScope: "ORIGIN_SHARED" as const,
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
softBudgetBytes: 1_000_000,
hardBudgetBytes: 2_000_000,
evictionPriority: "SYNCED_COPY" as const,
logoutAction: "KEEP_ORIGIN_SHARED" as const,
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
pressureAction: "RETAIN" as const,
unavailableFallback: "ONLINE_ONLY" as const,
});
function createSchemaRuntime(memory: MemoryIndexedDbFactory) {
return createIndexedDbRuntime<CurrentPayload, CurrentPayload, null>({
scope: TEST_SCOPE,
storagePolicy: TEST_STORAGE_POLICY,
schemaVersion: 1,
recordStore: "records",
governanceStore: "governance",
retentionStore: "retention",
retentionEligibilityIndex: "by-eligibility",
lifecycleMetadataStores: ["maintenance"],
idempotencyStore: "receipts",
idempotencyExpiryIndex: "by-expiry",
receiptRetentionMs: 60_000,
maxIdempotencyReceipts: 1_000,
migrations: [
{
id: "schema-v1",
fromVersion: 0,
toVersion: 1,
operations: [
{
kind: "CREATE_STORE",
name: "governance",
keyPath: "bindingKey",
},
{
kind: "CREATE_STORE",
name: "retention",
keyPath: "recordKey",
indexes: [
{
name: "by-eligibility",
keyPath: "eligibleAtEpochMs",
},
],
},
{
kind: "CREATE_STORE",
name: "records",
keyPath: "key",
},
{
kind: "CREATE_STORE",
name: "receipts",
keyPath: "idempotencyKey",
indexes: [
{
name: "by-expiry",
keyPath: "expiresAtEpochMs",
},
],
},
{
kind: "CREATE_STORE",
name: "maintenance",
keyPath: "checkpointKey",
},
],
},
],
codec: {
currentVersion: 2,
encode: (value) => ({ ok: true, value }),
measureStoredBytes: (value) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength,
decode: (version, value) =>
version === 2 &&
value !== null &&
typeof value === "object" &&
typeof (value as Partial<CurrentPayload>).label === "string"
? { ok: true, value: value as CurrentPayload }
: { ok: false },
fingerprint: () => "0".repeat(64),
},
queryPolicy: {
plan: () => ({ limit: 10 }),
},
factory: memory.factory,
authorizeLifecycle: () => ({
authorized: true,
proofToken: "authorityproof_002",
}),
});
}
async function prepareSchema(
memory: MemoryIndexedDbFactory,
): Promise<void> {
const runtime = createSchemaRuntime(memory);
expect(await runtime.open()).toMatchObject({ ok: true });
runtime.close();
}
function seedLegacy(
memory: MemoryIndexedDbFactory,
key: string,
legacyLabel: string,
revision = 1,
): void {
const measuredBytes =
new TextEncoder().encode(JSON.stringify({ label: legacyLabel }))
.byteLength +
512 +
key.length * 2;
memory.seed("records", {
key,
codecVersion: 1,
revision,
payload: { legacyLabel },
});
memory.seed("retention", {
recordKey: key,
writtenAtEpochMs: 1,
synchronization: "NONE",
measuredBytes,
});
const budget = memory.readRaw(
"governance",
"dataset-budget",
) as Readonly<{
bindingKey: string;
budgetVersion: number;
usedBytes: number;
receiptCount: number;
}>;
memory.seed("governance", {
...budget,
usedBytes: budget.usedBytes + measuredBytes,
});
}
function defaultPolicy(
migrate = vi.fn(
async ({ payload }: { payload: unknown }) => {
const legacyLabel =
payload &&
typeof payload === "object" &&
typeof (
payload as Readonly<{ legacyLabel?: unknown }>
).legacyLabel === "string"
? (
payload as Readonly<{ legacyLabel: string }>
).legacyLabel
: null;
return legacyLabel === null
? ({ ok: false } as const)
: ({
ok: true,
value: { label: legacyLabel },
} as const);
},
),
): IndexedDbDataMigrationPolicy<CurrentPayload> {
return {
migrationId: "records-to-codec-v2",
targetCodecVersion: 2,
measureStoredBytes: (value) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength,
isOldWriterDrainConfirmed: () => true,
migrate,
};
}
function createMaintenance(
memory: MemoryIndexedDbFactory,
policy: IndexedDbDataMigrationPolicy<CurrentPayload>,
options: Readonly<{
now?: () => number;
nowEpochMilliseconds?: () => number;
observe?: (event: IndexedDbObservation) => void;
factory?: IDBFactory;
}> = {},
) {
return createIndexedDbMaintenance<CurrentPayload>({
scope: TEST_SCOPE,
storagePolicy: TEST_STORAGE_POLICY,
schemaVersion: 1,
recordStore: "records",
governanceStore: "governance",
retentionStore: "retention",
checkpointStore: "maintenance",
checkpointKey: "records-codec",
idempotencyStore: "receipts",
idempotencyExpiryIndex: "by-expiry",
migrationPolicy: policy,
factory: memory.factory,
keyRange: memory.keyRange,
...options,
});
}
/**
* Records every `transaction.abort()` the adapter itself calls, with the mode
* of the transaction it aborted.
*
* A failure code alone cannot tell `fail` (record and abort) from
* `requestFailed` (record only): the fake aborts a transaction on a request
* error by itself, so both end at the same code while only one of them stops
* the writes queued behind it. Swapping the two is exactly the mistake this
* counter makes visible.
*/
function trackAborts(memory: MemoryIndexedDbFactory): Readonly<{
factory: IDBFactory;
aborts: readonly string[];
}> {
const aborts: string[] = [];
const wrapped = new WeakSet<object>();
const wrapDatabase = (database: IDBDatabase): IDBDatabase => {
if (wrapped.has(database)) return database;
wrapped.add(database);
const openTransaction = database.transaction.bind(database);
Object.defineProperty(database, "transaction", {
configurable: true,
value: (...args: Parameters<IDBDatabase["transaction"]>) => {
const transaction = openTransaction(...args);
const abort = transaction.abort.bind(transaction);
Object.defineProperty(transaction, "abort", {
configurable: true,
value: () => {
aborts.push(transaction.mode);
abort();
},
});
return transaction;
},
});
return database;
};
const factory = {
cmp: (first: IDBValidKey, second: IDBValidKey) =>
memory.factory.cmp(first, second),
open: (name: string, version?: number) => {
const request = memory.factory.open(name, version);
let stored: IDBDatabase | undefined = request.result;
if (stored) wrapDatabase(stored);
Object.defineProperty(request, "result", {
configurable: true,
get: () => stored,
set: (value: IDBDatabase | undefined) => {
stored = value ? wrapDatabase(value) : value;
},
});
return request;
},
} as unknown as IDBFactory;
return Object.freeze({ factory, aborts });
}
function seedReceipt(
memory: MemoryIndexedDbFactory,
idempotencyKey: string,
expiresAtEpochMs: number,
): void {
memory.seed("receipts", {
idempotencyKey,
operation: "PUT",
recordKey: `record-${idempotencyKey}`,
expectedRevision: null,
fingerprint: "a".repeat(64),
synchronization: "NONE",
revision: 1,
expiresAtEpochMs,
});
const budget = memory.readRaw(
"governance",
"dataset-budget",
) as Readonly<{
bindingKey: string;
budgetVersion: number;
usedBytes: number;
receiptCount: number;
}>;
memory.seed("governance", {
...budget,
receiptCount: budget.receiptCount + 1,
});
}
async function waitForWriteTransaction(
memory: MemoryIndexedDbFactory,
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (memory.lastTransaction?.mode === "readwrite") return;
await Promise.resolve();
}
throw new Error("Timed out waiting for receipt prune transaction.");
}
describe("IndexedDB bounded codec maintenance", () => {
it("refuses keyset migration until old-codec writers are durably drained", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "a-before-checkpoint", "old-writer");
const policy = {
...defaultPolicy(),
isOldWriterDrainConfirmed: () => false,
};
const maintenance = createMaintenance(memory, policy);
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toEqual({
ok: false,
error: {
code: "BLOCKED",
operation: "INDEXEDDB_MIGRATE",
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
},
});
expect(policy.migrate).not.toHaveBeenCalled();
expect(
memory.readRaw("maintenance", "records-codec"),
).toBeUndefined();
expect(memory.readRaw("records", "a-before-checkpoint")).toMatchObject({
codecVersion: 1,
});
});
it("reports a blocked open immediately instead of waiting out a deadline", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "blocked", "waiting");
const maintenance = createMaintenance(memory, defaultPolicy());
memory.blockNextOpen();
// MT-1. The blocked open is never released here. A blocked deadline could
// only settle from a timer, and no amount of microtask draining reaches a
// timer, so a batch that has already landed proves the blocked event
// itself is terminal. Give maintenance a blocked timeout and this test
// stops finishing instead of failing on a code.
let landed = false;
const pending = maintenance
.migrateCodecBatch({ maxRows: 10, maxDurationMs: 10_000 })
.then((result) => {
landed = true;
return result;
});
for (let tick = 0; tick < 50; tick += 1) await Promise.resolve();
expect(landed).toBe(true);
expect(await pending).toEqual({
ok: false,
error: {
code: "BLOCKED",
operation: "INDEXEDDB_MIGRATE",
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
},
});
// The connection the caller already gave up on is closed, not leaked.
memory.releaseBlockedOpen();
expect(memory.isConnectionClosed()).toBe(true);
expect(memory.readRaw("records", "blocked")).toMatchObject({
codecVersion: 1,
});
});
it("rejects an unexpected schema upgrade without creating the schema", async () => {
const memory = new MemoryIndexedDbFactory();
// No prepareSchema: the database does not exist yet, so opening at the
// exact schema version is an upgrade.
const maintenance = createMaintenance(memory, defaultPolicy());
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toEqual({
ok: false,
error: {
code: "MIGRATION_FAILED",
operation: "INDEXEDDB_MIGRATE",
retryable: false,
recovery: "READ_ONLY",
},
});
// MT-2. Maintenance owns no schema, so a rejected upgrade must leave the
// database untouched rather than open it at the right version with the
// wrong contents. Applying the upgrade instead would fail with the same
// code here and only surface later, as a database nobody can use.
expect(memory.hasStore("records")).toBe(false);
expect(memory.hasStore("governance")).toBe(false);
await prepareSchema(memory);
expect(memory.hasStore("records")).toBe(true);
});
it("closes the maintenance connection when another context needs a version change", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedReceipt(memory, "versionchange-receipt", 100);
const maintenance = createMaintenance(memory, defaultPolicy(), {
nowEpochMilliseconds: () => 200,
});
memory.clearLastTransaction();
memory.pauseTransactions();
const pending = maintenance.pruneExpiredReceipts({
maxRows: 10,
maxDurationMs: 10_000,
});
await waitForWriteTransaction(memory);
expect(memory.isConnectionClosed()).toBe(false);
// MT-3. The listener is registered on the successful open path only, so it
// covers exactly the window in which a batch holds the connection.
memory.triggerVersionChange(2);
expect(memory.isConnectionClosed()).toBe(true);
memory.resumeTransactions();
await pending;
});
it("resumes from a durable checkpoint and completes in bounded row batches", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "a", "alpha", 4);
seedLegacy(memory, "b", "beta", 7);
seedLegacy(memory, "c", "gamma", 9);
const observations: IndexedDbObservation[] = [];
const policy = defaultPolicy();
const maintenance = createMaintenance(memory, policy, {
observe: (event) => observations.push(event),
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 2,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 2,
checkpointedRows: 2,
migratedRows: 2,
concurrentlyChangedRows: 0,
budgetExhausted: false,
},
});
expect(memory.readRaw("maintenance", "records-codec")).toEqual({
checkpointKey: "records-codec",
migrationId: "records-to-codec-v2",
targetCodecVersion: 2,
lastKey: "b",
state: "MORE",
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 2,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "COMPLETE",
scannedRows: 1,
checkpointedRows: 1,
migratedRows: 1,
concurrentlyChangedRows: 0,
budgetExhausted: false,
},
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 2,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "COMPLETE",
scannedRows: 0,
checkpointedRows: 0,
migratedRows: 0,
concurrentlyChangedRows: 0,
budgetExhausted: false,
},
});
expect(policy.migrate).toHaveBeenCalledTimes(3);
expect(memory.readRaw("records", "a")).toEqual({
key: "a",
codecVersion: 2,
revision: 4,
payload: { label: "alpha" },
});
expect(memory.readRaw("records", "c")).toEqual({
key: "c",
codecVersion: 2,
revision: 9,
payload: { label: "gamma" },
});
const migratedBudget = memory.readRaw(
"governance",
"dataset-budget",
) as Readonly<{ usedBytes: number }>;
const migratedSidecars = ["a", "b", "c"].map(
(key) =>
memory.readRaw(
"retention",
key,
) as Readonly<{ measuredBytes: number }>,
);
expect(migratedBudget.usedBytes).toBe(
migratedSidecars.reduce(
(total, row) => total + row.measuredBytes,
0,
),
);
expect(JSON.stringify(observations)).not.toMatch(
/alpha|beta|gamma|records-codec/,
);
});
it("commits migrated records and their checkpoint atomically", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "atomic", "before");
const maintenance = createMaintenance(
memory,
defaultPolicy(),
);
memory.failNextWriteCommit(
new DOMException("private payload", "QuotaExceededError"),
);
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: false,
error: {
code: "QUOTA_EXCEEDED",
operation: "INDEXEDDB_MIGRATE",
},
});
expect(memory.readRaw("records", "atomic")).toEqual({
key: "atomic",
codecVersion: 1,
revision: 1,
payload: { legacyLabel: "before" },
});
expect(
memory.readRaw("maintenance", "records-codec"),
).toBeUndefined();
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: true,
value: { state: "COMPLETE", migratedRows: 1 },
});
});
it("runs async domain transforms outside transactions and honors abort before commit", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "abort", "unchanged");
let entered: (() => void) | undefined;
let release: (() => void) | undefined;
const transformStarted = new Promise<void>((resolve) => {
entered = resolve;
});
const transformGate = new Promise<void>((resolve) => {
release = resolve;
});
const policy = defaultPolicy(
vi.fn(async () => {
entered?.();
await transformGate;
return {
ok: true,
value: { label: "must-not-commit" },
} as const;
}),
);
const maintenance = createMaintenance(memory, policy);
const controller = new AbortController();
const pending = maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
signal: controller.signal,
});
await transformStarted;
controller.abort();
release?.();
expect(await pending).toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
expect(memory.readRaw("records", "abort")).toEqual({
key: "abort",
codecVersion: 1,
revision: 1,
payload: { legacyLabel: "unchanged" },
});
expect(
memory.readRaw("maintenance", "records-codec"),
).toBeUndefined();
});
it("rechecks revision and codec fencing before every migrated write", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "concurrent", "first", 1);
let changed = false;
const policy = defaultPolicy(
vi.fn(async () => {
if (!changed) {
changed = true;
seedLegacy(memory, "concurrent", "newer", 2);
}
return {
ok: true,
value: { label: changed ? "newer" : "first" },
} as const;
}),
);
const maintenance = createMaintenance(memory, policy);
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 1,
checkpointedRows: 0,
migratedRows: 0,
concurrentlyChangedRows: 1,
budgetExhausted: false,
},
});
expect(memory.readRaw("records", "concurrent")).toEqual({
key: "concurrent",
codecVersion: 1,
revision: 2,
payload: { legacyLabel: "newer" },
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: true,
value: {
state: "COMPLETE",
migratedRows: 1,
},
});
expect(memory.readRaw("records", "concurrent")).toEqual({
key: "concurrent",
codecVersion: 2,
revision: 2,
payload: { label: "newer" },
});
});
it("stops before transform work when its cooperative time budget is exhausted", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "budget", "later");
const policy = defaultPolicy();
let currentTime = 0;
const maintenance = createMaintenance(memory, policy, {
now: () => {
const value = currentTime;
currentTime += 5;
return value;
},
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 1,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 0,
checkpointedRows: 0,
migratedRows: 0,
concurrentlyChangedRows: 0,
budgetExhausted: true,
},
});
expect(policy.migrate).not.toHaveBeenCalled();
expect(memory.readRaw("records", "budget")).toMatchObject({
codecVersion: 1,
revision: 1,
});
});
it("stops codec migration commit at the cooperative deadline", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "row-a", "first");
seedLegacy(memory, "row-b", "second");
const policy = defaultPolicy();
// STO-06. The clock only advances past the deadline once the commit
// transaction is already open, so the stop must happen inside the commit
// chain rather than before transform.
let calls = 0;
const maintenance = createMaintenance(memory, policy, {
now: () => {
calls += 1;
// Scan, prepare and the first commit record stay inside the budget.
return calls <= 6 ? 0 : 5_000;
},
});
const result = await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 1_000,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
// The batch is incomplete and says so; it never claims a full pass.
expect(result.value.state).toBe("MORE");
expect(result.value.budgetExhausted).toBe(true);
expect(result.value.checkpointedRows).toBeLessThan(2);
});
it("fails closed when a historical payload cannot be transformed", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "invalid", "value");
const maintenance = createMaintenance(
memory,
defaultPolicy(vi.fn(async () => ({ ok: false } as const))),
);
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toEqual({
ok: false,
error: {
code: "MIGRATION_FAILED",
operation: "INDEXEDDB_MIGRATE",
retryable: false,
recovery: "READ_ONLY",
},
});
expect(memory.readRaw("records", "invalid")).toMatchObject({
codecVersion: 1,
});
});
it("rejects a size-increasing migration that exceeds the dataset hard budget", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedLegacy(memory, "oversized", "legacy");
const beforeBudget = memory.readRaw(
"governance",
"dataset-budget",
);
const policy = {
...defaultPolicy(),
measureStoredBytes: () => 2_000_000,
};
const tracker = trackAborts(memory);
const maintenance = createMaintenance(memory, policy, {
factory: tracker.factory,
});
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: false,
error: {
code: "MIGRATION_FAILED",
recovery: "READ_ONLY",
},
});
// The budget verdict aborts the commit transaction rather than only
// recording a failure, so the writes already queued behind it cannot
// commit. Recording without aborting would end at the same failure code.
expect(tracker.aborts).toEqual(["readwrite"]);
expect(memory.readRaw("records", "oversized")).toMatchObject({
codecVersion: 1,
});
expect(
memory.readRaw("governance", "dataset-budget"),
).toEqual(beforeBudget);
});
it("prunes only expired receipts and bounds each committed batch", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedReceipt(memory, "expired-a", 100);
seedReceipt(memory, "expired-b", 200);
seedReceipt(memory, "expires-now", 300);
seedReceipt(memory, "inside-replay-window", 301);
const maintenance = createMaintenance(
memory,
defaultPolicy(),
{ nowEpochMilliseconds: () => 300 },
);
expect(
await maintenance.pruneExpiredReceipts({
maxRows: 2,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 2,
deletedRows: 2,
budgetExhausted: false,
},
});
expect(
memory.readRaw("receipts", "inside-replay-window"),
).toBeDefined();
expect(
await maintenance.pruneExpiredReceipts({
maxRows: 2,
maxDurationMs: 10_000,
}),
).toEqual({
ok: true,
value: {
state: "COMPLETE",
scannedRows: 1,
deletedRows: 1,
budgetExhausted: false,
},
});
expect(
memory.readRaw("receipts", "expires-now"),
).toBeUndefined();
expect(
memory.readRaw("receipts", "inside-replay-window"),
).toBeDefined();
expect(
memory.readRaw("governance", "dataset-budget"),
).toMatchObject({ receiptCount: 1 });
});
it("reports prune success only after commit and rolls back quota failure", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedReceipt(memory, "quota-receipt", 100);
const tracker = trackAborts(memory);
const maintenance = createMaintenance(
memory,
defaultPolicy(),
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
);
memory.failNextWriteCommit(
new DOMException("private receipt", "QuotaExceededError"),
);
expect(
await maintenance.pruneExpiredReceipts({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: false,
error: { code: "QUOTA_EXCEEDED" },
});
// The commit is what failed, so the adapter never aborts anything itself;
// the quota code has to come from the transaction's own error.
expect(tracker.aborts).toEqual([]);
expect(
memory.readRaw("receipts", "quota-receipt"),
).toBeDefined();
});
it("aborts an in-flight prune transaction without deleting receipts", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedReceipt(memory, "abort-receipt", 100);
const tracker = trackAborts(memory);
const maintenance = createMaintenance(
memory,
defaultPolicy(),
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
);
memory.clearLastTransaction();
memory.pauseTransactions();
const controller = new AbortController();
const pending = maintenance.pruneExpiredReceipts({
maxRows: 10,
maxDurationMs: 10_000,
signal: controller.signal,
});
await waitForWriteTransaction(memory);
controller.abort();
expect(await pending).toMatchObject({
ok: false,
error: { code: "ABORTED" },
});
// Exactly one abort: the caller's signal. A second one would mean the
// cursor pump and the transaction both claim the abort.
expect(tracker.aborts).toEqual(["readwrite"]);
memory.resumeTransactions();
await Promise.resolve();
expect(
memory.readRaw("receipts", "abort-receipt"),
).toBeDefined();
});
it("honors the prune time budget before deleting a replay receipt", async () => {
const memory = new MemoryIndexedDbFactory();
await prepareSchema(memory);
seedReceipt(memory, "budget-receipt", 100);
let monotonicTime = 0;
const maintenance = createMaintenance(
memory,
defaultPolicy(),
{
now: () => {
const value = monotonicTime;
monotonicTime += 5;
return value;
},
nowEpochMilliseconds: () => 200,
},
);
expect(
await maintenance.pruneExpiredReceipts({
maxRows: 10,
maxDurationMs: 1,
}),
).toEqual({
ok: true,
value: {
state: "MORE",
scannedRows: 0,
deletedRows: 0,
budgetExhausted: true,
},
});
expect(
memory.readRaw("receipts", "budget-receipt"),
).toBeDefined();
});
});