UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

383 lines (331 loc) 11.5 kB
/** * Data Migration System for Atlas * * Handles migration from pre-SQLite JSON-based data to SQLite database * Ensures data integrity and provides rollback capabilities */ import { promises as fs } from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import { getSQLiteManager } from './sqlite-manager.js'; export interface MigrationResult { success: boolean; migratedFiles: string[]; errors: string[]; backupPath?: string; summary: { epics: number; stories: number; sprints: number; other: number; }; } export interface LegacyEpic { id: string; title: string; description: string; status: string; priority: string; goals: string[]; createdAt: string; updatedAt: string; storyIds: string[]; stories: any[]; progress: number; } export interface LegacyStory { id: string; title: string; description: string; status: string; priority: string; storyPoints?: number; epicId?: string; sprintId?: string; acceptanceCriteria?: any[]; assignee?: string; createdAt: string; updatedAt: string; tags?: string[]; } export interface LegacySprint { id: string; name: string; goal: string; status: string; startDate: string; endDate: string; capacity?: number; storyIds: string[]; createdAt: string; updatedAt: string; } export class DataMigrationManager { private atlasDir: string; private dataDir: string; private backupDir: string; constructor() { this.atlasDir = '.atlas'; this.dataDir = path.join(this.atlasDir, 'data'); this.backupDir = path.join(this.atlasDir, 'backups'); } /** * Check if legacy data exists that needs migration */ async detectLegacyData(): Promise<boolean> { try { const agileDir = path.join(this.dataDir, 'agile'); const files = ['epics.json', 'stories.json', 'sprints.json']; for (const file of files) { const filePath = path.join(agileDir, file); try { await fs.access(filePath); return true; // Found at least one legacy file } catch { // File doesn't exist, continue } } return false; } catch { return false; } } /** * Create backup of existing data before migration */ async createBackup(): Promise<string> { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(this.backupDir, `pre-migration-${timestamp}`); await fs.mkdir(backupPath, { recursive: true }); // Copy entire data directory await this.copyDirectory(this.dataDir, path.join(backupPath, 'data')); console.error(`✅ Backup created at: ${backupPath}`); return backupPath; } /** * Migrate all legacy data to SQLite */ async migrateData(): Promise<MigrationResult> { const result: MigrationResult = { success: false, migratedFiles: [], errors: [], summary: { epics: 0, stories: 0, sprints: 0, other: 0 } }; try { console.error('🔄 Starting data migration...'); // Create backup first result.backupPath = await this.createBackup(); // Get database connection const db = getSQLiteManager(); // Ensure database is initialized if (!db.isReady()) { throw new Error('Database not ready for migration'); } // Migrate agile data await this.migrateAgileData(result); // Migrate other data modules await this.migrateOtherData(result); result.success = result.errors.length === 0; if (result.success) { console.error('✅ Data migration completed successfully'); console.error(`📊 Migrated: ${result.summary.epics} epics, ${result.summary.stories} stories, ${result.summary.sprints} sprints`); } else { console.warn('⚠️ Migration completed with errors:', result.errors); } } catch (error) { result.errors.push(`Migration failed: ${error instanceof Error ? error.message : 'Unknown error'}`); console.error('❌ Migration failed:', error); } return result; } /** * Migrate agile data (epics, stories, sprints) */ private async migrateAgileData(result: MigrationResult): Promise<void> { const agileDir = path.join(this.dataDir, 'agile'); const db = getSQLiteManager(); // Migrate epics try { const epicsPath = path.join(agileDir, 'epics.json'); const epicsData = await this.readJsonFile<LegacyEpic[]>(epicsPath); if (epicsData && epicsData.length > 0) { console.error(`📋 Migrating ${epicsData.length} epics...`); for (const epic of epicsData) { await db.run( `INSERT OR REPLACE INTO agile_epics (id, title, description, status, priority, goals, created_at, updated_at, story_ids, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ epic.id, epic.title, epic.description, epic.status, epic.priority, JSON.stringify(epic.goals), new Date(epic.createdAt).getTime(), new Date(epic.updatedAt).getTime(), JSON.stringify(epic.storyIds), epic.progress ] ); } result.summary.epics = epicsData.length; result.migratedFiles.push(epicsPath); } } catch (error) { result.errors.push(`Epic migration failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Migrate stories try { const storiesPath = path.join(agileDir, 'stories.json'); const storiesData = await this.readJsonFile<LegacyStory[]>(storiesPath); if (storiesData && storiesData.length > 0) { console.log(`📝 Migrating ${storiesData.length} stories...`); for (const story of storiesData) { await db.run( `INSERT OR REPLACE INTO agile_stories (id, title, description, status, priority, story_points, epic_id, sprint_id, acceptance_criteria, assignee, created_at, updated_at, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ story.id, story.title, story.description, story.status, story.priority, story.storyPoints || null, story.epicId || null, story.sprintId || null, JSON.stringify(story.acceptanceCriteria || []), story.assignee || null, new Date(story.createdAt).getTime(), new Date(story.updatedAt).getTime(), JSON.stringify(story.tags || []) ] ); } result.summary.stories = storiesData.length; result.migratedFiles.push(storiesPath); } } catch (error) { result.errors.push(`Story migration failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Migrate sprints try { const sprintsPath = path.join(agileDir, 'sprints.json'); const sprintsData = await this.readJsonFile<LegacySprint[]>(sprintsPath); if (sprintsData && sprintsData.length > 0) { console.log(`🏃 Migrating ${sprintsData.length} sprints...`); for (const sprint of sprintsData) { await db.run( `INSERT OR REPLACE INTO agile_sprints (id, name, goal, status, start_date, end_date, capacity, story_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ sprint.id, sprint.name, sprint.goal, sprint.status, new Date(sprint.startDate).getTime(), new Date(sprint.endDate).getTime(), sprint.capacity || null, JSON.stringify(sprint.storyIds), new Date(sprint.createdAt).getTime(), new Date(sprint.updatedAt).getTime() ] ); } result.summary.sprints = sprintsData.length; result.migratedFiles.push(sprintsPath); } } catch (error) { result.errors.push(`Sprint migration failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Migrate other data modules (workspace, business, etc.) */ private async migrateOtherData(result: MigrationResult): Promise<void> { // This can be extended for other data types as needed console.log('📂 Checking other data modules...'); const otherModules = ['workspace', 'business', 'development']; for (const module of otherModules) { try { const moduleDir = path.join(this.dataDir, module); const files = await fs.readdir(moduleDir).catch(() => []); for (const file of files) { if (file.endsWith('.json')) { const filePath = path.join(moduleDir, file); result.migratedFiles.push(filePath); result.summary.other++; } } } catch { // Module directory doesn't exist, skip } } } /** * Archive migrated files (move to archive directory) */ async archiveMigratedFiles(migratedFiles: string[]): Promise<void> { const archiveDir = path.join(this.atlasDir, 'archive'); await fs.mkdir(archiveDir, { recursive: true }); for (const filePath of migratedFiles) { try { const fileName = path.basename(filePath); const archivePath = path.join(archiveDir, fileName); await fs.rename(filePath, archivePath); console.log(`📦 Archived: ${fileName}`); } catch (error) { console.warn(`⚠️ Failed to archive ${filePath}:`, error); } } } /** * Read and parse JSON file */ private async readJsonFile<T>(filePath: string): Promise<T | null> { try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content) as T; } catch { return null; } } /** * Copy directory recursively */ private async copyDirectory(src: string, dest: string): Promise<void> { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } } } /** * Auto-migrate data on server startup if needed */ export async function autoMigrateOnStartup(): Promise<void> { const migrationManager = new DataMigrationManager(); if (await migrationManager.detectLegacyData()) { console.log('🔍 Legacy data detected, starting automatic migration...'); const result = await migrationManager.migrateData(); if (result.success) { // Archive migrated files to prevent re-migration await migrationManager.archiveMigratedFiles(result.migratedFiles); console.log('✅ Legacy data migration completed successfully'); } else { console.error('❌ Migration failed. Check backup at:', result.backupPath); throw new Error('Data migration failed: ' + result.errors.join(', ')); } } }