Stage 5 gaps: real QuotaExceededError injection (P3), openDB oldVersion migration-seam test, wrapper oldVersion from IDBVersionChangeEvent

This commit is contained in:
Lumen Stage1 2026-09-30 14:15:46 -05:00
commit 8a1171fa41
2 changed files with 96 additions and 11 deletions

View file

@ -40,11 +40,14 @@ export function openDB(
): Promise<IDBDatabase> {
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.

View file

@ -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<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";
@ -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", () => {