feat: standalone MCP server (ra-h-mcp-server)
- Added apps/mcp-server-standalone/ with direct SQLite access - Works without RA-OS app running - Install via: npx ra-h-mcp-server - Updated docs/8_mcp.md with setup instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
5c480b01d6
commit
bcea76800b
@@ -0,0 +1,202 @@
|
||||
'use strict';
|
||||
|
||||
const { query, transaction, getDb } = require('./sqlite-client');
|
||||
|
||||
/**
|
||||
* Get all dimensions with counts.
|
||||
*/
|
||||
function getDimensions() {
|
||||
const sql = `
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.is_priority AS isPriority,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY d.is_priority DESC, d.name ASC
|
||||
`;
|
||||
|
||||
const rows = query(sql);
|
||||
|
||||
return rows.map(row => ({
|
||||
dimension: row.dimension,
|
||||
description: row.description,
|
||||
isPriority: Boolean(row.isPriority),
|
||||
count: Number(row.count)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a dimension.
|
||||
*/
|
||||
function createDimension(data) {
|
||||
const { name, description, isPriority = false } = data;
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = COALESCE(?, description),
|
||||
is_priority = COALESCE(?, is_priority),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING name, description, is_priority
|
||||
`);
|
||||
|
||||
const rows = stmt.all(
|
||||
trimmedName,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('Failed to create dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: rows[0].name,
|
||||
description: rows[0].description,
|
||||
isPriority: Boolean(rows[0].is_priority)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a dimension.
|
||||
*/
|
||||
function updateDimension(data) {
|
||||
const { name, currentName, newName, description, isPriority } = data;
|
||||
const db = getDb();
|
||||
|
||||
// Handle rename
|
||||
if (currentName && newName && currentName !== newName) {
|
||||
// Check if new name already exists
|
||||
const existing = query('SELECT name FROM dimensions WHERE name = ?', [newName]);
|
||||
if (existing.length > 0) {
|
||||
throw new Error('A dimension with this name already exists');
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
// Update dimensions table
|
||||
const dimStmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
`);
|
||||
const dimResult = dimStmt.run(newName, currentName);
|
||||
|
||||
if (dimResult.changes === 0) {
|
||||
throw new Error('Dimension not found');
|
||||
}
|
||||
|
||||
// Update node_dimensions
|
||||
const nodeDimStmt = db.prepare(`
|
||||
UPDATE node_dimensions
|
||||
SET dimension = ?
|
||||
WHERE dimension = ?
|
||||
`);
|
||||
nodeDimStmt.run(newName, currentName);
|
||||
});
|
||||
|
||||
return {
|
||||
dimension: newName,
|
||||
previousName: currentName,
|
||||
renamed: true
|
||||
};
|
||||
}
|
||||
|
||||
// Handle update (description/isPriority)
|
||||
const targetName = name || currentName;
|
||||
if (!targetName) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
params.push(description);
|
||||
}
|
||||
|
||||
if (isPriority !== undefined) {
|
||||
updates.push('is_priority = ?');
|
||||
params.push(isPriority ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('At least one update field must be provided');
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(targetName);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET ${updates.join(', ')}
|
||||
WHERE name = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(...params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Dimension not found');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: targetName,
|
||||
description,
|
||||
isPriority
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a dimension.
|
||||
*/
|
||||
function deleteDimension(name) {
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const removal = transaction(() => {
|
||||
const nodeDimStmt = db.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
|
||||
const dimStmt = db.prepare('DELETE FROM dimensions WHERE name = ?');
|
||||
|
||||
const removedLinks = nodeDimStmt.run(name).changes ?? 0;
|
||||
const removedRow = dimStmt.run(name).changes ?? 0;
|
||||
|
||||
return { removedLinks, removedRow };
|
||||
});
|
||||
|
||||
if (!removal.removedLinks && !removal.removedRow) {
|
||||
throw new Error('Dimension not found');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: name,
|
||||
deleted: true,
|
||||
removedLinks: removal.removedLinks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDimensions,
|
||||
createDimension,
|
||||
updateDimension,
|
||||
deleteDimension
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
const { query, getDb } = require('./sqlite-client');
|
||||
|
||||
/**
|
||||
* Get all edges.
|
||||
*/
|
||||
function getEdges(filters = {}) {
|
||||
const { nodeId, limit = 50 } = filters;
|
||||
|
||||
let sql = 'SELECT * FROM edges';
|
||||
const params = [];
|
||||
|
||||
if (nodeId) {
|
||||
sql += ' WHERE from_node_id = ? OR to_node_id = ?';
|
||||
params.push(nodeId, nodeId);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const rows = query(sql, params);
|
||||
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
context: parseContext(row.context)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edge by ID.
|
||||
*/
|
||||
function getEdgeById(id) {
|
||||
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
context: parseContext(row.context)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new edge.
|
||||
* Note: This is a simplified version without AI inference.
|
||||
* The main app handles edge type inference.
|
||||
*/
|
||||
function createEdge(edgeData) {
|
||||
const { from_node_id, to_node_id, explanation, source = 'mcp' } = edgeData;
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
|
||||
if (!from_node_id || !to_node_id) {
|
||||
throw new Error('from_node_id and to_node_id are required');
|
||||
}
|
||||
|
||||
if (!explanation || !explanation.trim()) {
|
||||
throw new Error('Edge explanation is required');
|
||||
}
|
||||
|
||||
// Simple context without AI inference
|
||||
// The main app can re-infer types when it loads
|
||||
const context = {
|
||||
type: 'related_to',
|
||||
confidence: 0.5,
|
||||
inferred_at: now,
|
||||
explanation: explanation.trim(),
|
||||
created_via: 'mcp'
|
||||
};
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
from_node_id,
|
||||
to_node_id,
|
||||
JSON.stringify(context),
|
||||
source,
|
||||
now
|
||||
);
|
||||
|
||||
const edgeId = Number(result.lastInsertRowid);
|
||||
return getEdgeById(edgeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an edge.
|
||||
*/
|
||||
function updateEdge(id, updates) {
|
||||
const { explanation, context: contextUpdates } = updates;
|
||||
const db = getDb();
|
||||
|
||||
const existing = getEdgeById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
}
|
||||
|
||||
// If explanation changed, update context
|
||||
if (explanation && explanation.trim()) {
|
||||
const now = new Date().toISOString();
|
||||
const newContext = {
|
||||
...existing.context,
|
||||
explanation: explanation.trim(),
|
||||
inferred_at: now,
|
||||
created_via: 'mcp'
|
||||
};
|
||||
|
||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||
stmt.run(JSON.stringify(newContext), id);
|
||||
} else if (contextUpdates) {
|
||||
const newContext = {
|
||||
...existing.context,
|
||||
...contextUpdates
|
||||
};
|
||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||
stmt.run(JSON.stringify(newContext), id);
|
||||
}
|
||||
|
||||
return getEdgeById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an edge.
|
||||
*/
|
||||
function deleteEdge(id) {
|
||||
const result = query('DELETE FROM edges WHERE id = ?', [id]);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connections for a node.
|
||||
*/
|
||||
function getNodeConnections(nodeId) {
|
||||
const sql = `
|
||||
SELECT
|
||||
e.*,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.id
|
||||
ELSE n_from.id
|
||||
END as connected_node_id,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.title
|
||||
ELSE n_from.title
|
||||
END as connected_node_title,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.description
|
||||
ELSE n_from.description
|
||||
END as connected_node_description
|
||||
FROM edges e
|
||||
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
|
||||
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
|
||||
WHERE e.from_node_id = ? OR e.to_node_id = ?
|
||||
ORDER BY e.created_at DESC
|
||||
`;
|
||||
|
||||
const rows = query(sql, [nodeId, nodeId, nodeId, nodeId, nodeId]);
|
||||
|
||||
return rows.map(row => ({
|
||||
edgeId: row.id,
|
||||
from_node_id: row.from_node_id,
|
||||
to_node_id: row.to_node_id,
|
||||
context: parseContext(row.context),
|
||||
connected_node: {
|
||||
id: row.connected_node_id,
|
||||
title: row.connected_node_title,
|
||||
description: row.connected_node_description
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edge count.
|
||||
*/
|
||||
function getEdgeCount() {
|
||||
const rows = query('SELECT COUNT(*) as count FROM edges');
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse context JSON safely.
|
||||
*/
|
||||
function parseContext(context) {
|
||||
if (!context) return null;
|
||||
if (typeof context === 'object') return context;
|
||||
try {
|
||||
return JSON.parse(context);
|
||||
} catch {
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEdges,
|
||||
getEdgeById,
|
||||
createEdge,
|
||||
updateEdge,
|
||||
deleteEdge,
|
||||
getNodeConnections,
|
||||
getEdgeCount
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
const { query, transaction, getDb } = require('./sqlite-client');
|
||||
|
||||
/**
|
||||
* Get nodes with optional filtering.
|
||||
*/
|
||||
function getNodes(filters = {}) {
|
||||
const { dimensions, search, limit = 100, offset = 0 } = filters;
|
||||
|
||||
let sql = `
|
||||
SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM nodes n
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// Filter by dimensions
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
|
||||
// Text search
|
||||
if (search) {
|
||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
// Sort by search relevance or updated_at
|
||||
if (search) {
|
||||
sql += ` ORDER BY
|
||||
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
|
||||
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
|
||||
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
|
||||
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
|
||||
n.updated_at DESC`;
|
||||
params.push(search, `${search}%`, `%${search}%`, `%${search}%`);
|
||||
} else {
|
||||
sql += ' ORDER BY n.updated_at DESC';
|
||||
}
|
||||
|
||||
sql += ` LIMIT ?`;
|
||||
params.push(limit);
|
||||
|
||||
if (offset > 0) {
|
||||
sql += ` OFFSET ?`;
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const rows = query(sql, params);
|
||||
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
dimensions_json: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single node by ID.
|
||||
*/
|
||||
function getNodeById(id) {
|
||||
const sql = `
|
||||
SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM nodes n
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
|
||||
const rows = query(sql, [id]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
dimensions_json: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node.
|
||||
*/
|
||||
function createNode(nodeData) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
link,
|
||||
type,
|
||||
dimensions = [],
|
||||
chunk,
|
||||
metadata = {}
|
||||
} = nodeData;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
|
||||
const nodeId = transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO nodes (title, description, content, link, type, metadata, chunk, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
title,
|
||||
description ?? null,
|
||||
content ?? null,
|
||||
link ?? null,
|
||||
type ?? null,
|
||||
JSON.stringify(metadata),
|
||||
chunk ?? null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
const id = Number(result.lastInsertRowid);
|
||||
|
||||
// Insert dimensions
|
||||
if (dimensions.length > 0) {
|
||||
const dimStmt = db.prepare(
|
||||
'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'
|
||||
);
|
||||
for (const dimension of dimensions) {
|
||||
dimStmt.run(id, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
return getNodeById(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing node.
|
||||
* Note: content is APPENDED by default (MCP tool behavior), not replaced.
|
||||
*/
|
||||
function updateNode(id, updates, options = {}) {
|
||||
const { appendContent = true } = options;
|
||||
const { title, description, content, link, type, dimensions, chunk, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
|
||||
// Check node exists
|
||||
const existing = getNodeById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
const setFields = [];
|
||||
const params = [];
|
||||
|
||||
if (title !== undefined) {
|
||||
setFields.push('title = ?');
|
||||
params.push(title);
|
||||
}
|
||||
if (description !== undefined) {
|
||||
setFields.push('description = ?');
|
||||
params.push(description);
|
||||
}
|
||||
if (content !== undefined) {
|
||||
if (appendContent && existing.content) {
|
||||
// Append to existing content
|
||||
setFields.push('content = ?');
|
||||
params.push(existing.content + '\n\n' + content);
|
||||
} else {
|
||||
setFields.push('content = ?');
|
||||
params.push(content);
|
||||
}
|
||||
}
|
||||
if (link !== undefined) {
|
||||
setFields.push('link = ?');
|
||||
params.push(link);
|
||||
}
|
||||
if (type !== undefined) {
|
||||
setFields.push('type = ?');
|
||||
params.push(type);
|
||||
}
|
||||
if (chunk !== undefined) {
|
||||
setFields.push('chunk = ?');
|
||||
params.push(chunk);
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
setFields.push('metadata = ?');
|
||||
params.push(JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
// Always update timestamp
|
||||
setFields.push('updated_at = ?');
|
||||
params.push(now);
|
||||
params.push(id);
|
||||
|
||||
if (setFields.length > 1) {
|
||||
const stmt = db.prepare(`UPDATE nodes SET ${setFields.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
// Handle dimensions separately
|
||||
if (Array.isArray(dimensions)) {
|
||||
db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
|
||||
const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
|
||||
for (const dim of dimensions) {
|
||||
dimStmt.run(id, dim);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return getNodeById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node.
|
||||
*/
|
||||
function deleteNode(id) {
|
||||
const result = query('DELETE FROM nodes WHERE id = ?', [id]);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node count.
|
||||
*/
|
||||
function getNodeCount() {
|
||||
const rows = query('SELECT COUNT(*) as count FROM nodes');
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNodes,
|
||||
getNodeById,
|
||||
createNode,
|
||||
updateNode,
|
||||
deleteNode,
|
||||
getNodeCount
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
'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();
|
||||
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(
|
||||
`Database not found at: ${dbPath}\n\n` +
|
||||
`Have you run RA-H at least once? The database is created when you first launch the app.\n\n` +
|
||||
`If your database is in a different location, set the RAH_DB_PATH environment variable.`
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.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
|
||||
};
|
||||
Reference in New Issue
Block a user