836 lines
28 KiB
TypeScript
836 lines
28 KiB
TypeScript
/* 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();
|
||
});
|
||
});
|