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-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-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-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 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`. - Mettre à jour `docs/STORAGE_SCHEMA.md`.
- Vérifier l'import et l'export si l'outil stocke des données. - 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 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. - 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. - Vérifier l'affichage dans la page toolbox complète.

View file

@ -35,6 +35,23 @@ Entrée type dans `modules` :
## Toolbox ## 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`. Les toolboxes sont stockées dans `kv/toolboxes`.
```json ```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 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 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");
@ -43,10 +42,6 @@ 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/);

View file

@ -1,34 +1,4 @@
// Rôle : fournit les helpers purs de versioning et rollback optimiste des données modules. // Rôle : applique les changements immuables de données de 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) { export function applyModuleDataValue(moduleData, key, value) {
const nextModuleData = { ...moduleData }; const nextModuleData = { ...moduleData };
if (value == null) delete nextModuleData[key]; if (value == null) delete nextModuleData[key];
@ -38,19 +8,6 @@ export function applyModuleDataValue(moduleData, key, value) {
export function removeModuleDataKeys(moduleData, keys) { export function removeModuleDataKeys(moduleData, keys) {
const nextModuleData = { ...moduleData }; const nextModuleData = { ...moduleData };
uniqueKeys(keys).forEach((key) => { for (const key of keys) delete nextModuleData[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; 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. // Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB.
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
getAllModuleData as dbGetAllModuleData, getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks, getLinks as dbGetLinks,
@ -20,59 +20,22 @@ import {
normalizeToolbox, normalizeToolbox,
prepareModuleDataForStorage prepareModuleDataForStorage
} from "./toolboxStorage.js"; } from "./toolboxStorage.js";
import { import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
applyModuleDataValue, import { createPersistenceQueue } from "./persistenceQueue.js";
commitModuleDataCheckpoints,
createModuleDataCheckpoint, function normalizeToolboxes(toolboxes) {
createModuleDataCheckpoints, return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
removeModuleDataKeys, }
rollbackModuleDataCheckpoints
} from "./optimisticModuleData.js";
export function useIndexedToolboxes(onError) { export function useIndexedToolboxes(onError) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [toolboxes, setToolboxesState] = useState([]); const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
const [links, setLinksState] = useState({}); const [queue] = useState(() => createPersistenceQueue(state, setState));
const [moduleData, setModuleDataState] = useState({});
const [toastPosition, setToastPositionState] = useState("right");
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); 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) => { const reportStorageError = useCallback((error) => {
onError(error?.message || "Erreur de stockage IndexedDB."); onError(error?.message || "Erreur de stockage IndexedDB.");
}, [onError]); }, [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 () => { const refreshQuota = useCallback(async () => {
try { try {
const estimate = await getStorageEstimate(); const estimate = await getStorageEstimate();
@ -88,19 +51,16 @@ export function useIndexedToolboxes(onError) {
let cancelled = false; let cancelled = false;
async function loadStore() { async function loadStore() {
try { try {
const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([ const [toolboxes, links, modules, toastPosition] = await Promise.all([
dbGetToolboxes(), dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right")
dbGetLinks(),
dbGetAllModuleData(),
dbGetSetting("toastPosition", "right")
]); ]);
if (cancelled) return; if (cancelled) return;
commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean)); queue.initialize({
commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {}); toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean),
const loadedModuleData = Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])); links: links && typeof links === "object" ? links : {},
confirmedModuleDataRef.current = loadedModuleData; moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])),
commitModuleData(loadedModuleData); toastPosition: toastPosition === "left" ? "left" : "right"
commitToastPosition(storedToastPosition === "left" ? "left" : "right"); });
setReady(true); setReady(true);
refreshQuota(); refreshQuota();
} catch (error) { } catch (error) {
@ -112,132 +72,84 @@ export function useIndexedToolboxes(onError) {
} }
loadStore(); loadStore();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [refreshQuota, reportStorageError]); }, [queue, refreshQuota, reportStorageError]);
function persistToolboxes(nextToolboxes) { function enqueue(apply, persist) {
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); const result = queue.enqueue(apply, persist, reportStorageError);
const previousToolboxes = toolboxesRef.current; void result.then((success) => { if (success) refreshQuota(); });
const version = ++writeVersionsRef.current.toolboxes; return result;
commitToolboxes(normalized); }
dbSetToolboxes(compactToolboxesForStorage(normalized))
.then(refreshQuota) function persistToolboxes(value) {
.catch((error) => { const apply = typeof value === "function" ? value : () => value;
if (writeVersionsRef.current.toolboxes === version) commitToolboxes(previousToolboxes); enqueue(
reportStorageError(error); (current) => ({ ...current, toolboxes: normalizeToolboxes(apply(current.toolboxes)) }),
}); (next) => dbSetToolboxes(compactToolboxesForStorage(next.toolboxes))
);
return true; return true;
} }
function persistLinks(nextLinks) { function persistLinks(value) {
const previousLinks = linksRef.current; enqueue(
const version = ++writeVersionsRef.current.links; (current) => ({ ...current, links: typeof value === "function" ? value(current.links) : value }),
commitLinks(nextLinks); (next) => dbSetLinks(next.links)
dbSetLinks(nextLinks) );
.then(refreshQuota)
.catch((error) => {
if (writeVersionsRef.current.links === version) commitLinks(previousLinks);
reportStorageError(error);
});
return true; return true;
} }
function getModuleData(toolboxId, moduleId, fallback) { function getModuleData(toolboxId, moduleId, fallback) {
const value = moduleDataRef.current[moduleStorageKey(toolboxId, moduleId)]; return queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] ?? fallback;
return value == null ? fallback : value;
} }
function updateModuleData(toolboxId, moduleId, value, moduleType = "") { 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 key = moduleStorageKey(toolboxId, moduleId);
const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key); const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); enqueue(
const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact); (current) => hasModule(current) ? { ...current, moduleData: applyModuleDataValue(current.moduleData, key, compact) } : current,
commitModuleData(nextModuleData); (next) => {
const previousQueue = moduleWriteQueuesRef.current[key] || Promise.resolve(); if (!hasModule(next)) return;
const operation = previousQueue.catch(() => {}).then(async () => { return compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
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]));
} }
reportStorageError(error); );
});
const queuedOperation = operation.finally(() => {
if (moduleWriteQueuesRef.current[key] === queuedOperation) delete moduleWriteQueuesRef.current[key];
});
moduleWriteQueuesRef.current[key] = queuedOperation;
return true; return true;
} }
function removeModuleData(toolboxId, moduleId) { function removeModuleData(toolboxId, moduleId) {
const key = moduleStorageKey(toolboxId, moduleId); const key = moduleStorageKey(toolboxId, moduleId);
const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key); return enqueue(
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]); (current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, [key]) }),
commitModuleData(removeModuleDataKeys(moduleDataRef.current, [key])); () => dbRemoveModuleData(key)
dbRemoveModuleData(key).then(refreshQuota).catch((error) => { );
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint]));
reportStorageError(error);
});
} }
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 Promise.resolve(true);
const checkpoints = createModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, keys); return enqueue(
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, checkpoints); (current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, keys) }),
commitModuleData(removeModuleDataKeys(moduleDataRef.current, keys)); () => dbRemoveModuleDataKeys(keys)
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => { );
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, checkpoints));
reportStorageError(error);
});
} }
async function persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData) { function persistToolboxSnapshot(updater) {
const normalizedToolboxes = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean); return enqueue((current) => {
const previous = { const next = updater(current);
toolboxes: toolboxesRef.current, return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) };
links: linksRef.current, }, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData));
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 setToastPosition(position) { function setToastPosition(position) {
const normalized = position === "left" ? "left" : "right"; const toastPosition = position === "left" ? "left" : "right";
const previousToastPosition = toastPositionRef.current; enqueue(
const version = ++writeVersionsRef.current.toastPosition; (current) => ({ ...current, toastPosition }),
commitToastPosition(normalized); () => dbSetSetting("toastPosition", toastPosition)
dbSetSetting("toastPosition", normalized) );
.then(refreshQuota)
.catch((error) => {
if (writeVersionsRef.current.toastPosition === version) commitToastPosition(previousToastPosition);
reportStorageError(error);
});
} }
return { return {
...state,
ready, ready,
toolboxes,
links,
moduleData,
toastPosition,
storageUsage, storageUsage,
setToolboxes: persistToolboxes, setToolboxes: persistToolboxes,
setLinks: persistLinks, setLinks: persistLinks,

View file

@ -46,41 +46,53 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
] ]
}; };
if (options.tutorial === true) toolbox.tutorial = true; if (options.tutorial === true) toolbox.tutorial = true;
if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null; const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] });
store.updateModuleData(toolbox.id, memosModuleId, { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] }, "memos"); const links = compactModuleDataForStorage("links", { links: [libraryLink] });
store.updateModuleData(toolbox.id, linksModuleId, { links: [libraryLink] }, "links"); void store.persistToolboxSnapshot((current) => ({
if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id }); 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; return toolbox;
} }
function updateToolbox(nextToolbox) { function updateToolbox(nextToolbox) {
store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id const updatedAt = new Date().toISOString();
? { ...nextToolbox, updatedAt: new Date().toISOString() } store.setToolboxes((toolboxes) => toolboxes.map((toolbox) => toolbox.id === nextToolbox.id
? { ...nextToolbox, updatedAt }
: toolbox)); : toolbox));
} }
function deleteToolbox(id) { function deleteToolbox(id) {
const toolbox = store.toolboxes.find((item) => item.id === id); return store.persistToolboxSnapshot((current) => {
const nextToolboxes = store.toolboxes.filter((item) => item.id !== id); const toolbox = current.toolboxes.find((item) => item.id === id);
const nextModuleData = { ...store.moduleData }; const moduleData = { ...current.moduleData };
(toolbox?.modules || []).forEach((module) => delete nextModuleData[`${id}:${module.id}`]); (toolbox?.modules || []).forEach((module) => delete moduleData[`${id}:${module.id}`]);
const nextLinks = { ...store.links }; return {
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => { toolboxes: current.toolboxes.filter((item) => item.id !== id),
if (toolboxId === id) delete nextLinks[gameId]; moduleData,
links: Object.fromEntries(Object.entries(current.links).filter(([, toolboxId]) => toolboxId !== id))
};
}); });
return store.persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData);
} }
function linkToolboxToGame(gameId, toolboxId) { function linkToolboxToGame(gameId, toolboxId) {
const previousToolboxId = store.links[gameId] || ""; const updatedAt = new Date().toISOString();
const nextLinks = { ...store.links }; return store.persistToolboxSnapshot((current) => {
if (toolboxId) nextLinks[gameId] = toolboxId; const links = { ...current.links };
else delete nextLinks[gameId]; const touched = new Set([links[gameId], toolboxId].filter(Boolean));
store.setLinks(nextLinks); if (toolboxId) links[gameId] = toolboxId;
const touched = new Set([previousToolboxId, toolboxId].filter(Boolean)); else delete links[gameId];
store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id) return {
? { ...toolbox, updatedAt: new Date().toISOString() } ...current,
: toolbox)); links,
toolboxes: current.toolboxes.map((toolbox) => touched.has(toolbox.id) ? { ...toolbox, updatedAt } : toolbox)
};
});
} }
async function addImageFiles(toolboxId, moduleId, files) { async function addImageFiles(toolboxId, moduleId, files) {
@ -125,8 +137,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
confirmLabel: "Créer", confirmLabel: "Créer",
onResolve: (confirmed) => { onResolve: (confirmed) => {
if (!confirmed) return; if (!confirmed) return;
store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId const updatedAt = new Date().toISOString();
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], 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)); : item));
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
notify("Checklist créée dans la toolbox associée."); 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); imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
const normalizedImport = normalizeToolbox(imported); const normalizedImport = normalizeToolbox(imported);
const nextModuleData = { ...store.moduleData }; const nextModuleData = {};
Object.entries(payload.modules || {}).forEach(([oldId, data]) => { Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
const nextId = moduleIdMap.get(oldId); const nextId = moduleIdMap.get(oldId);
const module = normalizedImport.modules.find((item) => item.id === nextId); 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; if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact;
} }
}); });
const nextLinks = { ...store.links }; const saved = await store.persistToolboxSnapshot((current) => ({
if (gameId) nextLinks[gameId] = normalizedImport.id; toolboxes: [normalizedImport, ...current.toolboxes],
const saved = await store.persistToolboxSnapshot([normalizedImport, ...store.toolboxes], nextLinks, nextModuleData); links: gameId ? { ...current.links, [gameId]: normalizedImport.id } : current.links,
moduleData: { ...current.moduleData, ...nextModuleData }
}));
if (!saved) return null; if (!saved) return null;
return normalizedImport; return normalizedImport;
} }
@ -183,12 +198,12 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap); const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap);
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() }); 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]) => { Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
const nextToolboxId = toolboxIdMap.get(oldToolboxId); const nextToolboxId = toolboxIdMap.get(oldToolboxId);
if (nextToolboxId) nextLinks[gameId] = nextToolboxId; if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
}); });
const nextModuleData = { ...store.moduleData }; const nextModuleData = {};
Object.entries(payload.modules).forEach(([key, data]) => { Object.entries(payload.modules).forEach(([key, data]) => {
const [oldToolboxId] = key.split(":"); const [oldToolboxId] = key.split(":");
const nextToolboxId = toolboxIdMap.get(oldToolboxId); const nextToolboxId = toolboxIdMap.get(oldToolboxId);
@ -198,7 +213,11 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
const compact = compactModuleDataForStorage(module?.type, data); const compact = compactModuleDataForStorage(module?.type, data);
if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact; 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."); 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, updateModuleData: store.updateModuleData,
updateToolboxOrder: (orderedIds) => { updateToolboxOrder: (orderedIds) => {
const order = new Map(orderedIds.map((id, index) => [id, index])); 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 = "") => { importToolbox: async (file, gameId = "") => {
try { try {

View file

@ -108,13 +108,18 @@ export async function setToolboxSnapshot(toolboxes, links, moduleData) {
const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite"); const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite");
const kv = transaction.objectStore(KV_STORE); const kv = transaction.objectStore(KV_STORE);
const modules = transaction.objectStore(MODULE_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.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error); transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => 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);
}
}); });
} }