collective

Browse / github.com/storytold/artcraft / patch

fix(video-editor): close IndexedDB connections after settled operations

fix(video-editor): close IndexedDB connections after settled operations ## Problem The live video editor uses the default IndexedDB project-storage adapter. Every operation opens a connection, but the transaction helper never closes it. Native save/load/list operations leave completed connections open, which can block a later database version upgrade; failure paths leak the same ownership. ## Change Close the connection in the transaction promise finalizer. Results, transaction completion and public storage methods retain their behavior. Add a standalone native-browser regression harness and exact reproduction instructions without new dependencies. The harness ro

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2092, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+165 −1 in 3 files
Review
pending
Shared
2026-10-28
Id
a1d9e6d4d8cf1dad9c4cd861

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts frontend/libs/components/video-editor/tests/README.md frontend/libs/components/video-editor/tests/indexeddb-project-storage.browser.html

diff --git a/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts b/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts--- a/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts+++ b/frontend/libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts@@ -43,7 +43,7 @@ function tx<T>(         t.oncomplete = () => resolve(result as T);         t.onerror = () => reject(t.error);         t.onabort = () => reject(t.error);-      }),+      }).finally(() => db.close()),   ); } diff --git a/frontend/libs/components/video-editor/tests/README.md b/frontend/libs/components/video-editor/tests/README.mdnew file mode 100644--- /dev/null+++ b/frontend/libs/components/video-editor/tests/README.md@@ -0,0 +1,29 @@+# Native IndexedDB storage checks++`indexeddb-project-storage.browser.html` exercises the actual storage adapter in a+browser using native IndexedDB requests, transactions, structured cloning and+version upgrades. It routes the adapter's database name to a dedicated test+namespace and deletes that test database between cases. The application database+is not touched.++From `frontend/`, compile only the adapter and serve the harness:++```sh+idb_check_dir=$(mktemp -d /tmp/artcraft-idb-check.XXXXXX)+node node_modules/esbuild/bin/esbuild \+  libs/components/video-editor/src/lib/adapters/default/indexeddb-project-storage.ts \+  --format=esm --target=es2022 --outfile="$idb_check_dir/indexeddb-storage.js"+cp libs/components/video-editor/tests/indexeddb-project-storage.browser.html \+  "$idb_check_dir/index.html"+python3 -m http.server 0 --bind 127.0.0.1 --directory "$idb_check_dir"+```++Open the local address and port printed by the server. All six cases should report+`PASS`. The final JSON is also available as `globalThis.runResult` for a browser+runner to collect. Stop the server and remove the generated directory afterward.++The two round-trip controls verify public storage behavior. The remaining checks+verify connection cleanup after successful operations, a blocked native version+upgrade, a synchronous `DataCloneError`, and an aborted native transaction. This+harness uses browser APIs because the library's default jsdom test environment+does not provide IndexedDB. It does not replace the ordinary library test suite.diff --git a/frontend/libs/components/video-editor/tests/indexeddb-project-storage.browser.html b/frontend/libs/components/video-editor/tests/indexeddb-project-storage.browser.htmlnew file mode 100644--- /dev/null+++ b/frontend/libs/components/video-editor/tests/indexeddb-project-storage.browser.html@@ -0,0 +1,135 @@+<!doctype html>+<meta charset="utf-8" />+<title>Native IndexedDB project-storage checks</title>+<pre id="results">Running native IndexedDB checks...</pre>+<script type="module">+  import { createIndexedDBProjectStorage } from "./indexeddb-storage.js";++  // Route the adapter to a dedicated test database. Transactions, structured+  // cloning, error events, aborts, and version upgrades remain native browser APIs.+  const TEST_DB = "artcraft-project-storage-browser-check";+  const nativeOpen = indexedDB.open.bind(indexedDB);+  const nativeClose = IDBDatabase.prototype.close;+  const nativePut = IDBObjectStore.prototype.put;+  const connections = new Set();+  const results = [];+  const storage = createIndexedDBProjectStorage();+  const project = {+    id: "native-proof", name: "Native", updatedAt: 100, data: { value: 1 },+  };++  indexedDB.open = (name, ...args) => {+    const request = nativeOpen(name === "video-editor" ? TEST_DB : name, ...args);+    request.addEventListener("success", () => connections.add(new WeakRef(request.result)));+    return request;+  };+  IDBDatabase.prototype.close = function () {+    for (const ref of connections) {+      if (ref.deref() === this) connections.delete(ref);+    }+    return nativeClose.call(this);+  };++  await check("control: save/load/list/delete round trip", async () => {+    await storage.saveProject(project);+    expect(await storage.loadProject(project.id), project, "saved data");+    expect(await storage.listProjects(), [+      { id: project.id, name: project.name, updatedAt: 100 },+    ], "list");+    await storage.deleteProject(project.id);+    expect(await storage.loadProject(project.id), null, "deleted data");+  });++  await check("success: settled save/load/list release connections", async () => {+    await storage.saveProject(project);+    await storage.loadProject(project.id);+    await storage.listProjects();+    expect([...connections].filter((ref) => ref.deref()).length, 0, "open connections after settled operations");+  });++  await check("success: completed storage does not block an upgrade", async () => {+    await storage.saveProject(project);+    await new Promise((resolve, reject) => {+      const request = nativeOpen(TEST_DB, 2);+      request.onblocked = () => {+        reject(new Error("Upgrade blocked by a completed storage operation"));+        for (const ref of [...connections]) ref.deref()?.close();+      };+      request.onerror = () => reject(request.error);+      request.onsuccess = () => {+        request.result.close();+        resolve();+      };+    });+  });++  await check("request construction failure releases its connection", async () => {+    let error;+    try {+      await storage.saveProject({ ...project, data: () => {} });+    } catch (caught) {+      error = caught;+    }+    expect(error?.name, "DataCloneError", "native cloning error");+    expect([...connections].filter((ref) => ref.deref()).length, 0, "open connections after failed request construction");+  });++  await check("native transaction abort releases connection and writes nothing", async () => {+    IDBObjectStore.prototype.put = function (...args) {+      const request = nativePut.apply(this, args);+      this.transaction.abort();+      return request;+    };+    let error;+    try {+      await storage.saveProject(project);+    } catch (caught) {+      error = caught;+    }+    if (!error) throw new Error("Aborted save must reject");+    expect([...connections].filter((ref) => ref.deref()).length, 0, "open connections after aborted write");+    IDBObjectStore.prototype.put = nativePut;+    expect(await storage.loadProject(project.id), null, "aborted data");+  });++  await check("control: create and delete public methods", async () => {+    const created = await storage.createProject("Created");+    expect((await storage.loadProject(created.id))?.name, "Created", "created name");+    await storage.deleteProject(created.id);+    expect(await storage.loadProject(created.id), null, "deleted created project");+  });++  indexedDB.open = nativeOpen;+  IDBDatabase.prototype.close = nativeClose;+  IDBObjectStore.prototype.put = nativePut;+  globalThis.runResult = {+    userAgent: navigator.userAgent,+    origin: location.origin,+    nativeIndexedDB: typeof IDBFactory === "function",+    results,+  };+  document.querySelector("#results").textContent = JSON.stringify(globalThis.runResult, null, 2);++  async function check(name, run) {+    try {+      await run();+      results.push({ name, status: "PASS" });+    } catch (error) {+      results.push({ name, status: "FAIL", error: String(error) });+    } finally {+      for (const ref of [...connections]) ref.deref()?.close();+      IDBObjectStore.prototype.put = nativePut;+      await new Promise((resolve, reject) => {+        const request = indexedDB.deleteDatabase(TEST_DB);+        request.onsuccess = resolve;+        request.onerror = () => reject(request.error);+      });+    }+  }++  function expect(value, expected, label) {+    if (JSON.stringify(value) !== JSON.stringify(expected)) {+      throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`);+    }+  }+</script>