Coalesce quota refreshes and consolidate shared helpers
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-10 20:03:57 +02:00
parent a4cf633ab4
commit 748415082b
19 changed files with 216 additions and 64 deletions

View file

@ -24,3 +24,5 @@
{"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."}} {"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."}}
{"id":"int-16ba759581961e395e133df1c6019895","kind":"field_change","created_at":"2026-09-10T17:21:13.173981957Z","actor":"Shinuwa","issue_id":"sokko-g-ra9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
{"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}

View file

@ -35,7 +35,9 @@ Le build est généré dans `website/dist`.
## Configuration ## Configuration
Le serveur lit automatiquement un fichier `.env` à la racine du projet. Le serveur et Vite lisent automatiquement un fichier `.env` facultatif à la racine
du projet, avec le parseur natif de Node. Les variables déjà définies dans le
processus sont prioritaires, y compris lorsqu'elles sont vides.
```bash ```bash
cp .env.example .env cp .env.example .env

View file

@ -52,6 +52,12 @@ sur sa clé et ne réécrivent pas les images des autres outils.
Cette coordination est locale à l'instance React ; elle ne synchronise pas Cette coordination est locale à l'instance React ; elle ne synchronise pas
l'édition entre plusieurs onglets. l'édition entre plusieurs onglets.
Les estimations de quota demandées après sauvegarde sont regroupées sur une
fenêtre de 250 ms, avec une seule estimation active. Une écriture intervenant
pendant le calcul déclenche une nouvelle estimation. Les sauvegardes ne sont
pas retardées ; en cas d'estimation indisponible, le dernier quota connu est
conservé.
Les toolboxes sont stockées dans `kv/toolboxes`. Les toolboxes sont stockées dans `kv/toolboxes`.
```json ```json

View file

@ -0,0 +1,10 @@
// Rôle : charge le fichier .env facultatif avec le parseur natif de Node.
import { loadEnvFile } from "node:process";
export function loadProjectEnv(file = ".env") {
try {
loadEnvFile(file);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}

View file

@ -1,5 +1,6 @@
// Rôle : sert l'application Vite construite ou les sources publiques en local. // Rôle : sert l'application Vite construite ou les sources publiques en local.
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; import { createReadStream, existsSync, statSync } from "node:fs";
import { loadProjectEnv } from "./scripts/load-project-env.mjs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { extname, join, relative, resolve } from "node:path"; import { extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -10,24 +11,7 @@ const distRoot = join(websiteRoot, "dist");
const root = existsSync(distRoot) ? distRoot : websiteRoot; const root = existsSync(distRoot) ? distRoot : websiteRoot;
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public"); const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
function loadEnvFile(file = ".env") { loadProjectEnv();
if (!existsSync(file)) return;
const lines = readFileSync(file, "utf8").split(/\r?\n/);
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 1) return;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = value;
});
}
loadEnvFile();
const port = Number(process.env.PORT || 5173); const port = Number(process.env.PORT || 5173);

4
tests/fixtures/project-env.txt vendored Normal file
View file

@ -0,0 +1,4 @@
# Rôle : fournit des variables non sensibles pour tester le chargement .env.
SOKKOG_ENV_FIXTURE_PORT=4321
SOKKOG_ENV_FIXTURE_QUOTED="value # inside"
SOKKOG_ENV_FIXTURE_COMMENT=value # outside

View file

@ -0,0 +1,20 @@
// Rôle : vérifie le parsing .env, la priorité du processus et les fichiers facultatifs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { loadProjectEnv } from "../scripts/load-project-env.mjs";
for (const existing of ["9999", ""]) {
test(`project env preserves an existing ${existing ? "nonempty" : "empty"} value`, (t) => {
const keys = ["SOKKOG_ENV_FIXTURE_PORT", "SOKKOG_ENV_FIXTURE_QUOTED", "SOKKOG_ENV_FIXTURE_COMMENT"];
const previous = keys.map((key) => process.env[key]);
t.after(() => keys.forEach((key, index) => {
if (previous[index] === undefined) delete process.env[key];
else process.env[key] = previous[index];
}));
keys.forEach((key) => { delete process.env[key]; });
process.env.SOKKOG_ENV_FIXTURE_PORT = existing;
loadProjectEnv("tests/fixtures/project-env.txt");
loadProjectEnv("tests/fixtures/missing-env-file");
assert.deepEqual(keys.map((key) => process.env[key]), [existing, "value # inside", "value"]);
});
}

View file

