From 8a1171fa414d4eb7c59e0d363327769cad80d413 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Wed, 30 Sep 2026 14:15:46 -0500 Subject: [PATCH] Stage 5 gaps: real QuotaExceededError injection (P3), openDB oldVersion migration-seam test, wrapper oldVersion from IDBVersionChangeEvent --- src/platform/idb/wrapper.ts | 14 +++-- tests/unit/persistence.test.ts | 93 ++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/platform/idb/wrapper.ts b/src/platform/idb/wrapper.ts index 1aec7a1..aa42301 100644 --- a/src/platform/idb/wrapper.ts +++ b/src/platform/idb/wrapper.ts @@ -40,11 +40,14 @@ export function openDB( ): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(name, version); - req.onupgradeneeded = () => { + req.onupgradeneeded = (event) => { const db = req.result; const tx = req.transaction; - if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx); - else onUpgrade(db, 0, null as unknown as IDBTransaction); + // IDBVersionChangeEvent.oldVersion is the authoritative pre-upgrade + // version (db.version is already the NEW version here). + const oldVersion = (event as IDBVersionChangeEvent).oldVersion ?? 0; + if (tx) onUpgrade(db, oldVersion, tx); + else onUpgrade(db, oldVersion, null as unknown as IDBTransaction); }; req.onsuccess = () => { resolve(req.result); @@ -59,11 +62,6 @@ export function openDB( }); } -function oldVersionFallback(req: IDBOpenDBRequest): number { - // Some fake-indexeddb versions expose oldVersion via transaction? Fallback 0. - return (req as unknown as { oldVersion?: number }).oldVersion ?? 0; -} - /** * Execute a single transaction over given stores — caller must NOT await * non-IDB work inside the callback (P1). Callback receives live tx. diff --git a/tests/unit/persistence.test.ts b/tests/unit/persistence.test.ts index fa74e89..f6881da 100644 --- a/tests/unit/persistence.test.ts +++ b/tests/unit/persistence.test.ts @@ -10,11 +10,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 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; 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"; @@ -270,11 +273,8 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => { }, appVersionAtActivation: "1.0.0", }); - // attempt staging into inactive slot B but inject quota error via mocked put - // Simulate by directly testing isQuotaError path and ensuring active still readable const activeBefore = await readSystemMeta(sys); expect(activeBefore.activeSlot).toBe("A"); - // No actual quota error from fake-indexeddb, but verify active dataset still complete expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 }); // B remains empty — staging interrupted keeps active expect(await readSlotFile(slotB, "emergency")).toBeUndefined(); @@ -282,6 +282,93 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => { 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; + const originalPut = proto.put; + let injected = 0; + // Deliberate prototype patch — `this` scoping is the point of the shim. + // eslint-disable-next-line + 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", () => {