Stage 5 gaps: real QuotaExceededError injection (P3), openDB oldVersion migration-seam test, wrapper oldVersion from IDBVersionChangeEvent
This commit is contained in:
parent
99419880a7
commit
8a1171fa41
2 changed files with 96 additions and 11 deletions
|
|
@ -40,11 +40,14 @@ export function openDB(
|
||||||
): Promise<IDBDatabase> {
|
): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open(name, version);
|
const req = indexedDB.open(name, version);
|
||||||
req.onupgradeneeded = () => {
|
req.onupgradeneeded = (event) => {
|
||||||
const db = req.result;
|
const db = req.result;
|
||||||
const tx = req.transaction;
|
const tx = req.transaction;
|
||||||
if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx);
|
// IDBVersionChangeEvent.oldVersion is the authoritative pre-upgrade
|
||||||
else onUpgrade(db, 0, null as unknown as IDBTransaction);
|
// 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 = () => {
|
req.onsuccess = () => {
|
||||||
resolve(req.result);
|
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
|
* Execute a single transaction over given stores — caller must NOT await
|
||||||
* non-IDB work inside the callback (P1). Callback receives live tx.
|
* non-IDB work inside the callback (P1). Callback receives live tx.
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||||
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
|
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
|
// polyfill globals for Node environment
|
||||||
const g = globalThis as unknown as Record<string, unknown>;
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
g.indexedDB = new FDBFactory() as unknown;
|
g.indexedDB = new FDBFactory() as unknown;
|
||||||
g.IDBKeyRange = FDBKeyRange as unknown;
|
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||||
|
g.IDBObjectStore = FDBObjectStore as unknown;
|
||||||
|
|
||||||
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
|
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
|
||||||
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
|
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",
|
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);
|
const activeBefore = await readSystemMeta(sys);
|
||||||
expect(activeBefore.activeSlot).toBe("A");
|
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 });
|
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
|
||||||
// B remains empty — staging interrupted keeps active
|
// B remains empty — staging interrupted keeps active
|
||||||
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
|
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
|
||||||
|
|
@ -282,6 +282,93 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
|
||||||
slotB.close();
|
slotB.close();
|
||||||
sys.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", () => {
|
describe("system-meta store — P2 single-txn activation + F-2/F-3", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue