From d0e1fd73b1abf6fd0536232b44b822f7670e7cab Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sat, 12 Sep 2026 23:28:05 +0200 Subject: [PATCH] Defer media loading and strengthen persistence tests --- .beads/interactions.jsonl | 2 + README.md | 2 + docs/FEATURE_CHECKLIST.md | 2 + docs/STORAGE_SCHEMA.md | 21 +- e2e/deferred-media.spec.js | 179 ++++++++++++++++++ e2e/persistence.spec.js | 19 ++ e2e/storage-startup.spec.js | 2 +- scripts/profile-toolbox-loading.mjs | 48 ++--- tests/deferred-module-data.test.mjs | 43 +++++ tests/helpers/data-validation.mjs | 1 + tests/static-toolboxes.test.mjs | 32 +--- website/public/data/site.json | 1 + .../src/features/toolboxes/ToolboxPages.jsx | 3 + .../toolboxes/modules/ModuleDataGate.jsx | 28 +++ .../toolboxes/modules/ToolboxModules.jsx | 5 +- .../toolboxes/storage/deferredModuleData.js | 29 +++ .../toolboxes/storage/useIndexedToolboxes.js | 54 +++++- .../features/toolboxes/useToolboxActions.js | 16 +- website/src/utils/indexedDbStorage.js | 43 ++++- 19 files changed, 459 insertions(+), 71 deletions(-) create mode 100644 e2e/deferred-media.spec.js create mode 100644 tests/deferred-module-data.test.mjs create mode 100644 website/src/features/toolboxes/modules/ModuleDataGate.jsx create mode 100644 website/src/features/toolboxes/storage/deferredModuleData.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 1b2ad9a..d4f5141 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -40,3 +40,5 @@ {"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}} {"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}} +{"id":"int-224b7820169f57116ea4fd87a50560f1","kind":"field_change","created_at":"2026-09-12T21:26:21.88315065Z","actor":"Shinuwa","issue_id":"sokko-g-bhy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented selective images/imageAnnotation hydration with local retry, preserved unread values in atomic snapshots, and serialized complete exports without populating the UI cache. No schema change; timer/calendar remain eager. check:full passed including 52 E2E; four production scenarios profiled alone, five reloads each. 40-media baseline 120.68 to 2.78 MiB JS heap, 610.1 to 445.4 ms median ready. Reports in .local/performance; docs updated. Opened media remain cached until reload. No git commit or push performed."}} +{"id":"int-4036248c53312cfaafae4ee37bb145bd","kind":"field_change","created_at":"2026-09-12T21:26:40.724321767Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} diff --git a/README.md b/README.md index 2e3ec61..c754b66 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ JavaScript après GC. Le rapport brut est écrit dans build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte. +Le profil suit les lectures groupées et ciblées et vérifie que les valeurs des +outils Images restent sur disque tant qu'aucune toolbox n'est ouverte. ## Contenu éditable diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 40f9aeb..3382b61 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -15,6 +15,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier l'import et l'export si l'outil stocke des données. - Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée. - Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer. +- Pour Images et Annotation d'images, vérifier la lecture différée en page complète et panneau latéral, la reprise après échec local et l'absence d'écrasement pendant le chargement. Les alertes des autres outils doivent rester actives sans ouvrir les médias. +- Vérifier qu'une création, un import ou une suppression préserve les médias non lus des autres outils ; les exports doivent les inclure et refuser un téléchargement partiel en cas d'échec. Une lecture en cours ne doit pas réintroduire des données supprimées. - Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header. - Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu. - Vérifier l'affichage dans la page toolbox complète. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 1d4ff9e..71dfc93 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -37,7 +37,7 @@ Entrée type dans `modules` : ### Ordre et confirmation des écritures -Le démarrage attend la lecture complète du registre, des liens, des modules et +Le démarrage attend la lecture du registre, des liens, des modules hors médias et de la position des notifications avant d'ouvrir l'interface. Un échec laisse le stockage non prêt : les méthodes d'écriture refusent les opérations, et la création, l'import et l'export ne peuvent pas utiliser un état vide incomplet. @@ -58,6 +58,25 @@ la fin de transaction. Une erreur synchrone pendant sa préparation déclenche l'abandon de la transaction. Les écritures ordinaires d'un module restent ciblées sur sa clé et ne réécrivent pas les images des autres outils. +Les données des outils `images` et `imageAnnotation` sont lues à leur première +ouverture, en page complète ou dans le panneau latéral. Les minuteurs et calendriers +restent chargés au démarrage pour assurer les alertes globales. Les couvertures, +incluses dans le registre des toolboxes, restent également chargées au démarrage. +Un marqueur uniquement en mémoire distingue un média non lu d'un outil vide ; il +n'est jamais persisté ni exporté. Les transactions de snapshot conservent les +entrées non lues, sauf celles explicitement supprimées de l'état courant. +Une lecture tardive ne réintroduit pas un outil supprimé entre-temps. + +Pendant la lecture, le contenu de l'outil est bloqué. Un échec affiche une reprise +locale sans empêcher les autres outils de fonctionner ni autoriser l'écrasement +du média non lu. Après ouverture, les médias restent en mémoire jusqu'au +rechargement de l'application ; il n'y a pas d'éviction à la fermeture. + +Les exports attendent les écritures précédentes dans la même file et lisent les +médias manquants depuis l'état confirmé, sans les ajouter au cache de l'interface. +Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les formats +IndexedDB et import/export sont inchangés. + Cette coordination est locale à l'instance React ; elle ne synchronise pas l'édition entre plusieurs onglets. diff --git a/e2e/deferred-media.spec.js b/e2e/deferred-media.spec.js new file mode 100644 index 0000000..3b1e490 --- /dev/null +++ b/e2e/deferred-media.spec.js @@ -0,0 +1,179 @@ +// Rôle : vérifie les lectures à la demande, les exports complets et la conservation des médias non chargés. +/* global window, document, IDBObjectStore, DOMException */ +import { test, expect } from "@playwright/test"; +import { Buffer } from "node:buffer"; + +async function setup(page) { + await page.route("**/__media-seed", (route) => route.fulfill({ contentType: "text/html", body: "Seed" })); + await page.goto("/__media-seed"); + const seeded = await page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 32; + const context = canvas.getContext("2d"); + context.fillStyle = "red"; + context.fillRect(0, 0, 32, 32); + const red = canvas.toDataURL(); + context.fillStyle = "green"; + context.fillRect(0, 0, 32, 32); + const green = canvas.toDataURL(); + const targetAt = Date.now() + 60000; + const toolboxes = [ + { id: "a", name: "Alpha", updatedAt: "2026-09-12T12:00:00Z", modules: [ + { id: "images", type: "images", title: "Images" }, + { id: "map", type: "imageAnnotation", title: "Carte" }, + { id: "timer", type: "timer", title: "Timer" } + ] }, + { id: "b", name: "Beta", updatedAt: "2026-09-12T12:00:00Z", modules: [{ id: "images", type: "images", title: "Images" }] } + ]; + const modules = { + "a:images": { images: [{ id: "red", label: "Rouge", dataUrl: red }] }, + "a:map": { image: green, markers: [{ id: "marker", x: 25, y: 50, label: "Entrée" }], drawings: { strokes: [{ id: "stroke", color: "#f6c453", width: 4, points: [{ x: 10, y: 10 }, { x: 80, y: 80 }] }] } }, + "a:timer": { countdowns: [{ id: "alert", type: "duration", label: "Alerte fermée", targetAt, durationMs: 60000, alertMode: "site" }] }, + "b:images": { images: [{ id: "green", label: "Vert", dataUrl: green }] } + }; + await db.setToolboxSnapshot(toolboxes, { mhwilds: "a" }, modules); + return { toolboxes, modules, targetAt }; + }); + await page.addInitScript(() => { + window.moduleReads = []; + window.failedMediaKey = ""; + const get = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function (key) { + if (this.name === "modules") { + window.moduleReads.push(key); + if (key === window.failedMediaKey) throw new DOMException("Média indisponible", "UnknownError"); + } + return get.call(this, key); + }; + }); + await page.goto("/#/toolboxes"); + await expect(page.locator(".toolbox-card")).toHaveCount(2); + return seeded; +} + +async function disk(page) { + return page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + return { toolboxes: await db.getToolboxes(), modules: Object.fromEntries((await db.getAllModuleData()).map((entry) => [entry.key, entry.data])) }; + }); +} + +async function exportJson(page, buttonName = "Exporter tout") { + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: buttonName, exact: true }).click(); + const stream = await (await download).createReadStream(); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString()); +} + +test("closed media stays unread while site-wide timer alerts still fire", async ({ page }) => { + const seeded = await setup(page); + expect(await page.evaluate(() => window.moduleReads)).not.toEqual([]); + expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); + await page.clock.install({ time: new Date(seeded.targetAt - 3000) }); + await page.clock.pauseAt(new Date(seeded.targetAt - 2000)); + await page.clock.runFor(3000); + await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible(); + expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); +}); + +test("creation, import and deletion preserve unread media and global exports include it", async ({ page }) => { + const seeded = await setup(page); + await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click(); + await page.getByRole("textbox", { name: "Nom", exact: true }).fill("New"); + await page.getByRole("button", { name: "Créer", exact: true }).click(); + await expect.poll(async () => (await disk(page)).toolboxes.length).toBe(3); + expect((await disk(page)).modules).toMatchObject(seeded.modules); + expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); + await page.goto("/#/toolboxes"); + await page.getByText("Importer une toolbox", { exact: true }).locator("input").setInputFiles({ + name: "import.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ toolbox: { id: "source", name: "Imported", modules: [] }, modules: {} })) + }); + await expect(page.locator(".toolbox-card")).toHaveCount(4); + expect((await disk(page)).modules).toMatchObject(seeded.modules); + expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true); + const exported = await exportJson(page); + for (const key of ["a:images", "a:map", "b:images"]) expect(exported.modules[key]).toEqual(seeded.modules[key]); + await page.getByRole("button", { name: "Supprimer Beta", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click(); + await expect.poll(async () => (await disk(page)).modules["b:images"]).toBeUndefined(); + expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]); + expect((await disk(page)).modules["a:map"]).toEqual(seeded.modules["a:map"]); +}); + +test("single exports include deferred media without reading other toolboxes or retaining it", async ({ page }) => { + const seeded = await setup(page); + const exported = await exportJson(page, "Exporter Alpha"); + const imageModule = exported.toolbox.modules.find((module) => module.type === "images"); + const mapModule = exported.toolbox.modules.find((module) => module.type === "imageAnnotation"); + expect(exported.modules[imageModule.id].images[0].dataUrl).toBe(seeded.modules["a:images"].images[0].dataUrl); + expect(exported.modules[mapModule.id].image).toBe(seeded.modules["a:map"].image); + expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); + const reads = await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length); + await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); + await expect(page.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); + expect(await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length)).toBe(reads + 1); +}); + +test("opening and editing one toolbox only reads its media and reuses it on return", async ({ page }) => { + const seeded = await setup(page); + await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); + const image = page.locator('[data-module-type="images"] .image-preview img'); + await expect(image).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); + await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBe(32); + await expect(page.locator(".annotation-marker")).toHaveCount(1); + expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); + const initialReads = await page.evaluate(() => window.moduleReads.length); + await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click(); + await page.getByRole("textbox", { name: "Libellé de l'image", exact: true }).fill("Edited"); + await expect.poll(async () => (await disk(page)).modules["a:images"].images[0].label).toBe("Edited"); + await page.goto("/#/toolboxes"); + await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); + await expect(page.getByText("Edited", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.moduleReads.length)).toBe(initialReads); +}); + +test("the game drawer loads only the linked toolbox media", async ({ page }) => { + const seeded = await setup(page); + await page.goto("/#/games/mhwilds"); + await page.getByRole("button", { name: "Ouvrir la toolbox Alpha", exact: true }).click(); + const drawer = page.locator("#toolbox-drawer .drawer-panel"); + await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); + await expect(drawer.locator(".annotation-marker")).toHaveCount(1); + expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false); +}); + +test("media read errors block only the affected tool and retry without overwriting it", async ({ page }) => { + const seeded = await setup(page); + await page.evaluate(() => { window.failedMediaKey = "a:images"; }); + await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click(); + const module = page.locator('[data-module-type="images"]'); + await expect(module.getByRole("alert")).toHaveText("Média indisponible"); + await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click(); + await expect(module.locator('input[type="file"], .image-preview')).toHaveCount(0); + await expect(page.locator(".annotation-marker")).toHaveCount(1); + expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]); + await module.getByRole("button", { name: "Réessayer" }).click(); + await expect(module.getByRole("alert")).toHaveText("Média indisponible"); + await page.evaluate(() => { window.failedMediaKey = ""; }); + await module.getByRole("button", { name: "Réessayer" }).click(); + await expect(module.locator(".image-preview img")).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); +}); + +test("failed media exports produce no partial download and can be retried", async ({ page }) => { + const seeded = await setup(page); + const downloads = []; + page.on("download", (download) => downloads.push(download)); + await page.evaluate(() => { window.failedMediaKey = "b:images"; }); + await page.getByRole("button", { name: "Exporter tout", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Erreur de stockage local" }); + await expect(dialog).toBeVisible(); + expect(downloads).toHaveLength(0); + await dialog.getByRole("button", { name: "Compris" }).click(); + await page.evaluate(() => { window.failedMediaKey = ""; }); + const exported = await exportJson(page); + expect(exported.modules["b:images"]).toEqual(seeded.modules["b:images"]); + expect(downloads).toHaveLength(1); +}); diff --git a/e2e/persistence.spec.js b/e2e/persistence.spec.js index cb3a6f5..d86a8a0 100644 --- a/e2e/persistence.spec.js +++ b/e2e/persistence.spec.js @@ -27,3 +27,22 @@ test("a synchronous snapshot error aborts all its IndexedDB writes", async ({ pa modules: [{ key: "saved:note", data: { text: "Keep" } }] }); }); + +test("partial snapshots preserve deferred values and roll back deletes on failure", async ({ page }) => { + await resetSokkoStorage(page); + const result = await page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { media: { image: "Keep" }, removed: { text: "Delete" } }); + let failed = false; + try { + await db.setToolboxSnapshot([{ id: "broken" }], {}, { broken: { invalid: () => {} } }, ["media"]); + } catch { failed = true; } + const afterFailure = await db.getAllModuleData(); + await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { note: { text: "New" } }, ["media"]); + return { failed, afterFailure, afterSuccess: await db.getAllModuleData() }; + }); + expect(result).toEqual({ failed: true, + afterFailure: [{ key: "media", data: { image: "Keep" } }, { key: "removed", data: { text: "Delete" } }], + afterSuccess: [{ key: "media", data: { image: "Keep" } }, { key: "note", data: { text: "New" } }] + }); +}); diff --git a/e2e/storage-startup.spec.js b/e2e/storage-startup.spec.js index 5e1a761..61340f2 100644 --- a/e2e/storage-startup.spec.js +++ b/e2e/storage-startup.spec.js @@ -120,7 +120,7 @@ test("commands reject writes, creation, imports and exports while the store is n created: actions.createToolbox("New"), imported: await actions.importToolbox({ text: forbidden }), importedAll: await actions.importAllToolboxes({ text: forbidden }), - exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(), + exported: await actions.exportToolbox("saved"), exportedAll: await actions.exportAllToolboxes(), notices: window.storageProbe.notices }; }); diff --git a/scripts/profile-toolbox-loading.mjs b/scripts/profile-toolbox-loading.mjs index 58a409b..34456ec 100644 --- a/scripts/profile-toolbox-loading.mjs +++ b/scripts/profile-toolbox-loading.mjs @@ -88,26 +88,28 @@ async function instrument(page, expectedCards) { observer.disconnect(); requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); })); } - const getAll = IDBObjectStore.prototype.getAll; - IDBObjectStore.prototype.getAll = function (...args) { - const start = performance.now(); - const request = getAll.apply(this, args); - if (this.name === "modules") { - const read = { startMs: start }; - metrics.reads.push(read); - request.addEventListener("success", () => { - read.requestMs = performance.now() - start; - read.entries = request.result.length; - }); - request.addEventListener("error", () => { read.error = request.error?.message; }); - this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; }); - this.transaction.addEventListener("complete", () => { - read.transactionMs = performance.now() - start; - checkReady(); - }); - } - return request; - }; + for (const method of ["getAll", "getAllKeys", "get"]) { + const original = IDBObjectStore.prototype[method]; + IDBObjectStore.prototype[method] = function (...args) { + const start = performance.now(); + const request = original.apply(this, args); + if (this.name === "modules") { + const read = { method, key: args[0], startMs: start }; + metrics.reads.push(read); + request.addEventListener("success", () => { + read.requestMs = performance.now() - start; + read.entries = method === "getAllKeys" ? 0 : method === "get" ? Number(request.result != null) : request.result.length; + }); + request.addEventListener("error", () => { read.error = request.error?.message; }); + this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; }); + this.transaction.addEventListener("complete", () => { + read.transactionMs = performance.now() - start; + checkReady(); + }); + } + return request; + }; + } new PerformanceObserver((list) => { for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration }); }).observe({ type: "longtask", buffered: true }); @@ -152,8 +154,8 @@ try { const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length })); assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document"); previousTimeOrigin = sample.timeOrigin; - assert.equal(sample.reads.length, 1, "Production must perform one initial modules read"); - assert.equal(sample.reads[0].entries, dataset.moduleCount); + assert.equal(sample.reads.reduce((sum, read) => sum + read.entries, 0), scenario.toolboxes, "Only memo values should load before opening a toolbox"); + assert.ok(sample.reads.every((read) => read.key == null || !String(read.key).endsWith(":images")), "Closed image modules must stay on disk"); assert.equal(sample.cards, scenario.toolboxes); assert.deepEqual(errors, []); await cdp.send("HeapProfiler.collectGarbage"); @@ -166,7 +168,7 @@ try { const summary = { readyMedianMs: median(samples.map((sample) => sample.readyMs)), readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)), - readMedianMs: median(samples.map((sample) => sample.reads[0].transactionMs)), + readMedianMs: median(samples.map((sample) => Math.max(...sample.reads.map((read) => read.startMs + read.transactionMs)) - Math.min(...sample.reads.map((read) => read.startMs)))), heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)), longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs))) }; diff --git a/tests/deferred-module-data.test.mjs b/tests/deferred-module-data.test.mjs new file mode 100644 index 0000000..bb07434 --- /dev/null +++ b/tests/deferred-module-data.test.mjs @@ -0,0 +1,43 @@ +// Rôle : vérifie la frontière entre médias différés, données vides et écritures concurrentes. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DEFERRED_MODULE_DATA as deferred, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "../website/src/features/toolboxes/storage/deferredModuleData.js"; +import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js"; + +test("only images and annotations are deferred; alert and text tools remain available", () => { + assert.deepEqual(getDeferredModuleKeys([{ id: "box", modules: [ + { id: "image", type: "images" }, { id: "map", type: "imageAnnotation" }, + { id: "timer", type: "timer" }, { id: "calendar", type: "calendar" }, + { id: "memos", type: "memos" }, { id: "notepad", type: "notepad" } + ] }]), ["box:image", "box:map"]); + assert.deepEqual(splitModuleData({ "box:image": deferred, "box:timer": { countdowns: [] } }), { + stored: { "box:timer": { countdowns: [] } }, deferred: ["box:image"] + }); +}); + +test("loading never replaces edits or resurrects deleted module data", () => { + const data = { pending: deferred, missing: deferred, edited: { images: ["new"] } }; + const loaded = mergeLoadedModuleData(data, ["pending", "missing", "edited", "deleted"], [ + { key: "pending", data: { image: "loaded" } }, { key: "edited", data: "old" }, { key: "deleted", data: "old" } + ]); + assert.deepEqual(loaded, { pending: { image: "loaded" }, edited: { images: ["new"] } }); + assert.equal(data.pending, deferred); + assert.equal(data.missing, deferred); +}); + +for (const failDelete of [false, true]) { + test(`a load queued during deletion follows its confirmed outcome (failure=${failDelete})`, async () => { + let finish; + const transaction = new Promise((resolve) => { finish = resolve; }); + const queue = createPersistenceQueue({ media: deferred }, () => {}); + const removal = queue.enqueue(() => ({}), async () => { + await transaction; + if (failDelete) throw new Error("abort"); + }); + const load = queue.enqueue((current) => mergeLoadedModuleData(current, ["media"], [{ key: "media", data: "kept" }]), () => {}); + finish(); + assert.equal(await removal, !failDelete); + assert.equal(await load, true); + assert.deepEqual(queue.getCurrent(), failDelete ? { media: "kept" } : {}); + }); +} diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 16d8016..5fec647 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -117,6 +117,7 @@ export function validateSiteContent(site) { "about.contribute.promptText", "toolboxes.eyebrow", "toolboxes.storageStatus.loadingTitle", + "toolboxes.storageStatus.mediaLoadingMessage", "toolboxes.storageStatus.loadingMessage", "toolboxes.storageStatus.errorTitle", "toolboxes.storageStatus.errorMessage", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 946940c..f85a11f 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -15,20 +15,17 @@ function countLines(source) { return source.trimEnd().split("\n").length; } -test("toolbox storage, cards and pages are wired", async () => { - const source = await readFile("website/src/main.jsx", "utf8"); +test("toolbox cards and pages retain their public UI conventions", async () => { const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8"); const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8"); const toolboxCoverEditor = await readFile("website/src/features/toolboxes/ToolboxCoverEditor.jsx", "utf8"); const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8"); const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8"); - const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8"); const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8"); const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8"); const calendarStorage = await readFile("website/src/features/toolboxes/storage/modules/calendar.js", "utf8"); const resetUtils = await readFile("website/src/features/toolboxes/modules/resetUtils.js", "utf8"); const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8"); - const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); @@ -36,25 +33,15 @@ test("toolbox storage, cards and pages are wired", async () => { const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8"); - assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); - assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/); - assert.match(source, /features\/toolboxes\/useToolboxActions\.js/); - assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/); - assert.match(indexedToolboxesHook, /getStorageEstimate/); - assert.match(toolboxActions, /export function useToolboxActions/); - assert.match(toolboxActions, /importAllToolboxesPayload/); - assert.match(toolboxActions, /async function addImageFiles/); assert.match(toolboxActions, /creationTemplate/); assert.match(toolboxActions, /type: "memos"/); assert.match(toolboxActions, /type: "links"/); assert.match(toolboxActions, /#\/library/); - assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/); assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/); assert.match(toolboxStorage, /customCover: normalizeToolboxCustomCover/); assert.match(toolboxStorage, /compact\.customCover = normalized\.customCover/); - assert.match(toolboxStorage, /export function createGlobalExportPayload/); assert.match(checklistStorage, /qtyTarget/); assert.match(checklistStorage, /qtyCurrent/); assert.match(checklistStorage, /hideCompletedSections/); @@ -62,23 +49,6 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(checklistStorage, /hideWhenComplete/); assert.match(equipmentStorage, /collapsed/); assert.match(toolboxStorage, /module\.scrollable/); - assert.match(toolboxStorage, /normalizeLinksData/); - assert.match(toolboxStorage, /normalizeCountersData/); - assert.match(toolboxStorage, /normalizeCombosData/); - assert.match(toolboxStorage, /normalizeCalculatorData/); - assert.match(toolboxStorage, /normalizeTableData/); - assert.match(toolboxStorage, /normalizeTimerData/); - assert.match(toolboxStorage, /normalizeCalendarData/); - assert.match(toolboxStorage, /normalizeTaskPlannerData/); - assert.match(toolboxStorage, /normalizeGoalTreeData/); - assert.match(toolboxStorage, /normalizeEquipmentPlannerData/); - assert.match(toolboxStorage, /summarizeEquipmentPlannerData/); - assert.match(toolboxStorage, /normalizeImageAnnotationData/); - assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); - assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/); - assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/); - assert.match(storageCompactorRegistry, /calendar: compactCalendarDataForStorage/); - assert.match(storageCompactorRegistry, /goalTree: compactGoalTreeDataForStorage/); assert.match(timerStorage, /scrollResults/); assert.match(calendarStorage, /normalizeCalendarData/); assert.match(resetUtils, /export function getLatestDailyReset/); diff --git a/website/public/data/site.json b/website/public/data/site.json index bf34c0c..10af6f4 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1795,6 +1795,7 @@ "toolboxes": { "storageStatus": { "loadingTitle": "Chargement des données locales", + "mediaLoadingMessage": "Chargement des médias…", "loadingMessage": "Lecture des toolboxes en cours…", "errorTitle": "Données locales indisponibles", "errorMessage": "Le chargement a échoué. Les modifications et les exports sont bloqués pour protéger les données existantes.", diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index a2eda72..8ac1cbd 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -194,6 +194,9 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto } const moduleContext = { getModuleData, + ensureModuleData: actions.ensureModuleData, + isModuleDataReady: actions.isModuleDataReady, + storageStatus: siteContent.toolboxes.storageStatus, storageReady, moduleText, normalizeChecklistData, diff --git a/website/src/features/toolboxes/modules/ModuleDataGate.jsx b/website/src/features/toolboxes/modules/ModuleDataGate.jsx new file mode 100644 index 0000000..e3aca0d --- /dev/null +++ b/website/src/features/toolboxes/modules/ModuleDataGate.jsx @@ -0,0 +1,28 @@ +// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec. +import { useEffect, useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; + +export function ModuleDataGate({ context, toolboxId, moduleId, children }) { + const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true; + const load = context.ensureModuleData; + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (ready || !load) return; + let cancelled = false; + void load(toolboxId, moduleId).catch((error) => { + if (!cancelled) setError(error?.message || context.storageStatus.errorTitle); + }); + return () => { cancelled = true; }; + }, [ready, load, toolboxId, moduleId, attempt, context.storageStatus?.errorTitle]); + if (ready) return children; + return ( +
+

{error || context.storageStatus.mediaLoadingMessage}

+ {error && } +
+ ); +} diff --git a/website/src/features/toolboxes/modules/ToolboxModules.jsx b/website/src/features/toolboxes/modules/ToolboxModules.jsx index 69182bd..6fae663 100644 --- a/website/src/features/toolboxes/modules/ToolboxModules.jsx +++ b/website/src/features/toolboxes/modules/ToolboxModules.jsx @@ -5,6 +5,7 @@ import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { supportsTextExchange } from "./textImport.js"; import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; +import { ModuleDataGate } from "./ModuleDataGate.jsx"; const MODULE_COLUMN_IDS = ["left", "right"]; @@ -264,7 +265,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
}> - + + +
diff --git a/website/src/features/toolboxes/storage/deferredModuleData.js b/website/src/features/toolboxes/storage/deferredModuleData.js new file mode 100644 index 0000000..3670457 --- /dev/null +++ b/website/src/features/toolboxes/storage/deferredModuleData.js @@ -0,0 +1,29 @@ +// Rôle : distingue les médias non lus des outils vides et fusionne les lectures sans ressusciter les suppressions. +export const DEFERRED_MODULE_DATA = Symbol("deferred module data"); + +export function getDeferredModuleKeys(toolboxes) { + return toolboxes.flatMap((toolbox) => (toolbox.modules || []) + .filter((module) => module.type === "images" || module.type === "imageAnnotation") + .map((module) => `${toolbox.id}:${module.id}`)); +} + +export function splitModuleData(moduleData) { + const stored = {}; + const deferred = []; + for (const [key, value] of Object.entries(moduleData)) { + if (value === DEFERRED_MODULE_DATA) deferred.push(key); + else stored[key] = value; + } + return { stored, deferred }; +} + +export function mergeLoadedModuleData(moduleData, keys, entries) { + const next = { ...moduleData }; + const loaded = new Map(entries.map((entry) => [entry.key, entry.data])); + for (const key of keys) { + if (next[key] !== DEFERRED_MODULE_DATA) continue; + if (loaded.has(key)) next[key] = loaded.get(key); + else delete next[key]; + } + return next; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 285c10b..dddecb8 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getAllModuleData as dbGetAllModuleData, + getModuleDataEntries as dbGetModuleDataEntries, getLinks as dbGetLinks, getSetting as dbGetSetting, getStorageEstimate, @@ -23,6 +24,7 @@ import { import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js"; import { createPersistenceQueue } from "./persistenceQueue.js"; import { createQuotaRefresh } from "./quotaRefresh.js"; +import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js"; function normalizeToolboxes(toolboxes) { return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); @@ -33,6 +35,7 @@ export function useIndexedToolboxes(onError) { const [loadError, setLoadError] = useState(""); const [loadAttempt, setLoadAttempt] = useState(0); const writableRef = useRef(false); + const pendingReadsRef = useRef(new Map()); const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" }); const [queue] = useState(() => createPersistenceQueue(state, setState)); const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); @@ -64,14 +67,20 @@ export function useIndexedToolboxes(onError) { let cancelled = false; async function loadStore() { try { - const [toolboxes, links, modules, toastPosition] = await Promise.all([ - dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right") + const [toolboxes, links, toastPosition] = await Promise.all([ + dbGetToolboxes(), dbGetLinks(), dbGetSetting("toastPosition", "right") ]); + const normalizedToolboxes = (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean); + const deferredKeys = getDeferredModuleKeys(normalizedToolboxes); + const modules = await dbGetAllModuleData(deferredKeys); if (cancelled) return; queue.initialize({ - toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean), + toolboxes: normalizedToolboxes, links: links && typeof links === "object" ? links : {}, - moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])), + moduleData: Object.fromEntries([ + ...deferredKeys.map((key) => [key, DEFERRED_MODULE_DATA]), + ...(modules || []).map((entry) => [entry.key, entry.data]) + ]), toastPosition: toastPosition === "left" ? "left" : "right" }); writableRef.current = true; @@ -125,6 +134,7 @@ export function useIndexedToolboxes(onError) { function updateModuleData(toolboxId, moduleId, value, moduleType = "") { if (!writableRef.current) return false; + if (queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] === DEFERRED_MODULE_DATA) return false; const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType); const key = moduleStorageKey(toolboxId, moduleId); const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId)); @@ -160,7 +170,38 @@ export function useIndexedToolboxes(onError) { return enqueue((current) => { const next = updater(current); return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) }; - }, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData)); + }, (next) => { + const { stored, deferred } = splitModuleData(next.moduleData); + return dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, stored, deferred); + }); + } + + const ensureModuleData = useCallback((toolboxId, moduleId) => { + const key = moduleStorageKey(toolboxId, moduleId); + if (queue.getCurrent().moduleData[key] !== DEFERRED_MODULE_DATA) return Promise.resolve(); + if (pendingReadsRef.current.has(key)) return pendingReadsRef.current.get(key); + const request = (async () => { + const entries = await dbGetModuleDataEntries([key]); + if (!writableRef.current) return; + await queue.enqueue((current) => ({ + ...current, moduleData: mergeLoadedModuleData(current.moduleData, [key], entries) + }), () => {}); + })(); + pendingReadsRef.current.set(key, request); + void request.then(() => pendingReadsRef.current.delete(key), () => pendingReadsRef.current.delete(key)); + return request; + }, [queue]); + + async function getExportSnapshot(toolboxId) { + if (!writableRef.current) return null; + let snapshot; + const success = await queue.enqueue((current) => current, async (current) => { + const { deferred } = splitModuleData(current.moduleData); + const keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`)); + const entries = await dbGetModuleDataEntries(keys); + snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) }; + }, reportStorageError); + return success ? snapshot : null; } function setToastPosition(position) { @@ -181,6 +222,9 @@ export function useIndexedToolboxes(onError) { setToolboxes: persistToolboxes, setLinks: persistLinks, getModuleData, + ensureModuleData, + isModuleDataReady: (toolboxId, moduleId) => state.moduleData[moduleStorageKey(toolboxId, moduleId)] !== DEFERRED_MODULE_DATA, + getExportSnapshot, updateModuleData, removeModuleData, removeToolboxModuleData, diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index c2902f4..5a479eb 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -234,6 +234,8 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM setImage, createChecklistFromList, addImageFiles, + ensureModuleData: store.ensureModuleData, + isModuleDataReady: store.isModuleDataReady, notify, removeModuleData: store.removeModuleData, updateModuleData: store.updateModuleData, @@ -275,16 +277,20 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM return false; } }, - exportToolbox: (id) => { + exportToolbox: async (id) => { if (!store.ready) return false; - const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id)); + const snapshot = await store.getExportSnapshot(id); + if (!snapshot) return false; + const toolbox = normalizeToolbox(snapshot.toolboxes.find((item) => item.id === id)); if (!toolbox) return; - downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`); + downloadJson(createToolboxExportPayload(toolbox, snapshot.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`); notify("Export de la toolbox lancé."); }, - exportAllToolboxes: () => { + exportAllToolboxes: async () => { if (!store.ready) return false; - downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`); + const snapshot = await store.getExportSnapshot(); + if (!snapshot) return false; + downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`); notify("Export global lancé."); } }; diff --git a/website/src/utils/indexedDbStorage.js b/website/src/utils/indexedDbStorage.js index f4994ae..3d3e764 100644 --- a/website/src/utils/indexedDbStorage.js +++ b/website/src/utils/indexedDbStorage.js @@ -80,8 +80,27 @@ export async function setSetting(key, value) { await setValue(`setting:${key}`, value); } -export async function getAllModuleData() { - return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll()); +export async function getAllModuleData(excludedKeys = []) { + if (!excludedKeys.length) return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll()); + const excluded = new Set(excludedKeys); + const keys = await storeRequest(MODULE_STORE, "readonly", (store) => store.getAllKeys()); + return getModuleDataEntries(keys.filter((key) => !excluded.has(key))); +} + +export async function getModuleDataEntries(keys) { + if (!keys.length) return []; + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(MODULE_STORE, "readonly"); + const entries = []; + transaction.oncomplete = () => resolve(entries); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + for (const key of keys) { + const request = transaction.objectStore(MODULE_STORE).get(key); + request.onsuccess = () => { if (request.result) entries.push(request.result); }; + } + }); } export async function setModuleData(key, data) { @@ -105,7 +124,7 @@ export async function removeModuleDataKeys(keys) { }); } -export async function setToolboxSnapshot(toolboxes, links, moduleData) { +export async function setToolboxSnapshot(toolboxes, links, moduleData, preservedKeys = []) { const db = await openDatabase(); await new Promise((resolve, reject) => { const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite"); @@ -117,7 +136,23 @@ export async function setToolboxSnapshot(toolboxes, links, moduleData) { try { kv.put(toolboxes, "toolboxes"); kv.put(links, "links"); - modules.clear(); + if (!preservedKeys.length) modules.clear(); + else { + // Parcourir uniquement les clés évite de décoder les médias conservés sur disque. + const kept = new Set([...preservedKeys, ...Object.keys(moduleData || {})]); + const request = modules.openKeyCursor(); + request.onsuccess = () => { + try { + const cursor = request.result; + if (!cursor) return; + if (!kept.has(cursor.key)) modules.delete(cursor.key); + cursor.continue(); + } catch (error) { + transaction.abort(); + reject(error); + } + }; + } Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data })); } catch (error) { transaction.abort();