feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user