Simpl-Resultat/src/services/db.ts
le king fu 8cb7e53698
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
fix(db): serialize DB access to fix "database is locked"; live-update log console
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>
2026-06-30 16:50:39 -04:00

144 lines
5.4 KiB
TypeScript

import Database from "@tauri-apps/plugin-sql";
import { invoke } from "@tauri-apps/api/core";
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> {
if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
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> {
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
// Repair migration checksums before loading (fixes "migration was modified" error)
try {
const repaired = await invoke<boolean>("repair_migrations", { dbFilename });
if (repaired) {
console.warn("Migration checksums repaired for", dbFilename);
}
} catch (e) {
console.error("Migration repair failed:", e);
}
rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
}
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
for (const sql of sqlStatements) {
await rawInstance.execute(sql);
}
}
export async function closeDb(): Promise<void> {
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
}