Improve IndexedDB module rollback handling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
fe91d18624
commit
43a9e791c5
4 changed files with 162 additions and 19 deletions
80
tests/optimistic-module-data.test.mjs
Normal file
80
tests/optimistic-module-data.test.mjs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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 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 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 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 storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
|
||||||
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
|
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
|
||||||
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.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(source, /features\/toolboxes\/useToolboxActions\.js/);
|
||||||
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
|
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
|
||||||
assert.match(indexedToolboxesHook, /getStorageEstimate/);
|
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, /export function useToolboxActions/);
|
||||||
assert.match(toolboxActions, /importAllToolboxesPayload/);
|
assert.match(toolboxActions, /importAllToolboxesPayload/);
|
||||||
assert.match(toolboxActions, /async function addImageFiles/);
|
assert.match(toolboxActions, /async function addImageFiles/);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,14 @@ import {
|
||||||
normalizeToolbox,
|
normalizeToolbox,
|
||||||
prepareModuleDataForStorage
|
prepareModuleDataForStorage
|
||||||
} from "./toolboxStorage.js";
|
} from "./toolboxStorage.js";
|
||||||
|
import {
|
||||||
|
applyModuleDataValue,
|
||||||
|
commitModuleDataCheckpoints,
|
||||||
|
createModuleDataCheckpoint,
|
||||||
|
createModuleDataCheckpoints,
|
||||||
|
removeModuleDataKeys,
|
||||||
|
rollbackModuleDataCheckpoints
|
||||||
|
} from "./optimisticModuleData.js";
|
||||||
|
|
||||||
export function useIndexedToolboxes(onError) {
|
export function useIndexedToolboxes(onError) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
@ -34,7 +42,7 @@ export function useIndexedToolboxes(onError) {
|
||||||
const writeVersionsRef = useRef({
|
const writeVersionsRef = useRef({
|
||||||
toolboxes: 0,
|
toolboxes: 0,
|
||||||
links: 0,
|
links: 0,
|
||||||
moduleData: 0,
|
moduleDataByKey: {},
|
||||||
toastPosition: 0
|
toastPosition: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -136,15 +144,13 @@ export function useIndexedToolboxes(onError) {
|
||||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||||
const compact = prepareModuleDataForStorage(toolboxesRef.current, toolboxId, moduleId, value, moduleType);
|
const compact = prepareModuleDataForStorage(toolboxesRef.current, toolboxId, moduleId, value, moduleType);
|
||||||
const key = moduleStorageKey(toolboxId, moduleId);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
const previousModuleData = moduleDataRef.current;
|
const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key);
|
||||||
const version = ++writeVersionsRef.current.moduleData;
|
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]);
|
||||||
const nextModuleData = { ...previousModuleData };
|
const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact);
|
||||||
if (compact == null) delete nextModuleData[key];
|
|
||||||
else nextModuleData[key] = compact;
|
|
||||||
commitModuleData(nextModuleData);
|
commitModuleData(nextModuleData);
|
||||||
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
|
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
|
||||||
operation.then(refreshQuota).catch((error) => {
|
operation.then(refreshQuota).catch((error) => {
|
||||||
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
|
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint]));
|
||||||
reportStorageError(error);
|
reportStorageError(error);
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -152,13 +158,11 @@ export function useIndexedToolboxes(onError) {
|
||||||
|
|
||||||
function removeModuleData(toolboxId, moduleId) {
|
function removeModuleData(toolboxId, moduleId) {
|
||||||
const key = moduleStorageKey(toolboxId, moduleId);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
const previousModuleData = moduleDataRef.current;
|
const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key);
|
||||||
const version = ++writeVersionsRef.current.moduleData;
|
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]);
|
||||||
const nextModuleData = { ...previousModuleData };
|
commitModuleData(removeModuleDataKeys(moduleDataRef.current, [key]));
|
||||||
delete nextModuleData[key];
|
|
||||||
commitModuleData(nextModuleData);
|
|
||||||
dbRemoveModuleData(key).then(refreshQuota).catch((error) => {
|
dbRemoveModuleData(key).then(refreshQuota).catch((error) => {
|
||||||
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
|
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint]));
|
||||||
reportStorageError(error);
|
reportStorageError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +170,11 @@ export function useIndexedToolboxes(onError) {
|
||||||
function removeToolboxModuleData(toolbox) {
|
function removeToolboxModuleData(toolbox) {
|
||||||
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
||||||
if (!keys.length) return;
|
if (!keys.length) return;
|
||||||
const previousModuleData = moduleDataRef.current;
|
const checkpoints = createModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, keys);
|
||||||
const version = ++writeVersionsRef.current.moduleData;
|
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, checkpoints);
|
||||||
const nextModuleData = { ...previousModuleData };
|
commitModuleData(removeModuleDataKeys(moduleDataRef.current, keys));
|
||||||
keys.forEach((key) => delete nextModuleData[key]);
|
|
||||||
commitModuleData(nextModuleData);
|
|
||||||
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => {
|
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => {
|
||||||
if (writeVersionsRef.current.moduleData === version) commitModuleData(previousModuleData);
|
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, checkpoints));
|
||||||
reportStorageError(error);
|
reportStorageError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue