mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-21 19:22:52 -05:00
Merge pull request #1384
This commit is contained in:
commit
0728169fc9
3 files changed
+210
-4
No files matched your search
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user