All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m7s
- Where a test presets a harness slot by position (PriceFetchConsentToggle, useOnboardingChecklist's mountWith, SystemsSettingsPage), a comment names the dependency on the component's hook order: any hook added before shifts the slots without a word (the `slot` helper of fakeReact). - docs/architecture.md, Tests: "Limites connues" lists the slot presets, and links to ADR 0021 for withoutFolders and for the catch-up measurement instead of repeating them. - ADR 0021 section 7: the relative-path limit is stated precisely (a run with two separators or more is still cut to its last segment; only the words of a folder holding a blank stay), and a second limit is written: a folder name holding one of the stops (a backtick, `;`, `|`, a closing parenthesis not followed by a separator...) ends the path there, and that name, or its start, stays in the line. - db.ts: the comment of withoutFolders says "a closing parenthesis not followed by a separator", as the code does, and points to ADR 0021. No behaviour change. Resolves #437 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
20 KiB
TypeScript
505 lines
20 KiB
TypeScript
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<unknown> = 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<T>(kind: LockKind, fn: () => Promise<T>): Promise<T> {
|
|
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<T> => {
|
|
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<T>(...)`) 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<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 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<T>(
|
|
fn: (db: Database) => Promise<T>
|
|
): Promise<T> {
|
|
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<void> {
|
|
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<Database> {
|
|
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 = /<profile folder>[\\/]|[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 = /(?:<profile folder>)?[^\s"`()<>]*[\\/][^\s"`()<>]*/g;
|
|
/** A run that names a place on disk: rooted, home, relative to `.`, a drive, or two separators. */
|
|
const LOOKS_LIKE_PATH = /^(?:<profile folder>|[\\/~.]|[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<void> {
|
|
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<boolean>("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<ProfilePreparation>("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<void> {
|
|
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<void> {
|
|
return runExclusive("connection close", () => closeUnlocked());
|
|
}
|