Stabilize cross-platform local runtime

This commit is contained in:
“BeeRad”
2026-03-21 17:48:48 +11:00
parent 253e0ae68f
commit 6dcf247a64
13 changed files with 605 additions and 144 deletions
+2 -1
View File
@@ -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();
+34 -32
View File
@@ -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
+120
View File
@@ -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',
};
}
+15 -16
View File
@@ -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++;
+25 -20
View File
@@ -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);
}
}
}
+16 -39
View File
@@ -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;
}
}
+1 -9
View File
@@ -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();