Database lock (the reported 0.10.0 regression): - tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool (Pool::connect => max_connections = 10) and exposes no JS transaction primitive. BEGIN and COMMIT issued as separate db.execute calls could land on different pooled connections, stranding an open write transaction on an idle connection — a permanent write lock until app restart. Triggered when the Bilan page's concurrent reads interleaved with an in-flight snapshot save. - Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction holds it across the whole BEGIN..COMMIT so a transaction's statements never span connections (and the saves become genuinely atomic). A reentrancy guard lets nested getDb() calls run directly instead of deadlocking. Applied to all 5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines, proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via in-place helper extraction. New db.test.ts covers serialization, cross-transaction non-interleaving, reentrancy, and lock release on error. Log console (Settings -> Systems -> Journaux): - getLogs returned the same mutated array reference, so useSyncExternalStore (identity comparison) never re-rendered on a new log -> the console never updated live. getLogs now returns an immutable snapshot rebuilt on each mutation (stable between mutations, new identity after each). - Add logInfo/logWarn/logError app-logging API and instrument the snapshot save (info on success, error on failure) so DB issues surface in the console. tsc + 635 vitest + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
export type LogLevel = "info" | "warn" | "error";
|
|
|
|
export interface LogEntry {
|
|
timestamp: number;
|
|
level: LogLevel;
|
|
message: string;
|
|
}
|
|
|
|
type LogListener = () => void;
|
|
|
|
const MAX_ENTRIES = 500;
|
|
const STORAGE_KEY = "simpl-resultat-logs";
|
|
const logs: LogEntry[] = [];
|
|
const listeners = new Set<LogListener>();
|
|
|
|
// Immutable view of `logs`, rebuilt on every mutation. `useSyncExternalStore`
|
|
// compares snapshots by identity (Object.is), so `getLogs` MUST return a NEW
|
|
// reference whenever the logs change — and a STABLE one between changes (never
|
|
// re-slice per call, which would loop the renderer). The old behaviour mutated
|
|
// `logs` in place and returned that same array, so the log console never
|
|
// updated live: a log produced while the panel was open was never re-rendered.
|
|
let logsSnapshot: readonly LogEntry[] = [];
|
|
|
|
function refreshSnapshot() {
|
|
logsSnapshot = logs.slice();
|
|
}
|
|
|
|
let initialized = false;
|
|
|
|
function loadFromStorage() {
|
|
try {
|
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
const parsed: LogEntry[] = JSON.parse(stored);
|
|
logs.push(...parsed);
|
|
refreshSnapshot();
|
|
}
|
|
} catch {
|
|
// ignore corrupted storage
|
|
}
|
|
}
|
|
|
|
function saveToStorage() {
|
|
try {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
|
|
} catch {
|
|
// ignore quota errors
|
|
}
|
|
}
|
|
|
|
function addEntry(level: LogLevel, args: unknown[]) {
|
|
const message = args
|
|
.map((a) => {
|
|
if (typeof a === "string") return a;
|
|
try {
|
|
return JSON.stringify(a);
|
|
} catch {
|
|
return String(a);
|
|
}
|
|
})
|
|
.join(" ");
|
|
|
|
logs.push({ timestamp: Date.now(), level, message });
|
|
if (logs.length > MAX_ENTRIES) {
|
|
logs.splice(0, logs.length - MAX_ENTRIES);
|
|
}
|
|
refreshSnapshot();
|
|
|
|
saveToStorage();
|
|
listeners.forEach((fn) => fn());
|
|
}
|
|
|
|
export function initLogCapture() {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
loadFromStorage();
|
|
|
|
const origLog = console.log.bind(console);
|
|
const origWarn = console.warn.bind(console);
|
|
const origError = console.error.bind(console);
|
|
|
|
console.log = (...args: unknown[]) => {
|
|
addEntry("info", args);
|
|
origLog(...args);
|
|
};
|
|
|
|
console.warn = (...args: unknown[]) => {
|
|
addEntry("warn", args);
|
|
origWarn(...args);
|
|
};
|
|
|
|
console.error = (...args: unknown[]) => {
|
|
addEntry("error", args);
|
|
origError(...args);
|
|
};
|
|
}
|
|
|
|
export function getLogs(): readonly LogEntry[] {
|
|
return logsSnapshot;
|
|
}
|
|
|
|
/// Extract the N most recent non-info entries formatted as a single string,
|
|
/// suitable for appending to a feedback body. Empty string if no qualifying
|
|
/// entries. Each line: `[ISO timestamp] LEVEL: message`.
|
|
export function getRecentErrorLogs(n: number): string {
|
|
if (n <= 0) return "";
|
|
const errors = logs.filter((l) => l.level !== "info");
|
|
const tail = errors.slice(Math.max(0, errors.length - n));
|
|
return tail
|
|
.map((l) => {
|
|
const iso = new Date(l.timestamp).toISOString();
|
|
return `[${iso}] ${l.level.toUpperCase()}: ${l.message}`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
export function clearLogs() {
|
|
logs.length = 0;
|
|
refreshSnapshot();
|
|
saveToStorage();
|
|
listeners.forEach((fn) => fn());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App logging API. Prefer these over raw `console.*` in application code so
|
|
// meaningful events land in the in-app log console (Settings → Systems →
|
|
// Journaux / Logs). They forward to the captured console methods, so entries
|
|
// also show up in devtools. `console.log` is captured as the `info` level.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function logInfo(...args: unknown[]): void {
|
|
console.log(...args);
|
|
}
|
|
|
|
export function logWarn(...args: unknown[]): void {
|
|
console.warn(...args);
|
|
}
|
|
|
|
export function logError(...args: unknown[]): void {
|
|
console.error(...args);
|
|
}
|
|
|
|
export function subscribe(listener: LogListener): () => void {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|