1141 lines
33 KiB
TypeScript
1141 lines
33 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createIndexedDbRuntime } from "../../src/adapters/storage/indexeddb/indexeddb-runtime.ts";
|
|
import { indexedDbPhysicalDatabaseName } from "../../src/adapters/storage/indexeddb/indexeddb-governance.ts";
|
|
import type {
|
|
IndexedDbObservation,
|
|
IndexedDbRuntimeDependencies,
|
|
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
|
|
import { mapIndexedDbException } from "../../src/adapters/storage/indexeddb/indexeddb-failure.ts";
|
|
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
|
|
|
type Item = Readonly<{
|
|
label: string;
|
|
rank: number;
|
|
}>;
|
|
|
|
type Query = Readonly<{
|
|
limit: number;
|
|
}>;
|
|
|
|
const TEST_SCOPE = Object.freeze({
|
|
authorityToken: "authoritytoken_001",
|
|
namespaceToken: "namespacetoken_001",
|
|
partitionToken: "partitiontoken_001",
|
|
accountScope: "ORIGIN_SHARED" as const,
|
|
});
|
|
|
|
const TEST_STORAGE_POLICY = Object.freeze({
|
|
owner: "platform-storage",
|
|
namespace: "indexeddb-runtime-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,
|
|
});
|
|
|
|
async function sha256Fingerprint(value: unknown): Promise<string> {
|
|
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
const digest = await globalThis.crypto.subtle.digest(
|
|
"SHA-256",
|
|
bytes,
|
|
);
|
|
return Array.from(
|
|
new Uint8Array(digest),
|
|
(byte) => byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
function dependencies(
|
|
memory: MemoryIndexedDbFactory,
|
|
overrides: Partial<
|
|
IndexedDbRuntimeDependencies<Item, Item, Query>
|
|
> = {},
|
|
): IndexedDbRuntimeDependencies<Item, Item, Query> {
|
|
return {
|
|
scope: TEST_SCOPE,
|
|
storagePolicy: TEST_STORAGE_POLICY,
|
|
schemaVersion: 1,
|
|
recordStore: "records",
|
|
governanceStore: "governance",
|
|
retentionStore: "retention",
|
|
retentionEligibilityIndex: "by-eligibility",
|
|
lifecycleMetadataStores: [],
|
|
idempotencyStore: "receipts",
|
|
idempotencyExpiryIndex: "by-expiry",
|
|
receiptRetentionMs: 60_000,
|
|
maxIdempotencyReceipts: 1_000,
|
|
migrations: [
|
|
{
|
|
id: "schema-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "records",
|
|
keyPath: "key",
|
|
indexes: [
|
|
{
|
|
name: "by-rank",
|
|
keyPath: "payload.rank",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "governance",
|
|
keyPath: "bindingKey",
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "retention",
|
|
keyPath: "recordKey",
|
|
indexes: [
|
|
{
|
|
name: "by-eligibility",
|
|
keyPath: "eligibleAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "receipts",
|
|
keyPath: "idempotencyKey",
|
|
indexes: [
|
|
{
|
|
name: "by-expiry",
|
|
keyPath: "expiresAtEpochMs",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
codec: {
|
|
currentVersion: 1,
|
|
encode: (value) => ({ ok: true, value }),
|
|
measureStoredBytes: (value) =>
|
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
|
decode: (version, value) =>
|
|
version === 1 &&
|
|
value !== null &&
|
|
typeof value === "object" &&
|
|
typeof (value as Partial<Item>).label === "string" &&
|
|
typeof (value as Partial<Item>).rank === "number"
|
|
? { ok: true, value: value as Item }
|
|
: { ok: false },
|
|
fingerprint: (value) =>
|
|
sha256Fingerprint([value.rank, value.label]),
|
|
},
|
|
queryPolicy: {
|
|
plan: (query) => ({
|
|
index: "by-rank",
|
|
direction: "next",
|
|
limit: query.limit,
|
|
}),
|
|
},
|
|
factory: memory.factory,
|
|
nowEpochMilliseconds: () => 1_000,
|
|
authorizeLifecycle: () => ({
|
|
authorized: true,
|
|
proofToken: "authorityproof_001",
|
|
}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function waitForTransaction(
|
|
memory: MemoryIndexedDbFactory,
|
|
): Promise<void> {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
if (memory.lastTransaction?.mode === "readwrite") return;
|
|
await new Promise<void>((resolve) => {
|
|
globalThis.setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
throw new Error("Timed out waiting for a fake readwrite transaction.");
|
|
}
|
|
|
|
describe("IndexedDB runtime", () => {
|
|
it("migrates, applies CAS atomically, replays idempotency keys, and pages by policy", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const observations: IndexedDbObservation[] = [];
|
|
const statuses: string[] = [];
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
observe: (event) => observations.push(event),
|
|
}),
|
|
);
|
|
runtime.subscribeStatus((status) => statuses.push(status.kind));
|
|
|
|
expect(await runtime.open()).toEqual({ ok: true, value: undefined });
|
|
expect(runtime.getStatus()).toEqual({
|
|
kind: "READY",
|
|
schemaVersion: 1,
|
|
});
|
|
|
|
const first = {
|
|
key: "record-alpha",
|
|
value: { label: "alpha", rank: 2 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "put-alpha-v1",
|
|
} as const;
|
|
expect(await runtime.compareAndSwap(first)).toEqual({
|
|
ok: true,
|
|
value: {
|
|
key: "record-alpha",
|
|
revision: 1,
|
|
replayed: false,
|
|
},
|
|
});
|
|
const storedFingerprint = (
|
|
memory.readRaw(
|
|
"receipts",
|
|
"put-alpha-v1",
|
|
) as Readonly<{ fingerprint: string }>
|
|
).fingerprint;
|
|
expect(storedFingerprint).toMatch(/^[a-f0-9]{64}$/u);
|
|
expect(storedFingerprint).not.toContain("alpha");
|
|
expect(await runtime.compareAndSwap(first)).toEqual({
|
|
ok: true,
|
|
value: {
|
|
key: "record-alpha",
|
|
revision: 1,
|
|
replayed: true,
|
|
},
|
|
});
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
...first,
|
|
value: { label: "changed", rank: 2 },
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONFLICT" },
|
|
});
|
|
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "record-beta",
|
|
value: { label: "beta", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "put-beta-v1",
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 1, replayed: false },
|
|
});
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "record-alpha",
|
|
value: { label: "alpha-v2", rank: 3 },
|
|
expectedRevision: 1,
|
|
idempotencyKey: "put-alpha-v2",
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 2, replayed: false },
|
|
});
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "record-alpha",
|
|
value: { label: "stale", rank: 4 },
|
|
expectedRevision: 1,
|
|
idempotencyKey: "stale-alpha",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONFLICT" },
|
|
});
|
|
|
|
expect(await runtime.read("record-alpha")).toEqual({
|
|
ok: true,
|
|
value: {
|
|
value: { label: "alpha-v2", rank: 3 },
|
|
revision: 2,
|
|
},
|
|
});
|
|
const firstPage = await runtime.query({ limit: 1 });
|
|
expect(firstPage).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
items: [{ label: "beta", rank: 1 }],
|
|
},
|
|
});
|
|
expect(firstPage.ok && firstPage.value.nextCursor).not.toBeNull();
|
|
const secondPage = await runtime.query(
|
|
{ limit: 1 },
|
|
firstPage.ok ? firstPage.value.nextCursor : null,
|
|
);
|
|
expect(secondPage).toEqual({
|
|
ok: true,
|
|
value: {
|
|
items: [{ label: "alpha-v2", rank: 3 }],
|
|
nextCursor: null,
|
|
},
|
|
});
|
|
|
|
const deletion = {
|
|
key: "record-alpha",
|
|
expectedRevision: 2,
|
|
idempotencyKey: "delete-alpha-v2",
|
|
} as const;
|
|
expect(await runtime.remove(deletion)).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 3, replayed: false },
|
|
});
|
|
expect(await runtime.remove(deletion)).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 3, replayed: true },
|
|
});
|
|
expect(await runtime.read("record-alpha")).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
|
|
expect(statuses).toEqual(["OPENING", "READY"]);
|
|
expect(JSON.stringify(observations)).not.toMatch(
|
|
/record-alpha|record-beta|alpha-v2|put-alpha/,
|
|
);
|
|
expect(observations).toContainEqual({
|
|
operation: "INDEXEDDB_MIGRATE",
|
|
outcome: "SUCCESS",
|
|
schemaVersion: 1,
|
|
countBucket: "1",
|
|
});
|
|
|
|
runtime.close();
|
|
expect(runtime.getStatus()).toEqual({ kind: "DISPOSED" });
|
|
});
|
|
|
|
it("waits for transaction complete and rolls request successes back on commit failure", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const runtime = createIndexedDbRuntime(dependencies(memory));
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
memory.failNextWriteCommit(
|
|
new DOMException("Do not expose this value.", "QuotaExceededError"),
|
|
);
|
|
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "rollback-target",
|
|
value: { label: "never-committed", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "rollback-operation",
|
|
}),
|
|
).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "QUOTA_EXCEEDED",
|
|
operation: "INDEXEDDB_WRITE",
|
|
retryable: false,
|
|
recovery: "READ_ONLY",
|
|
},
|
|
});
|
|
expect(await runtime.read("rollback-target")).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
});
|
|
|
|
it("aborts the whole in-flight transaction and cannot commit a late request", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const runtime = createIndexedDbRuntime(dependencies(memory));
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
memory.pauseTransactions();
|
|
const controller = new AbortController();
|
|
|
|
const pending = runtime.compareAndSwap({
|
|
key: "abort-target",
|
|
value: { label: "late", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "abort-operation",
|
|
signal: controller.signal,
|
|
});
|
|
await waitForTransaction(memory);
|
|
controller.abort();
|
|
|
|
expect(await pending).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "ABORTED",
|
|
operation: "INDEXEDDB_WRITE",
|
|
retryable: false,
|
|
recovery: "NONE",
|
|
},
|
|
});
|
|
memory.resumeTransactions();
|
|
await Promise.resolve();
|
|
expect(await runtime.read("abort-target")).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
});
|
|
|
|
it("shares one native open request across concurrent callers", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const nativeOpen = vi.spyOn(memory.factory, "open");
|
|
const runtime = createIndexedDbRuntime(dependencies(memory));
|
|
|
|
const results = await Promise.all([
|
|
runtime.open(),
|
|
runtime.open(),
|
|
runtime.open(),
|
|
]);
|
|
|
|
expect(nativeOpen).toHaveBeenCalledOnce();
|
|
expect(results).toEqual([
|
|
{ ok: true, value: undefined },
|
|
{ ok: true, value: undefined },
|
|
{ ok: true, value: undefined },
|
|
]);
|
|
});
|
|
|
|
it("isolates a caller abort from the shared native open request", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
memory.blockNextOpen();
|
|
const nativeOpen = vi.spyOn(memory.factory, "open");
|
|
const runtime = createIndexedDbRuntime(dependencies(memory));
|
|
const cancelledCaller = new AbortController();
|
|
|
|
const surviving = runtime.open();
|
|
const cancelled = runtime.open(cancelledCaller.signal);
|
|
await Promise.resolve();
|
|
cancelledCaller.abort();
|
|
|
|
expect(await cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { code: "ABORTED", operation: "INDEXEDDB_OPEN" },
|
|
});
|
|
expect(nativeOpen).toHaveBeenCalledOnce();
|
|
|
|
memory.releaseBlockedOpen();
|
|
expect(await surviving).toEqual({ ok: true, value: undefined });
|
|
expect(runtime.getStatus()).toEqual({ kind: "READY", schemaVersion: 1 });
|
|
});
|
|
|
|
it("times out a blocked upgrade, then closes a late successful connection", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
memory.blockNextOpen();
|
|
let timeout: (() => void) | undefined;
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
blockedTimeoutMs: 25,
|
|
scheduler: {
|
|
setTimeout: (callback) => {
|
|
timeout = callback;
|
|
return "blocked-timer";
|
|
},
|
|
clearTimeout: () => undefined,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const pending = runtime.open();
|
|
await Promise.resolve();
|
|
expect(runtime.getStatus()).toEqual({
|
|
kind: "BLOCKED",
|
|
currentVersion: 0,
|
|
targetVersion: 1,
|
|
});
|
|
timeout?.();
|
|
expect(await pending).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "BLOCKED",
|
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
},
|
|
});
|
|
|
|
memory.releaseBlockedOpen();
|
|
await Promise.resolve();
|
|
expect(memory.isConnectionClosed()).toBe(true);
|
|
expect(runtime.getStatus()).toEqual({
|
|
kind: "CLOSED",
|
|
reason: "NOT_OPENED",
|
|
});
|
|
});
|
|
|
|
it("retains native open ownership after a blocked timeout until late success", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
memory.blockNextOpen();
|
|
const nativeOpen = vi.spyOn(memory.factory, "open");
|
|
let timeout: (() => void) | undefined;
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
blockedTimeoutMs: 25,
|
|
scheduler: {
|
|
setTimeout: (callback) => {
|
|
timeout = callback;
|
|
return "blocked-timer";
|
|
},
|
|
clearTimeout: () => undefined,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const first = runtime.open();
|
|
await Promise.resolve();
|
|
timeout?.();
|
|
await expect(first).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { code: "BLOCKED" },
|
|
});
|
|
|
|
const second = runtime.open();
|
|
await Promise.resolve();
|
|
const nativeOpenCountBeforeLateSuccess = nativeOpen.mock.calls.length;
|
|
memory.releaseBlockedOpen();
|
|
|
|
await expect(second).resolves.toMatchObject({
|
|
ok: false,
|
|
error: { code: "BLOCKED" },
|
|
});
|
|
await Promise.resolve();
|
|
expect(nativeOpenCountBeforeLateSuccess).toBe(1);
|
|
expect(memory.isConnectionClosed()).toBe(true);
|
|
expect(runtime.getStatus()).toEqual({
|
|
kind: "CLOSED",
|
|
reason: "NOT_OPENED",
|
|
});
|
|
});
|
|
|
|
it("closes immediately on versionchange and isolates listener failures", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const onVersionChange = vi.fn(() => {
|
|
throw new Error("listener failure");
|
|
});
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, { onVersionChange }),
|
|
);
|
|
runtime.subscribeStatus(() => {
|
|
throw new Error("subscriber failure");
|
|
});
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
|
|
memory.triggerVersionChange(2);
|
|
|
|
expect(runtime.getStatus()).toEqual({
|
|
kind: "CLOSED",
|
|
reason: "VERSION_CHANGE",
|
|
});
|
|
expect(memory.isConnectionClosed()).toBe(true);
|
|
expect(onVersionChange).toHaveBeenCalledOnce();
|
|
expect(await runtime.read("after-versionchange")).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", recovery: "REOPEN" },
|
|
});
|
|
});
|
|
|
|
it("uses a closed, cross-realm-safe DOMException mapping", () => {
|
|
expect(
|
|
mapIndexedDbException(
|
|
{ name: "QuotaExceededError", message: "private-record-key" },
|
|
"INDEXEDDB_WRITE",
|
|
),
|
|
).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "QUOTA_EXCEEDED",
|
|
operation: "INDEXEDDB_WRITE",
|
|
retryable: false,
|
|
recovery: "READ_ONLY",
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify(
|
|
mapIndexedDbException(
|
|
{ name: "SomethingNew", message: "private-record-key" },
|
|
"INDEXEDDB_READ",
|
|
),
|
|
),
|
|
).not.toContain("private-record-key");
|
|
});
|
|
|
|
it("replays only inside the configured receipt window and expires safely", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
let currentTime = 1_000;
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
receiptRetentionMs: 100,
|
|
nowEpochMilliseconds: () => currentTime,
|
|
}),
|
|
);
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
const operation = {
|
|
key: "retention-record",
|
|
value: { label: "retained", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "retention-operation",
|
|
} as const;
|
|
|
|
expect(await runtime.compareAndSwap(operation)).toMatchObject({
|
|
ok: true,
|
|
value: { replayed: false },
|
|
});
|
|
expect(
|
|
memory.readRaw("receipts", "retention-operation"),
|
|
).toMatchObject({ expiresAtEpochMs: 1_100 });
|
|
currentTime = 1_099;
|
|
expect(await runtime.compareAndSwap(operation)).toMatchObject({
|
|
ok: true,
|
|
value: { replayed: true },
|
|
});
|
|
|
|
currentTime = 1_100;
|
|
expect(await runtime.compareAndSwap(operation)).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONFLICT" },
|
|
});
|
|
expect(
|
|
memory.readRaw("receipts", "retention-operation"),
|
|
).toBeDefined();
|
|
expect(await runtime.read("retention-record")).toMatchObject({
|
|
ok: true,
|
|
value: { revision: 1 },
|
|
});
|
|
});
|
|
|
|
it("rejects non-digest fingerprints before storing PII in a receipt", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const base = dependencies(memory);
|
|
const runtime = createIndexedDbRuntime({
|
|
...base,
|
|
codec: {
|
|
...base.codec,
|
|
fingerprint: () => "private-user-label",
|
|
},
|
|
});
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "private-key",
|
|
value: { label: "private-user-label", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "private-operation",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
expect(
|
|
memory.readRaw("receipts", "private-operation"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("rejects adversarial cursor/range keys before native conversion", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
let rangeKey: unknown = "safe";
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
queryPolicy: {
|
|
plan: () => ({
|
|
limit: 1,
|
|
range: {
|
|
kind: "ONLY",
|
|
value: rangeKey,
|
|
} as never,
|
|
}),
|
|
},
|
|
}),
|
|
);
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
|
|
rangeKey = new ArrayBuffer(4_097);
|
|
expect(await runtime.query({ limit: 1 })).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
rangeKey = Array.from({ length: 65 }, (_, index) => index);
|
|
expect(await runtime.query({ limit: 1 })).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
|
|
let nested: unknown = "leaf";
|
|
for (let depth = 0; depth < 10; depth += 1) {
|
|
nested = [nested];
|
|
}
|
|
expect(
|
|
await runtime.query(
|
|
{ limit: 1 },
|
|
{
|
|
indexKey: nested,
|
|
primaryKey: "primary",
|
|
} as never,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
});
|
|
|
|
it("rejects destructive DDL in the additive-only migration planner", () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const base = dependencies(memory);
|
|
|
|
expect(() =>
|
|
createIndexedDbRuntime({
|
|
...base,
|
|
migrations: [
|
|
{
|
|
id: "destructive-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "DELETE_STORE",
|
|
name: "records",
|
|
} as never,
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toThrowError(
|
|
expect.objectContaining({ name: "InvalidStateError" }),
|
|
);
|
|
});
|
|
|
|
it("fails closed when the migrated schema omits a required runtime store", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const base = dependencies(memory);
|
|
const runtime = createIndexedDbRuntime({
|
|
...base,
|
|
migrations: [
|
|
{
|
|
id: "incomplete-v1",
|
|
fromVersion: 0,
|
|
toVersion: 1,
|
|
operations: [
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "records",
|
|
keyPath: "key",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(await runtime.open()).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "MIGRATION_FAILED",
|
|
operation: "INDEXEDDB_MIGRATE",
|
|
retryable: false,
|
|
recovery: "READ_ONLY",
|
|
},
|
|
});
|
|
expect(memory.isConnectionClosed()).toBe(true);
|
|
});
|
|
|
|
it("derives physical identity only from opaque scope and rejects an override", () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const physicalName = indexedDbPhysicalDatabaseName(TEST_SCOPE);
|
|
expect(physicalName).toBe(
|
|
"ca-idb-v1:authoritytoken_001.namespacetoken_001.partitiontoken_001",
|
|
);
|
|
expect(physicalName).not.toContain(
|
|
TEST_STORAGE_POLICY.namespace,
|
|
);
|
|
expect(() =>
|
|
createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
databaseNameAssertion: "customer-account-42",
|
|
}),
|
|
),
|
|
).toThrow(TypeError);
|
|
});
|
|
|
|
it("fails closed when the same physical scope is rebound to another policy", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const first = createIndexedDbRuntime(dependencies(memory));
|
|
expect(await first.open()).toMatchObject({ ok: true });
|
|
first.close();
|
|
|
|
const rebound = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
storagePolicy: {
|
|
...TEST_STORAGE_POLICY,
|
|
namespace: "different-dataset-policy",
|
|
},
|
|
}),
|
|
);
|
|
expect(await rebound.open()).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: "POLICY_REJECTED",
|
|
operation: "INDEXEDDB_OPEN",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("snapshots governance and enforces TTL logically before bounded deletion", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
let epochMs = 1_000;
|
|
const authorizeLifecycle = vi.fn((_request: unknown) => ({
|
|
authorized: true as const,
|
|
proofToken: "ttl_authority_proof_001",
|
|
}));
|
|
const mutableScope = structuredClone(TEST_SCOPE);
|
|
const mutablePolicy = {
|
|
...structuredClone(TEST_STORAGE_POLICY),
|
|
retention: { kind: "TTL" as const, maxAgeMs: 10 },
|
|
};
|
|
const mutablePlan = {
|
|
index: "by-rank",
|
|
direction: "next" as const,
|
|
limit: 10,
|
|
};
|
|
const observations: IndexedDbObservation[] = [];
|
|
const mutableConfiguration = dependencies(memory, {
|
|
scope: mutableScope,
|
|
storagePolicy: mutablePolicy,
|
|
keyRange: memory.keyRange,
|
|
nowEpochMilliseconds: () => epochMs,
|
|
nowMonotonicMilliseconds: () => 0,
|
|
authorizeLifecycle,
|
|
observe: (event) => observations.push(event),
|
|
queryPolicy: { plan: () => mutablePlan },
|
|
});
|
|
const runtime = createIndexedDbRuntime(mutableConfiguration);
|
|
(
|
|
mutableScope as unknown as { authorityToken: string }
|
|
).authorityToken = "mutated_business_id";
|
|
(
|
|
mutablePolicy as unknown as {
|
|
namespace: string;
|
|
retention: { maxAgeMs: number };
|
|
}
|
|
).namespace = "mutated-policy";
|
|
(
|
|
mutablePolicy as unknown as {
|
|
retention: { maxAgeMs: number };
|
|
}
|
|
).retention.maxAgeMs = 100_000;
|
|
(
|
|
mutableConfiguration as unknown as {
|
|
recordStore: string;
|
|
schemaVersion: number;
|
|
maxIdempotencyReceipts: number;
|
|
}
|
|
).recordStore = "mutated-record-store";
|
|
(
|
|
mutableConfiguration as unknown as {
|
|
schemaVersion: number;
|
|
}
|
|
).schemaVersion = 99;
|
|
(
|
|
mutableConfiguration as unknown as {
|
|
maxIdempotencyReceipts: number;
|
|
}
|
|
).maxIdempotencyReceipts = 0;
|
|
(
|
|
mutableConfiguration.codec as unknown as {
|
|
measureStoredBytes: () => number;
|
|
}
|
|
).measureStoredBytes = () => Number.NaN;
|
|
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "ttl-record",
|
|
value: { label: "expires", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "ttl-create",
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
const pendingQuery = runtime.query({ limit: 10 });
|
|
mutablePlan.limit = 0;
|
|
(
|
|
mutablePlan as unknown as { index: string }
|
|
).index = "mutated-index";
|
|
expect(await pendingQuery).toMatchObject({
|
|
ok: true,
|
|
value: { items: [{ label: "expires", rank: 1 }] },
|
|
});
|
|
mutablePlan.limit = 10;
|
|
mutablePlan.index = "by-rank";
|
|
epochMs = 1_011;
|
|
expect(await runtime.read("ttl-record")).toMatchObject({
|
|
ok: false,
|
|
error: { code: "EXPIRED_RESOURCE" },
|
|
});
|
|
expect(await runtime.query({ limit: 10 })).toEqual({
|
|
ok: true,
|
|
value: { items: [], nextCursor: null },
|
|
});
|
|
expect(
|
|
await runtime.enforceLifecycleBatch({
|
|
action: "RETENTION_SWEEP",
|
|
maxRows: 10,
|
|
maxDurationMs: 1_000,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { state: "COMPLETE", deletedRows: 1 },
|
|
});
|
|
expect(authorizeLifecycle).toHaveBeenCalledOnce();
|
|
expect(authorizeLifecycle.mock.calls[0]?.[0]).toMatchObject({
|
|
scope: TEST_SCOPE,
|
|
storagePolicy: {
|
|
namespace: TEST_STORAGE_POLICY.namespace,
|
|
retention: { kind: "TTL", maxAgeMs: 10 },
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify([
|
|
memory.readRaw("governance", "dataset-binding"),
|
|
memory.readRaw("governance", "dataset-budget"),
|
|
memory.readRaw("receipts", "ttl-create"),
|
|
observations,
|
|
]),
|
|
).not.toContain("ttl_authority_proof_001");
|
|
});
|
|
|
|
it("requires lifecycle authority and only deletes sync-confirmed rows", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const authorizeLifecycle = vi
|
|
.fn()
|
|
.mockReturnValueOnce({ authorized: false })
|
|
.mockReturnValue({
|
|
authorized: true,
|
|
proofToken: "sync_authority_proof_001",
|
|
});
|
|
const runtime = createIndexedDbRuntime(
|
|
dependencies(memory, {
|
|
scope: {
|
|
...TEST_SCOPE,
|
|
accountScope: "OPAQUE_PARTITION",
|
|
},
|
|
storagePolicy: {
|
|
...TEST_STORAGE_POLICY,
|
|
accountScope: "OPAQUE_PARTITION",
|
|
retention: { kind: "UNTIL_SYNCED" },
|
|
logoutAction: "EXPORT_THEN_PURGE",
|
|
accountDeletionAction: "PURGE_PARTITION",
|
|
},
|
|
keyRange:
|
|
memory.keyRange as NonNullable<
|
|
IndexedDbRuntimeDependencies<Item, Item, Query>["keyRange"]
|
|
>,
|
|
authorizeLifecycle,
|
|
nowMonotonicMilliseconds: () => 0,
|
|
}),
|
|
);
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "sync-record",
|
|
value: { label: "pending", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "sync-pending",
|
|
synchronization: "PENDING",
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
await runtime.enforceLifecycleBatch({
|
|
action: "RETENTION_SWEEP",
|
|
maxRows: 10,
|
|
maxDurationMs: 1_000,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "POLICY_REJECTED" },
|
|
});
|
|
expect(memory.readRaw("records", "sync-record")).toBeDefined();
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "sync-record",
|
|
value: { label: "confirmed", rank: 1 },
|
|
expectedRevision: 1,
|
|
idempotencyKey: "sync-confirmed",
|
|
synchronization: "CONFIRMED",
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
await runtime.enforceLifecycleBatch({
|
|
action: "RETENTION_SWEEP",
|
|
maxRows: 10,
|
|
maxDurationMs: 1_000,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: { deletedRows: 1 },
|
|
});
|
|
});
|
|
|
|
it("purges records, orphan metadata, receipts, and counters in bounded lifecycle batches", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const base = dependencies(memory);
|
|
const authorizeLifecycle = vi.fn(() => ({
|
|
authorized: true as const,
|
|
proofToken: "session_authority_proof_001",
|
|
}));
|
|
const runtime = createIndexedDbRuntime({
|
|
...base,
|
|
storagePolicy: {
|
|
...TEST_STORAGE_POLICY,
|
|
retention: { kind: "SESSION" },
|
|
},
|
|
lifecycleMetadataStores: ["lifecycle-metadata"],
|
|
migrations: [
|
|
{
|
|
...base.migrations[0]!,
|
|
operations: [
|
|
...base.migrations[0]!.operations,
|
|
{
|
|
kind: "CREATE_STORE",
|
|
name: "lifecycle-metadata",
|
|
keyPath: "metadataKey",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
authorizeLifecycle,
|
|
nowMonotonicMilliseconds: () => 0,
|
|
});
|
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
|
memory.seed("lifecycle-metadata", {
|
|
metadataKey: "migration-checkpoint",
|
|
lastRecordKey: "session-record",
|
|
});
|
|
expect(
|
|
await runtime.compareAndSwap({
|
|
key: "session-record",
|
|
value: { label: "session", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "session-create",
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
|
|
const receipts = [];
|
|
for (let batch = 0; batch < 5; batch += 1) {
|
|
const receipt = await runtime.enforceLifecycleBatch({
|
|
action: "SESSION_END",
|
|
maxRows: 1,
|
|
maxDurationMs: 1_000,
|
|
});
|
|
receipts.push(receipt);
|
|
if (receipt.ok && receipt.value.state === "COMPLETE") break;
|
|
}
|
|
|
|
expect(receipts).toHaveLength(3);
|
|
expect(receipts.at(-1)).toMatchObject({
|
|
ok: true,
|
|
value: { state: "COMPLETE", deletedRows: 1 },
|
|
});
|
|
expect(authorizeLifecycle).toHaveBeenCalledTimes(3);
|
|
expect(memory.readRaw("records", "session-record")).toBeUndefined();
|
|
expect(memory.readRaw("retention", "session-record")).toBeUndefined();
|
|
expect(memory.readRaw("receipts", "session-create")).toBeUndefined();
|
|
expect(
|
|
memory.readRaw(
|
|
"lifecycle-metadata",
|
|
"migration-checkpoint",
|
|
),
|
|
).toBeUndefined();
|
|
expect(
|
|
memory.readRaw("governance", "dataset-budget"),
|
|
).toMatchObject({ usedBytes: 0, receiptCount: 0 });
|
|
});
|
|
|
|
it("rejects invalid measurements, byte overage, and receipt-count overage atomically", async () => {
|
|
const invalidMemory = new MemoryIndexedDbFactory();
|
|
const invalidBase = dependencies(invalidMemory);
|
|
const invalid = createIndexedDbRuntime({
|
|
...invalidBase,
|
|
codec: {
|
|
...invalidBase.codec,
|
|
measureStoredBytes: () => Number.NaN,
|
|
},
|
|
});
|
|
expect(await invalid.open()).toMatchObject({ ok: true });
|
|
expect(
|
|
await invalid.compareAndSwap({
|
|
key: "invalid-measure",
|
|
value: { label: "invalid", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "invalid-measure-create",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
|
|
const boundedMemory = new MemoryIndexedDbFactory();
|
|
const boundedBase = dependencies(boundedMemory);
|
|
const bounded = createIndexedDbRuntime({
|
|
...boundedBase,
|
|
maxIdempotencyReceipts: 1,
|
|
storagePolicy: {
|
|
...TEST_STORAGE_POLICY,
|
|
softBudgetBytes: 500,
|
|
hardBudgetBytes: 620,
|
|
},
|
|
codec: {
|
|
...boundedBase.codec,
|
|
measureStoredBytes: () => 10,
|
|
},
|
|
});
|
|
expect(await bounded.open()).toMatchObject({ ok: true });
|
|
expect(
|
|
await bounded.compareAndSwap({
|
|
key: "a",
|
|
value: { label: "fits", rank: 1 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "first-receipt",
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
expect(
|
|
await bounded.compareAndSwap({
|
|
key: "b",
|
|
value: { label: "receipt-cap", rank: 2 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "second-receipt",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(boundedMemory.readRaw("records", "b")).toBeUndefined();
|
|
|
|
const hardMemory = new MemoryIndexedDbFactory();
|
|
const hardBase = dependencies(hardMemory);
|
|
const hardBounded = createIndexedDbRuntime({
|
|
...hardBase,
|
|
storagePolicy: {
|
|
...TEST_STORAGE_POLICY,
|
|
softBudgetBytes: 500,
|
|
hardBudgetBytes: 520,
|
|
},
|
|
codec: {
|
|
...hardBase.codec,
|
|
measureStoredBytes: () => 10,
|
|
},
|
|
});
|
|
expect(await hardBounded.open()).toMatchObject({ ok: true });
|
|
expect(
|
|
await hardBounded.compareAndSwap({
|
|
key: "x",
|
|
value: { label: "too-large", rank: 3 },
|
|
expectedRevision: null,
|
|
idempotencyKey: "hard-limit-receipt",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(hardMemory.readRaw("records", "x")).toBeUndefined();
|
|
});
|
|
});
|