import Database from 'better-sqlite3'; import { randomUUID } from 'node:crypto'; import type { StorageAdapter, StorageConfig } from '../types.js'; /* eslint-disable @typescript-eslint/no-explicit-any */ const COLLECTIONS = [ 'users', 'sessions', 'accounts', 'projects', 'missions', 'tasks', 'agents', 'conversations', 'messages', 'preferences', 'insights', 'skills', 'events', 'routing_rules', 'provider_credentials', 'agent_logs', 'teams', 'team_members', 'mission_tasks', 'tickets', 'summarization_jobs', 'appreciations', 'verifications', ] as const; function buildFilterClause(filter?: Record): { clause: string; params: unknown[]; } { if (!filter || Object.keys(filter).length === 0) return { clause: '', params: [] }; const conditions: string[] = []; const params: unknown[] = []; for (const [key, value] of Object.entries(filter)) { if (key === 'id') { conditions.push('id = ?'); params.push(value); } else { conditions.push(`json_extract(data_json, '$.${key}') = ?`); params.push(typeof value === 'object' ? JSON.stringify(value) : value); } } return { clause: ` WHERE ${conditions.join(' AND ')}`, params }; } export class SqliteAdapter implements StorageAdapter { readonly name = 'sqlite'; private db: Database.Database; constructor(config: Extract) { this.db = new Database(config.path); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); } async create>( collection: string, data: T, ): Promise { const id = (data as any).id ?? randomUUID(); const now = new Date().toISOString(); const rest = Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id')); this.db .prepare( `INSERT INTO ${collection} (id, data_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, ) .run(id, JSON.stringify(rest), now, now); return { ...data, id } as T & { id: string }; } async read>(collection: string, id: string): Promise { const row = this.db.prepare(`SELECT * FROM ${collection} WHERE id = ?`).get(id) as any; if (!row) return null; return { id: row.id, ...JSON.parse(row.data_json as string) } as T; } async update(collection: string, id: string, data: Record): Promise { const existing = this.db .prepare(`SELECT data_json FROM ${collection} WHERE id = ?`) .get(id) as any; if (!existing) return false; const merged = { ...JSON.parse(existing.data_json as string), ...data }; const now = new Date().toISOString(); const result = this.db .prepare(`UPDATE ${collection} SET data_json = ?, updated_at = ? WHERE id = ?`) .run(JSON.stringify(merged), now, id); return result.changes > 0; } async delete(collection: string, id: string): Promise { const result = this.db.prepare(`DELETE FROM ${collection} WHERE id = ?`).run(id); return result.changes > 0; } async find>( collection: string, filter?: Record, opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' }, ): Promise { const { clause, params } = buildFilterClause(filter); let query = `SELECT * FROM ${collection}${clause}`; if (opts?.orderBy) { const dir = opts.order === 'desc' ? 'DESC' : 'ASC'; const col = opts.orderBy === 'id' || opts.orderBy === 'created_at' || opts.orderBy === 'updated_at' ? opts.orderBy : `json_extract(data_json, '$.${opts.orderBy}')`; query += ` ORDER BY ${col} ${dir}`; } if (opts?.limit) { query += ` LIMIT ?`; params.push(opts.limit); } if (opts?.offset) { query += ` OFFSET ?`; params.push(opts.offset); } const rows = this.db.prepare(query).all(...params) as any[]; return rows.map((row) => ({ id: row.id, ...JSON.parse(row.data_json as string) }) as T); } async findOne>( collection: string, filter: Record, ): Promise { const results = await this.find(collection, filter, { limit: 1 }); return results[0] ?? null; } async count(collection: string, filter?: Record): Promise { const { clause, params } = buildFilterClause(filter); const row = this.db .prepare(`SELECT COUNT(*) as count FROM ${collection}${clause}`) .get(...params) as any; return row?.count ?? 0; } async transaction(fn: (tx: StorageAdapter) => Promise): Promise { const txAdapter = new SqliteTxAdapter(this.db); this.db.exec('BEGIN'); try { const result = await fn(txAdapter); this.db.exec('COMMIT'); return result; } catch (err) { this.db.exec('ROLLBACK'); throw err; } } async migrate(): Promise { const createTable = (name: string) => this.db.exec(` CREATE TABLE IF NOT EXISTS ${name} ( id TEXT PRIMARY KEY, data_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); for (const collection of COLLECTIONS) { createTable(collection); } } async close(): Promise { this.db.close(); } } /** * Transaction wrapper that uses the same db handle — better-sqlite3 transactions * are connection-level, so all statements on the same Database instance within * a db.transaction() callback participate in the transaction. */ class SqliteTxAdapter implements StorageAdapter { readonly name = 'sqlite'; private db: Database.Database; constructor(db: Database.Database) { this.db = db; } async create>( collection: string, data: T, ): Promise { const id = (data as any).id ?? randomUUID(); const now = new Date().toISOString(); const rest = Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id')); this.db .prepare( `INSERT INTO ${collection} (id, data_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, ) .run(id, JSON.stringify(rest), now, now); return { ...data, id } as T & { id: string }; } async read>(collection: string, id: string): Promise { const row = this.db.prepare(`SELECT * FROM ${collection} WHERE id = ?`).get(id) as any; if (!row) return null; return { id: row.id, ...JSON.parse(row.data_json as string) } as T; } async update(collection: string, id: string, data: Record): Promise { const existing = this.db .prepare(`SELECT data_json FROM ${collection} WHERE id = ?`) .get(id) as any; if (!existing) return false; const merged = { ...JSON.parse(existing.data_json as string), ...data }; const now = new Date().toISOString(); const result = this.db .prepare(`UPDATE ${collection} SET data_json = ?, updated_at = ? WHERE id = ?`) .run(JSON.stringify(merged), now, id); return result.changes > 0; } async delete(collection: string, id: string): Promise { const result = this.db.prepare(`DELETE FROM ${collection} WHERE id = ?`).run(id); return result.changes > 0; } async find>( collection: string, filter?: Record, opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' }, ): Promise { const { clause, params } = buildFilterClause(filter); let query = `SELECT * FROM ${collection}${clause}`; if (opts?.orderBy) { const dir = opts.order === 'desc' ? 'DESC' : 'ASC'; const col = opts.orderBy === 'id' || opts.orderBy === 'created_at' || opts.orderBy === 'updated_at' ? opts.orderBy : `json_extract(data_json, '$.${opts.orderBy}')`; query += ` ORDER BY ${col} ${dir}`; } if (opts?.limit) { query += ` LIMIT ?`; params.push(opts.limit); } if (opts?.offset) { query += ` OFFSET ?`; params.push(opts.offset); } const rows = this.db.prepare(query).all(...params) as any[]; return rows.map((row) => ({ id: row.id, ...JSON.parse(row.data_json as string) }) as T); } async findOne>( collection: string, filter: Record, ): Promise { const results = await this.find(collection, filter, { limit: 1 }); return results[0] ?? null; } async count(collection: string, filter?: Record): Promise { const { clause, params } = buildFilterClause(filter); const row = this.db .prepare(`SELECT COUNT(*) as count FROM ${collection}${clause}`) .get(...params) as any; return row?.count ?? 0; } async transaction(fn: (tx: StorageAdapter) => Promise): Promise { return fn(this); } async migrate(): Promise { // No-op inside transaction } async close(): Promise { // No-op inside transaction } }