@ -0,0 +1,90 @@
// Rôle : teste les rafales d'estimations, la concurrence et le nettoyage du quota.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createQuotaRefresh } from "../website/src/features/toolboxes/storage/quotaRefresh.js";
function setup(estimate) {
const timers = new Map();
const results = [];
let id = 0;
const refresh = createQuotaRefresh({
estimate,
onResult: (result) => results.push(result),
schedule: (callback) => { timers.set(++id, callback); return id; },
cancel: (key) => timers.delete(key)
});
function tick() {
assert.equal(timers.size, 1);
const [key, callback] = timers.entries().next().value;
timers.delete(key);
return callback();
}
return { ...refresh, timers, results, tick };
}
test("one hundred refresh requests trigger one estimate", async () => {
let calls = 0;
const refresh = setup(async () => { calls++; return { usage: 12 }; });
for (let i = 0; i < 100; i++) refresh.request();
await refresh.tick();
assert.equal(calls, 1);
assert.deepEqual(refresh.results, [{ usage: 12 }]);
assert.equal(refresh.timers.size, 0);
});
test("writes during estimation schedule one follow-up and discard the stale result", async () => {
let finish;
let calls = 0;
const refresh = setup(() => {
calls++;
return new Promise((resolve) => { finish = resolve; });
});
refresh.request();
const first = refresh.tick();
for (let i = 0; i < 100; i++) refresh.request();
assert.equal(calls, 1);
assert.equal(refresh.timers.size, 0);
finish({ usage: 1 });
await first;
assert.deepEqual(refresh.results, []);
const second = refresh.tick();
finish({ usage: 2 });
await second;
assert.equal(calls, 2);
assert.deepEqual(refresh.results, [{ usage: 2 }]);
});
test("dispose cancels scheduled work and ignores in-flight results", async () => {
let finish;
const refresh = setup(() => new Promise((resolve) => { finish = resolve; }));
refresh.request();
refresh.dispose();
refresh.request();
assert.equal(refresh.timers.size, 0);
const active = setup(() => new Promise((resolve) => { finish = resolve; }));
active.request();
const task = active.tick();
active.dispose();
finish({ usage: 5 });
await task;
assert.deepEqual(active.results, []);
assert.equal(active.timers.size, 0);
});
test("a failed estimate preserves the last result and allows recovery", async () => {
let fail = false;
const refresh = setup(async () => {
if (fail) throw new Error("unavailable");
return { usage: 3 };
});
refresh.request();
await refresh.tick();
fail = true;
refresh.request();
await refresh.tick();
assert.deepEqual(refresh.results, [{ usage: 3 }]);
fail = false;
refresh.request();
await refresh.tick();
assert.equal(refresh.results.length, 2);
});

View file

@ -113,9 +113,9 @@ test("server and vite support local env configuration", async () => {
const viteConfig = await readFile("vite.config.js", "utf8"); const viteConfig = await readFile("vite.config.js", "utf8");
const envExample = await readFile(".env.example", "utf8"); const envExample = await readFile(".env.example", "utf8");
assert.match(server, /function loadEnvFile/); assert.match(server, /load-project-env\.mjs/);
assert.match(server, /process\.env\.PORT/); assert.match(server, /process\.env\.PORT/);
assert.match(viteConfig, /function loadEnvFile/); assert.match(viteConfig, /load-project-env\.mjs/);
assert.match(viteConfig, /process\.env\.PORT/); assert.match(viteConfig, /process\.env\.PORT/);
assert.match(envExample, /PORT=5173/); assert.match(envExample, /PORT=5173/);
}); });

View file

@ -1,24 +1,9 @@
// Rôle : configure Vite et charge les variables locales communes au serveur.
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { existsSync, readFileSync } from "node:fs"; import { loadProjectEnv } from "./scripts/load-project-env.mjs";
function loadEnvFile(file = ".env") { loadProjectEnv();
if (!existsSync(file)) return;
readFileSync(file, "utf8").split(/\r?\n/).forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 1) return;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, "");
if (!process.env[key]) process.env[key] = value;
});
}
loadEnvFile();
export default defineConfig({ export default defineConfig({
root: "website", root: "website",

View file

@ -3,12 +3,10 @@ import { useEffect, useRef, useState } from "react";
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx"; import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js"; import { lockBodyScroll } from "../utils/bodyScrollLock.js";
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
import { Icon } from "./Icon.jsx"; import { Icon } from "./Icon.jsx";
import { Tooltip } from "./Tooltip.jsx"; import { Tooltip } from "./Tooltip.jsx";
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
const DRAWING_WIDTHS = [2, 4, 8, 12];
function clampPercent(value) { function clampPercent(value) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0; if (!Number.isFinite(parsed)) return 0;

View file

@ -1,4 +1,5 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. // Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
import { toggleListValue } from "../../../utils/collectionUtils.js";
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js"; import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { CombosEditor } from "./CombosEditor.jsx"; import { CombosEditor } from "./CombosEditor.jsx";
@ -40,9 +41,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
} }
function toggleCategoryCollapsed(category) { function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category) const collapsedCategories = toggleListValue(data.collapsedCategories, category);
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories }); save({ ...data, collapsedCategories });
} }

