From a4cf633ab44156ba74140daed61edb6ad81c089c Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Thu, 10 Sep 2026 19:09:48 +0200 Subject: [PATCH] Serialize toolbox writes and preserve concurrent edits --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 1 + docs/STORAGE_SCHEMA.md | 17 ++ e2e/persistence.spec.js | 29 +++ tests/optimistic-module-data.test.mjs | 80 ------- tests/persistence-queue.test.mjs | 155 +++++++++++++ tests/static-toolboxes.test.mjs | 5 - .../toolboxes/storage/optimisticModuleData.js | 47 +--- .../toolboxes/storage/persistenceQueue.js | 50 ++++ .../toolboxes/storage/useIndexedToolboxes.js | 218 ++++++------------ .../features/toolboxes/useToolboxActions.js | 85 ++++--- website/src/utils/indexedDbStorage.js | 13 +- 12 files changed, 381 insertions(+), 320 deletions(-) create mode 100644 e2e/persistence.spec.js delete mode 100644 tests/optimistic-module-data.test.mjs create mode 100644 tests/persistence-queue.test.mjs create mode 100644 website/src/features/toolboxes/storage/persistenceQueue.js diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 8b37722..c4b3204 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -23,3 +23,4 @@ {"id":"int-88c39f90fdd5b81110acc4bdae55836c","kind":"field_change","created_at":"2026-09-10T11:49:36.768770213Z","actor":"Shinuwa","issue_id":"sokko-g-vfo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared lazy React registry consumed by toolbox and library; obsolete static import assertions removed; all 17 library types exercised; npm run check and 13 E2E tests pass."}} {"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}} {"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}} +{"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 1f3166a..132d5ad 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -12,6 +12,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`. - Mettre à jour `docs/STORAGE_SCHEMA.md`. - 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. - 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 6acf6f5..b5d64f7 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -35,6 +35,23 @@ Entrée type dans `modules` : ## Toolbox +### Ordre et confirmation des écritures + +Dans une instance de l'application, le registre, les liens, les modules et la +position des notifications partagent une file d'écritures. L'interface présente +les modifications en attente ; en cas d'échec, elle est reconstruite depuis le +dernier état confirmé, puis les opérations restantes sont réappliquées. + +La création, l'association, l'import et la suppression d'une toolbox utilisent +une transaction commune aux stores `kv` et `modules`. Les imports ajoutent leur +contenu à l'état courant au moment de l'exécution et annoncent leur succès après +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. + +Cette coordination est locale à l'instance React ; elle ne synchronise pas +l'édition entre plusieurs onglets. + Les toolboxes sont stockées dans `kv/toolboxes`. ```json diff --git a/e2e/persistence.spec.js b/e2e/persistence.spec.js new file mode 100644 index 0000000..cb3a6f5 --- /dev/null +++ b/e2e/persistence.spec.js @@ -0,0 +1,29 @@ +// Rôle : vérifie l'atomicité des snapshots avec une vraie transaction IndexedDB. +import { test, expect } from "@playwright/test"; +import { resetSokkoStorage } from "./helpers/storage.js"; + +test("a synchronous snapshot error aborts all its IndexedDB writes", 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: [] }], { game: "saved" }, { "saved:note": { text: "Keep" } }); + let errorName = ""; + try { + await db.setToolboxSnapshot([{ id: "broken" }], {}, { "broken:note": { invalid: () => {} } }); + } catch (error) { + errorName = error.name; + } + return { + errorName, + toolboxes: await db.getToolboxes(), + links: await db.getLinks(), + modules: await db.getAllModuleData() + }; + }); + expect(result).toEqual({ + errorName: "DataCloneError", + toolboxes: [{ id: "saved", modules: [] }], + links: { game: "saved" }, + modules: [{ key: "saved:note", data: { text: "Keep" } }] + }); +}); diff --git a/tests/optimistic-module-data.test.mjs b/tests/optimistic-module-data.test.mjs deleted file mode 100644 index 76ab17d..0000000 --- a/tests/optimistic-module-data.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -// Rôle : teste les rollbacks optimistes par clé des données de modules IndexedDB. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - applyModuleDataValue, - commitModuleDataCheckpoints, - createModuleDataCheckpoint, - createModuleDataCheckpoints, - removeModuleDataKeys, - rollbackModuleDataCheckpoints -} from "../website/src/features/toolboxes/storage/optimisticModuleData.js"; - -test("module data rollback restores a failed simple write", () => { - const initialData = { "tbx:mod-a": { text: "before" } }; - const initialVersions = {}; - const checkpoint = createModuleDataCheckpoint(initialData, initialVersions, "tbx:mod-a"); - const versions = commitModuleDataCheckpoints(initialVersions, [checkpoint]); - const optimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" }); - - assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData); -}); - -test("module data rollback ignores a stale failure for the same module", () => { - const initialData = { "tbx:mod-a": { text: "before" } }; - const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a"); - const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]); - const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" }); - const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-a"); - const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]); - const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-a", { text: "latest" }); - - assert.strictEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), secondOptimisticData); -}); - -test("module data rollback restores one module even when another module changed later", () => { - const initialData = { - "tbx:mod-a": { text: "before-a" }, - "tbx:mod-b": { text: "before-b" } - }; - const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a"); - const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]); - const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after-a" }); - const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-b"); - const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]); - const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-b", { text: "after-b" }); - - assert.deepEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), { - "tbx:mod-a": { text: "before-a" }, - "tbx:mod-b": { text: "after-b" } - }); -}); - -test("module data rollback for batch removal restores only untouched keys", () => { - const initialData = { - "tbx:mod-a": { text: "before-a" }, - "tbx:mod-b": { text: "before-b" }, - "tbx:mod-c": { text: "before-c" } - }; - const batchCheckpoints = createModuleDataCheckpoints(initialData, {}, ["tbx:mod-a", "tbx:mod-b"]); - const batchVersions = commitModuleDataCheckpoints({}, batchCheckpoints); - const removedData = removeModuleDataKeys(initialData, ["tbx:mod-a", "tbx:mod-b"]); - const laterCheckpoint = createModuleDataCheckpoint(removedData, batchVersions, "tbx:mod-b"); - const laterVersions = commitModuleDataCheckpoints(batchVersions, [laterCheckpoint]); - const laterData = applyModuleDataValue(removedData, "tbx:mod-b", { text: "latest-b" }); - - assert.deepEqual(rollbackModuleDataCheckpoints(laterData, laterVersions, batchCheckpoints), { - "tbx:mod-a": { text: "before-a" }, - "tbx:mod-b": { text: "latest-b" }, - "tbx:mod-c": { text: "before-c" } - }); -}); - -test("module data rollback keeps removal of an initially missing key stable", () => { - const initialData = { "tbx:mod-a": { text: "before-a" } }; - const checkpoint = createModuleDataCheckpoint(initialData, {}, "tbx:missing"); - const versions = commitModuleDataCheckpoints({}, [checkpoint]); - const optimisticData = removeModuleDataKeys(initialData, ["tbx:missing"]); - - assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData); -}); diff --git a/tests/persistence-queue.test.mjs b/tests/persistence-queue.test.mjs new file mode 100644 index 0000000..a5b827b --- /dev/null +++ b/tests/persistence-queue.test.mjs @@ -0,0 +1,155 @@ +// Rôle : teste les écritures confirmées, échecs successifs et imports concurrents. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js"; +import { useToolboxActions } from "../website/src/features/toolboxes/useToolboxActions.js"; +import { applyModuleDataValue, removeModuleDataKeys } from "../website/src/features/toolboxes/storage/optimisticModuleData.js"; + +function deferred() { + let resolve, reject; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} + +for (const field of ["toolboxes", "links", "moduleData", "toastPosition"]) { + for (const outcomes of [[true, true], [true, false], [false, true], [false, false]]) { + test(`${field}: confirms only successful writes ${outcomes}`, async () => { + let disk = { [field]: "A" }; + const errors = []; + const queue = createPersistenceQueue(disk, () => {}); + const gates = [deferred(), deferred()]; + let active = 0; + const write = (index) => queue.enqueue( + (current) => ({ ...current, [field]: index ? "C" : "B" }), + async (next) => { + assert.equal(++active, 1); + try { await gates[index].promise; disk = next; } + finally { active--; } + }, + (error) => errors.push(error.message) + ); + const first = write(0); + const second = write(1); + assert.equal(queue.getCurrent()[field], "C"); + outcomes[0] ? gates[0].resolve() : gates[0].reject(new Error("first")); + assert.equal(await first, outcomes[0]); + assert.equal(queue.getCurrent()[field], "C"); + outcomes[1] ? gates[1].resolve() : gates[1].reject(new Error("second")); + assert.equal(await second, outcomes[1]); + assert.deepEqual(queue.getCurrent(), disk); + assert.equal(disk[field], outcomes[1] ? "C" : outcomes[0] ? "B" : "A"); + assert.equal(errors.length, outcomes.filter((ok) => !ok).length); + }); + } +} + +test("failed snapshot cannot undo a later edit or reintroduce a failed earlier write", async () => { + const initial = { toolboxes: ["existing"], links: {}, moduleData: { note: "A" } }; + let disk = initial; + const queue = createPersistenceQueue(initial, () => {}); + const gate = deferred(); + const earlier = queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), () => gate.promise); + const snapshot = queue.enqueue( + (s) => ({ ...s, toolboxes: ["imported", ...s.toolboxes] }), + async () => { throw new Error("abort"); } + ); + const edit = queue.enqueue((s) => ({ ...s, moduleData: { note: "C" } }), async (next) => { disk = next; }); + gate.reject(new Error("quota")); + assert.deepEqual(await Promise.all([earlier, snapshot, edit]), [false, false, true]); + assert.deepEqual(queue.getCurrent(), { ...initial, moduleData: { note: "C" } }); + assert.deepEqual(disk, queue.getCurrent()); +}); + +test("imports merge after file reading and preserve edits queued during the transaction", async () => { + const initial = { toolboxes: [{ id: "existing", modules: [] }], links: {}, moduleData: { note: "A" } }; + let disk = initial; + const queue = createPersistenceQueue(initial, () => {}); + const transaction = deferred(); + const fileRead = deferred(); + const notices = []; + const actions = useToolboxActions({ + store: { + ...initial, + persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { + await transaction.promise; + disk = next; + }) + }, + notify: (value) => notices.push(value), + setConfirmModal: (value) => assert.fail(value.message) + }); + const imported = actions.importToolbox({ text: () => fileRead.promise }, "game"); + await queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), async (next) => { disk = next; }); + fileRead.resolve(JSON.stringify({ toolbox: { id: "source", name: "Import", modules: [] }, modules: {} })); + await Promise.resolve(); + assert.equal(notices.length, 0); + const edit = queue.enqueue((s) => ({ ...s, moduleData: { ...s.moduleData, note: "C" } }), async (next) => { disk = next; }); + transaction.resolve(); + const toolbox = await imported; + await edit; + assert.equal(disk.moduleData.note, "C"); + assert.deepEqual(disk.toolboxes.map((t) => t.id), [toolbox.id, "existing"]); + assert.equal(disk.links.game, toolbox.id); + assert.deepEqual(queue.getCurrent(), disk); + assert.equal(notices.length, 1); +}); + +test("failed module removal restores the confirmed value after an earlier failed edit", async () => { + const initial = { moduleData: { note: { text: "A" }, other: { text: "Other" } } }; + const queue = createPersistenceQueue(initial, () => {}); + const first = queue.enqueue( + (s) => ({ ...s, moduleData: applyModuleDataValue(s.moduleData, "note", { text: "B" }) }), + async () => { throw new Error("quota"); } + ); + const second = queue.enqueue( + (s) => ({ ...s, moduleData: removeModuleDataKeys(s.moduleData, ["note"]) }), + async () => { throw new Error("abort"); } + ); + assert.deepEqual(await Promise.all([first, second]), [false, false]); + assert.deepEqual(queue.getCurrent(), initial); +}); + +test("global imports started from the same render both survive", async () => { + const initial = { toolboxes: [], links: {}, moduleData: {} }; + let disk = initial; + const queue = createPersistenceQueue(initial, () => {}); + const actions = useToolboxActions({ + store: { + ...initial, + persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; }) + }, + notify() {}, + setConfirmModal: (value) => assert.fail(value.message) + }); + const file = (id) => ({ text: async () => JSON.stringify({ + toolboxes: [{ id, name: id, modules: [{ id: "note", type: "notepad" }] }], + links: { [id]: id }, + modules: { [`${id}:note`]: { text: id } } + }) }); + assert.deepEqual(await Promise.all([ + actions.importAllToolboxes(file("one")), actions.importAllToolboxes(file("two")) + ]), [true, true]); + assert.equal(disk.toolboxes.length, 2); + assert.equal(Object.keys(disk.moduleData).length, 2); + assert.deepEqual(Object.keys(disk.links).sort(), ["one", "two"]); + assert.deepEqual(queue.getCurrent(), disk); +}); + +test("failed toolbox deletion preserves registry links and modules together", async () => { + const initial = { + toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }], + links: { game: "existing" }, + moduleData: { "existing:note": { text: "Keep" } } + }; + const queue = createPersistenceQueue(initial, () => {}); + const actions = useToolboxActions({ + store: { + ...initial, + persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); }) + } + }); + const deleted = actions.deleteToolbox("existing"); + assert.deepEqual(queue.getCurrent(), { toolboxes: [], links: {}, moduleData: {} }); + assert.equal(await deleted, false); + assert.deepEqual(queue.getCurrent(), initial); +}); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 7dd1be4..8cac18b 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -29,7 +29,6 @@ test("toolbox storage, cards and pages are wired", async () => { 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 optimisticModuleData = await readFile("website/src/features/toolboxes/storage/optimisticModuleData.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"); @@ -43,10 +42,6 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(source, /features\/toolboxes\/useToolboxActions\.js/); assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/); assert.match(indexedToolboxesHook, /getStorageEstimate/); - assert.match(indexedToolboxesHook, /moduleDataByKey/); - assert.match(indexedToolboxesHook, /rollbackModuleDataCheckpoints/); - assert.match(optimisticModuleData, /export function createModuleDataCheckpoint/); - assert.match(optimisticModuleData, /export function rollbackModuleDataCheckpoints/); assert.match(toolboxActions, /export function useToolboxActions/); assert.match(toolboxActions, /importAllToolboxesPayload/); assert.match(toolboxActions, /async function addImageFiles/); diff --git a/website/src/features/toolboxes/storage/optimisticModuleData.js b/website/src/features/toolboxes/storage/optimisticModuleData.js index 34e413e..8221d4c 100644 --- a/website/src/features/toolboxes/storage/optimisticModuleData.js +++ b/website/src/features/toolboxes/storage/optimisticModuleData.js @@ -1,34 +1,4 @@ -// Rôle : fournit les helpers purs de versioning et rollback optimiste des données modules. -function hasOwnValue(data, key) { - return Object.prototype.hasOwnProperty.call(data, key); -} - -function uniqueKeys(keys) { - return [...new Set((Array.isArray(keys) ? keys : []).map((key) => String(key || "")).filter(Boolean))]; -} - -export function createModuleDataCheckpoint(moduleData, versionsByKey, key) { - const version = (versionsByKey[key] || 0) + 1; - return { - key, - version, - hadValue: hasOwnValue(moduleData, key), - value: moduleData[key] - }; -} - -export function createModuleDataCheckpoints(moduleData, versionsByKey, keys) { - return uniqueKeys(keys).map((key) => createModuleDataCheckpoint(moduleData, versionsByKey, key)); -} - -export function commitModuleDataCheckpoints(versionsByKey, checkpoints) { - const nextVersions = { ...versionsByKey }; - checkpoints.forEach((checkpoint) => { - nextVersions[checkpoint.key] = checkpoint.version; - }); - return nextVersions; -} - +// Rôle : applique les changements immuables de données de modules. export function applyModuleDataValue(moduleData, key, value) { const nextModuleData = { ...moduleData }; if (value == null) delete nextModuleData[key]; @@ -38,19 +8,6 @@ export function applyModuleDataValue(moduleData, key, value) { export function removeModuleDataKeys(moduleData, keys) { const nextModuleData = { ...moduleData }; - uniqueKeys(keys).forEach((key) => { - delete nextModuleData[key]; - }); - return nextModuleData; -} - -export function rollbackModuleDataCheckpoints(moduleData, versionsByKey, checkpoints) { - let nextModuleData = moduleData; - checkpoints.forEach((checkpoint) => { - if (versionsByKey[checkpoint.key] !== checkpoint.version) return; - if (nextModuleData === moduleData) nextModuleData = { ...moduleData }; - if (checkpoint.hadValue) nextModuleData[checkpoint.key] = checkpoint.value; - else delete nextModuleData[checkpoint.key]; - }); + for (const key of keys) delete nextModuleData[key]; return nextModuleData; } diff --git a/website/src/features/toolboxes/storage/persistenceQueue.js b/website/src/features/toolboxes/storage/persistenceQueue.js new file mode 100644 index 0000000..5502c77 --- /dev/null +++ b/website/src/features/toolboxes/storage/persistenceQueue.js @@ -0,0 +1,50 @@ +// Rôle : sérialise les écritures et reconstruit l'état optimiste depuis les succès confirmés. +export function createPersistenceQueue(initialState, onChange) { + let confirmed = initialState; + let current = initialState; + let running = false; + const pending = []; + + function publish() { + current = pending.reduce((state, operation) => operation.apply(state), confirmed); + onChange(current); + } + + async function drain() { + if (running) return; + running = true; + while (pending.length) { + const operation = pending[0]; + let success = false; + let failure; + try { + const next = operation.apply(confirmed); + await operation.persist(next); + confirmed = next; + success = true; + } catch (error) { + failure = error; + } + pending.shift(); + publish(); + operation.resolve(success); + if (!success) operation.onError?.(failure); + } + running = false; + } + + return { + getCurrent: () => current, + initialize(state) { + if (running || pending.length) throw new Error("Cannot initialize while writes are pending"); + confirmed = state; + publish(); + }, + enqueue(apply, persist, onError) { + const result = new Promise((resolve) => pending.push({ apply, persist, onError, resolve })); + publish(); + void drain(); + return result; + } + }; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index bc1429a..05594a5 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -1,5 +1,5 @@ -// Rôle : synchronise les toolboxes, données d'outils, liens de jeux et quota via IndexedDB. -import { useCallback, useEffect, useRef, useState } from "react"; +// Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB. +import { useCallback, useEffect, useState } from "react"; import { getAllModuleData as dbGetAllModuleData, getLinks as dbGetLinks, @@ -20,59 +20,22 @@ import { normalizeToolbox, prepareModuleDataForStorage } from "./toolboxStorage.js"; -import { - applyModuleDataValue, - commitModuleDataCheckpoints, - createModuleDataCheckpoint, - createModuleDataCheckpoints, - removeModuleDataKeys, - rollbackModuleDataCheckpoints -} from "./optimisticModuleData.js"; +import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js"; +import { createPersistenceQueue } from "./persistenceQueue.js"; + +function normalizeToolboxes(toolboxes) { + return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); +} export function useIndexedToolboxes(onError) { const [ready, setReady] = useState(false); - const [toolboxes, setToolboxesState] = useState([]); - const [links, setLinksState] = useState({}); - const [moduleData, setModuleDataState] = useState({}); - const [toastPosition, setToastPositionState] = useState("right"); + 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 }); - const toolboxesRef = useRef(toolboxes); - const linksRef = useRef(links); - const moduleDataRef = useRef(moduleData); - const confirmedModuleDataRef = useRef(moduleData); - const moduleWriteQueuesRef = useRef({}); - const toastPositionRef = useRef(toastPosition); - const writeVersionsRef = useRef({ - toolboxes: 0, - links: 0, - moduleDataByKey: {}, - toastPosition: 0 - }); - const reportStorageError = useCallback((error) => { onError(error?.message || "Erreur de stockage IndexedDB."); }, [onError]); - function commitToolboxes(nextToolboxes) { - toolboxesRef.current = nextToolboxes; - setToolboxesState(nextToolboxes); - } - - function commitLinks(nextLinks) { - linksRef.current = nextLinks; - setLinksState(nextLinks); - } - - function commitModuleData(nextModuleData) { - moduleDataRef.current = nextModuleData; - setModuleDataState(nextModuleData); - } - - function commitToastPosition(nextToastPosition) { - toastPositionRef.current = nextToastPosition; - setToastPositionState(nextToastPosition); - } - const refreshQuota = useCallback(async () => { try { const estimate = await getStorageEstimate(); @@ -88,19 +51,16 @@ export function useIndexedToolboxes(onError) { let cancelled = false; async function loadStore() { try { - const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([ - dbGetToolboxes(), - dbGetLinks(), - dbGetAllModuleData(), - dbGetSetting("toastPosition", "right") + const [toolboxes, links, modules, toastPosition] = await Promise.all([ + dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right") ]); if (cancelled) return; - commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); - commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); - const loadedModuleData = Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])); - confirmedModuleDataRef.current = loadedModuleData; - commitModuleData(loadedModuleData); - commitToastPosition(storedToastPosition === "left" ? "left" : "right"); + queue.initialize({ + toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean), + links: links && typeof links === "object" ? links : {}, + moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])), + toastPosition: toastPosition === "left" ? "left" : "right" + }); setReady(true); refreshQuota(); } catch (error) { @@ -112,132 +72,84 @@ export function useIndexedToolboxes(onError) { } loadStore(); return () => { cancelled = true; }; - }, [refreshQuota, reportStorageError]); + }, [queue, refreshQuota, reportStorageError]); - function persistToolboxes(nextToolboxes) { - const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); - const previousToolboxes = toolboxesRef.current; - const version = ++writeVersionsRef.current.toolboxes; - commitToolboxes(normalized); - dbSetToolboxes(compactToolboxesForStorage(normalized)) - .then(refreshQuota) - .catch((error) => { - if (writeVersionsRef.current.toolboxes === version) commitToolboxes(previousToolboxes); - reportStorageError(error); - }); + function enqueue(apply, persist) { + const result = queue.enqueue(apply, persist, reportStorageError); + void result.then((success) => { if (success) refreshQuota(); }); + return result; + } + + function persistToolboxes(value) { + const apply = typeof value === "function" ? value : () => value; + enqueue( + (current) => ({ ...current, toolboxes: normalizeToolboxes(apply(current.toolboxes)) }), + (next) => dbSetToolboxes(compactToolboxesForStorage(next.toolboxes)) + ); return true; } - function persistLinks(nextLinks) { - const previousLinks = linksRef.current; - const version = ++writeVersionsRef.current.links; - commitLinks(nextLinks); - dbSetLinks(nextLinks) - .then(refreshQuota) - .catch((error) => { - if (writeVersionsRef.current.links === version) commitLinks(previousLinks); - reportStorageError(error); - }); + function persistLinks(value) { + enqueue( + (current) => ({ ...current, links: typeof value === "function" ? value(current.links) : value }), + (next) => dbSetLinks(next.links) + ); return true; } function getModuleData(toolboxId, moduleId, fallback) { - const value = moduleDataRef.current[moduleStorageKey(toolboxId, moduleId)]; - return value == null ? fallback : value; + return queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] ?? fallback; } function updateModuleData(toolboxId, moduleId, value, moduleType = "") { - const compact = prepareModuleDataForStorage(toolboxesRef.current, toolboxId, moduleId, value, moduleType); + const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType); const key = moduleStorageKey(toolboxId, moduleId); - const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key); - writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); - const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact); - commitModuleData(nextModuleData); - const previousQueue = moduleWriteQueuesRef.current[key] || Promise.resolve(); - const operation = previousQueue.catch(() => {}).then(async () => { - if (compact == null) await dbRemoveModuleData(key); - else await dbSetModuleData(key, compact); - confirmedModuleDataRef.current = applyModuleDataValue(confirmedModuleDataRef.current, key, compact); - await refreshQuota(); - }).catch((error) => { - if (moduleDataRef.current[key] === compact) { - commitModuleData(applyModuleDataValue(moduleDataRef.current, key, confirmedModuleDataRef.current[key])); + const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId)); + enqueue( + (current) => hasModule(current) ? { ...current, moduleData: applyModuleDataValue(current.moduleData, key, compact) } : current, + (next) => { + if (!hasModule(next)) return; + return compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); } - reportStorageError(error); - }); - const queuedOperation = operation.finally(() => { - if (moduleWriteQueuesRef.current[key] === queuedOperation) delete moduleWriteQueuesRef.current[key]; - }); - moduleWriteQueuesRef.current[key] = queuedOperation; + ); return true; } function removeModuleData(toolboxId, moduleId) { const key = moduleStorageKey(toolboxId, moduleId); - const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key); - writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); - commitModuleData(removeModuleDataKeys(moduleDataRef.current, [key])); - dbRemoveModuleData(key).then(refreshQuota).catch((error) => { - commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint])); - reportStorageError(error); - }); + return enqueue( + (current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, [key]) }), + () => dbRemoveModuleData(key) + ); } function removeToolboxModuleData(toolbox) { const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id)); - if (!keys.length) return; - const checkpoints = createModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, keys); - writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, checkpoints); - commitModuleData(removeModuleDataKeys(moduleDataRef.current, keys)); - dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => { - commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, checkpoints)); - reportStorageError(error); - }); + if (!keys.length) return Promise.resolve(true); + return enqueue( + (current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, keys) }), + () => dbRemoveModuleDataKeys(keys) + ); } - async function persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData) { - const normalizedToolboxes = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); - const previous = { - toolboxes: toolboxesRef.current, - links: linksRef.current, - moduleData: moduleDataRef.current - }; - commitToolboxes(normalizedToolboxes); - commitLinks(nextLinks); - commitModuleData(nextModuleData); - try { - await dbSetToolboxSnapshot(compactToolboxesForStorage(normalizedToolboxes), nextLinks, nextModuleData); - confirmedModuleDataRef.current = nextModuleData; - await refreshQuota(); - return true; - } catch (error) { - commitToolboxes(previous.toolboxes); - commitLinks(previous.links); - commitModuleData(previous.moduleData); - reportStorageError(error); - return false; - } + function persistToolboxSnapshot(updater) { + return enqueue((current) => { + const next = updater(current); + return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) }; + }, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData)); } function setToastPosition(position) { - const normalized = position === "left" ? "left" : "right"; - const previousToastPosition = toastPositionRef.current; - const version = ++writeVersionsRef.current.toastPosition; - commitToastPosition(normalized); - dbSetSetting("toastPosition", normalized) - .then(refreshQuota) - .catch((error) => { - if (writeVersionsRef.current.toastPosition === version) commitToastPosition(previousToastPosition); - reportStorageError(error); - }); + const toastPosition = position === "left" ? "left" : "right"; + enqueue( + (current) => ({ ...current, toastPosition }), + () => dbSetSetting("toastPosition", toastPosition) + ); } return { + ...state, ready, - toolboxes, - links, - moduleData, - toastPosition, storageUsage, setToolboxes: persistToolboxes, setLinks: persistLinks, diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index adefd1f..9bbd75b 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -46,41 +46,53 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM ] }; if (options.tutorial === true) toolbox.tutorial = true; - if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null; - store.updateModuleData(toolbox.id, memosModuleId, { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] }, "memos"); - store.updateModuleData(toolbox.id, linksModuleId, { links: [libraryLink] }, "links"); - if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id }); + const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] }); + const links = compactModuleDataForStorage("links", { links: [libraryLink] }); + void store.persistToolboxSnapshot((current) => ({ + toolboxes: [toolbox, ...current.toolboxes], + links: gameId ? { ...current.links, [gameId]: toolbox.id } : current.links, + moduleData: { + ...current.moduleData, + [globalModuleKey(toolbox.id, memosModuleId)]: memos, + [globalModuleKey(toolbox.id, linksModuleId)]: links + } + })); return toolbox; } function updateToolbox(nextToolbox) { - store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id - ? { ...nextToolbox, updatedAt: new Date().toISOString() } + const updatedAt = new Date().toISOString(); + store.setToolboxes((toolboxes) => toolboxes.map((toolbox) => toolbox.id === nextToolbox.id + ? { ...nextToolbox, updatedAt } : toolbox)); } function deleteToolbox(id) { - const toolbox = store.toolboxes.find((item) => item.id === id); - const nextToolboxes = store.toolboxes.filter((item) => item.id !== id); - const nextModuleData = { ...store.moduleData }; - (toolbox?.modules || []).forEach((module) => delete nextModuleData[`${id}:${module.id}`]); - const nextLinks = { ...store.links }; - Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { - if (toolboxId === id) delete nextLinks[gameId]; + return store.persistToolboxSnapshot((current) => { + const toolbox = current.toolboxes.find((item) => item.id === id); + const moduleData = { ...current.moduleData }; + (toolbox?.modules || []).forEach((module) => delete moduleData[`${id}:${module.id}`]); + return { + toolboxes: current.toolboxes.filter((item) => item.id !== id), + moduleData, + links: Object.fromEntries(Object.entries(current.links).filter(([, toolboxId]) => toolboxId !== id)) + }; }); - return store.persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData); } function linkToolboxToGame(gameId, toolboxId) { - const previousToolboxId = store.links[gameId] || ""; - const nextLinks = { ...store.links }; - if (toolboxId) nextLinks[gameId] = toolboxId; - else delete nextLinks[gameId]; - store.setLinks(nextLinks); - const touched = new Set([previousToolboxId, toolboxId].filter(Boolean)); - store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id) - ? { ...toolbox, updatedAt: new Date().toISOString() } - : toolbox)); + const updatedAt = new Date().toISOString(); + return store.persistToolboxSnapshot((current) => { + const links = { ...current.links }; + const touched = new Set([links[gameId], toolboxId].filter(Boolean)); + if (toolboxId) links[gameId] = toolboxId; + else delete links[gameId]; + return { + ...current, + links, + toolboxes: current.toolboxes.map((toolbox) => touched.has(toolbox.id) ? { ...toolbox, updatedAt } : toolbox) + }; + }); } async function addImageFiles(toolboxId, moduleId, files) { @@ -125,8 +137,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM confirmLabel: "Créer", onResolve: (confirmed) => { if (!confirmed) return; - store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId - ? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() } + const updatedAt = new Date().toISOString(); + store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId + ? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt } : item)); store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); notify("Checklist créée dans la toolbox associée."); @@ -147,7 +160,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM }); imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap); const normalizedImport = normalizeToolbox(imported); - const nextModuleData = { ...store.moduleData }; + const nextModuleData = {}; Object.entries(payload.modules || {}).forEach(([oldId, data]) => { const nextId = moduleIdMap.get(oldId); const module = normalizedImport.modules.find((item) => item.id === nextId); @@ -156,9 +169,11 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact; } }); - const nextLinks = { ...store.links }; - if (gameId) nextLinks[gameId] = normalizedImport.id; - const saved = await store.persistToolboxSnapshot([normalizedImport, ...store.toolboxes], nextLinks, nextModuleData); + const saved = await store.persistToolboxSnapshot((current) => ({ + toolboxes: [normalizedImport, ...current.toolboxes], + links: gameId ? { ...current.links, [gameId]: normalizedImport.id } : current.links, + moduleData: { ...current.moduleData, ...nextModuleData } + })); if (!saved) return null; return normalizedImport; } @@ -183,12 +198,12 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap); return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() }); }); - const nextLinks = { ...store.links }; + const nextLinks = {}; Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => { const nextToolboxId = toolboxIdMap.get(oldToolboxId); if (nextToolboxId) nextLinks[gameId] = nextToolboxId; }); - const nextModuleData = { ...store.moduleData }; + const nextModuleData = {}; Object.entries(payload.modules).forEach(([key, data]) => { const [oldToolboxId] = key.split(":"); const nextToolboxId = toolboxIdMap.get(oldToolboxId); @@ -198,7 +213,11 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM const compact = compactModuleDataForStorage(module?.type, data); if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact; }); - const saved = await store.persistToolboxSnapshot([...importedToolboxes, ...store.toolboxes], nextLinks, nextModuleData); + const saved = await store.persistToolboxSnapshot((current) => ({ + toolboxes: [...importedToolboxes, ...current.toolboxes], + links: { ...current.links, ...nextLinks }, + moduleData: { ...current.moduleData, ...nextModuleData } + })); if (!saved) throw new Error("Impossible d'enregistrer l'import global."); } @@ -219,7 +238,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM updateModuleData: store.updateModuleData, updateToolboxOrder: (orderedIds) => { const order = new Map(orderedIds.map((id, index) => [id, index])); - store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999))); + store.setToolboxes((toolboxes) => [...toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999))); }, importToolbox: async (file, gameId = "") => { try { diff --git a/website/src/utils/indexedDbStorage.js b/website/src/utils/indexedDbStorage.js index 92ac88c..340d29b 100644 --- a/website/src/utils/indexedDbStorage.js +++ b/website/src/utils/indexedDbStorage.js @@ -108,13 +108,18 @@ export async function setToolboxSnapshot(toolboxes, links, moduleData) { const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite"); const kv = transaction.objectStore(KV_STORE); const modules = transaction.objectStore(MODULE_STORE); - kv.put(toolboxes, "toolboxes"); - kv.put(links, "links"); - modules.clear(); - Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data })); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); + try { + kv.put(toolboxes, "toolboxes"); + kv.put(links, "links"); + modules.clear(); + Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data })); + } catch (error) { + transaction.abort(); + reject(error); + } }); }