MCP GitHub Issue Server
by sammcj
/**
* Database backup and recovery system
*/
import { Database } from 'sqlite';
import { Logger } from '../../../logging/index.js';
import { ErrorCodes, createError } from '../../../errors/index.js';
import { promises as fs } from 'fs';
import { join, basename } from 'path';
import { PlatformCapabilities } from '../../../utils/platform-utils.js';
export interface BackupMetadata {
timestamp: number;
version: number;
size: number;
checksum: string;
comment?: string;
}
export interface BackupOptions {
compress?: boolean;
vacuum?: boolean;
comment?: string;
}
export interface RestoreOptions {
force?: boolean;
verify?: boolean;
}
export class BackupManager {
private readonly logger: Logger;
private readonly backupDir: string;
private readonly maxBackups: number;
constructor(options: { backupDir: string; maxBackups?: number }) {
this.logger = Logger.getInstance().child({ component: 'BackupManager' });
this.backupDir = options.backupDir;
this.maxBackups = options.maxBackups || 10;
}
/**
* Initialize backup system
*/
async initialize(): Promise<void> {
try {
// Create backup directory if it doesn't exist with platform-appropriate permissions
await fs.mkdir(this.backupDir, {
recursive: true,
mode: PlatformCapabilities.getFileMode(0o755),
});
// Verify backup directory is writable
await fs.access(this.backupDir, fs.constants.W_OK);
this.logger.info('Backup system initialized', {
backupDir: this.backupDir,
maxBackups: this.maxBackups,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to initialize backup system', { error: errorMessage });
throw createError(
ErrorCodes.STORAGE_INIT,
'Failed to initialize backup system',
errorMessage
);
}
}
/**
* Create a backup
*/
async createBackup(db: Database, dbPath: string, options: BackupOptions = {}): Promise<string> {
const timestamp = Date.now();
const backupName = `backup_${timestamp}.db`;
const backupPath = join(this.backupDir, backupName);
try {
// Ensure database is in a consistent state
await db.run('PRAGMA wal_checkpoint(TRUNCATE)');
// Vacuum database if requested
if (options.vacuum) {
await db.run('VACUUM');
}
// Create backup
await this.copyDatabase(dbPath, backupPath);
// Calculate checksum
const checksum = await this.calculateChecksum(backupPath);
// Save metadata
const metadata: BackupMetadata = {
timestamp,
version: await this.getCurrentVersion(db),
size: (await fs.stat(backupPath)).size,
checksum,
comment: options.comment,
};
await this.saveMetadata(backupName, metadata);
// Compress if requested
if (options.compress) {
await this.compressBackup(backupPath);
}
// Clean up old backups
await this.cleanupOldBackups();
this.logger.info('Backup created successfully', {
backup: backupName,
size: metadata.size,
compressed: options.compress,
});
return backupPath;
} catch (error) {
// Clean up failed backup
try {
await fs.unlink(backupPath);
} catch {
// Ignore cleanup errors
}
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to create backup', { error: errorMessage });
throw createError(ErrorCodes.STORAGE_ERROR, 'Failed to create backup', errorMessage);
}
}
/**
* Restore from backup
*/
async restoreBackup(
db: Database,
dbPath: string,
backupPath: string,
options: RestoreOptions = {}
): Promise<void> {
try {
// Verify backup exists
await fs.access(backupPath);
// Get backup metadata
const backupName = basename(backupPath);
const metadata = await this.getMetadata(backupName);
if (!metadata) {
throw new Error('Backup metadata not found');
}
// Verify backup if requested
if (options.verify) {
const checksum = await this.calculateChecksum(backupPath);
if (checksum !== metadata.checksum) {
throw new Error('Backup checksum verification failed');
}
}
// Close database connection
await db.close();
// Create backup of current database
const currentBackupPath = `${dbPath}.bak`;
await this.copyDatabase(dbPath, currentBackupPath);
try {
// Restore backup
await this.copyDatabase(backupPath, dbPath);
this.logger.info('Backup restored successfully', {
backup: backupName,
version: metadata.version,
});
} catch (error) {
// Restore failed, revert to original
await this.copyDatabase(currentBackupPath, dbPath);
throw error;
} finally {
// Clean up temporary backup
try {
await fs.unlink(currentBackupPath);
} catch {
// Ignore cleanup errors
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to restore backup', { error: errorMessage });
throw createError(ErrorCodes.STORAGE_ERROR, 'Failed to restore backup', errorMessage);
}
}
/**
* List available backups
*/
async listBackups(): Promise<
{
name: string;
metadata: BackupMetadata;
}[]
> {
try {
const files = await fs.readdir(this.backupDir);
const backups = [];
for (const file of files) {
if (file.startsWith('backup_') && file.endsWith('.db')) {
const metadata = await this.getMetadata(file);
if (metadata) {
backups.push({ name: file, metadata });
}
}
}
return backups.sort((a, b) => b.metadata.timestamp - a.metadata.timestamp);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to list backups', { error: errorMessage });
throw createError(ErrorCodes.STORAGE_ERROR, 'Failed to list backups', errorMessage);
}
}
/**
* Delete a backup
*/
async deleteBackup(backupName: string): Promise<void> {
try {
const backupPath = join(this.backupDir, backupName);
await fs.unlink(backupPath);
// Delete metadata
const metadataPath = `${backupPath}.meta`;
try {
await fs.unlink(metadataPath);
} catch {
// Ignore metadata deletion errors
}
this.logger.info('Backup deleted', { backup: backupName });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to delete backup', { error: errorMessage });
throw createError(ErrorCodes.STORAGE_ERROR, 'Failed to delete backup', errorMessage);
}
}
private async getCurrentVersion(db: Database): Promise<number> {
const result = await db.get<{ version: number }>(
'SELECT MAX(version) as version FROM migrations'
);
return result?.version || 0;
}
private async copyDatabase(source: string, destination: string): Promise<void> {
// Handle file locking in a platform-agnostic way
try {
await fs.access(destination);
// If destination exists, ensure it's not locked
await fs.unlink(destination).catch(() => {});
} catch (error) {
// Ignore if destination doesn't exist
}
await fs.copyFile(source, destination);
}
private async calculateChecksum(filePath: string): Promise<string> {
const { createHash } = await import('crypto');
const content = await fs.readFile(filePath);
return createHash('sha256').update(content).digest('hex');
}
private async saveMetadata(backupName: string, metadata: BackupMetadata): Promise<void> {
const metadataPath = join(this.backupDir, `${backupName}.meta`);
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
}
private async getMetadata(backupName: string): Promise<BackupMetadata | null> {
try {
const metadataPath = join(this.backupDir, `${backupName}.meta`);
const content = await fs.readFile(metadataPath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
private async compressBackup(backupPath: string): Promise<void> {
const { gzip } = await import('zlib');
const { promisify } = await import('util');
const gzipAsync = promisify(gzip);
const content = await fs.readFile(backupPath);
const compressed = await gzipAsync(content);
await fs.writeFile(`${backupPath}.gz`, compressed);
await fs.unlink(backupPath);
}
private async cleanupOldBackups(): Promise<void> {
const backups = await this.listBackups();
if (backups.length > this.maxBackups) {
// Keep newest backups, delete oldest
const toDelete = backups.slice(this.maxBackups);
for (const backup of toDelete) {
await this.deleteBackup(backup.name);
}
}
}
}