UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

727 lines (635 loc) 20.9 kB
import sqlite3 from 'sqlite3'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export interface QueryResult<T = any> { success: boolean; data?: T; error?: string; rowsAffected?: number; } export interface TransactionContext { query<T = any>(sql: string, params?: any[]): Promise<T[]>; run(sql: string, params?: any[]): Promise<{ changes: number; lastID: number }>; get<T = any>(sql: string, params?: any[]): Promise<T | undefined>; } /** * SQLite Database Manager for Atlas * Provides a clean interface for database operations with proper error handling */ export class SQLiteManager { private db: sqlite3.Database | null = null; private isInitialized = false; private isInitializing = false; private dbPath: string; constructor(dbPath?: string) { this.dbPath = dbPath || process.env.ATLAS_DB_PATH || '.atlas/atlas.db'; } /** * Initialize the database connection and schema */ async initialize(): Promise<void> { // Prevent multiple initialization attempts if (this.isInitialized) { return; } if (this.isInitializing) { // Wait for the current initialization to complete while (this.isInitializing) { await new Promise(resolve => setTimeout(resolve, 100)); } return; } this.isInitializing = true; try { // Ensure the directory exists const dbDir = path.dirname(this.dbPath); await fs.mkdir(dbDir, { recursive: true }); // Connect to the database this.db = await this.openDatabase(); // Enable foreign keys and other pragmas await this.runPragmas(); // Load and execute schema await this.initializeSchema(); this.isInitialized = true; this.isInitializing = false; console.error(`📊 SQLite database initialized at: ${this.dbPath}`); } catch (error) { this.isInitializing = false; console.error('❌ Failed to initialize SQLite database:', error); throw error; } } /** * Open database connection */ private openDatabase(): Promise<sqlite3.Database> { return new Promise((resolve, reject) => { const db = new sqlite3.Database(this.dbPath, (err) => { if (err) { reject(err); } else { resolve(db); } }); }); } /** * Set database pragmas */ private async runPragmas(): Promise<void> { if (!this.db) throw new Error('Database not connected'); const pragmas = [ 'PRAGMA foreign_keys = ON', 'PRAGMA journal_mode = WAL', 'PRAGMA synchronous = NORMAL', 'PRAGMA cache_size = 1000' ]; for (const pragma of pragmas) { await this.runQuery(pragma); } } /** * Load and execute the database schema */ private async initializeSchema(): Promise<void> { if (!this.db) { throw new Error('Database not connected'); } try { const schemaPath = path.join(__dirname, 'schema.sql'); const schema = await fs.readFile(schemaPath, 'utf-8'); // Execute schema statements await this.execMultiple(schema); console.error('✅ Database schema initialized'); // Check and perform migration if needed await this.checkAndPerformMigration(); } catch (error) { console.error('❌ Failed to initialize database schema:', error); throw error; } } /** * Check if migration is needed and perform one-time migration from JSON files */ private async checkAndPerformMigration(): Promise<void> { try { // Check migration status const migrationCheck = await this.getInternal<{ value: string }>( 'SELECT value FROM atlas_metadata WHERE key = ?', ['migration_status'] ); if (migrationCheck.success && migrationCheck.data && migrationCheck.data.value === 'completed') { console.error('📋 Migration already completed, skipping'); return; } // Import and run migration const { DataMigration } = await import('./migration.js'); const migration = new DataMigration(this, '.atlas', true); // Use internal methods during initialization const migrationStatus = await migration.checkMigrationStatus(); if (migrationStatus.needsMigration) { console.error('🔄 Performing one-time migration from JSON to SQLite...'); const result = await migration.migrate(); if (result.success) { // Mark migration as completed await this.runInternal( 'INSERT OR REPLACE INTO atlas_metadata (key, value, updated_at) VALUES (?, ?, ?)', ['migration_status', 'completed', Date.now()] ); console.error('✅ Migration completed successfully'); } else { console.error('❌ Migration failed:', result.error); throw new Error(`Migration failed: ${result.error}`); } } else if (migrationStatus.hasLegacyData) { // Has legacy data but migration not required - force migration anyway console.error('🔄 Forcing migration due to legacy data presence...'); const result = await migration.migrate(); if (result.success) { await this.runInternal( 'INSERT OR REPLACE INTO atlas_metadata (key, value, updated_at) VALUES (?, ?, ?)', ['migration_status', 'completed', Date.now()] ); console.error('✅ Migration completed successfully'); } else { console.error('❌ Migration failed:', result.error); throw new Error(`Migration failed: ${result.error}`); } } else { // No migration needed, mark as completed anyway await this.runInternal( 'INSERT OR REPLACE INTO atlas_metadata (key, value, updated_at) VALUES (?, ?, ?)', ['migration_status', 'completed', Date.now()] ); console.error('📋 No migration needed, fresh installation'); } } catch (error) { console.error('❌ Migration check/execution failed:', error); throw error; } // Check and perform schema migrations await this.checkAndPerformSchemaMigration(); } /** * Check if schema migration is needed and perform schema updates */ private async checkAndPerformSchemaMigration(): Promise<void> { try { // Check if memories table has new columns const tableInfo = await this.query<{ name: string }>( "PRAGMA table_info(memories)" ); if (tableInfo.success && tableInfo.data) { const columns = tableInfo.data.map(col => col.name); const requiredColumns = ['title', 'tags', 'importance', 'category', 'source', 'created_by']; const missingColumns = requiredColumns.filter(col => !columns.includes(col)); if (missingColumns.length > 0) { console.error('🔄 Updating memories table schema...'); // Add missing columns for (const column of missingColumns) { let defaultValue = ''; let columnType = 'TEXT'; switch (column) { case 'tags': defaultValue = " DEFAULT '[]'"; break; case 'importance': defaultValue = " DEFAULT 'medium'"; break; default: defaultValue = ''; } await this.runInternal( `ALTER TABLE memories ADD COLUMN ${column} ${columnType}${defaultValue}` ); } console.error('✅ Memories table schema updated'); } } // Check if agile_epics table needs schema updates const epicTableInfo = await this.query<{ name: string }>( "PRAGMA table_info(agile_epics)" ); if (epicTableInfo.success && epicTableInfo.data) { const epicColumns = epicTableInfo.data.map(col => col.name); const requiredEpicColumns = [ { name: 'goals', type: 'TEXT', defaultValue: "'[]'" }, { name: 'owner', type: 'TEXT', defaultValue: null }, { name: 'target_date', type: 'INTEGER', defaultValue: null }, { name: 'repositories', type: 'TEXT', defaultValue: "'[]'" } ]; let updated = false; for (const column of requiredEpicColumns) { if (!epicColumns.includes(column.name)) { console.error(`🔄 Adding ${column.name} column to agile_epics table...`); const defaultClause = column.defaultValue ? ` DEFAULT ${column.defaultValue}` : ''; await this.runInternal( `ALTER TABLE agile_epics ADD COLUMN ${column.name} ${column.type}${defaultClause}` ); updated = true; } } if (updated) { console.error('✅ Agile epics table schema updated'); } } } catch (error) { console.error('❌ Schema migration failed:', error); throw error; } } /** * Execute multiple SQL statements */ private execMultiple(sql: string): Promise<void> { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not connected')); return; } this.db.exec(sql, (err) => { if (err) { reject(err); } else { resolve(); } }); }); } /** * Internal get method for use during initialization */ private async getInternal<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> { try { const row = await new Promise<T | undefined>((resolve, reject) => { this.db!.get(sql, params, (err, row) => { if (err) { reject(err); } else { resolve(row as T | undefined); } }); }); return { success: true, data: row }; } catch (error) { console.error('❌ Get error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Internal run method for use during initialization */ private async runInternal(sql: string, params: any[] = []): Promise<QueryResult<{ changes: number; lastInsertRowid: number }>> { try { const info = await this.runQuery(sql, params); return { success: true, data: { changes: info.changes, lastInsertRowid: info.lastID }, rowsAffected: info.changes }; } catch (error) { console.error('❌ Run error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Execute a single SQL statement */ private runQuery(sql: string, params: any[] = []): Promise<{ changes: number; lastID: number }> { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not connected')); return; } this.db.run(sql, params, function(err) { if (err) { reject(err); } else { resolve({ changes: this.changes, lastID: this.lastID }); } }); }); } /** * Execute a SELECT query and return all rows */ async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T[]>> { if (!this.ensureInitialized()) { return { success: false, error: 'Database not initialized' }; } try { const rows = await new Promise<T[]>((resolve, reject) => { this.db!.all(sql, params, (err, rows) => { if (err) { reject(err); } else { resolve(rows as T[]); } }); }); return { success: true, data: rows }; } catch (error) { console.error('❌ Query error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Execute a query and return the first row */ async get<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> { if (!this.ensureInitialized()) { return { success: false, error: 'Database not initialized' }; } try { const row = await new Promise<T | undefined>((resolve, reject) => { this.db!.get(sql, params, (err, row) => { if (err) { reject(err); } else { resolve(row as T | undefined); } }); }); return { success: true, data: row }; } catch (error) { console.error('❌ Get error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Execute a SELECT query and return all rows (alias for query) */ async all<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T[]>> { return this.query<T>(sql, params); } /** * Execute an INSERT, UPDATE, or DELETE query */ async run(sql: string, params: any[] = []): Promise<QueryResult<{ changes: number; lastInsertRowid: number }>> { if (!this.ensureInitialized()) { return { success: false, error: 'Database not initialized' }; } try { const info = await this.runQuery(sql, params); return { success: true, data: { changes: info.changes, lastInsertRowid: info.lastID }, rowsAffected: info.changes }; } catch (error) { console.error('❌ Run error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Execute multiple operations in a transaction */ async transaction<T>(fn: (ctx: TransactionContext) => Promise<T>): Promise<QueryResult<T>> { if (!this.ensureInitialized()) { return { success: false, error: 'Database not initialized' }; } try { await this.runQuery('BEGIN TRANSACTION'); const context: TransactionContext = { query: <U>(sql: string, params: any[] = []): Promise<U[]> => { return new Promise((resolve, reject) => { this.db!.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows as U[]); }); }); }, run: (sql: string, params: any[] = []): Promise<{ changes: number; lastID: number }> => { return this.runQuery(sql, params); }, get: <U>(sql: string, params: any[] = []): Promise<U | undefined> => { return new Promise((resolve, reject) => { this.db!.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row as U | undefined); }); }); } }; const result = await fn(context); await this.runQuery('COMMIT'); return { success: true, data: result }; } catch (error) { console.error('❌ Transaction error:', error); try { await this.runQuery('ROLLBACK'); } catch (rollbackError) { console.error('❌ Rollback error:', rollbackError); } return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Check if a table exists */ async tableExists(tableName: string): Promise<boolean> { const result = await this.get<{ count: number }>( "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name=?", [tableName] ); return result.success && result.data?.count === 1; } /** * Get database statistics */ async getStats(): Promise<QueryResult<{ tables: Array<{ name: string; rowCount: number }>; dbSize: number; pageCount: number; pageSize: number; }>> { if (!this.ensureInitialized()) { return { success: false, error: 'Database not initialized' }; } try { // Get table names const tablesResult = await this.query<{ name: string }>( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" ); if (!tablesResult.success || !tablesResult.data) { return { success: false, error: 'Failed to get table list' }; } // Get row counts for each table const tables = []; for (const table of tablesResult.data) { const countResult = await this.get<{ count: number }>( `SELECT COUNT(*) as count FROM "${table.name}"` ); tables.push({ name: table.name, rowCount: countResult.data?.count || 0 }); } // Get database size info const pageCountResult = await this.get<{ page_count: number }>('PRAGMA page_count'); const pageSizeResult = await this.get<{ page_size: number }>('PRAGMA page_size'); const pageCount = pageCountResult.data?.page_count || 0; const pageSize = pageSizeResult.data?.page_size || 0; return { success: true, data: { tables, dbSize: pageCount * pageSize, pageCount, pageSize } }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * Close the database connection */ async close(): Promise<void> { if (this.db) { await new Promise<void>((resolve, reject) => { this.db!.close((err) => { if (err) { reject(err); } else { resolve(); } }); }); this.db = null; this.isInitialized = false; console.log('📊 SQLite database connection closed'); } } /** * Check if the database is initialized */ private ensureInitialized(): boolean { if (!this.isInitialized || !this.db) { console.error('❌ Database not initialized. Call initialize() first.'); return false; } return true; } /** * Get the database path */ getDbPath(): string { return this.dbPath; } /** * Internal get method for use during initialization (exposed for DataMigration) */ getForMigration<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> { return this.getInternal<T>(sql, params); } /** * Internal run method for use during initialization (exposed for DataMigration) */ runForMigration(sql: string, params: any[] = []): Promise<QueryResult<{ changes: number; lastInsertRowid: number }>> { return this.runInternal(sql, params); } /** * Get database connection info */ getConnectionInfo(): { isInitialized: boolean; dbPath: string } { return { isInitialized: this.isInitialized, dbPath: this.dbPath }; } /** * Check if database is ready for operations */ isReady(): boolean { return this.isInitialized && this.db !== null; } /** * Wait for database to be ready with timeout */ async waitForReady(timeoutMs: number = 10000): Promise<boolean> { const startTime = Date.now(); while (!this.isReady() && Date.now() - startTime < timeoutMs) { await new Promise(resolve => setTimeout(resolve, 100)); } return this.isReady(); } } // Singleton instance for convenience let globalInstance: SQLiteManager | null = null; export function getSQLiteManager(): SQLiteManager { if (!globalInstance) { globalInstance = new SQLiteManager(); } return globalInstance; } export function createSQLiteManager(dbPath?: string): SQLiteManager { return new SQLiteManager(dbPath); } /** * Utility function for dashboard APIs to ensure database is ready */ export async function ensureDatabaseReady(retries: number = 3, delayMs: number = 1000): Promise<SQLiteManager> { const db = getSQLiteManager(); // If database is not initialized, initialize it if (!db.isReady()) { try { await db.initialize(); } catch (error) { // If initialization fails, it might be because another process is initializing // Continue with retry logic below } } for (let attempt = 1; attempt <= retries; attempt++) { if (db.isReady()) { return db; } console.warn(`⚠️ Database not ready, attempt ${attempt}/${retries}, waiting ${delayMs}ms...`); if (attempt < retries) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } throw new Error('Database not ready after maximum retries'); }