View file

@ -1,4 +1,5 @@
// Rôle : fournit l'outil liens avec ajout manuel et échange texte. // Rôle : fournit l'outil liens avec ajout manuel et échange texte.
import { toggleListValue } from "../../../utils/collectionUtils.js";
import { useState } from "react"; import { useState } from "react";
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx"; import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
@ -143,9 +144,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
} }
function toggleCategoryCollapsed(category) { function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category) const collapsedCategories = toggleListValue(data.collapsedCategories, category);
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories }); save({ ...data, collapsedCategories });
} }

View file

@ -1,4 +1,5 @@
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte. // Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
import { toggleListValue } from "../../../utils/collectionUtils.js";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx"; import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
@ -131,9 +132,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
} }
function toggleCategoryCollapsed(category) { function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category) const collapsedCategories = toggleListValue(data.collapsedCategories, category);
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories }); save({ ...data, collapsedCategories });
} }

View file

@ -1,7 +1,6 @@
// Rôle : regroupe les constantes et helpers DOM du Bloc Note riche. // Rôle : regroupe les constantes et helpers DOM du Bloc Note riche.
export const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; export { DRAWING_COLORS as TEXT_COLORS, DRAWING_WIDTHS } from "../../../utils/drawingOptions.js";
export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"]; export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
export const DRAWING_WIDTHS = [2, 4, 8, 12];
export function getPlainText(element) { export function getPlainText(element) {
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim(); return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();

View file

@ -0,0 +1,38 @@
// Rôle : regroupe les estimations de quota et ignore les résultats devenus obsolètes.
export function createQuotaRefresh({ estimate, onResult, delay = 250, schedule = setTimeout, cancel = clearTimeout }) {
let timer = null;
let running = false;
let dirty = false;
let disposed = false;
function request() {
if (disposed) return;
dirty = true;
if (!running && timer === null) timer = schedule(run, delay);
}
async function run() {
timer = null;
if (disposed) return;
dirty = false;
running = true;
try {
const result = await estimate();
if (!disposed && !dirty) onResult(result);
} catch {
// Une estimation indisponible ne remet pas à zéro le dernier quota connu.
} finally {
running = false;
if (!disposed && dirty) request();
}
}
return {
request,
dispose() {
disposed = true;
if (timer !== null) cancel(timer);
timer = null;
}
};
}

View file

@ -1,5 +1,5 @@
// Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB. // Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
getAllModuleData as dbGetAllModuleData, getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks, getLinks as dbGetLinks,
@ -22,6 +22,7 @@ import {
} from "./toolboxStorage.js"; } from "./toolboxStorage.js";
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js"; import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
import { createPersistenceQueue } from "./persistenceQueue.js"; import { createPersistenceQueue } from "./persistenceQueue.js";
import { createQuotaRefresh } from "./quotaRefresh.js";
function normalizeToolboxes(toolboxes) { function normalizeToolboxes(toolboxes) {
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
@ -32,20 +33,29 @@ export function useIndexedToolboxes(onError) {
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" }); const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
const [queue] = useState(() => createPersistenceQueue(state, setState)); const [queue] = useState(() => createPersistenceQueue(state, setState));
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
const quotaRefreshRef = useRef(null);
const reportStorageError = useCallback((error) => { const reportStorageError = useCallback((error) => {
onError(error?.message || "Erreur de stockage IndexedDB."); onError(error?.message || "Erreur de stockage IndexedDB.");
}, [onError]); }, [onError]);
const refreshQuota = useCallback(async () => { useEffect(() => {
try { const refresh = createQuotaRefresh({
const estimate = await getStorageEstimate(); estimate: getStorageEstimate,
onResult: (estimate) => {
const used = estimate.usage || 0; const used = estimate.usage || 0;
const limit = estimate.quota || 0; const limit = estimate.quota || 0;
setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 }); setStorageUsage((current) => current.used === used && current.limit === limit
} catch { ? current
setStorageUsage({ used: 0, limit: 0, ratio: 0 }); : { used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
} }
});
quotaRefreshRef.current = refresh;
return () => {
refresh.dispose();
quotaRefreshRef.current = null;
};
}, []); }, []);
const refreshQuota = useCallback(() => quotaRefreshRef.current?.request(), []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;

View file

@ -0,0 +1,4 @@
// Rôle : fournit les modifications immuables communes aux listes de valeurs.
export function toggleListValue(values, value) {
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
}

View file

@ -0,0 +1,3 @@
// Rôle : partage les couleurs et épaisseurs des outils de dessin.
export const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
export const DRAWING_WIDTHS = [2, 4, 8, 12];