Merge pull request #1384

This commit is contained in:
Brandon Thomas committed 2026-10-18 09:35:33 -04:00
commit 0728169fc9
3 files changed
+210 -4

No files matched your search

+24 -4
View File
@@ -57,6 +57,26 @@ function req(r) {
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
}
// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has
// committed. A request's success doesn't mean the write is stored: the transaction can still
// abort afterwards (quota at commit, the browser closing), rolling it back, and a write
// reported as saved then is never retried (#1242). A failed request aborts the transaction
// with its error; an explicit abort leaves no error, hence the AbortError.
function write(db, store, op) {
return new Promise((res, rej) => {
let tx = null;
try {
tx = db.transaction(store, "readwrite");
tx.oncomplete = () => res();
tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError"));
op(tx.objectStore(store));
} catch (e) {
try { tx?.abort(); } catch {}
rej(e);
}
});
}
async function idb() {
if (!self.indexedDB) throw new Error("no IndexedDB");
const open = indexedDB.open(DB, 1);
@@ -70,10 +90,10 @@ async function idb() {
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
},
async put(key, data, modified) {
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified }));
},
async del(key) {
await req(tx("readwrite").delete(key));
await write(db, STORE, (s) => s.delete(key));
},
};
}
@@ -550,7 +570,7 @@ async function openFolder(handle, remember) {
if (remember) {
try {
const db = await handlesDb();
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
await write(db, "folders", (s) => s.put({ name, handle }));
} catch (e) { console.info("media browser: cannot remember the folder", e); }
}
return { name, entries };
@@ -602,7 +622,7 @@ export async function browseForget(name) {
folders.delete(name);
try {
const db = await handlesDb();
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
await write(db, "folders", (s) => s.delete(name));
} catch {}
}
+180
View File
@@ -0,0 +1,180 @@
// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no
// browser or Wasm build needed:
//
// node apps/effectcraft-web/tests/storage.mjs
//
// A write is acknowledged only once its transaction has committed, not when its request
// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be
// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite
// transaction commits once its requests are done, unless it was aborted (which rolls back its
// writes). `fault` makes the next request do something else.
let fault = null; // "abortAfterSuccess" | "requestError" | "throw"
const databases = new Map();
class Request {
constructor(tx) {
this.transaction = tx;
this.result = undefined;
this.error = null;
}
}
class Store {
constructor(tx, rows) {
this.tx = tx;
this.rows = rows;
}
request(apply) {
const tx = this.tx;
if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError");
const f = fault;
fault = null;
if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError");
const r = new Request(tx);
tx.pending++;
setTimeout(() => {
tx.pending--;
if (tx.finished) return;
if (f === "requestError") {
r.error = new DOMException("the key already exists", "ConstraintError");
const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
r.onerror?.(e);
tx.onerror?.(e);
if (!e.defaultPrevented) tx.fail(r.error);
return;
}
r.result = apply(tx.staged);
r.onsuccess?.({ target: r });
if (f === "abortAfterSuccess") tx.abort();
tx.maybeCommit();
}, 0);
return r;
}
put(v) {
return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; });
}
delete(k) {
return this.request((rows) => { rows.delete(k); });
}
get(k) {
return this.request((rows) => rows.get(k));
}
getAll() {
return this.request((rows) => [...rows.values()]);
}
}
class Transaction {
constructor(db, name) {
this.db = db;
this.name = name;
this.staged = new Map(db.stores.get(name));
this.pending = 0;
this.finished = false;
this.error = null;
setTimeout(() => this.maybeCommit(), 0);
}
objectStore(name) {
const s = new Store(this, this.staged);
s.keyPath = this.db.keyPaths.get(name);
return s;
}
maybeCommit() {
if (this.finished || this.pending > 0) return;
this.finished = true;
this.db.stores.set(this.name, this.staged);
setTimeout(() => this.oncomplete?.(), 0);
}
fail(error) {
this.error = error;
this.abort();
}
abort() {
if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError");
this.finished = true;
setTimeout(() => this.onabort?.(), 0);
}
}
class Database {
constructor() {
this.stores = new Map();
this.keyPaths = new Map();
}
createObjectStore(name, { keyPath }) {
this.stores.set(name, new Map());
this.keyPaths.set(name, keyPath);
}
transaction(name) {
if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError");
return new Transaction(this, name);
}
}
globalThis.indexedDB = {
open(name) {
const r = new Request(null);
setTimeout(() => {
let db = databases.get(name);
if (!db) {
db = new Database();
databases.set(name, db);
r.result = db;
r.onupgradeneeded?.();
}
r.result = db;
r.onsuccess?.({ target: r });
}, 0);
return r;
},
};
const host = await import(pathToFileURL(join(here, "../js/host.js")));
assert.equal(await host.storeOpen("indexeddb"), "indexeddb");
const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key);
const bytes = (s) => new TextEncoder().encode(s);
const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data));
// A healthy write resolves once committed, and the store has it then.
await host.storePut("project.json", bytes("v1"), 1);
assert.equal(text(stored("project.json")), "v1", "a resolved put is committed");
// The put's request succeeds, then the transaction aborts: the put fails, the old value stays.
fault = "abortAfterSuccess";
await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported");
assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back");
// The same for a delete: it fails and the entry stays.
fault = "abortAfterSuccess";
await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported");
assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back");
// A failed request aborts its transaction with the request's error.
fault = "requestError";
await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" });
assert.equal(text(stored("project.json")), "v1");
// A request that throws at once (a value that can't be stored) fails the write.
fault = "throw";
await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" });
assert.equal(text(stored("project.json")), "v1");
// The retry after a failure commits the latest value, and a later delete removes it.
await host.storePut("project.json", bytes("v5"), 5);
assert.equal(text(stored("project.json")), "v5");
await host.storeDelete("project.json");
assert.equal(stored("project.json"), undefined, "a resolved delete is committed");
const all = await host.storeLoadAll();
assert.deepEqual(all, [], "nothing else was written");
await tick();
console.log("storage: ok");
+6
View File
@@ -433,6 +433,12 @@ a fake DOM: a picked file that can't be read is reported while the others are ad
device-lost notice saves, flushes before reloading and reports a failed flush
(`node apps/effectcraft-web/tests/page.mjs`).
`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js`
with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one
whose transaction aborts after its request succeeded (or whose request fails or throws) rejects
and leaves the stored entry as it was, so the flush keeps it for a retry (#1242)
(`node apps/effectcraft-web/tests/storage.mjs`).
It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the
CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that
replies when done while the page keeps running, and the Media Browser over browser storage; and