UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

739 lines (733 loc) 23.7 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import Database from "better-sqlite3"; import { FeatureAwareDatabaseAdapter } from "./database-adapter.js"; import { logger } from "../monitoring/logger.js"; import { DatabaseError, ErrorCode, ValidationError } from "../errors/index.js"; import * as fs from "fs/promises"; import * as path from "path"; class SQLiteAdapter extends FeatureAwareDatabaseAdapter { db = null; dbPath; inTransactionFlag = false; constructor(projectId, config) { super(projectId, config); this.dbPath = config.dbPath; } getFeatures() { return { supportsFullTextSearch: false, // Could enable with FTS5 supportsVectorSearch: false, supportsPartitioning: false, supportsAnalytics: false, supportsCompression: false, supportsMaterializedViews: false, supportsParallelQueries: false }; } async connect() { if (this.db) return; const config = this.config; const dir = path.dirname(this.dbPath); await fs.mkdir(dir, { recursive: true }); this.db = new Database(this.dbPath); this.db.pragma("foreign_keys = ON"); if (config.walMode !== false) { this.db.pragma("journal_mode = WAL"); } if (config.busyTimeout) { this.db.pragma(`busy_timeout = ${config.busyTimeout}`); } if (config.cacheSize) { this.db.pragma(`cache_size = ${config.cacheSize}`); } if (config.synchronous) { this.db.pragma(`synchronous = ${config.synchronous}`); } logger.info("SQLite database connected", { dbPath: this.dbPath }); } async disconnect() { if (!this.db) return; this.db.close(); this.db = null; logger.info("SQLite database disconnected"); } /** * Get raw database handle for testing purposes * @internal */ getRawDatabase() { return this.db; } isConnected() { return this.db !== null && this.db.open; } async ping() { if (!this.db) return false; try { this.db.prepare("SELECT 1").get(); return true; } catch (error) { logger.debug("Database ping failed", { error: error instanceof Error ? error.message : String(error) }); return false; } } async initializeSchema() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.exec(` CREATE TABLE IF NOT EXISTS frames ( frame_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, project_id TEXT NOT NULL, parent_frame_id TEXT REFERENCES frames(frame_id), depth INTEGER NOT NULL DEFAULT 0, type TEXT NOT NULL, name TEXT NOT NULL, state TEXT DEFAULT 'active', inputs TEXT DEFAULT '{}', outputs TEXT DEFAULT '{}', digest_text TEXT, digest_json TEXT DEFAULT '{}', created_at INTEGER DEFAULT (unixepoch()), closed_at INTEGER ); CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, frame_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL, ts INTEGER DEFAULT (unixepoch()), FOREIGN KEY(frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS anchors ( anchor_id TEXT PRIMARY KEY, frame_id TEXT NOT NULL, project_id TEXT NOT NULL, type TEXT NOT NULL, text TEXT NOT NULL, priority INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()), metadata TEXT DEFAULT '{}', FOREIGN KEY(frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at INTEGER DEFAULT (unixepoch()) ); -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_frames_run ON frames(run_id); CREATE INDEX IF NOT EXISTS idx_frames_project ON frames(project_id); CREATE INDEX IF NOT EXISTS idx_frames_parent ON frames(parent_frame_id); CREATE INDEX IF NOT EXISTS idx_frames_state ON frames(state); CREATE INDEX IF NOT EXISTS idx_frames_created ON frames(created_at DESC); CREATE INDEX IF NOT EXISTS idx_events_frame ON events(frame_id); CREATE INDEX IF NOT EXISTS idx_events_seq ON events(frame_id, seq); CREATE INDEX IF NOT EXISTS idx_anchors_frame ON anchors(frame_id); -- Set initial schema version if not exists INSERT OR IGNORE INTO schema_version (version) VALUES (1); `); try { this.ensureCascadeConstraints(); } catch (e) { logger.warn("Failed to ensure cascade constraints", e); } } /** * Ensure ON DELETE CASCADE exists for events/anchors referencing frames * Migrates existing tables in-place if needed without data loss. */ ensureCascadeConstraints() { if (!this.db) return; const needsCascade = (table) => { const rows = this.db.prepare( `PRAGMA foreign_key_list(${table})` ).all(); return rows.some( (r) => r.table === "frames" && String(r.on_delete).toUpperCase() !== "CASCADE" ); }; const migrateTable = (table) => { const createSql = table === "events" ? `CREATE TABLE events_new ( event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, frame_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL, ts INTEGER DEFAULT (unixepoch()), FOREIGN KEY(frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE );` : `CREATE TABLE anchors_new ( anchor_id TEXT PRIMARY KEY, frame_id TEXT NOT NULL, project_id TEXT NOT NULL, type TEXT NOT NULL, text TEXT NOT NULL, priority INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()), metadata TEXT DEFAULT '{}', FOREIGN KEY(frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE );`; const cols = table === "events" ? "event_id, run_id, frame_id, seq, event_type, payload, ts" : "anchor_id, frame_id, project_id, type, text, priority, created_at, metadata"; const idxSql = table === "events" ? [ "CREATE INDEX IF NOT EXISTS idx_events_frame ON events(frame_id);", "CREATE INDEX IF NOT EXISTS idx_events_seq ON events(frame_id, seq);" ] : [ "CREATE INDEX IF NOT EXISTS idx_anchors_frame ON anchors(frame_id);" ]; this.db.exec("PRAGMA foreign_keys = OFF;"); this.db.exec("BEGIN;"); this.db.exec(createSql); this.db.prepare( `INSERT INTO ${table === "events" ? "events_new" : "anchors_new"} (${cols}) SELECT ${cols} FROM ${table}` ).run(); this.db.exec(`DROP TABLE ${table};`); this.db.exec(`ALTER TABLE ${table}_new RENAME TO ${table};`); for (const stmt of idxSql) this.db.exec(stmt); this.db.exec("COMMIT;"); this.db.exec("PRAGMA foreign_keys = ON;"); logger.info(`Migrated ${table} to include ON DELETE CASCADE`); }; if (needsCascade("events")) migrateTable("events"); if (needsCascade("anchors")) migrateTable("anchors"); } async migrateSchema(targetVersion) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const currentVersion = await this.getSchemaVersion(); if (currentVersion >= targetVersion) { logger.info("Schema already at target version", { currentVersion, targetVersion }); return; } for (let v = currentVersion + 1; v <= targetVersion; v++) { logger.info(`Applying migration to version ${v}`); this.db.prepare("UPDATE schema_version SET version = ?").run(v); } } async getSchemaVersion() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); try { const result = this.db.prepare("SELECT MAX(version) as version FROM schema_version").get(); return result?.version || 0; } catch (error) { logger.debug("Schema version table not found, returning 0", { error: error instanceof Error ? error.message : String(error) }); return 0; } } // Frame operations async createFrame(frame) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const frameId = frame.frame_id || this.generateId(); this.db.prepare( ` INSERT INTO frames ( frame_id, run_id, project_id, parent_frame_id, depth, type, name, state, inputs, outputs, digest_text, digest_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ).run( frameId, frame.run_id, frame.project_id || this.projectId, frame.parent_frame_id || null, frame.depth || 0, frame.type, frame.name, frame.state || "active", JSON.stringify(frame.inputs || {}), JSON.stringify(frame.outputs || {}), frame.digest_text || null, JSON.stringify(frame.digest_json || {}) ); return frameId; } async getFrame(frameId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const row = this.db.prepare("SELECT * FROM frames WHERE frame_id = ?").get(frameId); if (!row) return null; return { ...row, inputs: JSON.parse(row.inputs || "{}"), outputs: JSON.parse(row.outputs || "{}"), digest_json: JSON.parse(row.digest_json || "{}") }; } async updateFrame(frameId, updates) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const fields = []; const values = []; if (updates.state !== void 0) { fields.push("state = ?"); values.push(updates.state); } if (updates.outputs !== void 0) { fields.push("outputs = ?"); values.push(JSON.stringify(updates.outputs)); } if (updates.digest_text !== void 0) { fields.push("digest_text = ?"); values.push(updates.digest_text); } if (updates.digest_json !== void 0) { fields.push("digest_json = ?"); values.push(JSON.stringify(updates.digest_json)); } if (updates.closed_at !== void 0) { fields.push("closed_at = ?"); values.push(updates.closed_at); } if (fields.length === 0) return; values.push(frameId); this.db.prepare( ` UPDATE frames SET ${fields.join(", ")} WHERE frame_id = ? ` ).run(...values); } async deleteFrame(frameId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); await this.deleteFrameAnchors(frameId); await this.deleteFrameEvents(frameId); this.db.prepare("DELETE FROM frames WHERE frame_id = ?").run(frameId); } async getActiveFrames(runId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); let query = "SELECT * FROM frames WHERE state = 'active'"; const params = []; if (runId) { query += " AND run_id = ?"; params.push(runId); } query += " ORDER BY depth ASC, created_at ASC"; const rows = this.db.prepare(query).all(...params); return rows.map((row) => ({ ...row, inputs: JSON.parse(row.inputs || "{}"), outputs: JSON.parse(row.outputs || "{}"), digest_json: JSON.parse(row.digest_json || "{}") })); } async closeFrame(frameId, outputs) { await this.updateFrame(frameId, { state: "closed", outputs, closed_at: Date.now() }); } // Event operations async createEvent(event) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const eventId = event.event_id || this.generateId(); this.db.prepare( ` INSERT INTO events (event_id, run_id, frame_id, seq, event_type, payload, ts) VALUES (?, ?, ?, ?, ?, ?, ?) ` ).run( eventId, event.run_id, event.frame_id, event.seq || 0, event.event_type, JSON.stringify(event.payload || {}), event.ts || Date.now() ); return eventId; } async getFrameEvents(frameId, options) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); let query = "SELECT * FROM events WHERE frame_id = ?"; query += this.buildOrderByClause( options?.orderBy || "seq", options?.orderDirection ); query += this.buildLimitClause(options?.limit, options?.offset); const rows = this.db.prepare(query).all(frameId); return rows.map((row) => ({ ...row, payload: JSON.parse(row.payload || "{}") })); } async deleteFrameEvents(frameId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.prepare("DELETE FROM events WHERE frame_id = ?").run(frameId); } // Anchor operations async createAnchor(anchor) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const anchorId = anchor.anchor_id || this.generateId(); this.db.prepare( ` INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority, metadata) VALUES (?, ?, ?, ?, ?, ?, ?) ` ).run( anchorId, anchor.frame_id, anchor.project_id || this.projectId, anchor.type, anchor.text, anchor.priority || 0, JSON.stringify(anchor.metadata || {}) ); return anchorId; } async getFrameAnchors(frameId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const rows = this.db.prepare( ` SELECT * FROM anchors WHERE frame_id = ? ORDER BY priority DESC, created_at ASC ` ).all(frameId); return rows.map((row) => ({ ...row, metadata: JSON.parse(row.metadata || "{}") })); } async deleteFrameAnchors(frameId) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.prepare("DELETE FROM anchors WHERE frame_id = ?").run(frameId); } // Limited search (basic LIKE queries) async search(options) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const sql = ` SELECT *, CASE WHEN name LIKE ? THEN 1.0 WHEN digest_text LIKE ? THEN 0.8 WHEN inputs LIKE ? THEN 0.6 ELSE 0.5 END as score FROM frames WHERE name LIKE ? OR digest_text LIKE ? OR inputs LIKE ? ORDER BY score DESC `; const params = Array(6).fill(`%${options.query}%`); let rows = this.db.prepare(sql).all(...params); if (options.scoreThreshold) { rows = rows.filter((row) => row.score >= options.scoreThreshold); } if (options.limit || options.offset) { const start = options.offset || 0; const end = options.limit ? start + options.limit : rows.length; rows = rows.slice(start, end); } return rows.map((row) => ({ ...row, inputs: JSON.parse(row.inputs || "{}"), outputs: JSON.parse(row.outputs || "{}"), digest_json: JSON.parse(row.digest_json || "{}") })); } async searchByVector(_embedding, _options) { logger.warn("Vector search not supported in SQLite adapter"); return []; } async searchHybrid(textQuery, _embedding, weights) { return this.search({ query: textQuery, ...weights }); } // Basic aggregation async aggregate(table, options) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const metrics = options.metrics.map( (m) => `${m.operation}(${m.field}) AS ${m.alias || `${m.operation}_${m.field}`}` ).join(", "); let sql = `SELECT ${options.groupBy.join(", ")}, ${metrics} FROM ${table}`; sql += ` GROUP BY ${options.groupBy.join(", ")}`; if (options.having) { const havingClauses = Object.entries(options.having).map( ([key, value]) => `${key} ${typeof value === "object" ? value.op : "="} ?` ); sql += ` HAVING ${havingClauses.join(" AND ")}`; } return this.db.prepare(sql).all(...Object.values(options.having || {})); } // Pattern detection (basic) async detectPatterns(timeRange) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); let sql = ` SELECT type as pattern, type, COUNT(*) as frequency, MAX(created_at) as last_seen FROM frames `; const params = []; if (timeRange) { sql += " WHERE created_at >= ? AND created_at <= ?"; params.push( Math.floor(timeRange.start.getTime() / 1e3), Math.floor(timeRange.end.getTime() / 1e3) ); } sql += " GROUP BY type HAVING COUNT(*) > 1 ORDER BY frequency DESC"; const rows = this.db.prepare(sql).all(...params); return rows.map((row) => ({ pattern: row.pattern, type: row.type, frequency: row.frequency, lastSeen: new Date(row.last_seen * 1e3) })); } // Bulk operations async executeBulk(operations) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); await this.inTransaction(async () => { for (const op of operations) { switch (op.type) { case "insert": const insertCols = Object.keys(op.data); const insertPlaceholders = insertCols.map(() => "?").join(","); this.db.prepare( `INSERT INTO ${op.table} (${insertCols.join(",")}) VALUES (${insertPlaceholders})` ).run(...Object.values(op.data)); break; case "update": const updateSets = Object.keys(op.data).map((k) => `${k} = ?`).join(","); const whereClause = this.buildWhereClause(op.where || {}); this.db.prepare( `UPDATE ${op.table} SET ${updateSets} ${whereClause}` ).run(...Object.values(op.data), ...Object.values(op.where || {})); break; case "delete": const deleteWhere = this.buildWhereClause(op.where || {}); this.db.prepare(`DELETE FROM ${op.table} ${deleteWhere}`).run( ...Object.values(op.where || {}) ); break; } } }); } async vacuum() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.pragma("vacuum"); logger.info("SQLite database vacuumed"); } async analyze() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.pragma("analyze"); logger.info("SQLite database analyzed"); } // Statistics async getStats() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); const stats = { totalFrames: this.db.prepare("SELECT COUNT(*) as count FROM frames").get().count, activeFrames: this.db.prepare( "SELECT COUNT(*) as count FROM frames WHERE state = 'active'" ).get().count, totalEvents: this.db.prepare("SELECT COUNT(*) as count FROM events").get().count, totalAnchors: this.db.prepare("SELECT COUNT(*) as count FROM anchors").get().count, diskUsage: 0 }; try { const fileStats = await fs.stat(this.dbPath); stats.diskUsage = fileStats.size; } catch (error) { logger.debug("Failed to get database file size", { dbPath: this.dbPath, error: error instanceof Error ? error.message : String(error) }); } return stats; } async getQueryStats() { logger.warn("Query stats not available for SQLite"); return []; } // Transaction support async beginTransaction() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.prepare("BEGIN").run(); this.inTransactionFlag = true; } async commitTransaction() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.prepare("COMMIT").run(); this.inTransactionFlag = false; } async rollbackTransaction() { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); this.db.prepare("ROLLBACK").run(); this.inTransactionFlag = false; } async inTransaction(callback) { await this.beginTransaction(); try { await callback(this); await this.commitTransaction(); } catch (error) { await this.rollbackTransaction(); throw error; } } // Export/Import async exportData(tables, format) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); if (format !== "json") { throw new ValidationError( `Format ${format} not supported for SQLite export`, ErrorCode.VALIDATION_FAILED, { format, supportedFormats: ["json"] } ); } const data = {}; for (const table of tables) { data[table] = this.db.prepare(`SELECT * FROM ${table}`).all(); } return Buffer.from(JSON.stringify(data, null, 2)); } async importData(data, format, options) { if (!this.db) throw new DatabaseError( "Database not connected", ErrorCode.DB_CONNECTION_FAILED ); if (format !== "json") { throw new ValidationError( `Format ${format} not supported for SQLite import`, ErrorCode.VALIDATION_FAILED, { format, supportedFormats: ["json"] } ); } const parsed = JSON.parse(data.toString()); await this.inTransaction(async () => { for (const [table, rows] of Object.entries(parsed)) { if (options?.truncate) { this.db.prepare(`DELETE FROM ${table}`).run(); } for (const row of rows) { const cols = Object.keys(row); const placeholders = cols.map(() => "?").join(","); if (options?.upsert) { const updates = cols.map((c) => `${c} = excluded.${c}`).join(","); this.db.prepare( `INSERT INTO ${table} (${cols.join(",")}) VALUES (${placeholders}) ON CONFLICT DO UPDATE SET ${updates}` ).run(...Object.values(row)); } else { this.db.prepare( `INSERT INTO ${table} (${cols.join(",")}) VALUES (${placeholders})` ).run(...Object.values(row)); } } } }); } } export { SQLiteAdapter }; //# sourceMappingURL=sqlite-adapter.js.map