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>
1011 lines
29 KiB
TypeScript
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();
|
|
});
|
|
});
|