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 = 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(fn: () => Promise): Promise { 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(...)`) 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 { 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( fn: (db: Database) => Promise ): Promise { 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 { if (rawInstance) { await rawInstance.close(); rawInstance = null; serializedInstance = null; } // Repair migration checksums before loading (fixes "migration was modified" error) try { const repaired = await invoke("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 { 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 { if (rawInstance) { await rawInstance.close(); rawInstance = null; serializedInstance = null; } }