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

@ -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."}}

View file

@ -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/<outil>.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.

View file

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

29
e2e/persistence.spec.js Normal file
View file

@ -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" } }]
});
});

View file

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

View file

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

View file

@ -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/);

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 {

View file

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