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>
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Regression coverage for the "database is locked" fix (#228 follow-up).
|
|
//
|
|
// tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
|
|
// and exposes no transaction primitive, so a BEGIN and its COMMIT issued as
|
|
// separate db.execute calls could land on different pooled connections and
|
|
// strand an open write transaction (= permanent lock). db.ts funnels every
|
|
// operation through one FIFO lock, and withTransaction holds it for the whole
|
|
// BEGIN..COMMIT. These tests assert that serialization, without a real SQLite.
|
|
|
|
// Ordered log of every fake-db operation's start/end, shared with the fake db.
|
|
const events: string[] = [];
|
|
// Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db.
|
|
const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb }));
|
|
|
|
interface FakeDb {
|
|
execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>;
|
|
select: (q: string, p?: unknown[]) => Promise<unknown[]>;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
vi.mock("@tauri-apps/plugin-sql", () => ({
|
|
default: { load: vi.fn(async () => h.fakeDb) },
|
|
}));
|
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => false) }));
|
|
|
|
import { connectToProfile, getDb, withTransaction } from "./db";
|
|
|
|
// Each op records start, yields a real macrotask (a window for interleaving if
|
|
// the lock were broken), then records end. The lock must keep them ordered.
|
|
function makeFakeDb(): FakeDb {
|
|
return {
|
|
execute: vi.fn(async (q: string) => {
|
|
events.push(`start:${q}`);
|
|
await new Promise((r) => setTimeout(r, 2));
|
|
events.push(`end:${q}`);
|
|
return { rowsAffected: 0 };
|
|
}),
|
|
select: vi.fn(async (q: string) => {
|
|
events.push(`start:${q}`);
|
|
await new Promise((r) => setTimeout(r, 2));
|
|
events.push(`end:${q}`);
|
|
return [];
|
|
}),
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
events.length = 0;
|
|
h.fakeDb = makeFakeDb();
|
|
await connectToProfile("test.db");
|
|
});
|
|
|
|
describe("db serialization (database-locked fix)", () => {
|
|
it("serializes concurrent execute calls — no interleaving", async () => {
|
|
const db = await getDb();
|
|
// Fire both without awaiting between them: they race for the lock.
|
|
await Promise.all([db.execute("A"), db.execute("B")]);
|
|
// A must fully complete before B starts (FIFO, no overlap).
|
|
expect(events).toEqual(["start:A", "end:A", "start:B", "end:B"]);
|
|
});
|
|
|
|
it("holds the lock across a whole withTransaction — an outside read cannot interleave", async () => {
|
|
const db = await getDb();
|
|
// Queue the transaction first, then an outside read.
|
|
const txn = withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
await tx.execute("WRITE");
|
|
await tx.execute("COMMIT");
|
|
});
|
|
const outside = db.select("READ");
|
|
await Promise.all([txn, outside]);
|
|
// The outside read runs entirely AFTER the transaction's COMMIT — the
|
|
// pre-fix bug was exactly an outside read interleaving mid-transaction.
|
|
expect(events).toEqual([
|
|
"start:BEGIN",
|
|
"end:BEGIN",
|
|
"start:WRITE",
|
|
"end:WRITE",
|
|
"start:COMMIT",
|
|
"end:COMMIT",
|
|
"start:READ",
|
|
"end:READ",
|
|
]);
|
|
});
|
|
|
|
it("lets a nested getDb() call run directly inside a transaction — no deadlock", async () => {
|
|
// A service called inside withTransaction might reach for getDb() (the
|
|
// serialized proxy) instead of the threaded handle. The reentrancy guard
|
|
// must run it directly rather than queue it behind the held lock (deadlock).
|
|
const result = await withTransaction(async () => {
|
|
const inner = await getDb();
|
|
await inner.execute("NESTED");
|
|
return "ok";
|
|
});
|
|
expect(result).toBe("ok");
|
|
expect(events).toEqual(["start:NESTED", "end:NESTED"]);
|
|
});
|
|
|
|
it("releases the lock after a failed transaction so later ops still run", async () => {
|
|
const db = await getDb();
|
|
await expect(
|
|
withTransaction(async (tx) => {
|
|
await tx.execute("BEGIN");
|
|
throw new Error("boom");
|
|
})
|
|
).rejects.toThrow("boom");
|
|
// The lock must be free again: a subsequent op completes (no stuck queue).
|
|
await db.execute("AFTER");
|
|
expect(events).toContain("start:AFTER");
|
|
expect(events).toContain("end:AFTER");
|
|
});
|
|
|
|
it("withTransaction rejects when no profile is connected", async () => {
|
|
const { closeDb } = await import("./db");
|
|
await closeDb();
|
|
await expect(withTransaction(async () => undefined)).rejects.toThrow(
|
|
/No database connection/
|
|
);
|
|
});
|
|
});
|