Files
tech-log-frontend/tests/unit/indexeddb-maintenance.test.ts
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

843 lines
22 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;
}> = {},
) {
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,
});
}
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("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 maintenance = createMaintenance(memory, policy);
expect(
await maintenance.migrateCodecBatch({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: false,
error: {
code: "MIGRATION_FAILED",
recovery: "READ_ONLY",
},
});
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 maintenance = createMaintenance(
memory,
defaultPolicy(),
{ nowEpochMilliseconds: () => 200 },
);
memory.failNextWriteCommit(
new DOMException("private receipt", "QuotaExceededError"),
);
expect(
await maintenance.pruneExpiredReceipts({
maxRows: 10,
maxDurationMs: 10_000,
}),
).toMatchObject({
ok: false,
error: { code: "QUOTA_EXCEEDED" },
});
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 maintenance = createMaintenance(
memory,
defaultPolicy(),
{ nowEpochMilliseconds: () => 200 },
);
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" },
});
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();
});
});