Merge pull request 'fix(balance): "database is locked" after abandoning a snapshot + live log console' (#230) from fix-database-locked-serialization into main

This commit is contained in:
maximus 2026-06-30 21:19:41 +00:00
commit f64be5c31d
20 changed files with 472 additions and 57 deletions

View file

@ -2,6 +2,11 @@
## [Non publié]
### Corrigé
- Bilan : correction d'une erreur **« database is locked »** qui pouvait apparaître après avoir commencé un snapshot puis quitté la page avant d'enregistrer, bloquant ensuite toute écriture jusqu'au redémarrage de l'app. Tout l'accès à la base est désormais sérialisé, et une transaction tient une seule connexion sur toute sa durée — une transaction d'écriture ne peut donc plus rester bloquée entre deux connexions SQLite (la cause racine). Aucune donnée enregistrée n'a été menacée ; seul le snapshot en cours était perdu (#230).
- La console de journaux intégrée (Paramètres → Systèmes → Journaux) se met maintenant à jour **en direct** à mesure que les événements surviennent, au lieu de n'afficher que les entrées présentes avant l'ouverture de la page. Les enregistrements de snapshot et leurs échecs sont désormais journalisés, donc les problèmes de base de données y apparaissent (#230).
## [0.10.0] - 2026-06-29
### Ajouté

View file

@ -2,6 +2,11 @@
## [Unreleased]
### Fixed
- Balance: fixed a **"database is locked"** error that could appear after starting a snapshot and leaving the page before saving, then blocked every later write until the app was restarted. All database access is now serialized, and a transaction holds a single connection for its whole duration — so a write transaction can no longer be stranded across SQLite connections (the root cause). No stored data was ever at risk; only the in-progress snapshot was lost (#230).
- The in-app log console (Settings → Systems → Logs) now updates **live** as events happen, instead of only showing entries that existed before the page was opened. Snapshot saves and their failures are now logged, so database issues surface there (#230).
## [0.10.0] - 2026-06-29
### Added

View file

@ -27,9 +27,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),

View file

@ -14,9 +14,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../services/db";
import {

View file

@ -8,9 +8,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("../../services/db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "../../services/db";
import {

View file

@ -13,11 +13,15 @@ import { describe, it, expect, vi } from "vitest";
// db is imported transitively (hook → balance.service → db → tauri plugins).
// Stub it so no module-load path tries a real connection.
vi.mock("../services/db", () => ({
getDb: vi.fn(),
connectToProfile: vi.fn(),
initializeNewProfileDb: vi.fn(),
}));
vi.mock("../services/db", () => {
const getDb = vi.fn();
return {
getDb,
connectToProfile: vi.fn(),
initializeNewProfileDb: vi.fn(),
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import {
reducer,

View file

@ -31,6 +31,7 @@ import {
useEffect,
useRef,
} from "react";
import { logInfo, logError } from "../services/logService";
import type {
BalanceAccountWithCategory,
BalanceAssetType,
@ -798,10 +799,16 @@ export function useSnapshotEditor(options: Options = {}) {
moveToDate,
});
dispatch({ type: "CLEAR_DIRTY" });
logInfo(
`Balance: snapshot saved (${state.snapshotDate}, ${
simpleLines.length + detailedLines.length
} lines)`
);
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
await loadForDate(state.snapshotDate);
return { snapshotId };
} catch (e) {
logError(`Balance: snapshot save failed (${state.snapshotDate})`, e);
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {

View file

@ -1,8 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),

View file

@ -10,7 +10,7 @@
// + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db";
import { getDb, withTransaction } from "./db";
import { loadProfiles } from "./profileService";
import type {
AccountReturn,
@ -705,7 +705,15 @@ export async function proposeStarterAccounts(
): Promise<number[]> {
const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key));
if (wanted.length === 0) return [];
const db = await getDb();
return withTransaction((db) =>
proposeStarterAccountsInTransaction(db, wanted)
);
}
async function proposeStarterAccountsInTransaction(
db: SqlExecutor,
wanted: StarterDef[]
): Promise<number[]> {
let inTxn = false;
const inserted: number[] = [];
try {
@ -1496,7 +1504,16 @@ export async function upsertSnapshotLines(
// through validateDetailedSnapshot, scalar lines through the unchanged pass).
validateAllLines(lines);
const db = await getDb();
return withTransaction((db) =>
upsertSnapshotLinesInTransaction(db, snapshotId, lines)
);
}
async function upsertSnapshotLinesInTransaction(
db: SqlExecutor,
snapshotId: number,
lines: SnapshotLineInput[]
): Promise<void> {
// Strategy: clear and rewrite, wrapped in an explicit transaction so the
// line + holdings writes commit together. Snapshot lines are small (one per
// active account, typically < 20). CASCADE on snapshot_line_id wipes the old
@ -1575,7 +1592,19 @@ export async function saveSnapshotAtomic(input: {
// lines keep the unchanged validateLineKindInvariants pass.
validateAllLines(input.lines);
const db = await getDb();
return withTransaction((db) => saveSnapshotAtomicInTransaction(db, input));
}
async function saveSnapshotAtomicInTransaction(
db: SqlExecutor,
input: {
existingSnapshotId: number | null;
snapshot_date: string;
notes?: string | null;
lines: SnapshotLineInput[];
moveToDate?: string | null;
}
): Promise<{ snapshotId: number }> {
let inTxn = false;
try {
await db.execute("BEGIN");

View file

@ -6,9 +6,17 @@ import {
KEYWORD_MAX_LENGTH,
} from "./categorizationService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db";

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { getDb, withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import type { Keyword, RecentTransaction } from "../shared/types";
/**
@ -222,7 +223,16 @@ export async function applyKeywordWithReassignment(
}
const keyword = validation.value;
const db = await getDb();
return withTransaction((db) =>
applyKeywordInTransaction(db, input, keyword),
);
}
async function applyKeywordInTransaction(
db: Database,
input: ApplyKeywordInput,
keyword: string,
): Promise<ApplyKeywordResult> {
await db.execute("BEGIN");
try {
// Is there already a row for this keyword spelling?

View file

@ -3,9 +3,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// `getDb` is stubbed with a fake DB that captures every SQL statement issued
// during applyMigration. This is a unit test — we do NOT run a real SQLite.
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
// `setPreference` goes through the same fake DB — no extra stubbing needed
// because userPreferenceService itself calls getDb().

View file

@ -1,4 +1,5 @@
import { getDb } from "./db";
import { withTransaction } from "./db";
import type Database from "@tauri-apps/plugin-sql";
import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService";
@ -178,7 +179,17 @@ export async function applyMigration(
return outcome;
}
const db = await getDb();
return withTransaction((db) =>
applyMigrationInTransaction(db, plan, backup, outcome),
);
}
async function applyMigrationInTransaction(
db: Database,
plan: MigrationPlan,
backup: BackupResult,
outcome: MigrationOutcome,
): Promise<MigrationOutcome> {
const mapping = buildMappingFromRows(plan.rows);
await db.execute("BEGIN");

123
src/services/db.test.ts Normal file
View file

@ -0,0 +1,123 @@
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/
);
});
});

View file

@ -1,19 +1,113 @@
import Database from "@tauri-apps/plugin-sql";
import { invoke } from "@tauri-apps/api/core";
let dbInstance: Database | null = null;
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<unknown> = 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<T>(fn: () => Promise<T>): Promise<T> {
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<T>(...)`) 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<Database> {
if (!dbInstance) {
if (!serializedInstance) {
throw new Error("No database connection. Call connectToProfile() first.");
}
return dbInstance;
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<T>(
fn: (db: Database) => Promise<T>
): Promise<T> {
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<void> {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
// Repair migration checksums before loading (fixes "migration was modified" error)
try {
@ -24,23 +118,27 @@ export async function connectToProfile(dbFilename: string): Promise<void> {
} catch (e) {
console.error("Migration repair failed:", e);
}
dbInstance = await Database.load(`sqlite:${dbFilename}`);
rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
}
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
dbInstance = await Database.load(`sqlite:${dbFilename}`);
rawInstance = await Database.load(`sqlite:${dbFilename}`);
serializedInstance = serialize(rawInstance);
for (const sql of sqlStatements) {
await dbInstance.execute(sql);
await rawInstance.execute(sql);
}
}
export async function closeDb(): Promise<void> {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
if (rawInstance) {
await rawInstance.close();
rawInstance = null;
serializedInstance = null;
}
}

View file

@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import { getRecentErrorLogs, clearLogs, initLogCapture } from "./logService";
import {
getRecentErrorLogs,
getLogs,
clearLogs,
initLogCapture,
} from "./logService";
beforeAll(() => {
// Patch console.* so addEntry runs. Idempotent.
@ -54,3 +59,39 @@ describe("getRecentErrorLogs", () => {
vi.useRealTimers();
});
});
// Regression: the log console (Settings → Systems → Journaux) stayed empty
// because getLogs returned the same mutated array reference on every call, so
// useSyncExternalStore (which compares snapshots by identity) never re-rendered
// when a new log arrived. getLogs must return a STABLE reference between
// mutations (no per-call re-slice, or React loops) and a NEW one after each.
describe("getLogs — live-update snapshot", () => {
beforeEach(() => {
clearLogs();
});
it("returns a stable reference between mutations", () => {
expect(getLogs()).toBe(getLogs());
console.warn("x");
const snap = getLogs();
expect(snap).toBe(getLogs());
});
it("returns a NEW reference after a log so the store re-renders", () => {
const before = getLogs();
console.warn("new entry");
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(before.length + 1);
expect(after[after.length - 1].message).toBe("new entry");
});
it("returns a NEW reference after clearLogs", () => {
console.warn("y");
const before = getLogs();
clearLogs();
const after = getLogs();
expect(after).not.toBe(before);
expect(after).toHaveLength(0);
});
});

View file

@ -13,6 +13,18 @@ const STORAGE_KEY = "simpl-resultat-logs";
const logs: LogEntry[] = [];
const listeners = new Set<LogListener>();
// Immutable view of `logs`, rebuilt on every mutation. `useSyncExternalStore`
// compares snapshots by identity (Object.is), so `getLogs` MUST return a NEW
// reference whenever the logs change — and a STABLE one between changes (never
// re-slice per call, which would loop the renderer). The old behaviour mutated
// `logs` in place and returned that same array, so the log console never
// updated live: a log produced while the panel was open was never re-rendered.
let logsSnapshot: readonly LogEntry[] = [];
function refreshSnapshot() {
logsSnapshot = logs.slice();
}
let initialized = false;
function loadFromStorage() {
@ -21,6 +33,7 @@ function loadFromStorage() {
if (stored) {
const parsed: LogEntry[] = JSON.parse(stored);
logs.push(...parsed);
refreshSnapshot();
}
} catch {
// ignore corrupted storage
@ -51,6 +64,7 @@ function addEntry(level: LogLevel, args: unknown[]) {
if (logs.length > MAX_ENTRIES) {
logs.splice(0, logs.length - MAX_ENTRIES);
}
refreshSnapshot();
saveToStorage();
listeners.forEach((fn) => fn());
@ -83,7 +97,7 @@ export function initLogCapture() {
}
export function getLogs(): readonly LogEntry[] {
return logs;
return logsSnapshot;
}
/// Extract the N most recent non-info entries formatted as a single string,
@ -103,10 +117,30 @@ export function getRecentErrorLogs(n: number): string {
export function clearLogs() {
logs.length = 0;
refreshSnapshot();
saveToStorage();
listeners.forEach((fn) => fn());
}
// ---------------------------------------------------------------------------
// App logging API. Prefer these over raw `console.*` in application code so
// meaningful events land in the in-app log console (Settings → Systems →
// Journaux / Logs). They forward to the captured console methods, so entries
// also show up in devtools. `console.log` is captured as the `info` level.
// ---------------------------------------------------------------------------
export function logInfo(...args: unknown[]): void {
console.log(...args);
}
export function logWarn(...args: unknown[]): void {
console.warn(...args);
}
export function logError(...args: unknown[]): void {
console.error(...args);
}
export function subscribe(listener: LogListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);

View file

@ -1,9 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db";

View file

@ -8,9 +8,13 @@ import {
} from "./reportService";
// Mock the db module
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
import { getDb } from "./db";