S00-005: Select idb and verify transactional storage failure policies
Changes: Propose ADR 0004 comparing native IndexedDB, idb and Dexie; select idb 8.0.4 with account, transaction, quota, upgrade and key ownership policy. Repair rollback and add injected quota/blocked upgrade checks, exact wrapper lock, runtime guard, license texts and reproduction notes. Preserve historical recovery artifacts. Validation: Fresh-cache npm ci and npm run check passed on Node 24.21.0/npm 11.19.0; manifest, HTTPS/SHA512 and license checks passed. make check passed 72 tooling tests; tasks.py validate and git diff --check passed. Native browser quota, disk durability and process restart were not tested; ADR remains proposed for independent QA. Task: S00-005 Agent: auto-storage-0ce544ac4b40 Role: storage Action: claim Status: review Task title: Выбрать IndexedDB wrapper и migration policy
This commit is contained in:
1 parent
8f918bc040
commit
0e2b3098df
17 files changed
+713
-128
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import assert from 'node:assert/strict';
|
||||
assert.equal(process.version, 'v24.21.0');
|
||||
assert.match(process.env.npm_config_user_agent ?? '', /^npm\/11\.19\.0 /);
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDB, deleteDB } from 'idb';
|
||||
|
||||
const name = 'luma-design-quota-blocked';
|
||||
await deleteDB(name);
|
||||
let db = await openDB(name, 1, { upgrade(db) {
|
||||
for (const store of ['history', 'checkpoints', 'settings', 'crypto']) db.createObjectStore(store);
|
||||
} });
|
||||
// Illustrative composite keys, not an application contract.
|
||||
await db.put('history', { text: 'Synthetic other account' }, ['B', 'm1']);
|
||||
await db.put('checkpoints', 'before', 'A');
|
||||
await db.put('settings', 'B-setting', ['B', 'theme']);
|
||||
await db.put('crypto', 'B-opaque-fixture-not-key-material', ['B', 'state']);
|
||||
|
||||
// Explicit quota fault injection: fake-indexeddb has no real disk quota.
|
||||
async function saveBatch(injectFailure) {
|
||||
const tx = db.transaction(['history', 'checkpoints'], 'readwrite');
|
||||
const completion = tx.done;
|
||||
completion.catch(() => {});
|
||||
try {
|
||||
await tx.objectStore('history').put({ text: 'Synthetic pending' }, ['A', 'm1']);
|
||||
await tx.objectStore('checkpoints').put('after', 'A');
|
||||
if (injectFailure) throw new DOMException('Synthetic quota injection', 'QuotaExceededError');
|
||||
await completion;
|
||||
return { saved: true };
|
||||
} catch (error) {
|
||||
try { tx.abort(); } catch { /* Engine may have already aborted. */ }
|
||||
await completion.catch(() => {});
|
||||
return { saved: false, reason: error.name };
|
||||
}
|
||||
}
|
||||
assert.deepEqual(await saveBatch(true), { saved: false, reason: 'QuotaExceededError' });
|
||||
assert.equal(await db.get('history', ['A', 'm1']), undefined);
|
||||
assert.equal(await db.get('checkpoints', 'A'), 'before');
|
||||
assert.deepEqual(await db.get('history', ['B', 'm1']), { text: 'Synthetic other account' });
|
||||
assert.equal(await db.get('settings', ['B', 'theme']), 'B-setting');
|
||||
assert.equal(await db.get('crypto', ['B', 'state']), 'B-opaque-fixture-not-key-material');
|
||||
assert.deepEqual(await saveBatch(false), { saved: true });
|
||||
db.close();
|
||||
db = await openDB(name, 1);
|
||||
assert.equal(await db.get('checkpoints', 'A'), 'after');
|
||||
assert.deepEqual(await db.get('history', ['A', 'm1']), { text: 'Synthetic pending' });
|
||||
|
||||
let versionChange = false;
|
||||
let upgraded = false;
|
||||
db.addEventListener('versionchange', () => { versionChange = true; });
|
||||
let signalBlocked;
|
||||
const blocked = new Promise(resolve => { signalBlocked = resolve; });
|
||||
const pending = openDB(name, 2, {
|
||||
blocked() { signalBlocked(); },
|
||||
upgrade(db) { upgraded = true; db.createObjectStore('v2Marker'); },
|
||||
});
|
||||
const timer = setTimeout(() => { throw new Error('blocked event timeout'); }, 3000);
|
||||
await blocked;
|
||||
assert.equal(versionChange, true);
|
||||
assert.equal(upgraded, false);
|
||||
assert.equal(db.version, 1);
|
||||
db.close();
|
||||
const next = await pending;
|
||||
clearTimeout(timer);
|
||||
assert.equal(upgraded, true);
|
||||
assert.equal(next.version, 2);
|
||||
assert.equal(await next.get('checkpoints', 'A'), 'after');
|
||||
next.close();
|
||||
await assert.rejects(openDB(name, 1), { name: 'VersionError' });
|
||||
await deleteDB(name);
|
||||
console.log('PASS quota policy (injected): no saved success or checkpoint advance on abort; account isolation; retry/reopen');
|
||||
console.log('PASS blocked upgrade: versionchange then blocked; close releases upgrade; stale version refused');
|
||||
@@ -1,108 +1,46 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import assert from "node:assert/strict";
|
||||
import 'fake-indexeddb/auto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDB, deleteDB } from 'idb';
|
||||
|
||||
// Bounded storage experiment (S00-005, focused check "Rollback experiment").
|
||||
// Uses fake-indexeddb 6.2.5 (in-memory, pure-JS IndexedDB implementation) to exercise
|
||||
// the transaction/schema-upgrade rollback guarantees that a Web storage layer relies
|
||||
// on for STORE-001 (transactional saves or verifiable equivalent recovery).
|
||||
// This is an API-semantics probe, not a browser-durability claim.
|
||||
|
||||
const DB = "rollback-probe";
|
||||
|
||||
function open(version, upgrade) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, version);
|
||||
const events = { upgraded: false, blocked: false };
|
||||
req.onupgradeneeded = () => {
|
||||
events.upgraded = true;
|
||||
if (upgrade) upgrade(req.result);
|
||||
};
|
||||
req.onsuccess = () => resolve({ db: req.result, events });
|
||||
req.onerror = () => reject(new Error(`open(v${version}): ${req.error?.name}`));
|
||||
req.onblocked = () => { events.blocked = true; };
|
||||
});
|
||||
}
|
||||
|
||||
function getAll(db, store) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, "readonly");
|
||||
tx.objectStore(store).getAll().onsuccess = (e) => resolve(e.target.result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function seedV1() {
|
||||
const { db } = await open(1, (db) => {
|
||||
db.createObjectStore("accounts", { keyPath: "accountId" });
|
||||
});
|
||||
db.close();
|
||||
// write a durable per-account record (configuration, non-secret fixture)
|
||||
const w = await open(1);
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = w.db.transaction("accounts", "readwrite");
|
||||
tx.objectStore("accounts").add({ accountId: "A1", displayName: "Alice" });
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
w.db.close();
|
||||
}
|
||||
|
||||
// Scenario 1 (rollback): upgrade v1 -> v2 that fails mid-way because a data migration
|
||||
// violates a new unique index inside the versionchange transaction. The whole upgrade
|
||||
// must abort and revert: schema version stays at v1, the new index/store is absent,
|
||||
// and pre-existing v1 data is untouched.
|
||||
async function failedUpgradeRollsBack() {
|
||||
const req = indexedDB.open(DB, 2);
|
||||
let aborted = true;
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
const accounts = db.createObjectStore("accounts", { keyPath: "accountId" });
|
||||
accounts.createIndex("displayName", "displayName", { unique: true });
|
||||
// Both rows share the same unique displayName -> the second write fails and
|
||||
// aborts the versionchange transaction (organic failure, not a manual abort).
|
||||
accounts.add({ accountId: "A1", displayName: "Shared" });
|
||||
accounts.add({ accountId: "A2", displayName: "Shared" });
|
||||
};
|
||||
req.onsuccess = () => { aborted = false; req.result.close(); };
|
||||
req.onerror = () => { /* default abort */ };
|
||||
|
||||
await new Promise((resolve) => { req.onsuccess = resolve; req.onerror = resolve; });
|
||||
|
||||
assert.equal(aborted, true, "failing data migration must abort the upgrade (no fake success)");
|
||||
|
||||
// Schema version must remain at v1 after the abort.
|
||||
const meta = await indexedDB.databases().then((l) => l.find((d) => d.name === DB));
|
||||
assert.equal(meta.version, 1, "failed upgrade must keep persisted schema version at v1");
|
||||
|
||||
const { db } = await open(1);
|
||||
assert.ok(db.objectStoreNames.contains("accounts"), "v1 accounts store survives");
|
||||
const rows = await getAll(db, "accounts");
|
||||
assert.equal(rows.length, 1, "no partial migration rows persisted");
|
||||
assert.equal(rows[0].accountId, "A1", "pre-existing v1 data intact after rollback");
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Scenario 2 (retry after rollback): after a clean rollback a corrected migration can be
|
||||
// retried and commits fully to v2 without a stale schema or lost data.
|
||||
async function retryAfterRollbackSucceeds() {
|
||||
const { db, events } = await open(2, (db) => {
|
||||
const accounts = db.createObjectStore("accounts", { keyPath: "accountId" });
|
||||
accounts.createIndex("displayName", "displayName", { unique: true });
|
||||
accounts.add({ accountId: "A1", displayName: "Alice" });
|
||||
accounts.add({ accountId: "A2", displayName: "Bob" });
|
||||
});
|
||||
assert.equal(events.upgraded, true, "retry upgrade runs");
|
||||
assert.equal(db.version, 2, "retry commits to v2");
|
||||
const rows = await getAll(db, "accounts");
|
||||
assert.equal(rows.length, 2, "both accounts migrated");
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await seedV1();
|
||||
await failedUpgradeRollsBack();
|
||||
await retryAfterRollbackSucceeds();
|
||||
console.log("rollback.mjs PASS: failed v1->v2 upgrade rolled back atomically (schema v1 kept, no partial data); corrected retry committed cleanly to v2.");
|
||||
}
|
||||
|
||||
await main();
|
||||
// Synthetic API semantics; reopening is not a disk/process restart.
|
||||
const name = 'luma-design-rollback';
|
||||
await deleteDB(name);
|
||||
let db = await openDB(name, 1, { upgrade(db) {
|
||||
db.createObjectStore('accounts', { keyPath: 'accountId' });
|
||||
} });
|
||||
await db.put('accounts', { accountId: 'A1', displayName: 'Fixture One' });
|
||||
db.close();
|
||||
let constraint;
|
||||
await assert.rejects(openDB(name, 2, { upgrade(db, old, next, tx) {
|
||||
assert.equal(old, 1);
|
||||
db.createObjectStore('migrationMarker');
|
||||
const accounts = tx.objectStore('accounts');
|
||||
accounts.createIndex('displayName', 'displayName', { unique: true });
|
||||
accounts.put({ accountId: 'A1', displayName: 'Duplicate' }).catch(() => {});
|
||||
accounts.add({ accountId: 'A2', displayName: 'Duplicate' }).catch(error => { constraint = error.name; });
|
||||
// Observe promise rejections without suppressing native request error/abort.
|
||||
tx.done.catch(() => {});
|
||||
} }), { name: 'AbortError' });
|
||||
assert.equal(constraint, 'ConstraintError');
|
||||
db = await openDB(name, 1);
|
||||
assert.equal(db.version, 1);
|
||||
assert.deepEqual([...db.objectStoreNames], ['accounts']);
|
||||
assert.deepEqual([...db.transaction('accounts').store.indexNames], []);
|
||||
assert.deepEqual(await db.getAll('accounts'), [{ accountId: 'A1', displayName: 'Fixture One' }]);
|
||||
db.close();
|
||||
db = await openDB(name, 2, { upgrade(db, old, next, tx) {
|
||||
db.createObjectStore('migrationMarker');
|
||||
const accounts = tx.objectStore('accounts');
|
||||
accounts.createIndex('displayName', 'displayName', { unique: true });
|
||||
accounts.add({ accountId: 'A2', displayName: 'Fixture Two' }).catch(() => {});
|
||||
tx.done.catch(() => {});
|
||||
} });
|
||||
assert.equal(db.version, 2);
|
||||
assert.equal((await db.getAll('accounts')).length, 2);
|
||||
assert.equal((await db.getFromIndex('accounts', 'displayName', 'Fixture Two')).accountId, 'A2');
|
||||
db.close();
|
||||
db = await openDB(name, 2);
|
||||
assert.equal((await db.getAll('accounts')).length, 2);
|
||||
db.close();
|
||||
await deleteDB(name);
|
||||
console.log('PASS rollback: ConstraintError; v1 schema/data restored; corrected v2 retry and reopen');
|
||||
Reference in new issue
Block a user