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({ 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).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 { 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 { 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, options: Readonly<{ now?: () => number; nowEpochMilliseconds?: () => number; observe?: (event: IndexedDbObservation) => void; }> = {}, ) { return createIndexedDbMaintenance({ 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 { 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((resolve) => { entered = resolve; }); const transformGate = new Promise((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("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(); }); });