Serialize toolbox writes and preserve concurrent edits
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-10 19:09:48 +02:00
parent 7521d50626
commit a4cf633ab4
12 changed files with 381 additions and 320 deletions

View file

@ -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;
}

View file

@ -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;
}
};
}

View file

@ -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,

View file

@ -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 {