@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
JavaScript
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