@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
622 lines • 22 kB
JavaScript
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);
/**
* SQLite Database Manager for Atlas
* Provides a clean interface for database operations with proper error handling
*/
export class SQLiteManager {
db = null;
isInitialized = false;
isInitializing = false;
dbPath;
constructor(dbPath) {
this.dbPath = dbPath || process.env.ATLAS_DB_PATH || '.atlas/atlas.db';
}
/**
* Initialize the database connection and schema
*/
async initialize() {
// 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
*/
openDatabase() {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
reject(err);
}
else {
resolve(db);
}
});
});
}
/**
* Set database pragmas
*/
async runPragmas() {
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
*/
async initializeSchema() {
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
*/
async checkAndPerformMigration() {
try {
// Check migration status
const migrationCheck = await this.getInternal('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
*/
async checkAndPerformSchemaMigration() {
try {
// Check if memories table has new columns
const tableInfo = await this.query("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("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
*/
execMultiple(sql) {
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
*/
async getInternal(sql, params = []) {
try {
const row = await new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) {
reject(err);
}
else {
resolve(row);
}
});
});
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
*/
async runInternal(sql, params = []) {
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
*/
runQuery(sql, params = []) {
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(sql, params = []) {
if (!this.ensureInitialized()) {
return { success: false, error: 'Database not initialized' };
}
try {
const rows = await new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) {
reject(err);
}
else {
resolve(rows);
}
});
});
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(sql, params = []) {
if (!this.ensureInitialized()) {
return { success: false, error: 'Database not initialized' };
}
try {
const row = await new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) {
reject(err);
}
else {
resolve(row);
}
});
});
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(sql, params = []) {
return this.query(sql, params);
}
/**
* Execute an INSERT, UPDATE, or DELETE query
*/
async run(sql, params = []) {
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(fn) {
if (!this.ensureInitialized()) {
return { success: false, error: 'Database not initialized' };
}
try {
await this.runQuery('BEGIN TRANSACTION');
const context = {
query: (sql, params = []) => {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err)
reject(err);
else
resolve(rows);
});
});
},
run: (sql, params = []) => {
return this.runQuery(sql, params);
},
get: (sql, params = []) => {
return new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err)
reject(err);
else
resolve(row);
});
});
}
};
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) {
const result = await this.get("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() {
if (!this.ensureInitialized()) {
return { success: false, error: 'Database not initialized' };
}
try {
// Get table names
const tablesResult = await this.query("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(`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('PRAGMA page_count');
const pageSizeResult = await this.get('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() {
if (this.db) {
await new Promise((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
*/
ensureInitialized() {
if (!this.isInitialized || !this.db) {
console.error('❌ Database not initialized. Call initialize() first.');
return false;
}
return true;
}
/**
* Get the database path
*/
getDbPath() {
return this.dbPath;
}
/**
* Internal get method for use during initialization (exposed for DataMigration)
*/
getForMigration(sql, params = []) {
return this.getInternal(sql, params);
}
/**
* Internal run method for use during initialization (exposed for DataMigration)
*/
runForMigration(sql, params = []) {
return this.runInternal(sql, params);
}
/**
* Get database connection info
*/
getConnectionInfo() {
return {
isInitialized: this.isInitialized,
dbPath: this.dbPath
};
}
/**
* Check if database is ready for operations
*/
isReady() {
return this.isInitialized && this.db !== null;
}
/**
* Wait for database to be ready with timeout
*/
async waitForReady(timeoutMs = 10000) {
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 = null;
export function getSQLiteManager() {
if (!globalInstance) {
globalInstance = new SQLiteManager();
}
return globalInstance;
}
export function createSQLiteManager(dbPath) {
return new SQLiteManager(dbPath);
}
/**
* Utility function for dashboard APIs to ensure database is ready
*/
export async function ensureDatabaseReady(retries = 3, delayMs = 1000) {
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');
}
//# sourceMappingURL=sqlite-manager.js.map