mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user