Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/8724-native-sqlite-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(db):** fall back to Node's built-in SQLite driver when better-sqlite3 is unavailable, while preserving read-only and existing-file safeguards
129 changes: 76 additions & 53 deletions src/lib/db/adapters/driverFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import type { SqliteAdapter } from "./types";

const _require = createRequire(import.meta.url);

type DriverLoader = (moduleName: string) => unknown;

type NodeSqliteOptions = {
readOnly?: boolean;
};

function toNodeSqliteOptions(options?: Record<string, unknown>): NodeSqliteOptions | undefined {
if (options?.readonly !== true) return undefined;
return { readOnly: true };
}

/**
* Logs the underlying cause of a swallowed sync-driver failure (#7288
* secondary finding). tryOpenSync() used to swallow both driver errors in
Expand Down Expand Up @@ -67,70 +78,82 @@ function getSqlJsPendingCache(): Map<string, Promise<SqliteAdapter>> {
return globalThis.__omnirouteSqlJsInitPromises;
}

/** Tenta abrir com better-sqlite3 e node:sqlite sincronamente. Retorna null se ambos falharem. */
export function tryOpenSync(
filePath: string,
options?: Record<string, unknown>
): SqliteAdapter | null {
// Bun ships a supported SQLite implementation. Prefer it over the native
// Node addon, which Bun intentionally skips because its ABI is incompatible.
if (process.versions.bun) {
try {
const { Database } = _require("bun:sqlite") as {
Database: new (p: string, options?: Record<string, unknown>) => BunSqliteDatabaseLike;
};
if (
options?.fileMustExist === true &&
filePath !== ":memory:" &&
!existsSync(filePath)
) {
throw new Error(`SQLite file does not exist: ${filePath}`);
/**
* @internal
*
* Builds the synchronous driver cascade. Keeping the loader injectable makes
* the real node:sqlite branch testable without changing the public adapter API.
*/
export function createSyncDriverFactory(load: DriverLoader) {
return function tryOpenSync(
filePath: string,
options?: Record<string, unknown>
): SqliteAdapter | null {
// Bun ships a supported SQLite implementation. Prefer it over the native
// Node addon, which Bun intentionally skips because its ABI is incompatible.
if (process.versions.bun) {
try {
const { Database } = load("bun:sqlite") as {
Database: new (p: string, options?: Record<string, unknown>) => BunSqliteDatabaseLike;
};
if (options?.fileMustExist === true && filePath !== ":memory:" && !existsSync(filePath)) {
throw new Error(`SQLite file does not exist: ${filePath}`);
}
const db = new Database(filePath, {
...(options?.readonly === true
? { readonly: true }
: { readwrite: true, create: options?.fileMustExist !== true }),
});
return createBunSqliteAdapter(db, filePath);
} catch (err) {
logSwallowedDriverError("bun:sqlite", err);
}
const db = new Database(filePath, {
...(options?.readonly === true
? { readonly: true }
: { readwrite: true, create: options?.fileMustExist !== true }),
});
return createBunSqliteAdapter(db, filePath);
} catch (err) {
logSwallowedDriverError("bun:sqlite", err);
}
}

// better-sqlite3: rápido, nativo — skip em Bun
if (!process.versions.bun) {
try {
const BetterSqlite = _require("better-sqlite3") as {
new (p: string, o?: object): import("better-sqlite3").Database;
};
const db = new BetterSqlite(filePath, options);
return createBetterSqliteAdapter(db);
} catch (err) {
// continua para próximo driver
logSwallowedDriverError("better-sqlite3", err);
}
}

// node:sqlite: built-in desde Node 22.5 — skip em Bun
if (!process.versions.bun) {
const [maj, min] = (process.versions.node ?? "0.0").split(".").map(Number);
if (maj > 22 || (maj === 22 && min >= 5)) {
// better-sqlite3: rápido, nativo — skip em Bun
if (!process.versions.bun) {
try {
const { DatabaseSync } = _require("node:sqlite") as {
DatabaseSync: new (p: string) => NodeSqliteDatabaseLike;
const BetterSqlite = load("better-sqlite3") as {
new (p: string, o?: object): import("better-sqlite3").Database;
};
const db = new DatabaseSync(filePath);
return createNodeSqliteAdapterFromDatabase(db, filePath);
const db = new BetterSqlite(filePath, options);
return createBetterSqliteAdapter(db);
} catch (err) {
// continua
logSwallowedDriverError("node:sqlite", err);
// continua para próximo driver
logSwallowedDriverError("better-sqlite3", err);
}
}
}

return null;
// node:sqlite: built-in desde Node 22.5 — skip em Bun
if (!process.versions.bun) {
const [maj, min] = (process.versions.node ?? "0.0").split(".").map(Number);
if (maj > 22 || (maj === 22 && min >= 5)) {
try {
if (options?.fileMustExist === true && filePath !== ":memory:" && !existsSync(filePath)) {
throw new Error(`SQLite file does not exist: ${filePath}`);
}
const { DatabaseSync } = load("node:sqlite") as {
DatabaseSync: new (p: string, options?: NodeSqliteOptions) => NodeSqliteDatabaseLike;
};
const nodeOptions = toNodeSqliteOptions(options);
const db = nodeOptions
? new DatabaseSync(filePath, nodeOptions)
: new DatabaseSync(filePath);
return createNodeSqliteAdapterFromDatabase(db, filePath);
} catch (err) {
// continua
logSwallowedDriverError("node:sqlite", err);
}
}
}

return null;
};
}

/** Tenta abrir com better-sqlite3 e node:sqlite sincronamente. Retorna null se ambos falharem. */
export const tryOpenSync = createSyncDriverFactory(_require);

/**
* Pré-inicializa sql.js para um filePath.
* Armazena em globalThis para acesso posterior via getSqlJsAdapter().
Expand Down
145 changes: 144 additions & 1 deletion tests/unit/db-adapters/driverFactory.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";

const { tryOpenSync, openDatabaseAsync, preInitSqlJs, getSqlJsAdapter } =
const { createSyncDriverFactory, tryOpenSync, openDatabaseAsync, preInitSqlJs, getSqlJsAdapter } =
await import("../../../src/lib/db/adapters/driverFactory.ts");

const require = createRequire(import.meta.url);
const isBun = Boolean(process.versions.bun);

function forceNodeSqlite() {
return createSyncDriverFactory((moduleName: string) => {
if (moduleName === "better-sqlite3") {
throw new Error("forced better-sqlite3 load failure");
}
return require(moduleName);
});
}

function createTempDatabasePath(t: Parameters<typeof test>[1]) {
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 }));
return databasePath;
}

describe("driverFactory", () => {
test("tryOpenSync retorna adapter síncrono ou null", () => {
const adapter = tryOpenSync(":memory:");
Expand All @@ -19,6 +42,126 @@ describe("driverFactory", () => {
}
});

if (!isBun) {
test("prefers better-sqlite3 when it loads", (t) => {
const adapter = tryOpenSync(":memory:");
if (!adapter || adapter.driver !== "better-sqlite3") {
adapter?.close();
t.skip("better-sqlite3 is not available in this environment");
return;
}

assert.ok(adapter);
assert.equal(adapter.driver, "better-sqlite3");
adapter.close();
});

test("prefers better-sqlite3 before node:sqlite in the driver cascade", () => {
const fakeBetterSqlite = {
close() {},
name: ":memory:",
open: true,
};
let nodeSqliteRequested = false;
const openWithPreferredDriver = createSyncDriverFactory((moduleName: string) => {
if (moduleName === "better-sqlite3") {
return function FakeBetterSqlite() {
return fakeBetterSqlite;
};
}
if (moduleName === "node:sqlite") {
nodeSqliteRequested = true;
}
throw new Error(`unexpected driver load: ${moduleName}`);
});

const adapter = openWithPreferredDriver(":memory:");
assert.ok(adapter);
assert.equal(adapter.driver, "better-sqlite3");
assert.equal(nodeSqliteRequested, false);
adapter.close();
});

test("forced node:sqlite fallback creates, reopens, and queries a writable database", (t) => {
const databasePath = createTempDatabasePath(t);
const openNodeSqlite = forceNodeSqlite();

const writer = openNodeSqlite(databasePath);
assert.ok(writer);
assert.equal(writer.driver, "node:sqlite");
writer.exec("CREATE TABLE items (value TEXT)");
writer.prepare("INSERT INTO items VALUES (?)").run("native");
writer.close();

const reader = openNodeSqlite(databasePath);
assert.ok(reader);
assert.equal(reader.driver, "node:sqlite");
assert.equal(
(reader.prepare("SELECT value FROM items").get() as { value: string }).value,
"native"
);
reader.close();
});

test("forced node:sqlite fallback does not create missing existing-only paths", (t) => {
const databasePath = createTempDatabasePath(t);
const adapter = forceNodeSqlite()(databasePath, { readonly: true, fileMustExist: true });

assert.equal(adapter, null);
assert.equal(fs.existsSync(databasePath), false);
assert.equal(fs.existsSync(`${databasePath}-wal`), false);
assert.equal(fs.existsSync(`${databasePath}-shm`), false);
});

test("forced node:sqlite fallback preserves existing read-only behavior", (t) => {
const databasePath = createTempDatabasePath(t);
const { DatabaseSync } = require("node:sqlite") as {
DatabaseSync: new (filePath: string) => {
close(): void;
exec(sql: string): void;
};
};
const seed = new DatabaseSync(databasePath);
seed.exec("CREATE TABLE items (value TEXT); INSERT INTO items VALUES ('seed');");
seed.close();

const adapter = forceNodeSqlite()(databasePath, { readonly: true, fileMustExist: true });
assert.ok(adapter);
assert.equal(adapter.driver, "node:sqlite");
assert.equal(
(adapter.prepare("SELECT value FROM items").get() as { value: string }).value,
"seed"
);
assert.throws(() => adapter.exec("INSERT INTO items VALUES ('write')"));
adapter.close();

const check = new DatabaseSync(databasePath);
const row = check.prepare("SELECT value FROM items").get() as { value: string };
check.close();
assert.equal(row.value, "seed");
});

test("forced node:sqlite fallback keeps extension loading disabled", () => {
const adapter = forceNodeSqlite()(":memory:");
assert.ok(adapter);
assert.equal(adapter.driver, "node:sqlite");

const raw = adapter.raw as { loadExtension(path: string): void };
assert.throws(() => raw.loadExtension("not-a-trusted-extension"), {
code: "ERR_INVALID_STATE",
});
adapter.close();
});
}

test("retains the existing cascade when native drivers are unavailable", () => {
const openWithoutNativeDrivers = createSyncDriverFactory(() => {
throw new Error("forced driver load failure");
});

assert.equal(openWithoutNativeDrivers(":memory:"), null);
});

test("openDatabaseAsync sempre retorna um adapter válido", async () => {
const adapter = await openDatabaseAsync(":memory:");
assert.ok(["better-sqlite3", "node:sqlite", "bun:sqlite", "sql.js"].includes(adapter.driver));
Expand Down