diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index b8747fffa7..970e9ec11f 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -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> | 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 { 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; } diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index efeaf78ccd..02b9982f27 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -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); diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 300bda50f1..4aa8e1d345 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -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(); /** * 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( diff --git a/tests/unit/db-core-native-error.test.ts b/tests/unit/db-core-native-error.test.ts index ae836978fb..309e33b8a9 100644 --- a/tests/unit/db-core-native-error.test.ts +++ b/tests/unit/db-core-native-error.test.ts @@ -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); +}); diff --git a/tests/unit/db-migration-runner.test.ts b/tests/unit/db-migration-runner.test.ts index 02295df2e0..7fb4f7b86c 100644 --- a/tests/unit/db-migration-runner.test.ts +++ b/tests/unit/db-migration-runner.test.ts @@ -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,