Simpl-Resultat/src/services/db.test.ts
le king fu 8cb7e53698
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
fix(db): serialize DB access to fix "database is locked"; live-update log console
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>
2026-06-30 16:50:39 -04:00

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/
);
});
});