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>
144 lines
5.4 KiB
TypeScript
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;
|
|
}
|
|
}
|