mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
feat(db): add node sqlite adapter parity (#8871)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
This commit is contained in:
1
changelog.d/features/8870-node-sqlite-adapter-parity.md
Normal file
1
changelog.d/features/8870-node-sqlite-adapter-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Database**: The `node:sqlite` fallback now uses SQLite's native backup API and real immediate write transactions, improving backup consistency and concurrent-write behavior when `better-sqlite3` is unavailable
|
||||
@@ -19,6 +19,7 @@ export function createNodeSqliteAdapterFromDatabase(
|
||||
onClose?: () => void
|
||||
): SqliteAdapter {
|
||||
let _isOpen = true;
|
||||
let transactionDepth = 0;
|
||||
type NodeSqliteStatement = ReturnType<NodeSqliteDatabaseLike["prepare"]>;
|
||||
interface CachedStatement {
|
||||
stmt: NodeSqliteStatement;
|
||||
@@ -69,6 +70,27 @@ export function createNodeSqliteAdapterFromDatabase(
|
||||
}
|
||||
}
|
||||
|
||||
function runImmediate(fn: () => void): void {
|
||||
if (transactionDepth > 0) {
|
||||
runSavepoint(fn);
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
transactionDepth += 1;
|
||||
try {
|
||||
fn();
|
||||
db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
try {
|
||||
db.exec("ROLLBACK");
|
||||
} catch {} // The failed transaction may already have released its write lock.
|
||||
throw error;
|
||||
} finally {
|
||||
transactionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
try {
|
||||
onClose?.();
|
||||
@@ -124,12 +146,25 @@ export function createNodeSqliteAdapterFromDatabase(
|
||||
return db.prepare(sql).all();
|
||||
},
|
||||
transaction<T>(fn: (...args: unknown[]) => T): (...args: unknown[]) => T {
|
||||
return (...args: unknown[]) => runSavepoint(fn, ...args);
|
||||
return (...args: unknown[]) => {
|
||||
transactionDepth += 1;
|
||||
try {
|
||||
return runSavepoint(fn, ...args);
|
||||
} finally {
|
||||
transactionDepth -= 1;
|
||||
}
|
||||
};
|
||||
},
|
||||
immediate(fn: () => void): void {
|
||||
runSavepoint(() => fn());
|
||||
runImmediate(fn);
|
||||
},
|
||||
async backup(destination: string): Promise<void> {
|
||||
const { backup } = await import("node:sqlite");
|
||||
if (typeof backup === "function") {
|
||||
await backup(db as never, destination);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, describe } from "node:test";
|
||||
import { test, describe, type TestContext } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -26,7 +26,7 @@ function forceNodeSqlite() {
|
||||
});
|
||||
}
|
||||
|
||||
function createTempDatabasePath(t: Parameters<typeof test>[1]) {
|
||||
function createTempDatabasePath(t: TestContext) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-node-sqlite-"));
|
||||
const databasePath = path.join(dir, "database.sqlite");
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
@@ -134,6 +134,7 @@ describe("driverFactory", () => {
|
||||
DatabaseSync: new (filePath: string) => {
|
||||
close(): void;
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): { get(): unknown };
|
||||
};
|
||||
};
|
||||
const seed = new DatabaseSync(databasePath);
|
||||
@@ -167,6 +168,101 @@ describe("driverFactory", () => {
|
||||
});
|
||||
adapter.close();
|
||||
});
|
||||
|
||||
test("forced node:sqlite backup preserves WAL-backed data", async (t) => {
|
||||
const sourcePath = createTempDatabasePath(t);
|
||||
const destinationPath = path.join(path.dirname(sourcePath), "backup.sqlite");
|
||||
const openNodeSqlite = forceNodeSqlite();
|
||||
const source = openNodeSqlite(sourcePath);
|
||||
assert.ok(source);
|
||||
assert.equal(source.driver, "node:sqlite");
|
||||
source.exec("PRAGMA journal_mode = WAL; CREATE TABLE items (value TEXT);");
|
||||
source.prepare("INSERT INTO items VALUES (?)").run("backup value");
|
||||
|
||||
await source.backup(destinationPath);
|
||||
source.close();
|
||||
|
||||
const destination = openNodeSqlite(destinationPath, { fileMustExist: true });
|
||||
assert.ok(destination);
|
||||
assert.equal(destination.driver, "node:sqlite");
|
||||
assert.equal(
|
||||
(destination.prepare("SELECT value FROM items").get() as { value: string }).value,
|
||||
"backup value"
|
||||
);
|
||||
destination.close();
|
||||
});
|
||||
|
||||
test("forced node:sqlite immediate commits, rolls back, and nests savepoints", (t) => {
|
||||
const databasePath = createTempDatabasePath(t);
|
||||
const adapter = forceNodeSqlite()(databasePath);
|
||||
assert.ok(adapter);
|
||||
assert.equal(adapter.driver, "node:sqlite");
|
||||
adapter.exec("CREATE TABLE items (value TEXT)");
|
||||
|
||||
adapter.immediate(() => {
|
||||
adapter.prepare("INSERT INTO items VALUES (?)").run("committed");
|
||||
});
|
||||
assert.throws(() =>
|
||||
adapter.immediate(() => {
|
||||
adapter.prepare("INSERT INTO items VALUES (?)").run("rolled back");
|
||||
throw new Error("rollback");
|
||||
})
|
||||
);
|
||||
adapter.immediate(() => {
|
||||
adapter.prepare("INSERT INTO items VALUES (?)").run("outer before");
|
||||
const nested = adapter.transaction(() => {
|
||||
adapter.prepare("INSERT INTO items VALUES (?)").run("inner rolled back");
|
||||
throw new Error("nested rollback");
|
||||
});
|
||||
assert.throws(() => nested());
|
||||
adapter.prepare("INSERT INTO items VALUES (?)").run("outer after");
|
||||
});
|
||||
|
||||
const rows = adapter.prepare("SELECT value FROM items ORDER BY rowid").all() as Array<{
|
||||
value: string;
|
||||
}>;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.value),
|
||||
["committed", "outer before", "outer after"]
|
||||
);
|
||||
adapter.close();
|
||||
});
|
||||
|
||||
test("forced node:sqlite immediate blocks a competing writer", (t) => {
|
||||
const databasePath = createTempDatabasePath(t);
|
||||
const openNodeSqlite = forceNodeSqlite();
|
||||
const first = openNodeSqlite(databasePath);
|
||||
assert.ok(first);
|
||||
first.exec("CREATE TABLE items (value TEXT)");
|
||||
|
||||
const { DatabaseSync } = require("node:sqlite") as {
|
||||
DatabaseSync: new (
|
||||
filePath: string,
|
||||
options: { timeout: number }
|
||||
) => {
|
||||
close(): void;
|
||||
exec(sql: string): void;
|
||||
};
|
||||
};
|
||||
const second = new DatabaseSync(databasePath, { timeout: 50 });
|
||||
try {
|
||||
first.immediate(() => {
|
||||
assert.throws(() => second.exec("INSERT INTO items VALUES ('competing writer')"), {
|
||||
code: "ERR_SQLITE_ERROR",
|
||||
});
|
||||
first.prepare("INSERT INTO items VALUES (?)").run("owner");
|
||||
});
|
||||
|
||||
const rows = first.prepare("SELECT value FROM items").all() as Array<{ value: string }>;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.value),
|
||||
["owner"]
|
||||
);
|
||||
} finally {
|
||||
second.close();
|
||||
first.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("retains the existing cascade when native drivers are unavailable", () => {
|
||||
|
||||
@@ -80,6 +80,46 @@ test("createNodeSqliteAdapterFromDatabase uses savepoints for transactions", ()
|
||||
assert.equal(db.execCalls[1].startsWith("RELEASE "), true);
|
||||
});
|
||||
|
||||
test("createNodeSqliteAdapterFromDatabase uses BEGIN IMMEDIATE outside transactions", () => {
|
||||
const db = new FakeDb();
|
||||
const adapter = createNodeSqliteAdapterFromDatabase(db, ":memory:");
|
||||
|
||||
adapter.immediate(() => {});
|
||||
|
||||
assert.deepEqual(db.execCalls, ["BEGIN IMMEDIATE", "COMMIT"]);
|
||||
});
|
||||
|
||||
test("createNodeSqliteAdapterFromDatabase rolls back failed immediate transactions", () => {
|
||||
const db = new FakeDb();
|
||||
const adapter = createNodeSqliteAdapterFromDatabase(db, ":memory:");
|
||||
|
||||
assert.throws(() =>
|
||||
adapter.immediate(() => {
|
||||
throw new Error("fail");
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(db.execCalls, ["BEGIN IMMEDIATE", "ROLLBACK"]);
|
||||
});
|
||||
|
||||
test("createNodeSqliteAdapterFromDatabase nests transactions in immediate savepoints", () => {
|
||||
const db = new FakeDb();
|
||||
const adapter = createNodeSqliteAdapterFromDatabase(db, ":memory:");
|
||||
|
||||
adapter.immediate(() => {
|
||||
const nested = adapter.transaction(() => {
|
||||
throw new Error("inner failure");
|
||||
});
|
||||
assert.throws(() => nested());
|
||||
});
|
||||
|
||||
assert.equal(db.execCalls[0], "BEGIN IMMEDIATE");
|
||||
assert.match(db.execCalls[1], /^SAVEPOINT /);
|
||||
assert.match(db.execCalls[2], /^ROLLBACK TO /);
|
||||
assert.match(db.execCalls[3], /^RELEASE /);
|
||||
assert.equal(db.execCalls[4], "COMMIT");
|
||||
});
|
||||
|
||||
test("createNodeSqliteAdapterFromDatabase finalizes cached statements on close", () => {
|
||||
const db = new FakeDb();
|
||||
let closedHookCalls = 0;
|
||||
|
||||
Reference in New Issue
Block a user