import Database from "@tauri-apps/plugin-sql"; import { invoke } from "@tauri-apps/api/core"; import { logError, logInfo, logWarn } from "./logService"; let rawInstance: Database | null = null; let serializedInstance: Database | null = null; // --------------------------------------------------------------------------- // DB access serialization (#228 follow-up — fix "database is locked"; #408). // // 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. // // Rules (#408): // - The lock is strict and NOT reentrant. Inside a `withTransaction` callback, // use ONLY the handle the callback receives: it runs directly on the // connection the transaction holds. Calling `getDb()` from a callback (or // from anything it calls) queues behind the transaction that waits for it: // a deadlock, reported in the Journal after BLOCKED_ALERT_MS (see below). // - The transaction handle is revoked when the callback settles: a call on it // afterwards (an orphan promise) throws `TransactionHandleRevokedError` // instead of running outside the lock. `close()` on it always throws. // - Opening, closing and switching a profile each run as ONE exclusive section // (`connectToProfile`, `initializeNewProfileDb`, `closeDb`), so they wait for // the operations already queued and for a running transaction, and nothing // runs between closing the old pool and opening the new one. // - Every open or close bumps a connection generation. A handle checks its // generation when its operation leaves the queue: an operation queued for a // connection that was closed meanwhile throws `DbConnectionClosedError` // rather than running on the next profile's database or on a closed pool. // `close()` on a `getDb()` handle is checked the same way, so a handle kept // from a previous profile cannot close the current one. // - A failing `close()` of the old pool never blocks a switch: the state is // already cleared and the generation bumped, the failure goes to the Journal // and the next profile still opens (see `closeUnlocked`). // --------------------------------------------------------------------------- /** An operation queued for a connection closed before it could run. */ export class DbConnectionClosedError extends Error { constructor() { super("connection closed by profile switch"); this.name = "DbConnectionClosedError"; } } /** A transaction handle used after its `withTransaction` callback settled. */ export class TransactionHandleRevokedError extends Error { constructor() { super("transaction handle used after its withTransaction callback ended"); this.name = "TransactionHandleRevokedError"; } } /** `close()` called on a transaction handle: only the profile functions close. */ export class TransactionHandleCloseError extends Error { constructor() { super("close() is not allowed on a transaction handle; use closeDb()"); this.name = "TransactionHandleCloseError"; } } /** What holds the lock, named in the blocking alert. */ type LockKind = | "query" | "transaction" | "profile opening" | "profile initialization" | "connection close"; /** * An operation still waiting for the lock after this delay writes ONE warning * to the Journal, naming what holds the lock and since when. One possible * cause is a `getDb()` call from inside a `withTransaction` callback * (deadlock), but a long transaction (a large restore, a category migration on * a big profile) also exceeds the delay, so the warning names it as a possible * cause only. A profile catch-up (`prepare_profile_db`) that takes longer also * triggers it, naming a "profile opening": expected, nothing can run during a * switch. * `logWarn` only writes to memory and sessionStorage, never to the DB, so it * is safe to call while the lock is held. */ export const BLOCKED_ALERT_MS = 15_000; let lockTail: Promise = Promise.resolve(); let lockHolder: { kind: LockKind; since: number } | null = null; let generation = 0; /** Run `fn` after every previously-queued DB operation has settled (FIFO). */ function runExclusive(kind: LockKind, fn: () => Promise): Promise { const queuedAt = Date.now(); let started = false; const alert = setTimeout(() => { if (started) return; const holder = lockHolder; const waited = Math.round((Date.now() - queuedAt) / 1000); logWarn( holder ? `DB lock: a ${kind} has waited ${waited} s, the lock is held by a ${holder.kind} since ${new Date(holder.since).toISOString()}` + (holder.kind === "transaction" ? " (possible cause: a getDb() call inside a withTransaction callback, which deadlocks; the callback must use its own handle)" : "") : `DB lock: a ${kind} has waited ${waited} s behind the queue` ); }, BLOCKED_ALERT_MS); const run = async (): Promise => { started = true; clearTimeout(alert); lockHolder = { kind, since: Date.now() }; try { return await fn(); } finally { lockHolder = null; } }; const result = lockTail.then(run, run); // Keep the chain alive regardless of fn's outcome; only the tail swallows. lockTail = result.then( () => undefined, () => undefined ); return result; } /** Throw when the connection a handle was made for is gone. */ function assertGeneration(handleGeneration: number): void { if (handleGeneration !== generation) { throw new DbConnectionClosedError(); } } /** * Wrap the raw Database so `execute`/`select` are serialized through the lock * and checked against the connection generation once they leave the queue. * The proxy keeps the `Database` type, so call sites (including * `db.select(...)`) are unchanged. */ function serialize(db: Database, handleGeneration: number): Database { return new Proxy(db, { get(target, prop, receiver) { if (prop === "execute") { return (query: string, bindValues?: unknown[]) => runExclusive("query", async () => { assertGeneration(handleGeneration); return target.execute(query, bindValues); }); } if (prop === "select") { return (query: string, bindValues?: unknown[]) => runExclusive("query", async () => { assertGeneration(handleGeneration); return target.select(query, bindValues); }); } if (prop === "close") { // Same check as execute/select: a handle kept from a previous profile // must not close the current profile's connection (#408 review). return () => runExclusive("connection close", async () => { assertGeneration(handleGeneration); await closeUnlocked(); }); } const value = Reflect.get(target, prop, receiver); return typeof value === "function" ? value.bind(target) : value; }, }); } /** * The handle a `withTransaction` callback receives: runs directly on the * connection (the transaction already holds the lock), refuses `close()`, and * throws once `revoke()` ran at the end of the callback. * * Its generation check is a defence, not reachable today: only the exclusive * profile sections change the generation, and none of them can run while a * transaction holds the lock, so nothing closes the connection under a * running callback. It stays in case a future path closes without the lock. */ function transactionHandle( db: Database, handleGeneration: number ): { handle: Database; revoke: () => void } { let active = true; const check = () => { if (!active) throw new TransactionHandleRevokedError(); assertGeneration(handleGeneration); }; const handle = new Proxy(db, { get(target, prop, receiver) { if (prop === "execute") { return async (query: string, bindValues?: unknown[]) => { check(); return target.execute(query, bindValues); }; } if (prop === "select") { return async (query: string, bindValues?: unknown[]) => { check(); return target.select(query, bindValues); }; } if (prop === "close") { return async () => { throw new TransactionHandleCloseError(); }; } const value = Reflect.get(target, prop, receiver); return typeof value === "function" ? value.bind(target) : value; }, }); return { handle, revoke: () => { active = false; }, }; } 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 transaction * handle and is responsible for issuing BEGIN/COMMIT (and ROLLBACK on error) * on it — `withTransaction` only guarantees exclusive, non-interleaved access * for the whole BEGIN..COMMIT. Never call `getDb()` from `fn` (deadlock). */ export async function withTransaction( fn: (db: Database) => Promise ): Promise { if (!rawInstance) { throw new Error("No database connection. Call connectToProfile() first."); } const db = rawInstance; const handleGeneration = generation; return runExclusive("transaction", async () => { assertGeneration(handleGeneration); const { handle, revoke } = transactionHandle(db, handleGeneration); try { return await fn(handle); } finally { revoke(); } }); } // Internal helpers for the profile sections below. They NEVER take the lock: // only call them from inside a `runExclusive` section. /** * Drop the current connection, then close its pool. Never rejects: the state * is cleared and the generation bumped BEFORE `close()`, so a failing close * leaves a coherent "no connection" state (every older handle refuses to run) * and only writes a warning to the Journal. A switch therefore still opens the * next profile, and `closeDb()` resolves: the app no longer references the old * pool, and keeping it would leave the app without any usable connection. */ async function closeUnlocked(): Promise { if (rawInstance) { const closing = rawInstance; rawInstance = null; serializedInstance = null; generation += 1; try { await closing.close(); } catch (e) { logWarn( withoutFolders( `DB: closing the previous connection failed, continuing without it: ${e instanceof Error ? e.message : String(e)}` ) ); } } } async function loadUnlocked(dbFilename: string): Promise { const db = await Database.load(`sqlite:${dbFilename}`); generation += 1; rawInstance = db; serializedInstance = serialize(db, generation); return db; } /** What `prepare_profile_db` did to one profile database (#390). */ export interface ProfilePreparation { catch_up: { floor: number | null; recorded: number[]; applied: number[]; fk_repaired: Array<[string, number]>; fk_violations: Array<[string, number]>; backup_file: string | null; } | null; catch_up_error: string | null; /** * File name of the copy taken before the catch-up, set even when the * catch-up stopped afterwards (#395). */ catch_up_backup: string | null; repaired_flags: number; elapsed_ms: number; } /** * Where a path starts: the placeholder Rust's `scrub` puts for the profile * folder, a drive (`C:\`), the home (`~/`), `./` or `../`, or a separator * (rooted path, UNC share). Only after the start of the text or one of * BEFORE_START, so the `/` of `I/O` starts nothing. */ const PATH_START = /[\\/]|[A-Za-z]:[\\/]|~[\\/]|\.{1,2}[\\/]|[\\/]/y; const BEFORE_START = /[\s'"`(=:,[]/; /** What ends the folders of a path for sure: a line break, a double quote or a backtick, `;`, `|`. */ const PATH_STOP = /[\n\r"`;|]/; /** The last segment of a path: the file name, kept. */ const LAST_SEGMENT = /[^\s"`()<>\\/]*/y; /** * Fallback for what the first pass leaves: a run of non-blank characters * holding a `/` or `\` that no PATH_START opened (`dir/sub/x.db`, `I/O`). */ const PATH_RUN = /(?:)?[^\s"`()<>]*[\\/][^\s"`()<>]*/g; /** A run that names a place on disk: rooted, home, relative to `.`, a drive, or two separators. */ const LOOKS_LIKE_PATH = /^(?:|[\\/~.]|[A-Za-z]:)|[\\/].*[\\/]/; /** Length of the path start at `i`, 0 when no path starts there. */ function pathStartAt(text: string, i: number): number { if (i > 0 && !BEFORE_START.test(text[i - 1])) return 0; PATH_START.lastIndex = i; return PATH_START.exec(text)?.[0].length ?? 0; } /** Whether a new path starts after the blank at `k` (quotes and an opening parenthesis skipped). */ function newPathAfter(text: string, k: number): boolean { let j = k + 1; while (j < text.length && /['"`(]/.test(text[j])) j++; return pathStartAt(text, j) > 0; } /** Index of the last separator of the path whose start ends at `from` - 1. */ function lastSeparator(text: string, from: number): number { let last = from - 1; for (let k = from; k < text.length; k++) { const c = text[k]; const next = text[k + 1] ?? ""; if (c === "\\" || c === "/") { last = k; continue; } if (PATH_STOP.test(c)) break; // Sentence punctuation: Windows forbids `:` in a name, and no user name holds `,`. if ((c === "," || c === ":") && (next === "" || /\s/.test(next))) break; // `Program Files (x86)\…` goes on; `(see /x/y.db) and…` stops. if (c === ")" && next !== "\\" && next !== "/") break; if (/\s/.test(c) && newPathAfter(text, k)) break; } return last; } /** * Keep only the last segment of every path in `text`: error lines can be * attached to a feedback report, and a folder holds the OS user name * (Loi 25). A folder may contain blanks (`C:\Users\Jean Dupont\…`, * `/home/marie claire/…`): from its start, a path runs to its LAST separator * before a line break, a double quote, a backtick, `;`, `|`, a `,` or `:` * followed by a blank, a closing parenthesis not followed by a separator, or a * blank followed by another path. When nothing ends it, the rule is * conservative: text after a path is dropped up to the last separator of the * line (`/a/x.db then I/O` gives `O`), rather than letting a folder through. * Limits in ADR 0021, section 7. A quote right before the path is * not part of it, so both quotes stay around the file name * (`'C:\Users\Bob\x.db'` gives `'x.db'`). Any other separator becomes `-` (SQLite's "disk I/O error" reads * "disk I-O error"), so an error line never contains a `/` nor a `\` (#395). */ export function withoutFolders(text: string): string { let out = ""; let i = 0; while (i < text.length) { const start = pathStartAt(text, i); if (start === 0) { out += text[i]; i += 1; continue; } const from = lastSeparator(text, i + start) + 1; LAST_SEGMENT.lastIndex = from; const segment = LAST_SEGMENT.exec(text)?.[0] ?? ""; out += segment; i = from + segment.length; } return out.replace(PATH_RUN, (run) => LOOKS_LIKE_PATH.test(run) ? run.split(/[\\/]/).filter(Boolean).pop() ?? "" : run.replace(/[\\/]/g, "-") ); } /** * Journal lines for one preparation: only what changed. Names files, never * folders: an error line can be attached to a feedback report, and the * folder holds the OS user name. */ export function describePreparation( dbFilename: string, report: ProfilePreparation | null | undefined ): { info: string[]; errors: string[] } { const info: string[] = []; const errors: string[] = []; if (!report || typeof report !== "object") return { info, errors }; const catchUp = report.catch_up; if (catchUp && catchUp.applied.length > 0) { const first = catchUp.applied[0]; const last = catchUp.applied[catchUp.applied.length - 1]; info.push( `Profile ${dbFilename}: database updated with versions ${first} to ${last} in ${report.elapsed_ms} ms` + (catchUp.backup_file ? `, copy of the previous state kept as ${catchUp.backup_file}` : "") ); } else if (catchUp && catchUp.recorded.length > 0) { info.push( `Profile ${dbFilename}: migration history recorded up to version ${catchUp.recorded[catchUp.recorded.length - 1]}` ); } for (const [table, rows] of catchUp?.fk_repaired ?? []) { info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} detached from a missing parent`); } for (const [table, rows] of catchUp?.fk_violations ?? []) { info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} point to a missing row`); } if (report.catch_up_error) { const copy = report.catch_up_backup ? withoutFolders(report.catch_up_backup) : ""; errors.push( withoutFolders( `Profile ${dbFilename}: database update stopped: ${report.catch_up_error}` + (copy ? `; copy of the previous state kept as ${copy}` : "") ) ); } if (report.repaired_flags > 0) { info.push(`Profile ${dbFilename}: ${report.repaired_flags} manual categorization flag(s) repaired`); } return { info, errors }; } export function connectToProfile(dbFilename: string): Promise { return runExclusive("profile opening", async () => { // The old pool is fully closed before the preparation touches the file. await closeUnlocked(); // 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); } // Catch a secondary profile up on the migrations tauri-plugin-sql never // runs for it, and repair legacy values in every profile (#390). Fails // open, like the repair above: the profile still opens. try { const report = await invoke("prepare_profile_db", { dbFilename }); const lines = describePreparation(dbFilename, report); lines.info.forEach((line) => logInfo(line)); lines.errors.forEach((line) => logError(line)); } catch (e) { logError( withoutFolders( `Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}` ) ); } await loadUnlocked(dbFilename); }); } export function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise { return runExclusive("profile initialization", async () => { await closeUnlocked(); const db = await loadUnlocked(dbFilename); for (const sql of sqlStatements) { await db.execute(sql); } }); } export function closeDb(): Promise { return runExclusive("connection close", () => closeUnlocked()); }