@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
383 lines (331 loc) • 11.5 kB
text/typescript
/**
* 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(', '));
}
}
}