Files
ra-h-os/apps/mcp-server-standalone/services/sqlite-client.js
T

417 lines
12 KiB
JavaScript

'use strict';
const Database = require('better-sqlite3');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
/**
* Get the database path.
* Priority: RAH_DB_PATH env var > default app data location
*/
function getDatabasePath() {
if (process.env.RAH_DB_PATH) {
return process.env.RAH_DB_PATH;
}
// Default: ~/Library/Application Support/RA-H/db/rah.sqlite
return path.join(
os.homedir(),
'Library',
'Application Support',
'RA-H',
'db',
'rah.sqlite'
);
}
let db = null;
/**
* Initialize the database connection.
* Call this once at startup.
*/
function initDatabase() {
if (db) {
return db;
}
const dbPath = getDatabasePath();
// Auto-create database if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
console.error('[RA-H] Creating new database at:', dbPath);
console.error('[RA-H] Database created successfully');
} else {
db = new Database(dbPath);
}
// Configure SQLite for performance
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = 5000');
db.pragma('busy_timeout = 5000');
ensureCoreSchema(db);
// Migrations for existing databases
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(c => c.name);
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
let hasSourceColumn = nodeCols.includes('source');
if (!hasSourceColumn) {
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
hasSourceColumn = true;
console.error('[RA-H] Migrated nodes: added source column');
}
if (nodeCols.includes('content')) {
db.exec(`
UPDATE nodes
SET source = content
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND content IS NOT NULL
AND LENGTH(TRIM(content)) > 0;
`);
}
if (nodeCols.includes('notes')) {
db.exec(`
UPDATE nodes
SET source = notes
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND notes IS NOT NULL
AND LENGTH(TRIM(notes)) > 0;
`);
}
if (nodeCols.includes('chunk')) {
db.exec(`
UPDATE nodes
SET source = chunk
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
`);
}
if (hasSourceColumn) {
db.exec(`
UPDATE nodes
SET source = title || CASE
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
THEN char(10) || char(10) || description
ELSE ''
END
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
`);
}
if (!edgeCols.includes('explanation')) {
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
try {
db.exec(`
UPDATE edges SET explanation = json_extract(context, '$.explanation')
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
`);
} catch {}
console.error('[RA-H] Migrated edges: added explanation column');
}
if (!nodeCols.includes('context_id')) {
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
console.error('[RA-H] Migrated nodes: added context_id column');
}
if (!contextCols.includes('description')) {
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
}
if (!contextCols.includes('icon')) {
db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
}
if (!contextCols.includes('created_at')) {
db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
}
if (!contextCols.includes('updated_at')) {
db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
}
db.exec(`
UPDATE contexts
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
`);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dimension_count INTEGER NOT NULL,
assignment_count INTEGER NOT NULL,
payload TEXT
);
`);
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
const snapshotCount = Number((db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() || {}).count || 0);
if (snapshotCount === 0) {
const dimensionCount = hasLegacyDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM dimensions').get() || {}).count || 0)
: 0;
const assignmentCount = hasLegacyNodeDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() || {}).count || 0)
: 0;
const payload = hasLegacyNodeDimensions
? ((db.prepare(`
SELECT COALESCE(
json_group_array(
json_object(
'node_id', nd.node_id,
'dimension', nd.dimension,
'description', d.description,
'icon', d.icon,
'is_priority', d.is_priority
)
),
'[]'
) AS payload
FROM node_dimensions nd
LEFT JOIN dimensions d ON d.name = nd.dimension
`).get() || {}).payload || '[]')
: '[]';
db.prepare(`
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (?, ?, ?)
`).run(dimensionCount, assignmentCount, payload);
}
db.exec(`
DROP INDEX IF EXISTS idx_dim_by_dimension;
DROP INDEX IF EXISTS idx_dim_by_node;
DROP TABLE IF EXISTS node_dimensions;
DROP TABLE IF EXISTS dimensions;
`);
}
return db;
}
function ensureCoreSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
source TEXT,
link TEXT,
event_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked',
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
`);
ensureEdgesTableSchema(db);
}
function ensureEdgesTableSchema(db) {
const hasEdgesTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'").get();
if (!hasEdgesTable) {
db.exec(`
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
`);
} else {
const edgeColNames = new Set(db.prepare('PRAGMA table_info(edges)').all().map((col) => col.name));
const needsLegacyRewrite =
!edgeColNames.has('from_node_id') ||
!edgeColNames.has('to_node_id') ||
!edgeColNames.has('source') ||
!edgeColNames.has('created_at') ||
!edgeColNames.has('context') ||
edgeColNames.has('from_id') ||
edgeColNames.has('to_id') ||
edgeColNames.has('description') ||
edgeColNames.has('updated_at');
if (needsLegacyRewrite) {
rebuildLegacyEdgesTable(db, edgeColNames);
}
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
`);
}
function rebuildLegacyEdgesTable(db, edgeColNames) {
const fromExpr = edgeColNames.has('from_node_id')
? 'from_node_id'
: edgeColNames.has('from_id')
? 'from_id'
: 'NULL';
const toExpr = edgeColNames.has('to_node_id')
? 'to_node_id'
: edgeColNames.has('to_id')
? 'to_id'
: 'NULL';
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
const explanationExpr = edgeColNames.has('explanation')
? 'explanation'
: edgeColNames.has('description')
? 'description'
: edgeColNames.has('context')
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
: 'NULL';
console.error('[RA-H] Migrating legacy edges table to canonical schema');
let flippedForeignKeys = false;
try {
db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
db.exec('BEGIN TRANSACTION;');
db.exec(`
DROP INDEX IF EXISTS idx_edges_from;
DROP INDEX IF EXISTS idx_edges_to;
ALTER TABLE edges RENAME TO edges_legacy_migration;
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
SELECT
id,
${fromExpr},
${toExpr},
${sourceExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${contextExpr},
${explanationExpr}
FROM edges_legacy_migration
WHERE ${fromExpr} IS NOT NULL
AND ${toExpr} IS NOT NULL;
DROP TABLE edges_legacy_migration;
COMMIT;
`);
} catch (error) {
try {
db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
/**
* Get the database instance.
* Throws if not initialized.
*/
function getDb() {
if (!db) {
throw new Error('Database not initialized. Call initDatabase() first.');
}
return db;
}
/**
* Execute a query and return rows.
*/
function query(sql, params = []) {
const database = getDb();
const stmt = database.prepare(sql);
const sqlLower = sql.trim().toLowerCase();
if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.startsWith('pragma') || sqlLower.includes('returning')) {
return params.length > 0 ? stmt.all(...params) : stmt.all();
} else {
const result = params.length > 0 ? stmt.run(...params) : stmt.run();
return {
changes: result.changes,
lastInsertRowid: Number(result.lastInsertRowid)
};
}
}
/**
* Execute a query in a transaction.
*/
function transaction(callback) {
const database = getDb();
const txn = database.transaction(callback);
return txn();
}
/**
* Close the database connection.
*/
function closeDatabase() {
if (db) {
db.close();
db = null;
}
}
module.exports = {
initDatabase,
getDb,
query,
transaction,
closeDatabase,
getDatabasePath
};