812 lines
21 KiB
TypeScript
812 lines
21 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("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();
|
|
});
|
|
});
|