diff --git a/app/api/nodes/search/route.ts b/app/api/nodes/search/route.ts index 91cf7a5..15db5ca 100644 --- a/app/api/nodes/search/route.ts +++ b/app/api/nodes/search/route.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { nodeService } from '@/services/database'; +import { getSQLiteClient } from '@/services/database/sqlite-client'; export const runtime = 'nodejs'; export async function GET(request: NextRequest) { try { + const integrity = getSQLiteClient().getIntegrityReport(); const searchParams = request.nextUrl.searchParams; const query = searchParams.get('q'); const limit = parseInt(searchParams.get('limit') || '10'); @@ -35,7 +37,10 @@ export async function GET(request: NextRequest) { success: true, data: results, count: results.length, - query: query.trim() + query: query.trim(), + degraded: integrity.state !== 'healthy', + integrityState: integrity.state, + integritySummary: integrity.state !== 'healthy' ? integrity.summary : undefined, }); } catch (error) { console.error('Error searching nodes:', error); diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index 93f1626..ddef019 100644 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -220,31 +220,6 @@ function checkFtsAvailability() { return _ftsAvailability; } -function rebuildFtsIndexes() { - const fts = checkFtsAvailability(); - if (!fts.nodes && !fts.chunks) return; - - const db = getDb(); - if (fts.nodes) { - try { - db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')"); - log('Rebuilt nodes_fts index'); - } catch (err) { - log('Warning: Failed to rebuild nodes_fts:', err.message); - _ftsAvailability.nodes = false; - } - } - if (fts.chunks) { - try { - db.exec("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')"); - log('Rebuilt chunks_fts index'); - } catch (err) { - log('Warning: Failed to rebuild chunks_fts:', err.message); - _ftsAvailability.chunks = false; - } - } -} - // Security: Only allow read-only SQL statements function isReadOnlyQuery(sql) { const normalized = sql.trim().toLowerCase(); @@ -283,7 +258,6 @@ async function main() { try { initDatabase(); log('Database connected:', getDatabasePath()); - rebuildFtsIndexes(); } catch (error) { log('ERROR:', error.message); process.exit(1); diff --git a/scripts/database/sqlite-repair-fts.sh b/scripts/database/sqlite-repair-fts.sh new file mode 100755 index 0000000..a23b8c9 --- /dev/null +++ b/scripts/database/sqlite-repair-fts.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Repair rebuildable FTS corruption by rebuilding a clean SQLite file from the +# readable canonical tables, then swapping the rebuilt file into place. + +DB_PATH=${SQLITE_DB_PATH:-} +if [ -z "${DB_PATH}" ] && [ -f ".env.local" ]; then + DB_PATH=$(grep -E '^SQLITE_DB_PATH=' .env.local | sed 's/^SQLITE_DB_PATH=//' | sed 's/^"\(.*\)"$/\1/') || true +fi +if [ -z "${DB_PATH}" ]; then + DB_PATH="$HOME/Library/Application Support/RA-H/db/rah.sqlite" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Resolved DB not found: $DB_PATH" >&2 + exit 1 +fi + +DB_DIR="$(dirname "$DB_PATH")" +DB_NAME="$(basename "$DB_PATH")" +TS=$(date +"%Y%m%d_%H%M%S") +RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}" +REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}" +QUOTED_DB_PATH=${DB_PATH//\'/\'\'} + +mkdir -p "$RAW_BACKUP_DIR" + +cp -p "$DB_PATH" "$RAW_BACKUP_DIR/$DB_NAME" +if [ -f "${DB_PATH}-wal" ]; then + cp -p "${DB_PATH}-wal" "$RAW_BACKUP_DIR/${DB_NAME}-wal" +fi +if [ -f "${DB_PATH}-shm" ]; then + cp -p "${DB_PATH}-shm" "$RAW_BACKUP_DIR/${DB_NAME}-shm" +fi + +echo "Raw backup saved to: $RAW_BACKUP_DIR" + +echo "Preflight source probes:" +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM nodes;" | sed 's/^/ nodes: /' +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM edges;" | sed 's/^/ edges: /' +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM chunks;" | sed 's/^/ chunks: /' +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM nodes_fts;" | sed 's/^/ nodes_fts: /' || true +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM chunks_fts;" | sed 's/^/ chunks_fts: /' || true + +rm -f "$REBUILT_DB" "${REBUILT_DB}-wal" "${REBUILT_DB}-shm" + +sqlite3 "$REBUILT_DB" < { +} + +// Health check utility +export async function checkDatabaseHealth(): Promise { try { return checkSQLiteDatabaseHealth(); } catch (error) { @@ -22,20 +42,26 @@ export async function checkDatabaseHealth(): Promise<{ connected: false, vectorExtension: false, tablesExist: false, + quickCheckOk: false, + integrityCheckOk: false, + foreignKeyViolations: -1, + lostAndFoundExists: false, + ftsTables: { nodes: false, chunks: false }, + vecTables: { nodes: false, chunks: false }, + integrityState: 'corrupt', + repairableFtsTables: [], + canRepairFts: false, + summary: 'Database health check failed before classification.', error: error instanceof Error ? error.message : 'Unknown error' }; } } -async function checkSQLiteDatabaseHealth(): Promise<{ - connected: boolean; - vectorExtension: boolean; - tablesExist: boolean; - error?: string; -}> { +async function checkSQLiteDatabaseHealth(): Promise { try { const { getSQLiteClient } = await import('./sqlite-client'); const sqlite = getSQLiteClient(); + const integrity = sqlite.getIntegrityReport(true); const connected = await sqlite.testConnection(); if (!connected) { @@ -43,6 +69,16 @@ async function checkSQLiteDatabaseHealth(): Promise<{ connected: false, vectorExtension: false, tablesExist: false, + quickCheckOk: false, + integrityCheckOk: false, + foreignKeyViolations: -1, + lostAndFoundExists: false, + ftsTables: { nodes: false, chunks: false }, + vecTables: { nodes: false, chunks: false }, + integrityState: integrity.state, + repairableFtsTables: integrity.repairableFtsTables, + canRepairFts: integrity.canRepairFts, + summary: integrity.summary, error: 'SQLite connection failed' }; } @@ -57,13 +93,34 @@ async function checkSQLiteDatabaseHealth(): Promise<{ return { connected, vectorExtension, - tablesExist + tablesExist, + quickCheckOk: integrity.quickCheck.ok, + integrityCheckOk: integrity.integrityCheck.ok, + foreignKeyViolations: integrity.foreignKeyViolations, + lostAndFoundExists: integrity.lostAndFoundExists, + ftsTables: integrity.ftsTables, + vecTables: integrity.vecTables, + integrityState: integrity.state, + repairableFtsTables: integrity.repairableFtsTables, + canRepairFts: integrity.canRepairFts, + summary: integrity.summary, + error: integrity.error, }; } catch (error) { return { connected: false, vectorExtension: false, tablesExist: false, + quickCheckOk: false, + integrityCheckOk: false, + foreignKeyViolations: -1, + lostAndFoundExists: false, + ftsTables: { nodes: false, chunks: false }, + vecTables: { nodes: false, chunks: false }, + integrityState: 'corrupt', + repairableFtsTables: [], + canRepairFts: false, + summary: 'Database health check failed during execution.', error: error instanceof Error ? error.message : 'Unknown error' }; } diff --git a/src/services/database/nodes.ts b/src/services/database/nodes.ts index 74a854f..8a01c1e 100644 --- a/src/services/database/nodes.ts +++ b/src/services/database/nodes.ts @@ -498,14 +498,11 @@ export class NodeService { if (!search) return 0; const ftsQuery = sanitizeFtsQuery(search); - const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'" - ).get(); const { clauses, params } = this.buildNodeFilterClauses(filters); - if (ftsExists && ftsQuery) { - const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + if (sqlite.canUseFtsTable('nodes') && ftsQuery) { try { + const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const result = sqlite.query<{ total: number }>(` WITH matched_nodes AS ( SELECT rowid @@ -520,7 +517,7 @@ export class NodeService { return Number(result.rows[0]?.total ?? 0); } catch (error) { - sqlite.disableNodesFts('nodes_fts query failed during count search', error); + sqlite.disableFtsTable('nodes', 'nodes_fts query failed during count search', error); } } @@ -549,11 +546,7 @@ export class NodeService { ): NodeSearchRow[] { const ftsQuery = sanitizeFtsQuery(search); if (!ftsQuery) return []; - - const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'" - ).get(); - if (!ftsExists) return []; + if (!sqlite.canUseFtsTable('nodes')) return []; const { clauses, params } = this.buildNodeFilterClauses(filters); const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; @@ -584,7 +577,7 @@ export class NodeService { return result.rows; } catch (error) { - sqlite.disableNodesFts('nodes_fts query failed during node search', error); + sqlite.disableFtsTable('nodes', 'nodes_fts query failed during node search', error); return []; } } diff --git a/src/services/database/sqlite-client.ts b/src/services/database/sqlite-client.ts index 11ab8fb..6652540 100644 --- a/src/services/database/sqlite-client.ts +++ b/src/services/database/sqlite-client.ts @@ -19,14 +19,49 @@ export interface SQLiteQueryResult { lastInsertRowid?: number; } +type FtsSurfaceName = 'nodes' | 'chunks'; + +interface IntegrityProbeResult { + ok: boolean; + error?: string; + code?: string; +} + +export interface DatabaseIntegrityReport { + state: 'healthy' | 'degraded_fts' | 'corrupt'; + connected: boolean; + quickCheck: IntegrityProbeResult; + integrityCheck: IntegrityProbeResult; + baseTables: Record<'nodes' | 'edges' | 'chunks', IntegrityProbeResult>; + ftsTables: Record; + ftsProbeResults: Record; + repairableFtsTables: FtsSurfaceName[]; + canRepairFts: boolean; + foreignKeyViolations: number; + lostAndFoundExists: boolean; + vecTables: { + nodes: boolean; + chunks: boolean; + }; + summary: string; + error?: string; +} + class SQLiteClient { private static instance: SQLiteClient; private db: Database.Database; private config: SQLiteConfig; private readonly readOnly: boolean; private readonly vectorCapability: VectorCapability; - private nodesFtsUsable = true; - private nodesFtsDisabledReason: string | null = null; + private integrityReport: DatabaseIntegrityReport | null = null; + private readonly ftsUsable: Record = { + nodes: true, + chunks: true, + }; + private readonly ftsDisabledReason: Record = { + nodes: null, + chunks: null, + }; private constructor() { this.config = this.getSQLiteConfig(); @@ -73,6 +108,16 @@ class SQLiteClient { // Ensure logging schema (rename memory->logs if needed, create triggers/views) this.ensureLoggingAndMemorySchema(); this.ensureContextsSchema(); + this.integrityReport = this.inspectIntegrity(); + + if (this.integrityReport.state === 'healthy') { + this.ensureFtsTables(); + this.integrityReport = this.inspectIntegrity(); + } else { + console.warn( + `[SQLiteIntegrity] Skipping startup FTS mutation because database is ${this.integrityReport.state}: ${this.integrityReport.summary}` + ); + } } console.log('SQLite client initialized successfully'); @@ -118,6 +163,7 @@ class SQLiteClient { }; } } catch (error) { + this.refreshIntegrityReportForCorruptionError(error); console.error('SQLite query error:', error); throw this.handleError(error); } @@ -143,6 +189,7 @@ class SQLiteClient { try { return txn(); } catch (error) { + this.refreshIntegrityReportForCorruptionError(error); throw this.handleError(error); } } @@ -166,22 +213,11 @@ class SQLiteClient { } public isNodesFtsUsable(): boolean { - return this.nodesFtsUsable; + return this.canUseFtsTable('nodes'); } public disableNodesFts(reason: string, error?: unknown): void { - this.nodesFtsUsable = false; - if (this.nodesFtsDisabledReason === reason) { - return; - } - this.nodesFtsDisabledReason = reason; - - if (error && !this.isSqliteCorruptError(error)) { - console.warn(`[SQLite] nodes_fts disabled: ${reason}`, error); - return; - } - - console.warn(`[SQLite] nodes_fts disabled: ${reason}. Falling back to LIKE search for this database session.`); + this.disableFtsTable('nodes', reason, error); } public async checkTables(): Promise { @@ -229,6 +265,25 @@ class SQLiteClient { } } + public canUseFtsTable(tableName: FtsSurfaceName): boolean { + return this.ftsUsable[tableName] && this.getIntegrityReport().ftsTables[tableName]; + } + + public disableFtsTable(tableName: FtsSurfaceName, reason: string, error?: unknown): void { + this.ftsUsable[tableName] = false; + if (this.ftsDisabledReason[tableName] === reason) { + return; + } + this.ftsDisabledReason[tableName] = reason; + + if (error && !this.isSqliteCorruptError(error)) { + console.warn(`[SQLite] ${tableName}_fts disabled: ${reason}`, error); + return; + } + + console.warn(`[SQLite] ${tableName}_fts disabled: ${reason}. Falling back to non-FTS behavior for this database session.`); + } + private ensureVectorTables(): void { if (this.readOnly || !this.vectorCapability.available) { return; @@ -1073,17 +1128,6 @@ class SQLiteClient { } catch {} } - // Only create nodes_fts when it does not exist yet. - // Do not rewrite existing FTS tables on startup in the OS app. - try { - const ftsCheck = this.db.prepare("SELECT sql FROM sqlite_master WHERE name='nodes_fts'").get() as { sql?: string } | undefined; - if (!ftsCheck?.sql) { - this.db.exec("CREATE VIRTUAL TABLE nodes_fts USING fts5(title, source, description, content='nodes', content_rowid='id');"); - this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');"); - } - } catch (ftsErr) { - console.warn('Failed to initialize nodes_fts:', ftsErr); - } } catch (schemaErr) { console.warn('Final schema pass migration error:', schemaErr); } @@ -1183,6 +1227,237 @@ class SQLiteClient { tryRead('vec_chunks'); } + private ensureFtsTables(): void { + if (this.readOnly) { + return; + } + + try { + const ensureFts = ( + tableName: 'nodes_fts' | 'chunks_fts', + createSql: string, + triggerSql: string, + rebuildSql: string, + ) => { + const existing = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(tableName) as { sql?: string } | undefined; + + if (!existing?.sql) { + this.db.exec(createSql); + this.db.exec(rebuildSql); + } + + this.db.exec(triggerSql); + }; + + ensureFts( + 'nodes_fts', + "CREATE VIRTUAL TABLE nodes_fts USING fts5(title, source, description, content='nodes', content_rowid='id');", + ` + DROP TRIGGER IF EXISTS nodes_fts_ai; + DROP TRIGGER IF EXISTS nodes_fts_ad; + DROP TRIGGER IF EXISTS nodes_fts_au; + CREATE TRIGGER nodes_fts_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, title, source, description) + VALUES (new.id, new.title, new.source, new.description); + END; + CREATE TRIGGER nodes_fts_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, source, description) + VALUES('delete', old.id, old.title, old.source, old.description); + END; + CREATE TRIGGER nodes_fts_au AFTER UPDATE OF title, source, description ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, source, description) + VALUES('delete', old.id, old.title, old.source, old.description); + INSERT INTO nodes_fts(rowid, title, source, description) + VALUES (new.id, new.title, new.source, new.description); + END; + `, + "INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');", + ); + + ensureFts( + 'chunks_fts', + "CREATE VIRTUAL TABLE chunks_fts USING fts5(text, content='chunks', content_rowid='id');", + ` + DROP TRIGGER IF EXISTS chunks_fts_ai; + DROP TRIGGER IF EXISTS chunks_fts_ad; + DROP TRIGGER IF EXISTS chunks_fts_au; + CREATE TRIGGER chunks_fts_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text) + VALUES (new.id, new.text); + END; + CREATE TRIGGER chunks_fts_ad AFTER DELETE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text) + VALUES('delete', old.id, old.text); + END; + CREATE TRIGGER chunks_fts_au AFTER UPDATE OF text ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text) + VALUES('delete', old.id, old.text); + INSERT INTO chunks_fts(rowid, text) + VALUES (new.id, new.text); + END; + `, + "INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');", + ); + } catch (error) { + console.warn('Failed to ensure FTS tables:', error); + } + } + + private buildProbeError(error: unknown): IntegrityProbeResult { + const message = error instanceof Error ? error.message : String(error); + const code = + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' + ? String((error as { code: string }).code) + : undefined; + + return { ok: false, error: message, code }; + } + + private runIntegrityProbe(fn: () => void): IntegrityProbeResult { + try { + fn(); + return { ok: true }; + } catch (error) { + return this.buildProbeError(error); + } + } + + private refreshIntegrityReportForCorruptionError(error: unknown): void { + if (this.isSqliteCorruptError(error)) { + this.getIntegrityReport(true); + } + } + + private inspectIntegrity(): DatabaseIntegrityReport { + const connected = this.runIntegrityProbe(() => { + this.db.prepare('SELECT 1').get(); + }).ok; + + const quickCheck = this.runIntegrityProbe(() => { + const rows = this.db.prepare('PRAGMA quick_check').pluck().all() as string[]; + if (!(rows.length === 1 && rows[0] === 'ok')) { + throw new Error(rows.join('\n') || 'quick_check failed'); + } + }); + + const integrityCheck = this.runIntegrityProbe(() => { + const rows = this.db.prepare('PRAGMA integrity_check').pluck().all() as string[]; + if (!(rows.length === 1 && rows[0] === 'ok')) { + throw new Error(rows.join('\n') || 'integrity_check failed'); + } + }); + + const baseTables: Record<'nodes' | 'edges' | 'chunks', IntegrityProbeResult> = { + nodes: this.runIntegrityProbe(() => { + this.db.prepare('SELECT COUNT(*) FROM nodes').pluck().get(); + }), + edges: this.runIntegrityProbe(() => { + this.db.prepare('SELECT COUNT(*) FROM edges').pluck().get(); + }), + chunks: this.runIntegrityProbe(() => { + this.db.prepare('SELECT COUNT(*) FROM chunks').pluck().get(); + }), + }; + + const hasTable = (name: string) => + Boolean(this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)); + + const ftsSchemaExists: Record = { + nodes: hasTable('nodes_fts'), + chunks: hasTable('chunks_fts'), + }; + + const ftsProbeResults: Record = { + nodes: ftsSchemaExists.nodes + ? this.runIntegrityProbe(() => { + this.db.prepare('SELECT COUNT(*) FROM nodes_fts').pluck().get(); + }) + : { ok: false, error: 'nodes_fts missing' }, + chunks: ftsSchemaExists.chunks + ? this.runIntegrityProbe(() => { + this.db.prepare('SELECT COUNT(*) FROM chunks_fts').pluck().get(); + }) + : { ok: false, error: 'chunks_fts missing' }, + }; + + const ftsTables = { + nodes: ftsSchemaExists.nodes && ftsProbeResults.nodes.ok, + chunks: ftsSchemaExists.chunks && ftsProbeResults.chunks.ok, + }; + + let foreignKeyViolations = -1; + if (baseTables.nodes.ok && baseTables.edges.ok) { + const foreignKeyProbe = this.runIntegrityProbe(() => { + foreignKeyViolations = Number( + (this.db.prepare('SELECT COUNT(*) FROM pragma_foreign_key_check').pluck().get() as number | undefined) ?? 0 + ); + }); + if (!foreignKeyProbe.ok) { + foreignKeyViolations = -1; + } + } + + const lostAndFoundExists = hasTable('lost_and_found'); + const vecTables = { + nodes: hasTable('vec_nodes'), + chunks: hasTable('vec_chunks'), + }; + + const baseTablesReadable = Object.values(baseTables).every(probe => probe.ok); + const repairableFtsTables = (Object.entries(ftsProbeResults) as Array<[FtsSurfaceName, IntegrityProbeResult]>) + .filter(([name, probe]) => ftsSchemaExists[name] && !probe.ok) + .map(([name]) => name); + + let state: DatabaseIntegrityReport['state'] = 'healthy'; + if (!quickCheck.ok || !integrityCheck.ok) { + state = baseTablesReadable && repairableFtsTables.length > 0 ? 'degraded_fts' : 'corrupt'; + } else if (!baseTablesReadable) { + state = 'corrupt'; + } else if (repairableFtsTables.length > 0) { + state = 'degraded_fts'; + } + + let summary = 'Database integrity checks passed.'; + if (state === 'degraded_fts') { + summary = `Rebuildable FTS surfaces are degraded: ${repairableFtsTables.join(', ')}. Base tables are still readable.`; + } else if (state === 'corrupt') { + summary = 'Canonical database integrity checks are failing. Treat the database as corrupted.'; + } + + const firstError = + quickCheck.error || + integrityCheck.error || + Object.values(baseTables).find(probe => !probe.ok)?.error || + Object.values(ftsProbeResults).find(probe => !probe.ok)?.error; + + return { + state, + connected, + quickCheck, + integrityCheck, + baseTables, + ftsTables, + ftsProbeResults, + repairableFtsTables, + canRepairFts: state === 'degraded_fts' && repairableFtsTables.length > 0, + foreignKeyViolations, + lostAndFoundExists, + vecTables, + summary, + error: firstError, + }; + } + + public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport { + if (!this.integrityReport || forceRefresh) { + this.integrityReport = this.inspectIntegrity(); + } + return this.integrityReport; + } + private handleError(error: any): DatabaseError { return { message: error.message || 'SQLite operation failed', @@ -1201,7 +1476,10 @@ class SQLiteClient { } const sqliteError = error as Error & { code?: string }; - return sqliteError.code === 'SQLITE_CORRUPT' || /database disk image is malformed/i.test(sqliteError.message || ''); + return ( + sqliteError.code?.includes('CORRUPT') === true || + /database disk image is malformed/i.test(sqliteError.message || '') + ); } } diff --git a/src/services/embedding/autoEmbedQueue.ts b/src/services/embedding/autoEmbedQueue.ts index 5ba920e..0626551 100644 --- a/src/services/embedding/autoEmbedQueue.ts +++ b/src/services/embedding/autoEmbedQueue.ts @@ -1,5 +1,6 @@ import { embedNodeContent } from '@/services/embedding/ingestion'; import { nodeService } from '@/services/database'; +import { getSQLiteClient } from '@/services/database/sqlite-client'; interface AutoEmbedTask { nodeId: number; @@ -87,6 +88,13 @@ export class AutoEmbedQueue { } private async executeTask(task: AutoEmbedTask) { + const sqlite = getSQLiteClient(); + const integrity = sqlite.getIntegrityReport(); + if (!sqlite.canUseFtsTable('chunks')) { + console.warn('[AutoEmbedQueue] Skipping chunk write because chunks FTS is degraded:', integrity.summary); + return; + } + const node = await nodeService.getNodeById(task.nodeId); if (!node) { console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId); diff --git a/src/services/embedding/ingestion.ts b/src/services/embedding/ingestion.ts index bcaabab..92b08d7 100644 --- a/src/services/embedding/ingestion.ts +++ b/src/services/embedding/ingestion.ts @@ -1,4 +1,5 @@ import { nodeService } from '@/services/database'; +import { getSQLiteClient } from '@/services/database/sqlite-client'; import { NodeEmbedder } from '@/services/typescript/embed-nodes'; import { UniversalEmbedder } from '@/services/typescript/embed-universal'; import type { Node } from '@/types/database'; @@ -53,6 +54,16 @@ async function updateChunkStatus(nodeId: number, status: Node['chunk_status']) { } async function runChunkEmbedding(nodeId: number): Promise { + const sqlite = getSQLiteClient(); + const integrity = sqlite.getIntegrityReport(); + if (!sqlite.canUseFtsTable('chunks')) { + await updateChunkStatus(nodeId, 'error'); + return { + success: false, + error: `Chunk indexing is disabled while the chunks FTS surface is degraded. ${integrity.summary}` + }; + } + const embedder = new UniversalEmbedder(); try { await updateChunkStatus(nodeId, 'chunking');