From 43a9e791c51346bc732876d39b25b84884e637af Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Tue, 25 Aug 2026 08:55:11 +0200 Subject: [PATCH] Improve IndexedDB module rollback handling --- tests/optimistic-module-data.test.mjs | 80 +++++++++++++++++++ tests/static-toolboxes.test.mjs | 5 ++ .../toolboxes/storage/optimisticModuleData.js | 56 +++++++++++++ .../toolboxes/storage/useIndexedToolboxes.js | 40 +++++----- 4 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 tests/optimistic-module-data.test.mjs create mode 100644 website/src/features/toolboxes/storage/optimisticModuleData.js diff --git a/tests/optimistic-module-data.test.mjs b/tests/optimistic-module-data.test.mjs new file mode 100644 index 0000000..76ab17d --- /dev/null +++ b/tests/optimistic-module-data.test.mjs @@ -0,0 +1,80 @@ +// 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/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 3ac475d..4fd47be 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -23,6 +23,7 @@ test("toolbox storage, cards and pages are wired", async () => { const calendarStorage = await readFile("website/src/features/toolboxes/storage/modules/calendar.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"); @@ -36,6 +37,10 @@ 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 new file mode 100644 index 0000000..34e413e --- /dev/null +++ b/website/src/features/toolboxes/storage/optimisticModuleData.js @@ -0,0 +1,56 @@ +// 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; +} + +export function applyModuleDataValue(moduleData, key, value) { + const nextModuleData = { ...moduleData }; + if (value == null) delete nextModuleData[key]; + else nextModuleData[key] = value; + return nextModuleData; +} + +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]; + }); + return nextModuleData; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 8cef150..902e1dc 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -19,6 +19,14 @@ import { normalizeToolbox, prepareModuleDataForStorage } from "./toolboxStorage.js"; +import { + applyModuleDataValue, + commitModuleDataCheckpoints, + createModuleDataCheckpoint, + createModuleDataCheckpoints, + removeModuleDataKeys, + rollbackModuleDataCheckpoints +} from "./optimisticModuleData.js"; export function useIndexedToolboxes(onError) { const [ready, setReady] = useState(false); @@ -34,7 +42,7 @@ export function useIndexedToolboxes(onError) { const writeVersionsRef = useRef({ toolboxes: 0, links: 0, - moduleData: 0, + moduleDataByKey: {}, toastPosition: 0 }); @@ -136,15 +144,13 @@ export function useIndexedToolboxes(onError) { function updateModuleData(toolboxId, moduleId, value, moduleType = "") { const compact = prepareModuleDataForStorage(toolboxesRef.current, toolboxId, moduleId, value, moduleType); const key = moduleStorageKey(toolboxId, moduleId); - const previousModuleData = moduleDataRef.current; - const version = ++writeVersionsRef.current.moduleData; - const nextModuleData = { ...previousModuleData }; - if (compact == null) delete nextModuleData[key]; - else nextModuleData[key] = compact; + 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 operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact); operation.then(refreshQuota).catch((error) => { - if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData); + commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint])); reportStorageError(error); }); return true; @@ -152,13 +158,11 @@ export function useIndexedToolboxes(onError) { function removeModuleData(toolboxId, moduleId) { const key = moduleStorageKey(toolboxId, moduleId); - const previousModuleData = moduleDataRef.current; - const version = ++writeVersionsRef.current.moduleData; - const nextModuleData = { ...previousModuleData }; - delete nextModuleData[key]; - commitModuleData(nextModuleData); + 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) => { - if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData); + commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint])); reportStorageError(error); }); } @@ -166,13 +170,11 @@ export function useIndexedToolboxes(onError) { function removeToolboxModuleData(toolbox) { const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id)); if (!keys.length) return; - const previousModuleData = moduleDataRef.current; - const version = ++writeVersionsRef.current.moduleData; - const nextModuleData = { ...previousModuleData }; - keys.forEach((key) => delete nextModuleData[key]); - commitModuleData(nextModuleData); + 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) => { - if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData); + commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, checkpoints)); reportStorageError(error); }); }