fix(db): serialize DB access to fix "database is locked"; live-update log console
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s

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>
This commit is contained in:
le king fu 2026-06-30 16:50:39 -04:00
parent 36291585b2
commit 8cb7e53698
20 changed files with 472 additions and 57 deletions

View file

@ -2,6 +2,11 @@
## [Non publié] ## [Non publié]
### Corrigé
- Bilan : correction d'une erreur **« database is locked »** qui pouvait apparaître après avoir commencé un snapshot puis quitté la page avant d'enregistrer, bloquant ensuite toute écriture jusqu'au redémarrage de l'app. Tout l'accès à la base est désormais sérialisé, et une transaction tient une seule connexion sur toute sa durée — une transaction d'écriture ne peut donc plus rester bloquée entre deux connexions SQLite (la cause racine). Aucune donnée enregistrée n'a été menacée ; seul le snapshot en cours était perdu (#230).
- La console de journaux intégrée (Paramètres → Systèmes → Journaux) se met maintenant à jour **en direct** à mesure que les événements surviennent, au lieu de n'afficher que les entrées présentes avant l'ouverture de la page. Les enregistrements de snapshot et leurs échecs sont désormais journalisés, donc les problèmes de base de données y apparaissent (#230).
## [0.10.0] - 2026-06-29 ## [0.10.0] - 2026-06-29
### Ajouté ### Ajouté

View file

@ -2,6 +2,11 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Balance: fixed a **"database is locked"** error that could appear after starting a snapshot and leaving the page before saving, then blocked every later write until the app was restarted. All database access is now serialized, and a transaction holds a single connection for its whole duration — so a write transaction can no longer be stranded across SQLite connections (the root cause). No stored data was ever at risk; only the in-progress snapshot was lost (#230).
- The in-app log console (Settings → Systems → Logs) now updates **live** as events happen, instead of only showing entries that existed before the page was opened. Snapshot saves and their failures are now logged, so database issues surface there (#230).
## [0.10.0] - 2026-06-29 ## [0.10.0] - 2026-06-29
### Added ### Added

View file

@ -27,9 +27,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../services/db"; import { getDb } from "../services/db";
import { import {

View file

@ -8,9 +8,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../services/db", () => ({ vi.mock("../../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../../services/db"; import { getDb } from "../../services/db";
import { import {

View file

@ -13,11 +13,15 @@ import { describe, it, expect, vi } from "vitest";
// db is imported transitively (hook → balance.service → db → tauri plugins). // db is imported transitively (hook → balance.service → db → tauri plugins).
// Stub it so no module-load path tries a real connection. // Stub it so no module-load path tries a real connection.
vi.mock("../services/db", () => ({ vi.mock("../services/db", () => {
getDb: vi.fn(), const getDb = vi.fn();
return {
getDb,
connectToProfile: vi.fn(), connectToProfile: vi.fn(),
initializeNewProfileDb: vi.fn(), initializeNewProfileDb: vi.fn(),
})); withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { import {
reducer, reducer,

View file

@ -31,6 +31,7 @@ import {
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
import { logInfo, logError } from "../services/logService";
import type { import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceAssetType, BalanceAssetType,
@ -798,10 +799,16 @@ export function useSnapshotEditor(options: Options = {}) {
moveToDate, moveToDate,
}); });
dispatch({ type: "CLEAR_DIRTY" }); dispatch({ type: "CLEAR_DIRTY" });
logInfo(
`Balance: snapshot saved (${state.snapshotDate}, ${
simpleLines.length + detailedLines.length
} lines)`
);
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state. // Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
await loadForDate(state.snapshotDate); await loadForDate(state.snapshotDate);
return { snapshotId }; return { snapshotId };
} catch (e) { } catch (e) {
logError(`Balance: snapshot save failed (${state.snapshotDate})`, e);
dispatch({ type: "SET_ERROR", payload: describeError(e) }); dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e; throw e;
} finally { } finally {

View file

@ -1,8 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),

View file

@ -10,7 +10,7 @@
// + price-fetch work in Issue #142. // + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import { loadProfiles } from "./profileService"; import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn, AccountReturn,
@ -705,7 +705,15 @@ export async function proposeStarterAccounts(
): Promise<number[]> { ): Promise<number[]> {
const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key)); const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key));
if (wanted.length === 0) return []; if (wanted.length === 0) return [];
const db = await getDb(); return withTransaction((db) =>
proposeStarterAccountsInTransaction(db, wanted)
);
}
async function proposeStarterAccountsInTransaction(
db: SqlExecutor,
wanted: StarterDef[]
): Promise<number[]> {
let inTxn = false; let inTxn = false;
const inserted: number[] = []; const inserted: number[] = [];
try { try {
@ -1496,7 +1504,16 @@ export async function upsertSnapshotLines(
// through validateDetailedSnapshot, scalar lines through the unchanged pass). // through validateDetailedSnapshot, scalar lines through the unchanged pass).
validateAllLines(lines); validateAllLines(lines);
const db = await getDb(); return withTransaction((db) =>
upsertSnapshotLinesInTransaction(db, snapshotId, lines)
);
}
async function upsertSnapshotLinesInTransaction(
db: SqlExecutor,
snapshotId: number,
lines: SnapshotLineInput[]
): Promise<void> {
// Strategy: clear and rewrite, wrapped in an explicit transaction so the // Strategy: clear and rewrite, wrapped in an explicit transaction so the
// line + holdings writes commit together. Snapshot lines are small (one per // line + holdings writes commit together. Snapshot lines are small (one per
// active account, typically < 20). CASCADE on snapshot_line_id wipes the old // active account, typically < 20). CASCADE on snapshot_line_id wipes the old
@ -1575,7 +1592,19 @@ export async function saveSnapshotAtomic(input: {
// lines keep the unchanged validateLineKindInvariants pass. // lines keep the unchanged validateLineKindInvariants pass.
validateAllLines(input.lines); validateAllLines(input.lines);
const db = await getDb(); return withTransaction((db) => saveSnapshotAtomicInTransaction(db, input));
}
async function saveSnapshotAtomicInTransaction(
db: SqlExecutor,
input: {
existingSnapshotId: number | null;
snapshot_date: string;
notes?: string | null;
lines: SnapshotLineInput[];
moveToDate?: string | null;
}
): Promise<{ snapshotId: number }> {
let inTxn = false; let inTxn = false;
try { try {
await db.execute("BEGIN"); await db.execute("BEGIN");

View file

@ -6,9 +6,17 @@ import {
KEYWORD_MAX_LENGTH, KEYWORD_MAX_LENGTH,
} from "./categorizationService"; } from "./categorizationService";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb, withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import type { Keyword, RecentTransaction } from "../shared/types"; import type { Keyword, RecentTransaction } from "../shared/types";
/** /**
@ -222,7 +223,16 @@ export async function applyKeywordWithReassignment(
} }
const keyword = validation.value; const keyword = validation.value;
const db = await getDb(); return withTransaction((db) =>
applyKeywordInTransaction(db, input, keyword),
);
}
async function applyKeywordInTransaction(
db: Database,
input: ApplyKeywordInput,
keyword: string,
): Promise<ApplyKeywordResult> {
await db.execute("BEGIN"); await db.execute("BEGIN");
try { try {
// Is there already a row for this keyword spelling? // Is there already a row for this keyword spelling?

View file

@ -3,9 +3,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// `getDb` is stubbed with a fake DB that captures every SQL statement issued // `getDb` is stubbed with a fake DB that captures every SQL statement issued
// during applyMigration. This is a unit test — we do NOT run a real SQLite. // during applyMigration. This is a unit test — we do NOT run a real SQLite.
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
// `setPreference` goes through the same fake DB — no extra stubbing needed // `setPreference` goes through the same fake DB — no extra stubbing needed
// because userPreferenceService itself calls getDb(). // because userPreferenceService itself calls getDb().

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import { setPreference } from "./userPreferenceService"; import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService"; import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService"; import type { BackupResult } from "./categoryBackupService";
@ -178,7 +179,17 @@ export async function applyMigration(
return outcome; return outcome;
} }
const db = await getDb(); return withTransaction((db) =>
applyMigrationInTransaction(db, plan, backup, outcome),
);
}
async function applyMigrationInTransaction(
db: Database,
plan: MigrationPlan,
backup: BackupResult,
outcome: MigrationOutcome,
): Promise<MigrationOutcome> {
const mapping = buildMappingFromRows(plan.rows); const mapping = buildMappingFromRows(plan.rows);
await db.execute("BEGIN"); await db.execute("BEGIN");

123
src/services/db.test.ts Normal file
View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Regression coverage for the "database is locked" fix (#228 follow-up).
//
// tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
// and exposes no transaction primitive, so a BEGIN and its COMMIT issued as
// separate db.execute calls could land on different pooled connections and
// strand an open write transaction (= permanent lock). db.ts funnels every
// operation through one FIFO lock, and withTransaction holds it for the whole
// BEGIN..COMMIT. These tests assert that serialization, without a real SQLite.
// Ordered log of every fake-db operation's start/end, shared with the fake db.
const events: string[] = [];
// Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db.
const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb }));
interface FakeDb {
execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>;
select: (q: string, p?: unknown[]) => Promise<unknown[]>;
close: () => Promise<void>;
}
vi.mock("@tauri-apps/plugin-sql", () => ({
default: { load: vi.fn(async () => h.fakeDb) },
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => false) }));
import { connectToProfile, getDb, withTransaction } from "./db";
// Each op records start, yields a real macrotask (a window for interleaving if
// the lock were broken), then records end. The lock must keep them ordered.
function makeFakeDb(): FakeDb {
return {
execute: vi.fn(async (q: string) => {
events.push(`start:${q}`);
await new Promise((r) => setTimeout(r, 2));
events.push(`end:${q}`);
return { rowsAffected: 0 };
}),
select: vi.fn(async (q: string) => {
events.push(`start:${q}`);
await new Promise((r) => setTimeout(r, 2));
events.push(`end:${q}`);
return [];
}),
close: vi.fn(async () => {}),
};
}
beforeEach(async () => {
events.length = 0;
h.fakeDb = makeFakeDb();
await connectToProfile("test.db");
});
describe("db serialization (database-locked fix)", () => {
it("serializes concurrent execute calls — no interleaving", async () => {
const db = await getDb();
// Fire both without awaiting between them: they race for the lock.
await Promise.all([db.execute("A"), db.execute("B")]);
// A must fully complete before B starts (FIFO, no overlap).
expect(events).toEqual(["start:A", "end:A", "start:B", "end:B"]);
});
it("holds the lock across a whole withTransaction — an outside read cannot interleave", async () => {
const db = await getDb();
// Queue the transaction first, then an outside read.
const txn = withTransaction(async (tx) => {
await tx.execute("BEGIN");
await tx.execute("WRITE");
await tx.execute("COMMIT");
});
const outside = db.select("READ");
await Promise.all([txn, outside]);
// The outside read runs entirely AFTER the transaction's COMMIT — the
// pre-fix bug was exactly an outside read interleaving mid-transaction.
expect(events).toEqual([
"start:BEGIN",
"end:BEGIN",
"start:WRITE",
"end:WRITE",
"start:COMMIT",
"end:COMMIT",
"start:READ",
"end:READ",
]);
});
it("lets a nested getDb() call run directly inside a transaction — no deadlock", async () => {
// A service called inside withTransaction might reach for getDb() (the
// serialized proxy) instead of the threaded handle. The reentrancy guard
// must run it directly rather than queue it behind the held lock (deadlock).
const result = await withTransaction(async () => {
const inner = await getDb();
await inner.execute("NESTED");
return "ok";
});
expect(result).toBe("ok");
expect(events).toEqual(["start:NESTED", "end:NESTED"]);
});
it("releases the lock after a failed transaction so later ops still run", async () => {
const db = await getDb();
await expect(
withTransaction(async (tx) => {
await tx.execute("BEGIN");
throw new Error("boom");
})
).rejects.toThrow("boom");
// The lock must be free again: a subsequent op completes (no stuck queue).
await db.execute("AFTER");
expect(events).toContain("start:AFTER");
expect(events).toContain("end:AFTER");
});
it("withTransaction rejects when no profile is connected", async () => {
const { closeDb } = await import("./db");
await closeDb();
await expect(withTransaction(async () => undefined)).rejects.toThrow(
/No database connection/
);
});
});

View file

@ -1,19 +1,113 @@
import Database from "@tauri-apps/plugin-sql"; import Database from "@tauri-apps/plugin-sql";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
let dbInstance: Database | null = null; let rawInstance: Database | null = null;
let serializedInstance: Database | null = null;
// ---------------------------------------------------------------------------
// DB access serialization (#228 follow-up — fix "database is locked").
//
// tauri-plugin-sql exposes NO transaction primitive to the JS side and loads
// SQLite through a default multi-connection sqlx pool (`Pool::connect` =>
// max_connections = 10). Every db.execute / db.select is an independent
// `pool.acquire()`, so a `BEGIN` issued on one pooled connection and its
// matching `COMMIT` issued on another would strand an open write transaction
// on an idle connection — that connection then holds SQLite's write lock until
// the app restarts, and every later write fails with "database is locked".
//
// Fix: funnel EVERY db operation through a single FIFO async lock. Plain
// reads/writes take the lock per-statement; `withTransaction` holds it across
// the whole BEGIN..COMMIT. With no two operations ever overlapping in time,
// the pool keeps handing back its single idle connection, so a transaction's
// statements all run on the same connection and can never strand. Serializing
// all DB access is a non-issue for a local single-user app where queries are
// tiny and infrequent — and it makes the "atomic" saves genuinely atomic.
// ---------------------------------------------------------------------------
let lockTail: Promise<unknown> = Promise.resolve();
// True only while a `withTransaction` callback is executing. The lock is held
// for that whole callback, so anything it triggers (the threaded handle, or an
// accidental `getDb()` call from a nested service) must run DIRECTLY rather
// than re-entering the non-reentrant lock — otherwise it would deadlock behind
// the transaction that is waiting for it.
let inTransaction = false;
/** Run `fn` after every previously-queued DB operation has settled (FIFO). */
function runExclusive<T>(fn: () => Promise<T>): Promise<T> {
const result = lockTail.then(fn, fn);
// Keep the chain alive regardless of fn's outcome; only the tail swallows.
lockTail = result.then(
() => undefined,
() => undefined
);
return result;
}
/**
* Wrap the raw Database so `execute`/`select` are serialized through the lock.
* While a transaction holds the lock (`inTransaction`), calls run directly so a
* nested call never deadlocks. The proxy keeps the `Database` type, so call
* sites (including `db.select<T>(...)`) are unchanged.
*/
function serialize(db: Database): Database {
return new Proxy(db, {
get(target, prop, receiver) {
if (prop === "execute") {
return (query: string, bindValues?: unknown[]) =>
inTransaction
? target.execute(query, bindValues)
: runExclusive(() => target.execute(query, bindValues));
}
if (prop === "select") {
return (query: string, bindValues?: unknown[]) =>
inTransaction
? target.select(query, bindValues)
: runExclusive(() => target.select(query, bindValues));
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
export async function getDb(): Promise<Database> { export async function getDb(): Promise<Database> {
if (!dbInstance) { if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first."); throw new Error("No database connection. Call connectToProfile() first.");
} }
return dbInstance; return serializedInstance;
}
/**
* Run `fn` as one serialized transaction. The global DB lock is held for the
* ENTIRE callback so no other db.execute/select can interleave and strand the
* transaction on a different pooled connection. `fn` receives the db handle and
* is responsible for issuing BEGIN/COMMIT (and ROLLBACK on error), exactly as
* the inline pattern did before `withTransaction` only guarantees exclusive,
* non-interleaved access for the whole BEGIN..COMMIT.
*/
export async function withTransaction<T>(
fn: (db: Database) => Promise<T>
): Promise<T> {
if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
const db = serializedInstance;
return runExclusive(async () => {
inTransaction = true;
try {
return await fn(db);
} finally {
inTransaction = false;
}
});
} }
export async function connectToProfile(dbFilename: string): Promise<void> { export async function connectToProfile(dbFilename: string): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
// Repair migration checksums before loading (fixes "migration was modified" error) // Repair migration checksums before loading (fixes "migration was modified" error)
try { try {
@ -24,23 +118,27 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
} catch (e) { } catch (e) {
console.error("Migration repair failed:", e); console.error("Migration repair failed:", e);
} }
dbInstance = await Database.load(`sqlite:${dbFilename}`); rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
} }
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> { export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
dbInstance = await Database.load(`sqlite:${dbFilename}`); rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
for (const sql of sqlStatements) { for (const sql of sqlStatements) {
await dbInstance.execute(sql); await rawInstance.execute(sql);
} }
} }
export async function closeDb(): Promise<void> { export async function closeDb(): Promise<void> {
if (dbInstance) { if (rawInstance) {
await dbInstance.close(); await rawInstance.close();
dbInstance = null; rawInstance = null;
serializedInstance = null;
} }
} }

View file

@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService"; import {
getRecentErrorLogs,
getLogs,
clearLogs,
initLogCapture,
} from "./logService";
beforeAll(() => { beforeAll(() => {
// Patch console.* so addEntry runs. Idempotent. // Patch console.* so addEntry runs. Idempotent.
@ -54,3 +59,39 @@ describe("getRecentErrorLogs", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
// Regression: the log console (Settings → Systems → Journaux) stayed empty
// because getLogs returned the same mutated array reference on every call, so
// useSyncExternalStore (which compares snapshots by identity) never re-rendered
// when a new log arrived. getLogs must return a STABLE reference between
// mutations (no per-call re-slice, or React loops) and a NEW one after each.
describe("getLogs — live-update snapshot", () => {
beforeEach(() => {
clearLogs();
});
it("returns a stable reference between mutations", () => {
expect(getLogs()).toBe(getLogs());
console.warn("x");
const snap = getLogs();
expect(snap).toBe(getLogs());
});
it("returns a NEW reference after a log so the store re-renders", () => {
const before = getLogs();
console.warn("new entry");
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(before.length + 1);
expect(after[after.length - 1].message).toBe("new entry");
});
it("returns a NEW reference after clearLogs", () => {
console.warn("y");
const before = getLogs();
clearLogs();
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(0);
});
});

View file

@ -13,6 +13,18 @@ const STORAGE_KEY = "simpl-resultat-logs";
const logs: LogEntry[] = []; const logs: LogEntry[] = [];
const listeners = new Set<LogListener>(); 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; let initialized = false;
function loadFromStorage() { function loadFromStorage() {
@ -21,6 +33,7 @@ function loadFromStorage() {
if (stored) { if (stored) {
const parsed: LogEntry[] = JSON.parse(stored); const parsed: LogEntry[] = JSON.parse(stored);
logs.push(...parsed); logs.push(...parsed);
refreshSnapshot();
} }
} catch { } catch {
// ignore corrupted storage // ignore corrupted storage
@ -51,6 +64,7 @@ function addEntry(level: LogLevel, args: unknown[]) {
if (logs.length > MAX_ENTRIES) { if (logs.length > MAX_ENTRIES) {
logs.splice(0, logs.length - MAX_ENTRIES); logs.splice(0, logs.length - MAX_ENTRIES);
} }
refreshSnapshot();
saveToStorage(); saveToStorage();
listeners.forEach((fn) => fn()); listeners.forEach((fn) => fn());
@ -83,7 +97,7 @@ export function initLogCapture() {
} }
export function getLogs(): readonly LogEntry[] { export function getLogs(): readonly LogEntry[] {
return logs; return logsSnapshot;
} }
/// Extract the N most recent non-info entries formatted as a single string, /// Extract the N most recent non-info entries formatted as a single string,
@ -103,10 +117,30 @@ export function getRecentErrorLogs(n: number): string {
export function clearLogs() { export function clearLogs() {
logs.length = 0; logs.length = 0;
refreshSnapshot();
saveToStorage(); saveToStorage();
listeners.forEach((fn) => fn()); 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 { export function subscribe(listener: LogListener): () => void {
listeners.add(listener); listeners.add(listener);
return () => listeners.delete(listener); return () => listeners.delete(listener);

View file

@ -1,9 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService"; import { shiftMonth, getCartesSnapshot } from "./reportService";
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";

View file

@ -8,9 +8,13 @@ import {
} from "./reportService"; } from "./reportService";
// Mock the db module // Mock the db module
vi.mock("./db", () => ({ vi.mock("./db", () => {
getDb: vi.fn(), const getDb = vi.fn();
})); return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db"; import { getDb } from "./db";