Lumen/tests/unit/persistence.test.ts
2026-09-30 14:19:56 -05:00

836 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-non-null-assertion, @typescript-eslint/prefer-promise-reject-errors, @typescript-eslint/no-unsafe-call */
/**
* Stage 5 — local persistence tests.
* Covers: P1 per-file atomic, P2 single-txn activation, P3 Quota keeps active,
* P4/P6 helpers, P5 6MB cap, light verification, B-6 isolation, slot asset Blobs.
* Trace: SPIKE-01 P1–P8, IMPLEMENTATION-CONTRACT.md §9/§10, §19, §31 FA-5..FA-8
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBObjectStore from "fake-indexeddb/lib/FDBObjectStore";
// polyfill globals for Node environment
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
g.IDBObjectStore = FDBObjectStore as unknown;
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
import {
openDB,
withTx,
idbGet,
idbPut,
idbClear,
idbCount,
idbGetAll,
} from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES, SLOT_STAGING } from "../../src/platform/idb/names.js";
import { hasEnoughSpace, requestPersist } from "../../src/platform/idb/storage-helpers.js";
import {
openSystemDB,
readSystemMeta,
writeSystemMeta,
activateSlot,
clearReadbackPending,
incrementBootCount,
} from "../../src/data/system-meta/store.js";
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
import {
openSlotDB,
writeSlotFile,
readSlotFile,
readSlotFileMeta,
clearSlot,
writeSlotAsset,
readSlotAsset,
lightCheckSlot,
listSlotFiles,
initializeStaging,
readStagingProgress,
writeSlotFileWithProgress,
writeSlotAssetWithProgress,
markStagingComplete,
} from "../../src/data/slot/store.js";
import {
openUserDB,
addFavorite,
removeFavorite,
hasFavorite,
listFavorites,
countFavorites,
getPrefs,
putPrefs,
pushDiag,
listDiag,
} from "../../src/data/user/store.js";
function deleteDB(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const req = (globalThis as unknown as { indexedDB: IDBFactory }).indexedDB.deleteDatabase(name);
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error);
};
req.onblocked = () => {
resolve();
};
});
}
async function cleanAll(): Promise<void> {
await deleteDB(DB.SYSTEM);
await deleteDB(DB.SLOT_A);
await deleteDB(DB.SLOT_B);
await deleteDB(DB.USER);
}
describe("platform/idb wrapper — P1/P3/P5", () => {
beforeEach(async () => {
await cleanAll();
});
afterEach(async () => {
await cleanAll();
});
it("P5: MAX_RECORD_BYTES is 6MB and checkRecordSize enforces cap", () => {
expect(MAX_RECORD_BYTES).toBe(6 * 1024 * 1024);
expect(() => {
checkRecordSize(0);
}).not.toThrow();
expect(() => {
checkRecordSize(MAX_RECORD_BYTES);
}).not.toThrow();
expect(() => {
checkRecordSize(MAX_RECORD_BYTES + 1);
}).toThrow(RangeError);
expect(() => {
checkRecordSize(-1);
}).toThrow(RangeError);
});
it("P1: one short txn per file — bytes+progress together, abort leaves prior committed", async () => {
const db = await openDB("test-p1", 1, (d) => {
d.createObjectStore("s");
});
await idbPut(db, "s", { v: 1 }, "k1");
// simulate crash mid-txn: throw inside withTx
try {
await withTx(db, "s", "readwrite", async (tx) => {
const os = tx.objectStore("s");
os.put({ v: 999 }, "k2");
throw new Error("crash before commit");
});
} catch {
// expected
}
// k2 must not be committed; k1 still present
expect(await idbGet(db, "s", "k2")).toBeUndefined();
expect(await idbGet(db, "s", "k1")).toEqual({ v: 1 });
db.close();
await deleteDB("test-p1");
});
it("P1: no txn spans non-IDB await — wrapper keeps txn short (fast commit)", async () => {
const db = await openDB("test-p1-fast", 1, (d) => {
d.createObjectStore("s");
});
const start = Date.now();
await idbPut(db, "s", "val", "k");
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(500);
db.close();
await deleteDB("test-p1-fast");
});
it("isQuotaError detects QuotaExceededError by name", () => {
expect(isQuotaError(new DOMException("x", "QuotaExceededError"))).toBe(true);
expect(isQuotaError({ name: "QuotaExceededError" })).toBe(true);
expect(isQuotaError(new Error("other"))).toBe(false);
expect(isQuotaError({ code: 22 })).toBe(true);
});
it("wrapper helpers idbGet/idbPut/idbCount/idbClear work", async () => {
const db = await openDB("test-helpers", 1, (d) => {
d.createObjectStore("nums");
});
await idbPut(db, "nums", 42, "a");
expect(await idbGet(db, "nums", "a")).toBe(42);
expect(await idbCount(db, "nums")).toBe(1);
expect(await idbGetAll(db, "nums")).toEqual([42]);
await idbClear(db, "nums");
expect(await idbCount(db, "nums")).toBe(0);
db.close();
await deleteDB("test-helpers");
});
});
describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("writeSlotFile per-file atomic and 6MB cap enforced", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "emergency", {
bytes: 1000,
sha256: "a".repeat(64),
json: { section: "emergency" },
});
expect(await readSlotFile(db, "emergency")).toEqual({ section: "emergency" });
const meta = await readSlotFileMeta(db, "emergency");
expect(meta?.bytes).toBe(1000);
// over 6MB should throw
await expect(
writeSlotFile(db, "schedule", { bytes: 7 * 1024 * 1024, sha256: "b".repeat(64), json: {} }),
).rejects.toThrow(RangeError);
db.close();
});
it("slot holds multiple sections; lightCheckSlot verifies presence+size (boot ≤150ms target logic)", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "emergency", {
bytes: 41233,
sha256: "a".repeat(64),
json: { section: "emergency" },
});
await writeSlotFile(db, "schedule", {
bytes: 412201,
sha256: "b".repeat(64),
json: { section: "schedule" },
});
const ok = await lightCheckSlot(db, [
{ id: "emergency", bytes: 41233 },
{ id: "schedule", bytes: 412201 },
]);
expect(ok.ok).toBe(true);
const missing = await lightCheckSlot(db, [{ id: "map", bytes: 38122 }]);
expect(missing.ok).toBe(false);
const sizeMismatch = await lightCheckSlot(db, [{ id: "emergency", bytes: 1 }]);
expect(sizeMismatch.ok).toBe(false);
// timing: light check should be fast (no hashing)
const start = performance.now();
await lightCheckSlot(db, [{ id: "emergency", bytes: 41233 }]);
expect(performance.now() - start).toBeLessThan(150);
db.close();
});
it("slot assets as Blobs — write/read with same slot DB for one-failure-domain rollback", async () => {
const db = await openSlotDB("B");
const blob = new Blob(["fake-webp-bytes"], { type: "image/webp" });
await writeSlotAsset(db, "map-base-overview", {
bytes: blob.size,
sha256: "c".repeat(64),
blob,
});
const rec = await readSlotAsset(db, "map-base-overview");
expect(rec?.sha256).toBe("c".repeat(64));
expect(rec?.bytes).toBe(blob.size);
expect(rec?.blob).toBeInstanceOf(Blob);
expect(await rec?.blob.text()).toBe("fake-webp-bytes");
db.close();
});
it("clearSlot wipes both files and assets (GC / next-staging reclaim)", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "info", { bytes: 100, sha256: "a".repeat(64), json: {} });
const blob = new Blob(["x"]);
await writeSlotAsset(db, "img-1", { bytes: 1, sha256: "b".repeat(64), blob });
await clearSlot(db);
expect(await listSlotFiles(db)).toEqual([]);
expect(await readSlotAsset(db, "img-1")).toBeUndefined();
db.close();
});
it("P3 quota simulation — slot write failure keeps active dataset intact (old slot untouched)", async () => {
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
const sys = await openSystemDB();
// seed active dataset v1 in slot A
await writeSlotFile(slotA, "emergency", {
bytes: 100,
sha256: "a".repeat(64),
json: { section: "emergency", v: 1 },
});
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
const activeBefore = await readSystemMeta(sys);
expect(activeBefore.activeSlot).toBe("A");
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
// B remains empty — staging interrupted keeps active
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
slotA.close();
slotB.close();
sys.close();
});
it("P3 quota INJECTION — put() throwing QuotaExceededError rejects the write and keeps active slot intact", async () => {
// Real injection: patch IDBObjectStore.prototype.put so writes of the
// staged blob throw a genuine QuotaExceededError-shaped failure.
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
const sys = await openSystemDB();
await writeSlotFile(slotA, "emergency", {
bytes: 100,
sha256: "a".repeat(64),
json: { section: "emergency", v: 1 },
});
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
});
const proto = (globalThis as unknown as { IDBObjectStore: { prototype: IDBObjectStore } })
.IDBObjectStore.prototype;
// Deliberate prototype patch — unbound read/assign is the point of the shim.
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalPut = proto.put;
let injected = 0;
proto.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey) {
const rec = value as { bytes?: number } | null;
if (rec && typeof rec.bytes === "number" && rec.bytes >= 1024) {
injected++;
throw new DOMException("injected quota", "QuotaExceededError");
}
return originalPut.call(this, value, key);
} as typeof proto.put;
try {
const big = new Blob([new Uint8Array(4096)]);
await expect(
writeSlotAsset(slotB, "map-base-overview", {
bytes: big.size,
sha256: "b".repeat(64),
blob: big,
}),
).rejects.toSatisfy(isQuotaError);
expect(injected).toBeGreaterThan(0);
// P3 invariant: active dataset fully readable, inactive slot empty.
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
const meta = await readSystemMeta(sys);
expect(meta.activeSlot).toBe("A");
expect(await readSlotAsset(slotB, "map-base-overview")).toBeUndefined();
} finally {
proto.put = originalPut;
}
slotA.close();
slotB.close();
sys.close();
});
});
describe("migration seam — openDB versionchange (lumen-user migrations ride here)", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("openDB passes authoritative oldVersion to onUpgrade across versions", async () => {
// The seam lumen-user migrations will ride on: reopening at a higher
// version must report the PRE-upgrade version (not the new one) and run
// inside the versionchange txn so data from prior versions is preserved.
const name = "lumen-test-migrate";
const db1 = await openDB(name, 1, (db) => {
db.createObjectStore("prefs");
});
await idbPut(db1, "prefs", { theme: "amber" }, "prefs");
db1.close();
const seen: number[] = [];
const db2 = await openDB(name, 2, (db, oldVersion) => {
seen.push(oldVersion);
if (oldVersion < 2 && !db.objectStoreNames.contains("favs")) {
db.createObjectStore("favs");
}
});
expect(seen).toEqual([1]);
// prior-version data intact, new store usable
expect(await idbGet(db2, "prefs", "prefs")).toEqual({ theme: "amber" });
await idbPut(db2, "favs", { eventId: "e1" }, "e1");
db2.close();
await deleteDB(name);
});
});
describe("system-meta store — P2 single-txn activation + F-2/F-3", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("INITIAL_SYSTEM_META has no active slot (NOT_READY/BASELINE_ONLY start)", async () => {
const db = await openSystemDB();
const meta = await readSystemMeta(db);
expect(meta.activeSlot).toBeNull();
expect(meta.readbackPending).toBe(false);
expect(meta.verification).toBeNull();
db.close();
});
it("P2: activateSlot is single transaction flipping activeSlot + version + verification + readbackPending", async () => {
const db = await openSystemDB();
const next = await activateSlot(db, {
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 3,
verification: {
packageVersion: 3,
edition: "lumen-2026",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "sha256:abc",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
expect(next.activeSlot).toBe("A");
expect(next.activePackageVersion).toBe(3);
expect(next.readbackPending).toBe(true);
expect(next.verification?.manifestSha256).toBe("f".repeat(64));
// persisted
const re = await readSystemMeta(db);
expect(re.activeSlot).toBe("A");
expect(re.readbackPending).toBe(true);
db.close();
});
it("clearReadbackPending clears flag after spot-check", async () => {
const db = await openSystemDB();
await activateSlot(db, {
activeSlot: "B",
activeEdition: "lumen-2026",
activePackageVersion: 2,
verification: {
packageVersion: 2,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
await clearReadbackPending(db);
expect((await readSystemMeta(db)).readbackPending).toBe(false);
db.close();
});
it("system metadata has no staging journal", async () => {
const db = await openSystemDB();
expect("staging" in (await readSystemMeta(db))).toBe(false);
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
staging: {
targetSlot: "B",
edition: "legacy",
packageVersion: 1,
stagedFiles: [],
startedAt: 1,
lastProgressAt: 1,
},
} as never);
expect("staging" in (await readSystemMeta(db))).toBe(false);
db.close();
});
it("incrementBootCount for GC N≥3 heuristic", async () => {
const db = await openSystemDB();
expect(await incrementBootCount(db)).toBe(1);
expect(await incrementBootCount(db)).toBe(2);
expect(await incrementBootCount(db)).toBe(3);
db.close();
});
it("writeSystemMeta is atomic — concurrent reads see either old or new, never partial", async () => {
const db = await openSystemDB();
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activePackageVersion: 1,
} as never);
// overwrite in one txn
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
activeSlot: "B",
activePackageVersion: 2,
} as never);
const m = await readSystemMeta(db);
expect([1, 2]).toContain(m.activePackageVersion);
expect(m.activeSlot === "A" || m.activeSlot === "B").toBe(true);
db.close();
});
});
describe("slot-local staging journal — P1", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
function journal() {
return {
edition: "lumen-2026",
packageVersion: 2,
manifestSha256: "f".repeat(64),
startedAt: 1_000,
lastProgressAt: 1_000,
} as const;
}
it("commits each file/asset and its progress in one target-slot transaction", async () => {
const db = await openSlotDB("B");
let progress = await initializeStaging(db, journal());
progress = await writeSlotFileWithProgress(
db,
"emergency",
{
bytes: 10,
sha256: "a".repeat(64),
json: { section: "emergency" },
},
progress,
);
expect((await readStagingProgress(db))?.stagedFiles).toEqual(["emergency"]);
const blob = new Blob(["asset"]);
await writeSlotAssetWithProgress(
db,
"map-1",
{
bytes: blob.size,
sha256: "b".repeat(64),
blob,
},
progress,
);
expect((await readStagingProgress(db))?.stagedAssets).toEqual(["map-1"]);
expect((await markStagingComplete(db)).complete).toBe(true);
expect(await readSlotFile(db, "emergency")).toEqual({ section: "emergency" });
db.close();
});
it("aborting the short transaction commits neither file nor progress", async () => {
const db = await openSlotDB("B");
await initializeStaging(db, journal());
await expect(
withTx(db, [SLOT_FILES, SLOT_STAGING], "readwrite", async (tx) => {
tx.objectStore(SLOT_FILES).put(
{ id: "emergency", bytes: 1, sha256: "a".repeat(64), json: {} },
"emergency",
);
tx.objectStore(SLOT_STAGING).put(
{ ...journal(), stagedFiles: ["emergency"], stagedAssets: [], complete: false },
"journal",
);
throw new Error("simulated interruption");
}),
).rejects.toThrow("simulated interruption");
expect(await readSlotFile(db, "emergency")).toBeUndefined();
expect((await readStagingProgress(db))?.stagedFiles).toEqual([]);
db.close();
});
it("resumes from slot-local progress after reopening the slot", async () => {
let db = await openSlotDB("B");
const progress = await initializeStaging(db, journal());
await writeSlotFileWithProgress(
db,
"emergency",
{ bytes: 1, sha256: "a".repeat(64), json: {} },
progress,
);
db.close();
db = await openSlotDB("B");
const resumed = await readStagingProgress(db);
expect(resumed?.stagedFiles).toEqual(["emergency"]);
expect(resumed?.complete).toBe(false);
db.close();
});
it("clearing a slot clears its journal while user data remains separate", async () => {
const slot = await openSlotDB("B");
await initializeStaging(slot, journal());
await clearSlot(slot);
expect(await readStagingProgress(slot)).toBeUndefined();
slot.close();
});
it("slot-local writes do not write or transact against system metadata", async () => {
const system = await openSystemDB();
await writeSystemMeta(system, { ...INITIAL_SYSTEM_META, activeSlot: "A" });
const slot = await openSlotDB("B");
const progress = await initializeStaging(slot, journal());
await writeSlotFileWithProgress(
slot,
"emergency",
{ bytes: 1, sha256: "a".repeat(64), json: {} },
progress,
);
expect((await readSystemMeta(system)).activeSlot).toBe("A");
expect("staging" in (await readSystemMeta(system))).toBe(false);
slot.close();
system.close();
});
});
describe("user store — B-6 never touched by dataset updates / X3", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("favorites keyed by stable event id survive A/B slot ops (B-6, X3)", async () => {
const user = await openUserDB();
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
await addFavorite(user, { eventId: "evt-0113", addedAt: 1000 });
await addFavorite(user, { eventId: "evt-0114", addedAt: 2000 });
expect(await countFavorites(user)).toBe(2);
expect(await hasFavorite(user, "evt-0113")).toBe(true);
// dataset ops: clear slots (simulating staging wipe of inactive + rollback)
await clearSlot(slotA);
await clearSlot(slotB);
// favorites must survive
expect(await listFavorites(user)).toHaveLength(2);
expect(await hasFavorite(user, "evt-0113")).toBe(true);
// remove one
await removeFavorite(user, "evt-0113");
expect(await hasFavorite(user, "evt-0113")).toBe(false);
user.close();
slotA.close();
slotB.close();
});
it("prefs singleton persists", async () => {
const db = await openUserDB();
expect(await getPrefs(db)).toBeUndefined();
await putPrefs(db, {
festivalTimezone: "America/Chicago",
useDeviceTimezone: false,
theme: "dark",
});
expect(await getPrefs(db)).toMatchObject({
festivalTimezone: "America/Chicago",
useDeviceTimezone: false,
});
db.close();
});
it("diag ring buffer capped at 100, scrubbed manual share only", async () => {
const db = await openUserDB();
for (let i = 0; i < 105; i++) {
await pushDiag(db, { at: 1_000_000 + i, kind: "test", detail: `e-${i}` });
}
const diag = await listDiag(db);
expect(diag.length).toBeLessThanOrEqual(100);
db.close();
});
it("user DB is separate origin — deleting slot DB does not affect user (B-6)", async () => {
const user = await openUserDB();
await addFavorite(user, { eventId: "evt-x", addedAt: 1 });
user.close();
await deleteDB(DB.SLOT_A);
await deleteDB(DB.SLOT_B);
const user2 = await openUserDB();
expect(await hasFavorite(user2, "evt-x")).toBe(true);
user2.close();
});
});
describe("storage helpers — P4 free-space 2× check + P6 persist (SPIKE-01 P4/P6)", () => {
const originalNavigator = (globalThis as unknown as { navigator?: unknown }).navigator;
afterEach(() => {
if (originalNavigator === undefined) {
delete (globalThis as unknown as { navigator?: unknown }).navigator;
} else {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
writable: true,
configurable: true,
});
}
vi.restoreAllMocks();
});
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, "navigator", {
value,
writable: true,
configurable: true,
});
}
it("P4: hasEnoughSpace refuses if free < 2× required", async () => {
setNavigator({
storage: {
estimate: async () => ({ quota: 100_000_000, usage: 90_000_000 }), // free 10MB
},
});
// required 6MB → need 12MB free → should refuse
expect(await hasEnoughSpace(6 * 1024 * 1024)).toBe(false);
// required 4MB → need 8MB free → ok
expect(await hasEnoughSpace(4 * 1024 * 1024)).toBe(true);
});
it("P4: hasEnoughSpace returns true when estimate unavailable (allow, P3 will handle quota)", async () => {
setNavigator({});
expect(await hasEnoughSpace(40 * 1024 * 1024)).toBe(true);
setNavigator({
storage: { estimate: async () => ({}) },
});
expect(await hasEnoughSpace(40 * 1024 * 1024)).toBe(true);
});
it("P6: requestPersist returns boolean and never throws", async () => {
setNavigator({
storage: { persist: async () => true },
});
expect(await requestPersist()).toBe(true);
setNavigator({
storage: { persist: async () => false },
});
expect(await requestPersist()).toBe(false);
setNavigator({});
expect(await requestPersist()).toBe(false);
});
});
describe("boot light verification — eviction detection (P7, SPIKE-05)", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("boot: missing system meta → NOT_READY (eviction) — baseline still works", async () => {
const sys = await openSystemDB();
const meta = await readSystemMeta(sys);
expect(meta.activeSlot).toBeNull();
// no dataset → light check would fail; caller maps to NOT_READY/BASELINE_ONLY
sys.close();
// after eviction (delete DBs), user favorites gone? But baseline (emergency floor) is shell bytes, not IDB — must still render.
// Here we verify user DB also considered missing but floor is independent.
await deleteDB(DB.SYSTEM);
const sys2 = await openSystemDB();
expect((await readSystemMeta(sys2)).activeSlot).toBeNull();
sys2.close();
});
it("boot: active slot missing files → RECOVERY (FA-5/FA-6)", async () => {
const sys = await openSystemDB();
const slotA = await openSlotDB("A");
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
// slot A has no files → lightCheck fails → RECOVERY
const check = await lightCheckSlot(slotA, [{ id: "emergency", bytes: 100 }]);
expect(check.ok).toBe(false);
sys.close();
slotA.close();
});
it("boot: active slot present + lightCheck ok → READY (fast, no hashing)", async () => {
const sys = await openSystemDB();
const slotA = await openSlotDB("A");
await writeSlotFile(slotA, "emergency", { bytes: 100, sha256: "a".repeat(64), json: { v: 1 } });
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
const check = await lightCheckSlot(slotA, [{ id: "emergency", bytes: 100 }]);
expect(check.ok).toBe(true);
// verify readbackPending logic: activation set pending, boot clears it after spot check
await activateSlot(sys, {
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 2,
verification: {
packageVersion: 2,
edition: "lumen-2026",
manifestSha256: "b".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
expect((await readSystemMeta(sys)).readbackPending).toBe(true);
await clearReadbackPending(sys);
expect((await readSystemMeta(sys)).readbackPending).toBe(false);
sys.close();
slotA.close();
});
});
describe("A/B slot symmetry + GC / rollback depth 1", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("A/B inactive wipes only inactive, active untouched (SPIKE-02 invariant I-9)", async () => {
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
await writeSlotFile(slotA, "schedule", { bytes: 10, sha256: "a".repeat(64), json: { v: 1 } });
await clearSlot(slotB); // wipe inactive
expect(await readSlotFile(slotA, "schedule")).toEqual({ v: 1 });
expect(await readSlotFile(slotB, "schedule")).toBeUndefined();
slotA.close();
slotB.close();
});
it("assets as Blobs timing — heavy read-back instrumentation (≤28MB budget concept)", async () => {
const db = await openSlotDB("A");
const sizes = [512 * 1024, 1 * 1024 * 1024, 2 * 1024 * 1024];
const blobs = sizes.map((sz) => new Blob([new Uint8Array(sz)], { type: "image/webp" }));
const startWrite = performance.now();
for (let i = 0; i < blobs.length; i++) {
const b = blobs[i]!;
await writeSlotAsset(db, `asset-${i}`, { bytes: b.size, sha256: "a".repeat(64), blob: b });
}
const writeMs = performance.now() - startWrite;
// write of ~3.5MB should be well under 1s even on slow fake-indexeddb
expect(writeMs).toBeLessThan(5000);
const startRead = performance.now();
for (let i = 0; i < blobs.length; i++) {
const rec = await readSlotAsset(db, `asset-${i}`);
expect(rec?.bytes).toBe(sizes[i]);
}
const readMs = performance.now() - startRead;
expect(readMs).toBeLessThan(5000);
db.close();
});
});