fix(db): detect SQLite driver-unavailable errors to avoid destructive rename (#3274)

Detect SQLite driver-unavailable errors to avoid destructive DB rename + optional FTS5 migration guard (split from #3073).

Integrated into release/v3.8.12. Thanks @zhiru.
This commit is contained in:
Felipe Almeman
2026-06-06 02:15:17 -03:00
committed by GitHub
parent 6bfba384d8
commit 6674f6a4f2
5 changed files with 212 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
// src/lib/db/adapters/sqljsAdapter.ts
import fs from "node:fs";
import path from "node:path";
import type { SqliteAdapter, PreparedStatement, RunResult } from "./types";
const SAVE_DEBOUNCE_MS = 100;
@@ -7,11 +8,35 @@ const CHECKPOINT_INTERVAL_MS = 60_000;
let _sqlJsLib: Awaited<ReturnType<(typeof import("sql.js"))["default"]>> | null = null;
function resolveSqlJsWasmPath(): string {
const candidatePaths = [
path.join(process.cwd(), "node_modules", "sql.js", "dist", "sql-wasm.wasm"),
path.join(process.cwd(), ".next", "standalone", "node_modules", "sql.js", "dist", "sql-wasm.wasm"),
];
for (const candidatePath of candidatePaths) {
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
return candidatePaths[0];
}
async function loadSqlJs(): Promise<typeof _sqlJsLib> {
if (_sqlJsLib) return _sqlJsLib;
const initSqlJs = ((await import("sql.js")) as { default: (typeof import("sql.js"))["default"] })
.default;
_sqlJsLib = await initSqlJs();
const wasmPath = resolveSqlJsWasmPath();
_sqlJsLib = await initSqlJs({
locateFile(fileName) {
if (fileName === "sql-wasm.wasm") {
return wasmPath;
}
return fileName;
},
});
return _sqlJsLib;
}

View File

@@ -117,6 +117,16 @@ export function isNativeSqliteLoadError(error: unknown): boolean {
);
}
export function isSqliteDriverUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Nenhum driver SQLite disponível") ||
message.includes("Chame ensureDbInitialized() no startup") ||
message.includes("sql.js WASM ainda não foi pré-inicializado")
);
}
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object" || !("code" in error)) return undefined;
const code = (error as { code?: unknown }).code;
@@ -1248,7 +1258,11 @@ export function getDbInstance(): SqliteDatabase {
console.warn("[DB] Could not probe existing DB:", message);
// If the error is a Node module/ABI failure, throw it immediately to avoid renaming the database
if (isNativeSqliteLoadError(e) || message.includes("could not be found")) {
if (
isNativeSqliteLoadError(e) ||
isSqliteDriverUnavailableError(e) ||
message.includes("could not be found")
) {
throw e;
}
preservedCriticalState = captureCriticalDbState(sqliteFile);

View File

@@ -217,6 +217,8 @@ const PHYSICAL_SCHEMA_SENTINELS = [
] as const;
const INITIAL_SCHEMA_SENTINELS = ["provider_connections", "combos", "call_logs"] as const;
const OPTIONAL_FTS5_MIGRATION_VERSIONS = new Set(["022", "023"]);
const fts5SupportCache = new WeakMap<SqliteAdapter, boolean>();
/**
* Ensure the schema_migrations tracking table exists.
@@ -231,6 +233,41 @@ function ensureMigrationsTable(db: SqliteAdapter): void {
`);
}
function isOptionalFts5Migration(migration: { version: string; name: string }): boolean {
return OPTIONAL_FTS5_MIGRATION_VERSIONS.has(migration.version);
}
function supportsFts5(db: SqliteAdapter): boolean {
const cached = fts5SupportCache.get(db);
if (cached !== undefined) {
return cached;
}
try {
const probeTable = `__omniroute_fts5_probe_${Date.now()}_${Math.random().toString(36).slice(2)}`;
db.transaction(() => {
db.exec(`CREATE VIRTUAL TABLE "${probeTable}" USING fts5(content);`);
db.exec(`DROP TABLE "${probeTable}";`);
})();
fts5SupportCache.set(db, true);
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (/no such module:\s*fts5/i.test(message)) {
fts5SupportCache.set(db, false);
return false;
}
throw error;
}
}
function isDeferredUnsupportedMigration(
db: SqliteAdapter,
migration: { version: string; name: string }
): boolean {
return isOptionalFts5Migration(migration) && !supportsFts5(db);
}
/**
* Get all migration files sorted by version number.
*/
@@ -873,11 +910,23 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
}
return isMissing;
});
const deferredUnsupported = pending.filter((migration) => isDeferredUnsupportedMigration(db, migration));
const actionablePending = pending.filter(
(migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
if (pending.length === 0) {
return 0; // Nothing to do
}
if (deferredUnsupported.length > 0) {
const summary = deferredUnsupported.map((migration) => `${migration.version}_${migration.name}`).join(", ");
console.warn(
`[Migration] Deferring optional FTS5 migrations on driver ${db.driver}: ${summary}. ` +
`Memory search will fall back until a SQLite driver with FTS5 support is available.`
);
}
// ── Safety Check 2: Mass-migration detection (abort if existing DB + many migrations) ──
// Skip in test environments where fresh DBs legitimately have many pending migrations.
const isTestEnvironment =
@@ -891,16 +940,16 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
MAX_PENDING_MIGRATIONS_ON_EXISTING_DB > 0 &&
applied.size > 0 &&
pending.length > MAX_PENDING_MIGRATIONS_ON_EXISTING_DB
actionablePending.length > MAX_PENDING_MIGRATIONS_ON_EXISTING_DB
) {
const physicalBaseline = inferPhysicalSchemaBaseline(db);
const plausiblePendingCount = physicalBaseline
? getPlausiblePendingCount(files, physicalBaseline.version)
: null;
if (plausiblePendingCount !== null && pending.length <= plausiblePendingCount) {
if (plausiblePendingCount !== null && actionablePending.length <= plausiblePendingCount) {
console.warn(
`[Migration] Allowing ${pending.length} pending migrations on an existing database ` +
`[Migration] Allowing ${actionablePending.length} pending migrations on an existing database ` +
`because the physical schema only proves ${physicalBaseline?.version} ` +
`(${physicalBaseline?.description}).`
);
@@ -912,7 +961,7 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
`migration(s) are expected from a legitimate upgrade.`
: "";
const msg =
`[Migration] 🛑 ABORT: Detected ${pending.length} pending migrations on an existing database ` +
`[Migration] 🛑 ABORT: Detected ${actionablePending.length} pending migrations on an existing database ` +
`(threshold is ${MAX_PENDING_MIGRATIONS_ON_EXISTING_DB}). ` +
`This usually means the migration tracking table was accidentally wiped. ` +
`Running all migrations from scratch will cause data loss or schema errors.` +
@@ -932,6 +981,10 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
let count = 0;
for (const migration of pending) {
if (isDeferredUnsupportedMigration(db, migration)) {
continue;
}
const applyMigration = db.transaction(() => {
if (isSchemaAlreadyApplied(db, migration)) {
console.warn(

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isNativeSqliteLoadError } from "../../src/lib/db/core";
import { isNativeSqliteLoadError, isSqliteDriverUnavailableError } from "../../src/lib/db/core";
test("isNativeSqliteLoadError detects Module did not self-register", () => {
const err = new Error("Module did not self-register: better_sqlite3.node");
@@ -52,3 +52,16 @@ test("isNativeSqliteLoadError returns false for unrelated errors", () => {
assert.equal(isNativeSqliteLoadError(undefined), false);
assert.equal(isNativeSqliteLoadError("some string"), false);
});
test("isSqliteDriverUnavailableError detects pre-init sql.js fallback errors", () => {
const err = new Error(
"[DB] Nenhum driver SQLite disponível para '/tmp/storage.sqlite'. Chame ensureDbInitialized() no startup. sql.js WASM ainda não foi pré-inicializado."
);
assert.equal(isSqliteDriverUnavailableError(err), true);
});
test("isSqliteDriverUnavailableError returns false for unrelated errors", () => {
assert.equal(isSqliteDriverUnavailableError(new Error("SQLITE_BUSY: database is locked")), false);
assert.equal(isSqliteDriverUnavailableError(undefined), false);
});

View File

@@ -61,6 +61,47 @@ function createDb() {
return new Database(":memory:");
}
function createSqlJsLikeDb() {
const db = createDb();
return {
driver: "sql.js",
get open() {
return true;
},
get name() {
return ":memory:";
},
prepare(sql) {
return db.prepare(sql);
},
exec(sql) {
if (/fts5/i.test(sql)) {
throw new Error("no such module: fts5");
}
db.exec(sql);
},
pragma(pragmaStr, options) {
return db.pragma(pragmaStr, options);
},
transaction(fn) {
const tx = db.transaction((...args) => fn(...args));
return (...args) => tx(...args);
},
immediate(fn) {
fn();
},
async backup() {},
checkpoint() {},
close() {
db.close();
},
get raw() {
return db;
},
};
}
function createInitialSchemaTables(db) {
db.exec(`
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
@@ -578,6 +619,65 @@ test(
}
);
test(
"runMigrations defers optional FTS migrations when the current driver lacks fts5 support",
serial,
async () => {
const runner = await importFresh("src/lib/db/migrationRunner.ts");
const db = createSqlJsLikeDb();
try {
db.exec(`
CREATE TABLE _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE memories (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
session_id TEXT,
type TEXT NOT NULL,
key TEXT,
content TEXT NOT NULL,
metadata TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT
);
`);
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
"021",
"combo_call_log_targets"
);
const count = withMockedMigrationFs(
{
"022_add_memory_fts5.sql": REAL_022_ADD_MEMORY_FTS5_SQL,
"023_fix_memory_fts_uuid.sql": REAL_023_FIX_MEMORY_FTS_UUID_SQL,
"024_after_fts.sql": "CREATE TABLE after_fts (id INTEGER PRIMARY KEY);",
},
() => runner.runMigrations(db)
);
assert.equal(count, 1);
assert.deepEqual(
db.prepare("SELECT version FROM _omniroute_migrations ORDER BY version").all(),
[{ version: "021" }, { version: "024" }]
);
assert.equal(
db
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("memory_fts").count,
0
);
} finally {
db.close();
}
}
);
test(
"runMigrations allows a large pending set when the physical schema still looks like 001",
serial,