Stabilize cross-platform local runtime
This commit is contained in:
@@ -38,6 +38,7 @@ async function checkSQLiteDatabaseHealth(): Promise<{
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const connected = await sqlite.testConnection();
|
||||
const vectorCapability = sqlite.getVectorCapability();
|
||||
if (!connected) {
|
||||
return {
|
||||
connected: false,
|
||||
@@ -47,7 +48,7 @@ async function checkSQLiteDatabaseHealth(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
const vectorExtension = await sqlite.checkVectorExtension();
|
||||
const vectorExtension = vectorCapability.available;
|
||||
|
||||
// Check if main tables exist
|
||||
const tables = await sqlite.checkTables();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseError } from '@/types/database';
|
||||
import {
|
||||
ensureDatabaseDirectory,
|
||||
getDatabasePath,
|
||||
getVecExtensionPath,
|
||||
loadVecExtension,
|
||||
type VectorCapability,
|
||||
} from './sqlite-runtime';
|
||||
|
||||
export interface SQLiteConfig {
|
||||
dbPath: string;
|
||||
@@ -19,27 +24,26 @@ class SQLiteClient {
|
||||
private db: Database.Database;
|
||||
private config: SQLiteConfig;
|
||||
private readonly readOnly: boolean;
|
||||
private readonly vectorCapability: VectorCapability;
|
||||
|
||||
private constructor() {
|
||||
this.config = this.getSQLiteConfig();
|
||||
this.readOnly = process.env.SQLITE_READONLY === 'true';
|
||||
|
||||
// Initialize database connection
|
||||
const dbDirectory = path.dirname(this.config.dbPath);
|
||||
if (!this.readOnly && !fs.existsSync(dbDirectory)) {
|
||||
fs.mkdirSync(dbDirectory, { recursive: true });
|
||||
if (!this.readOnly) {
|
||||
ensureDatabaseDirectory(this.config.dbPath);
|
||||
}
|
||||
this.db = this.readOnly
|
||||
? new Database(this.config.dbPath, { readonly: true, fileMustExist: true })
|
||||
: new Database(this.config.dbPath);
|
||||
|
||||
// Load sqlite-vec extension
|
||||
try {
|
||||
this.db.loadExtension(this.config.vecExtensionPath);
|
||||
this.vectorCapability = loadVecExtension(this.db, this.config.vecExtensionPath);
|
||||
if (this.vectorCapability.available) {
|
||||
console.log('SQLite vector extension loaded successfully');
|
||||
} catch (error) {
|
||||
// Do not fail hard — allow the app to run without vector features
|
||||
console.error('Warning: Failed to load vector extension:', error);
|
||||
} else {
|
||||
console.warn(`Warning: ${this.vectorCapability.reason}`);
|
||||
}
|
||||
|
||||
// Configure SQLite settings
|
||||
@@ -58,8 +62,10 @@ class SQLiteClient {
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
// Ensure vector virtual tables are present and healthy
|
||||
this.ensureVectorTables();
|
||||
this.healVectorTablesIfCorrupt();
|
||||
if (this.vectorCapability.available) {
|
||||
this.ensureVectorTables();
|
||||
this.healVectorTablesIfCorrupt();
|
||||
}
|
||||
|
||||
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
|
||||
this.ensureLoggingAndMemorySchema();
|
||||
@@ -69,17 +75,9 @@ class SQLiteClient {
|
||||
}
|
||||
|
||||
private getSQLiteConfig(): SQLiteConfig {
|
||||
const dbPath = process.env.SQLITE_DB_PATH || path.join(
|
||||
process.env.HOME || '~',
|
||||
'Library/Application Support/RA-H/db/rah.sqlite'
|
||||
);
|
||||
|
||||
const vecExtensionPath = process.env.SQLITE_VEC_EXTENSION_PATH ||
|
||||
'./vendor/sqlite-extensions/vec0.dylib';
|
||||
|
||||
return {
|
||||
dbPath,
|
||||
vecExtensionPath
|
||||
dbPath: getDatabasePath(),
|
||||
vecExtensionPath: getVecExtensionPath(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +132,9 @@ class SQLiteClient {
|
||||
} as DatabaseError;
|
||||
}
|
||||
// Proactively validate/repair vec vtables before any write transaction
|
||||
this.healVectorTablesIfCorrupt();
|
||||
if (this.vectorCapability.available) {
|
||||
this.healVectorTablesIfCorrupt();
|
||||
}
|
||||
const txn = this.db.transaction(callback);
|
||||
try {
|
||||
return txn();
|
||||
@@ -154,13 +154,11 @@ class SQLiteClient {
|
||||
}
|
||||
|
||||
public async checkVectorExtension(): Promise<boolean> {
|
||||
try {
|
||||
const result = this.query('SELECT vec_version() as version');
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Vector extension check failed:', error);
|
||||
return false;
|
||||
}
|
||||
return this.vectorCapability.available;
|
||||
}
|
||||
|
||||
public getVectorCapability(): VectorCapability {
|
||||
return this.vectorCapability;
|
||||
}
|
||||
|
||||
public async checkTables(): Promise<string[]> {
|
||||
@@ -176,6 +174,10 @@ class SQLiteClient {
|
||||
}
|
||||
|
||||
public ensureVectorExtensions(): void {
|
||||
if (!this.vectorCapability.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Test for vec_nodes and vec_chunks; create them if missing
|
||||
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
|
||||
@@ -205,7 +207,7 @@ class SQLiteClient {
|
||||
}
|
||||
|
||||
private ensureVectorTables(): void {
|
||||
if (this.readOnly) {
|
||||
if (this.readOnly || !this.vectorCapability.available) {
|
||||
return;
|
||||
}
|
||||
// Wrapper to keep existing public API stable
|
||||
@@ -739,7 +741,7 @@ class SQLiteClient {
|
||||
}
|
||||
|
||||
private healVectorTablesIfCorrupt(): void {
|
||||
if (this.readOnly) {
|
||||
if (this.readOnly || !this.vectorCapability.available) {
|
||||
return;
|
||||
}
|
||||
// Attempt lightweight reads to detect CORRUPT_VTAB; if detected, drop/recreate vtables
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export type VectorCapability =
|
||||
| {
|
||||
available: true;
|
||||
backend: 'sqlite-vec';
|
||||
extensionPath: string;
|
||||
detail: string;
|
||||
}
|
||||
| {
|
||||
available: false;
|
||||
backend: 'none';
|
||||
extensionPath: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const VECTOR_CAPABILITY_KEY = '__rahVectorCapability';
|
||||
|
||||
function getHomeDirectory(): string {
|
||||
return os.homedir() || process.env.HOME || '~';
|
||||
}
|
||||
|
||||
export function getDefaultDbPath(): string {
|
||||
const homeDir = getHomeDirectory();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
||||
return path.join(appData, 'RA-H', 'db', 'rah.sqlite');
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
|
||||
}
|
||||
|
||||
return path.join(
|
||||
process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'),
|
||||
'RA-H',
|
||||
'db',
|
||||
'rah.sqlite'
|
||||
);
|
||||
}
|
||||
|
||||
export function getDatabasePath(): string {
|
||||
return process.env.SQLITE_DB_PATH || getDefaultDbPath();
|
||||
}
|
||||
|
||||
export function getDefaultVecExtensionPath(): string {
|
||||
const extension = process.platform === 'darwin'
|
||||
? 'dylib'
|
||||
: process.platform === 'win32'
|
||||
? 'dll'
|
||||
: 'so';
|
||||
|
||||
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', `vec0.${extension}`);
|
||||
}
|
||||
|
||||
export function getVecExtensionPath(): string {
|
||||
return process.env.SQLITE_VEC_EXTENSION_PATH || getDefaultVecExtensionPath();
|
||||
}
|
||||
|
||||
export function ensureDatabaseDirectory(dbPath: string): void {
|
||||
const dbDirectory = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDirectory)) {
|
||||
fs.mkdirSync(dbDirectory, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildVectorFailureReason(error: unknown, extensionPath: string): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return `sqlite-vec failed to load from ${extensionPath}. On Windows, install vec0.dll or switch to Qdrant. Original error: ${message}`;
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return `sqlite-vec failed to load from ${extensionPath}. Alpine/musl builds are unsupported; use Qdrant there. Original error: ${message}`;
|
||||
}
|
||||
|
||||
return `sqlite-vec failed to load from ${extensionPath}. Original error: ${message}`;
|
||||
}
|
||||
|
||||
export function loadVecExtension(db: Database.Database, extensionPath = getVecExtensionPath()): VectorCapability {
|
||||
try {
|
||||
db.loadExtension(extensionPath);
|
||||
const capability: VectorCapability = {
|
||||
available: true,
|
||||
backend: 'sqlite-vec',
|
||||
extensionPath,
|
||||
detail: 'sqlite-vec extension loaded successfully',
|
||||
};
|
||||
(db as Database.Database & Record<string, unknown>)[VECTOR_CAPABILITY_KEY] = capability;
|
||||
return capability;
|
||||
} catch (error) {
|
||||
const capability: VectorCapability = {
|
||||
available: false,
|
||||
backend: 'none',
|
||||
extensionPath,
|
||||
reason: buildVectorFailureReason(error, extensionPath),
|
||||
};
|
||||
(db as Database.Database & Record<string, unknown>)[VECTOR_CAPABILITY_KEY] = capability;
|
||||
return capability;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDbVectorCapability(db: Database.Database): VectorCapability {
|
||||
const capability = (db as Database.Database & Record<string, unknown>)[VECTOR_CAPABILITY_KEY];
|
||||
if (capability && typeof capability === 'object' && 'available' in capability) {
|
||||
return capability as VectorCapability;
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
backend: 'none',
|
||||
extensionPath: getVecExtensionPath(),
|
||||
reason: 'sqlite-vec capability was not initialized for this database connection',
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import {
|
||||
createDatabaseConnection,
|
||||
getDbVectorCapability,
|
||||
serializeFloat32Vector,
|
||||
formatEmbeddingText,
|
||||
batchProcess
|
||||
} from './sqlite-vec';
|
||||
import type { VectorCapability } from '@/services/database/sqlite-runtime';
|
||||
|
||||
interface NodeRecord {
|
||||
id: number;
|
||||
@@ -35,6 +37,7 @@ export class NodeEmbedder {
|
||||
private openaiClient: OpenAI;
|
||||
private openaiProvider: ReturnType<typeof createOpenAI>;
|
||||
private db: ReturnType<typeof createDatabaseConnection>;
|
||||
private readonly vectorCapability: VectorCapability;
|
||||
private processedCount: number = 0;
|
||||
private failedCount: number = 0;
|
||||
|
||||
@@ -47,6 +50,7 @@ export class NodeEmbedder {
|
||||
this.openaiClient = new OpenAI({ apiKey });
|
||||
this.openaiProvider = createOpenAI({ apiKey });
|
||||
this.db = createDatabaseConnection();
|
||||
this.vectorCapability = getDbVectorCapability(this.db);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,23 +142,18 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
const now = new Date().toISOString();
|
||||
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
|
||||
|
||||
// Update vec_nodes virtual table
|
||||
try {
|
||||
// Determine correct column name for primary key (node_id vs id)
|
||||
// Use declared PK column from your DB schema (confirmed: node_id)
|
||||
const pkCol = 'node_id';
|
||||
if (this.vectorCapability.available) {
|
||||
try {
|
||||
const pkCol = 'node_id';
|
||||
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
|
||||
deleteStmt.run(BigInt(node.id));
|
||||
|
||||
// Delete existing entry if any
|
||||
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
|
||||
deleteStmt.run(BigInt(node.id));
|
||||
|
||||
// Insert new entry (use bracketed string format compatible with sqlite-vec)
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
|
||||
insertStmt.run(BigInt(node.id), vectorString);
|
||||
} catch (vecError) {
|
||||
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
|
||||
// Continue - main embedding is still saved
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
|
||||
insertStmt.run(BigInt(node.id), vectorString);
|
||||
} catch (vecError) {
|
||||
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
|
||||
}
|
||||
}
|
||||
|
||||
this.processedCount++;
|
||||
|
||||
@@ -7,9 +7,11 @@ import OpenAI from 'openai';
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
|
||||
import {
|
||||
createDatabaseConnection,
|
||||
getDbVectorCapability,
|
||||
serializeFloat32Vector,
|
||||
batchProcess
|
||||
} from './sqlite-vec';
|
||||
import type { VectorCapability } from '@/services/database/sqlite-runtime';
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
@@ -37,6 +39,7 @@ export class UniversalEmbedder {
|
||||
private openaiClient: OpenAI;
|
||||
private db: ReturnType<typeof createDatabaseConnection>;
|
||||
private textSplitter: RecursiveCharacterTextSplitter;
|
||||
private readonly vectorCapability: VectorCapability;
|
||||
private vecChunksInsertSQL: string | null = null;
|
||||
|
||||
constructor() {
|
||||
@@ -47,6 +50,7 @@ export class UniversalEmbedder {
|
||||
|
||||
this.openaiClient = new OpenAI({ apiKey });
|
||||
this.db = createDatabaseConnection();
|
||||
this.vectorCapability = getDbVectorCapability(this.db);
|
||||
|
||||
// Configure text splitter (same as old KMS system)
|
||||
this.textSplitter = new RecursiveCharacterTextSplitter({
|
||||
@@ -87,12 +91,14 @@ export class UniversalEmbedder {
|
||||
const chunkIds = this.db.prepare('SELECT id FROM chunks WHERE node_id = ?').all(nodeId) as Array<{ id: number }>;
|
||||
|
||||
// Delete from vec_chunks first, one by one to ensure they're removed
|
||||
for (const chunk of chunkIds) {
|
||||
try {
|
||||
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteVecStmt.run(BigInt(chunk.id));
|
||||
} catch (error) {
|
||||
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
|
||||
if (this.vectorCapability.available) {
|
||||
for (const chunk of chunkIds) {
|
||||
try {
|
||||
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteVecStmt.run(BigInt(chunk.id));
|
||||
} catch (error) {
|
||||
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,21 +137,20 @@ export class UniversalEmbedder {
|
||||
|
||||
const chunkId = Number(result.lastInsertRowid);
|
||||
|
||||
// Insert into vec_chunks virtual table (use bracketed string format)
|
||||
try {
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
// First try to delete any existing vec_chunk with this ID
|
||||
if (this.vectorCapability.available) {
|
||||
try {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteStmt.run(BigInt(chunkId));
|
||||
} catch {}
|
||||
|
||||
// Now insert the new embedding
|
||||
const sql = 'INSERT INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
|
||||
const vecInsertStmt = this.db.prepare(sql);
|
||||
vecInsertStmt.run(BigInt(chunkId), vectorString);
|
||||
} catch (error) {
|
||||
console.warn(`Could not insert into vec_chunks for chunk ${chunkId}:`, error);
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
try {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
||||
deleteStmt.run(BigInt(chunkId));
|
||||
} catch {}
|
||||
|
||||
const sql = 'INSERT INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
|
||||
const vecInsertStmt = this.db.prepare(sql);
|
||||
vecInsertStmt.run(BigInt(chunkId), vectorString);
|
||||
} catch (error) {
|
||||
console.warn(`Could not insert into vec_chunks for chunk ${chunkId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
ensureDatabaseDirectory,
|
||||
getDatabasePath,
|
||||
getDbVectorCapability,
|
||||
getVecExtensionPath,
|
||||
loadVecExtension,
|
||||
} from '@/services/database/sqlite-runtime';
|
||||
|
||||
/**
|
||||
* Serialize a float array to binary format for vec0 storage
|
||||
@@ -30,53 +35,25 @@ export function deserializeFloat32Vector(blob: Buffer): number[] {
|
||||
return vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SQLite database path from environment or default location
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
const envPath = process.env.SQLITE_DB_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vec extension path from environment or default location
|
||||
*/
|
||||
export function getVecExtensionPath(): string {
|
||||
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path relative to project root
|
||||
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database connection with vec0 extension loaded
|
||||
*/
|
||||
export function createDatabaseConnection(): Database.Database {
|
||||
const dbPath = getDatabasePath();
|
||||
const vecPath = getVecExtensionPath();
|
||||
|
||||
ensureDatabaseDirectory(dbPath);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Load vec0 extension
|
||||
try {
|
||||
db.loadExtension(vecPath);
|
||||
} catch (error) {
|
||||
console.error('Warning: Could not load vec0 extension:', error);
|
||||
// Continue without vector support for non-vector operations
|
||||
|
||||
const capability = loadVecExtension(db, vecPath);
|
||||
if (!capability.available) {
|
||||
console.warn(`Warning: ${capability.reason}`);
|
||||
}
|
||||
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
|
||||
|
||||
/**
|
||||
* Format embedding text for node metadata
|
||||
*/
|
||||
@@ -113,4 +90,4 @@ export async function batchProcess<T, R>(
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,10 @@ import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { getDatabasePath } from '@/services/database/sqlite-runtime';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Get database path (same logic as sqlite-client.ts)
|
||||
function getDatabasePath(): string {
|
||||
return process.env.SQLITE_DB_PATH || path.join(
|
||||
process.env.HOME || '~',
|
||||
'Library/Application Support/RA-H/db/rah.sqlite'
|
||||
);
|
||||
}
|
||||
|
||||
// Security: Only allow SELECT statements
|
||||
function isReadOnlyQuery(sql: string): boolean {
|
||||
const normalized = sql.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user