diff --git a/ts/database/index.ts b/ts/database/index.ts index 891d86e..1ac8e2d 100644 --- a/ts/database/index.ts +++ b/ts/database/index.ts @@ -25,6 +25,7 @@ import type { import type { TBindValue } from './types.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; +import { MigrationRunner } from './migrations/index.ts'; // Import repositories import { @@ -71,7 +72,8 @@ export class OneboxDatabase { await this.createTables(); // Run migrations if needed - await this.runMigrations(); + const runner = new MigrationRunner(this.query.bind(this)); + runner.run(); // Initialize repositories with bound query function const queryFn = this.query.bind(this); @@ -241,724 +243,6 @@ export class OneboxDatabase { /** * Run database migrations */ - private async runMigrations(): Promise { - if (!this.db) throw new Error('Database not initialized'); - - try { - const currentVersion = this.getMigrationVersion(); - logger.info(`Current database migration version: ${currentVersion}`); - - // Migration 1: Initial schema - if (currentVersion === 0) { - logger.info('Setting initial migration version to 1'); - this.setMigrationVersion(1); - } - - // Migration 2: Convert timestamp columns from INTEGER to REAL - const updatedVersion = this.getMigrationVersion(); - if (updatedVersion < 2) { - logger.info('Running migration 2: Converting timestamps to REAL...'); - - // SSL certificates - this.query(` - CREATE TABLE ssl_certificates_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL UNIQUE, - cert_path TEXT NOT NULL, - key_path TEXT NOT NULL, - full_chain_path TEXT NOT NULL, - expiry_date REAL NOT NULL, - issuer TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); - this.query(`DROP TABLE ssl_certificates`); - this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); - - // Services - this.query(` - CREATE TABLE services_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - image TEXT NOT NULL, - registry TEXT, - env_vars TEXT NOT NULL, - port INTEGER NOT NULL, - domain TEXT, - container_id TEXT, - status TEXT NOT NULL DEFAULT 'stopped', - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO services_new SELECT * FROM services`); - this.query(`DROP TABLE services`); - this.query(`ALTER TABLE services_new RENAME TO services`); - - // Registries - this.query(` - CREATE TABLE registries_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - password_encrypted TEXT NOT NULL, - created_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO registries_new SELECT * FROM registries`); - this.query(`DROP TABLE registries`); - this.query(`ALTER TABLE registries_new RENAME TO registries`); - - // Nginx configs - this.query(` - CREATE TABLE nginx_configs_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - domain TEXT NOT NULL, - port INTEGER NOT NULL, - ssl_enabled INTEGER NOT NULL DEFAULT 0, - config_template TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); - this.query(`DROP TABLE nginx_configs`); - this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); - - // DNS records - this.query(` - CREATE TABLE dns_records_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - value TEXT NOT NULL, - cloudflare_id TEXT, - zone_id TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); - this.query(`DROP TABLE dns_records`); - this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`); - - // Metrics - this.query(` - CREATE TABLE metrics_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - timestamp REAL NOT NULL, - cpu_percent REAL NOT NULL, - memory_used INTEGER NOT NULL, - memory_limit INTEGER NOT NULL, - network_rx_bytes INTEGER NOT NULL, - network_tx_bytes INTEGER NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - this.query(`INSERT INTO metrics_new SELECT * FROM metrics`); - this.query(`DROP TABLE metrics`); - this.query(`ALTER TABLE metrics_new RENAME TO metrics`); - this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); - - // Logs - this.query(` - CREATE TABLE logs_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - timestamp REAL NOT NULL, - message TEXT NOT NULL, - level TEXT NOT NULL, - source TEXT NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - this.query(`INSERT INTO logs_new SELECT * FROM logs`); - this.query(`DROP TABLE logs`); - this.query(`ALTER TABLE logs_new RENAME TO logs`); - this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); - - // Users - this.query(` - CREATE TABLE users_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO users_new SELECT * FROM users`); - this.query(`DROP TABLE users`); - this.query(`ALTER TABLE users_new RENAME TO users`); - - // Settings - this.query(` - CREATE TABLE settings_new ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO settings_new SELECT * FROM settings`); - this.query(`DROP TABLE settings`); - this.query(`ALTER TABLE settings_new RENAME TO settings`); - - // Migrations table itself - this.query(` - CREATE TABLE migrations_new ( - version INTEGER PRIMARY KEY, - applied_at REAL NOT NULL - ) - `); - this.query(`INSERT INTO migrations_new SELECT * FROM migrations`); - this.query(`DROP TABLE migrations`); - this.query(`ALTER TABLE migrations_new RENAME TO migrations`); - - this.setMigrationVersion(2); - logger.success('Migration 2 completed: All timestamps converted to REAL'); - } - - // Migration 3: Domain management tables - const version3 = this.getMigrationVersion(); - if (version3 < 3) { - logger.info('Running migration 3: Creating domain management tables...'); - - this.query(` - CREATE TABLE domains ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL UNIQUE, - dns_provider TEXT, - cloudflare_zone_id TEXT, - is_obsolete INTEGER NOT NULL DEFAULT 0, - default_wildcard INTEGER NOT NULL DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - - this.query(` - CREATE TABLE certificates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain_id INTEGER NOT NULL, - cert_domain TEXT NOT NULL, - is_wildcard INTEGER NOT NULL DEFAULT 0, - cert_path TEXT NOT NULL, - key_path TEXT NOT NULL, - full_chain_path TEXT NOT NULL, - expiry_date REAL NOT NULL, - issuer TEXT NOT NULL, - is_valid INTEGER NOT NULL DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE - ) - `); - - this.query(` - CREATE TABLE cert_requirements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - domain_id INTEGER NOT NULL, - subdomain TEXT NOT NULL, - certificate_id INTEGER, - status TEXT NOT NULL DEFAULT 'pending', - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, - FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, - FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL - ) - `); - - interface OldSslCert { - id?: number; - domain?: string; - cert_path?: string; - key_path?: string; - full_chain_path?: string; - expiry_date?: number; - issuer?: string; - created_at?: number; - updated_at?: number; - [key: number]: unknown; - } - const existingCerts = this.query('SELECT * FROM ssl_certificates'); - - const now = Date.now(); - const domainMap = new Map(); - - for (const cert of existingCerts) { - const domain = String(cert.domain ?? (cert as Record)[1]); - if (!domainMap.has(domain)) { - this.query( - 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - [domain, null, 0, 1, now, now] - ); - const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id'); - const domainId = result[0].id ?? (result[0] as Record)[0]; - domainMap.set(domain, Number(domainId)); - } - } - - for (const cert of existingCerts) { - const domain = String(cert.domain ?? (cert as Record)[1]); - const domainId = domainMap.get(domain); - - this.query( - `INSERT INTO certificates ( - domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, - expiry_date, issuer, is_valid, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - domainId, - domain, - 0, - String(cert.cert_path ?? (cert as Record)[2]), - String(cert.key_path ?? (cert as Record)[3]), - String(cert.full_chain_path ?? (cert as Record)[4]), - Number(cert.expiry_date ?? (cert as Record)[5]), - String(cert.issuer ?? (cert as Record)[6]), - 1, - Number(cert.created_at ?? (cert as Record)[7]), - Number(cert.updated_at ?? (cert as Record)[8]) - ] - ); - } - - this.query('DROP TABLE ssl_certificates'); - this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); - this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)'); - - this.setMigrationVersion(3); - logger.success('Migration 3 completed: Domain management tables created'); - } - - // Migration 4: Add Onebox Registry support columns - const version4 = this.getMigrationVersion(); - if (version4 < 4) { - logger.info('Running migration 4: Adding Onebox Registry columns to services table...'); - - this.query(`ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0`); - this.query(`ALTER TABLE services ADD COLUMN registry_repository TEXT`); - this.query(`ALTER TABLE services ADD COLUMN registry_token TEXT`); - this.query(`ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'`); - this.query(`ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0`); - this.query(`ALTER TABLE services ADD COLUMN image_digest TEXT`); - - this.setMigrationVersion(4); - logger.success('Migration 4 completed: Onebox Registry columns added to services table'); - } - - // Migration 5: Registry tokens table - const version5 = this.getMigrationVersion(); - if (version5 < 5) { - logger.info('Running migration 5: Creating registry_tokens table...'); - - this.query(` - CREATE TABLE registry_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - token_type TEXT NOT NULL, - scope TEXT NOT NULL, - expires_at REAL, - created_at REAL NOT NULL, - last_used_at REAL, - created_by TEXT NOT NULL - ) - `); - - this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)'); - this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)'); - - this.setMigrationVersion(5); - logger.success('Migration 5 completed: Registry tokens table created'); - } - - // Migration 6: Drop registry_token column from services table - const version6 = this.getMigrationVersion(); - if (version6 < 6) { - logger.info('Running migration 6: Dropping registry_token column from services table...'); - - this.query(` - CREATE TABLE services_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - image TEXT NOT NULL, - registry TEXT, - env_vars TEXT, - port INTEGER NOT NULL, - domain TEXT, - container_id TEXT, - status TEXT NOT NULL, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - use_onebox_registry INTEGER DEFAULT 0, - registry_repository TEXT, - registry_image_tag TEXT DEFAULT 'latest', - auto_update_on_push INTEGER DEFAULT 0, - image_digest TEXT - ) - `); - - this.query(` - INSERT INTO services_new ( - id, name, image, registry, env_vars, port, domain, container_id, status, - created_at, updated_at, use_onebox_registry, registry_repository, - registry_image_tag, auto_update_on_push, image_digest - ) - SELECT - id, name, image, registry, env_vars, port, domain, container_id, status, - created_at, updated_at, use_onebox_registry, registry_repository, - registry_image_tag, auto_update_on_push, image_digest - FROM services - `); - - this.query('DROP TABLE services'); - this.query('ALTER TABLE services_new RENAME TO services'); - this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); - this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); - - this.setMigrationVersion(6); - logger.success('Migration 6 completed: registry_token column dropped from services table'); - } - - // Migration 7: Platform services tables - const version7 = this.getMigrationVersion(); - if (version7 < 7) { - logger.info('Running migration 7: Creating platform services tables...'); - - this.query(` - CREATE TABLE platform_services ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'stopped', - container_id TEXT, - config TEXT NOT NULL DEFAULT '{}', - admin_credentials_encrypted TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ) - `); - - this.query(` - CREATE TABLE platform_resources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform_service_id INTEGER NOT NULL, - service_id INTEGER NOT NULL, - resource_type TEXT NOT NULL, - resource_name TEXT NOT NULL, - credentials_encrypted TEXT NOT NULL, - created_at REAL NOT NULL, - FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - this.query(`ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'`); - - this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)'); - this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)'); - - this.setMigrationVersion(7); - logger.success('Migration 7 completed: Platform services tables created'); - } - - // Migration 8: Convert certificates table to store PEM content - const version8 = this.getMigrationVersion(); - if (version8 < 8) { - logger.info('Running migration 8: Converting certificates table to store PEM content...'); - - this.query(` - CREATE TABLE certificates_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain_id INTEGER NOT NULL, - cert_domain TEXT NOT NULL, - is_wildcard INTEGER NOT NULL DEFAULT 0, - cert_pem TEXT NOT NULL DEFAULT '', - key_pem TEXT NOT NULL DEFAULT '', - fullchain_pem TEXT NOT NULL DEFAULT '', - expiry_date REAL NOT NULL, - issuer TEXT NOT NULL, - is_valid INTEGER NOT NULL DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE - ) - `); - - this.query(` - INSERT INTO certificates_new (id, domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at) - SELECT id, domain_id, cert_domain, is_wildcard, '', '', '', expiry_date, issuer, 0, created_at, updated_at FROM certificates - `); - - this.query('DROP TABLE certificates'); - this.query('ALTER TABLE certificates_new RENAME TO certificates'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); - - this.setMigrationVersion(8); - logger.success('Migration 8 completed: Certificates table now stores PEM content'); - } - - // Migration 9: Backup system tables - const version9 = this.getMigrationVersion(); - if (version9 < 9) { - logger.info('Running migration 9: Creating backup system tables...'); - - // Add include_image_in_backup column to services table - this.query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`); - - // Create backups table - this.query(` - CREATE TABLE backups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - service_name TEXT NOT NULL, - filename TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - created_at REAL NOT NULL, - includes_image INTEGER NOT NULL, - platform_resources TEXT NOT NULL DEFAULT '[]', - checksum TEXT NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)'); - - this.setMigrationVersion(9); - logger.success('Migration 9 completed: Backup system tables created'); - } - - // Migration 10: Backup schedules table and extend backups table - const version10 = this.getMigrationVersion(); - if (version10 < 10) { - logger.info('Running migration 10: Creating backup schedules table...'); - - // Create backup_schedules table - this.query(` - CREATE TABLE backup_schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - service_name TEXT NOT NULL, - cron_expression TEXT NOT NULL, - retention_tier TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - last_run_at REAL, - next_run_at REAL, - last_status TEXT, - last_error TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)'); - - // Extend backups table with retention_tier and schedule_id columns - this.query('ALTER TABLE backups ADD COLUMN retention_tier TEXT'); - this.query('ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL'); - - this.setMigrationVersion(10); - logger.success('Migration 10 completed: Backup schedules table created'); - } - - // Migration 11: Add scope columns for global/pattern backup schedules - const version11 = this.getMigrationVersion(); - if (version11 < 11) { - logger.info('Running migration 11: Adding scope columns to backup_schedules...'); - - // Recreate backup_schedules table with nullable service_id/service_name and new scope columns - this.query(` - CREATE TABLE backup_schedules_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_type TEXT NOT NULL DEFAULT 'service', - scope_pattern TEXT, - service_id INTEGER, - service_name TEXT, - cron_expression TEXT NOT NULL, - retention_tier TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - last_run_at REAL, - next_run_at REAL, - last_status TEXT, - last_error TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - // Copy existing schedules (all are service-specific) - this.query(` - INSERT INTO backup_schedules_new ( - id, scope_type, scope_pattern, service_id, service_name, cron_expression, - retention_tier, enabled, last_run_at, next_run_at, last_status, last_error, - created_at, updated_at - ) - SELECT - id, 'service', NULL, service_id, service_name, cron_expression, - retention_tier, enabled, last_run_at, next_run_at, last_status, last_error, - created_at, updated_at - FROM backup_schedules - `); - - this.query('DROP TABLE backup_schedules'); - this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)'); - - this.setMigrationVersion(11); - logger.success('Migration 11 completed: Scope columns added to backup_schedules'); - } - - // Migration 12: GFS retention policy - replace retention_tier with per-tier retention counts - const version12 = this.getMigrationVersion(); - if (version12 < 12) { - logger.info('Running migration 12: Updating backup system for GFS retention policy...'); - - // Recreate backup_schedules table with new retention columns - this.query(` - CREATE TABLE backup_schedules_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_type TEXT NOT NULL DEFAULT 'service', - scope_pattern TEXT, - service_id INTEGER, - service_name TEXT, - cron_expression TEXT NOT NULL, - retention_hourly INTEGER NOT NULL DEFAULT 0, - retention_daily INTEGER NOT NULL DEFAULT 7, - retention_weekly INTEGER NOT NULL DEFAULT 4, - retention_monthly INTEGER NOT NULL DEFAULT 12, - enabled INTEGER NOT NULL DEFAULT 1, - last_run_at REAL, - next_run_at REAL, - last_status TEXT, - last_error TEXT, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - // Migrate existing data - convert old retention_tier to new format - // daily -> D:7, weekly -> W:4, monthly -> M:12, yearly -> M:12 (yearly becomes long monthly retention) - this.query(` - INSERT INTO backup_schedules_new ( - id, scope_type, scope_pattern, service_id, service_name, cron_expression, - retention_hourly, retention_daily, retention_weekly, retention_monthly, - enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at - ) - SELECT - id, scope_type, scope_pattern, service_id, service_name, cron_expression, - 0, -- retention_hourly - CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END, - CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END, - CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12 - WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END, - enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at - FROM backup_schedules - `); - - this.query('DROP TABLE backup_schedules'); - this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)'); - - // Recreate backups table without retention_tier column - this.query(` - CREATE TABLE backups_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - service_id INTEGER NOT NULL, - service_name TEXT NOT NULL, - filename TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - created_at REAL NOT NULL, - includes_image INTEGER NOT NULL, - platform_resources TEXT NOT NULL DEFAULT '[]', - checksum TEXT NOT NULL, - schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL, - FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE - ) - `); - - this.query(` - INSERT INTO backups_new ( - id, service_id, service_name, filename, size_bytes, created_at, - includes_image, platform_resources, checksum, schedule_id - ) - SELECT - id, service_id, service_name, filename, size_bytes, created_at, - includes_image, platform_resources, checksum, schedule_id - FROM backups - `); - - this.query('DROP TABLE backups'); - this.query('ALTER TABLE backups_new RENAME TO backups'); - this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)'); - this.query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)'); - - this.setMigrationVersion(12); - logger.success('Migration 12 completed: GFS retention policy schema updated'); - } - } catch (error) { - logger.error(`Migration failed: ${getErrorMessage(error)}`); - if (error instanceof Error && error.stack) { - logger.error(`Stack: ${error.stack}`); - } - throw error; - } - } - - /** - * Get current migration version - */ - private getMigrationVersion(): number { - if (!this.db) throw new Error('Database not initialized'); - - try { - const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations'); - if (result.length === 0) return 0; - - const versionValue = result[0].version ?? (result[0] as Record)[0]; - return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; - } catch (error) { - logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`); - return 0; - } - } - - /** - * Set migration version - */ - private setMigrationVersion(version: number): void { - if (!this.db) throw new Error('Database not initialized'); - - this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [ - version, - Date.now(), - ]); - logger.debug(`Migration version set to ${version}`); - } - /** * Close database connection */ diff --git a/ts/database/migrations/base-migration.ts b/ts/database/migrations/base-migration.ts new file mode 100644 index 0000000..666b968 --- /dev/null +++ b/ts/database/migrations/base-migration.ts @@ -0,0 +1,22 @@ +/** + * Abstract base class for database migrations. + * All migrations must extend this class and implement the abstract members. + */ + +import type { TQueryFunction } from '../types.ts'; + +export abstract class BaseMigration { + /** The migration version number (must be unique and sequential) */ + abstract readonly version: number; + + /** A short description of what this migration does */ + abstract readonly description: string; + + /** Execute the migration's SQL statements */ + abstract up(query: TQueryFunction): void; + + /** Returns a human-readable name for logging */ + getName(): string { + return `Migration ${this.version}: ${this.description}`; + } +} diff --git a/ts/database/migrations/index.ts b/ts/database/migrations/index.ts new file mode 100644 index 0000000..4983efd --- /dev/null +++ b/ts/database/migrations/index.ts @@ -0,0 +1,2 @@ +export { BaseMigration } from './base-migration.ts'; +export { MigrationRunner } from './migration-runner.ts'; diff --git a/ts/database/migrations/migration-001-initial.ts b/ts/database/migrations/migration-001-initial.ts new file mode 100644 index 0000000..c4b3701 --- /dev/null +++ b/ts/database/migrations/migration-001-initial.ts @@ -0,0 +1,12 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration001Initial extends BaseMigration { + readonly version = 1; + readonly description = 'Initial schema'; + + up(_query: TQueryFunction): void { + // Initial schema is created by createTables() in the database class. + // This migration just marks the initial version. + } +} diff --git a/ts/database/migrations/migration-002-timestamps-to-real.ts b/ts/database/migrations/migration-002-timestamps-to-real.ts new file mode 100644 index 0000000..6a9a80a --- /dev/null +++ b/ts/database/migrations/migration-002-timestamps-to-real.ts @@ -0,0 +1,170 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration002TimestampsToReal extends BaseMigration { + readonly version = 2; + readonly description = 'Convert timestamp columns from INTEGER to REAL'; + + up(query: TQueryFunction): void { + // SSL certificates + query(` + CREATE TABLE ssl_certificates_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`); + query(`DROP TABLE ssl_certificates`); + query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`); + + // Services + query(` + CREATE TABLE services_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT NOT NULL, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL DEFAULT 'stopped', + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + query(`INSERT INTO services_new SELECT * FROM services`); + query(`DROP TABLE services`); + query(`ALTER TABLE services_new RENAME TO services`); + + // Registries + query(` + CREATE TABLE registries_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + created_at REAL NOT NULL + ) + `); + query(`INSERT INTO registries_new SELECT * FROM registries`); + query(`DROP TABLE registries`); + query(`ALTER TABLE registries_new RENAME TO registries`); + + // Nginx configs + query(` + CREATE TABLE nginx_configs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain TEXT NOT NULL, + port INTEGER NOT NULL, + ssl_enabled INTEGER NOT NULL DEFAULT 0, + config_template TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`); + query(`DROP TABLE nginx_configs`); + query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`); + + // DNS records + query(` + CREATE TABLE dns_records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + value TEXT NOT NULL, + cloudflare_id TEXT, + zone_id TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + query(`INSERT INTO dns_records_new SELECT * FROM dns_records`); + query(`DROP TABLE dns_records`); + query(`ALTER TABLE dns_records_new RENAME TO dns_records`); + + // Metrics + query(` + CREATE TABLE metrics_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + cpu_percent REAL NOT NULL, + memory_used INTEGER NOT NULL, + memory_limit INTEGER NOT NULL, + network_rx_bytes INTEGER NOT NULL, + network_tx_bytes INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + query(`INSERT INTO metrics_new SELECT * FROM metrics`); + query(`DROP TABLE metrics`); + query(`ALTER TABLE metrics_new RENAME TO metrics`); + query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`); + + // Logs + query(` + CREATE TABLE logs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + timestamp REAL NOT NULL, + message TEXT NOT NULL, + level TEXT NOT NULL, + source TEXT NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + query(`INSERT INTO logs_new SELECT * FROM logs`); + query(`DROP TABLE logs`); + query(`ALTER TABLE logs_new RENAME TO logs`); + query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`); + + // Users + query(` + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + query(`INSERT INTO users_new SELECT * FROM users`); + query(`DROP TABLE users`); + query(`ALTER TABLE users_new RENAME TO users`); + + // Settings + query(` + CREATE TABLE settings_new ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ) + `); + query(`INSERT INTO settings_new SELECT * FROM settings`); + query(`DROP TABLE settings`); + query(`ALTER TABLE settings_new RENAME TO settings`); + + // Migrations table itself + query(` + CREATE TABLE migrations_new ( + version INTEGER PRIMARY KEY, + applied_at REAL NOT NULL + ) + `); + query(`INSERT INTO migrations_new SELECT * FROM migrations`); + query(`DROP TABLE migrations`); + query(`ALTER TABLE migrations_new RENAME TO migrations`); + } +} diff --git a/ts/database/migrations/migration-003-domain-management.ts b/ts/database/migrations/migration-003-domain-management.ts new file mode 100644 index 0000000..3951241 --- /dev/null +++ b/ts/database/migrations/migration-003-domain-management.ts @@ -0,0 +1,125 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration003DomainManagement extends BaseMigration { + readonly version = 3; + readonly description = 'Domain management tables'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + dns_provider TEXT, + cloudflare_zone_id TEXT, + is_obsolete INTEGER NOT NULL DEFAULT 0, + default_wildcard INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + query(` + CREATE TABLE certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL, + cert_domain TEXT NOT NULL, + is_wildcard INTEGER NOT NULL DEFAULT 0, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + full_chain_path TEXT NOT NULL, + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE + ) + `); + + query(` + CREATE TABLE cert_requirements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + domain_id INTEGER NOT NULL, + subdomain TEXT NOT NULL, + certificate_id INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE, + FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL + ) + `); + + // Migrate data from old ssl_certificates table + interface OldSslCert { + id?: number; + domain?: string; + cert_path?: string; + key_path?: string; + full_chain_path?: string; + expiry_date?: number; + issuer?: string; + created_at?: number; + updated_at?: number; + [key: number]: unknown; + } + const existingCerts = query('SELECT * FROM ssl_certificates'); + + const now = Date.now(); + const domainMap = new Map(); + + for (const cert of existingCerts) { + const domain = String(cert.domain ?? (cert as Record)[1]); + if (!domainMap.has(domain)) { + query( + 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + [domain, null, 0, 1, now, now], + ); + const result = query<{ id?: number; [key: number]: unknown }>( + 'SELECT last_insert_rowid() as id', + ); + const domainId = result[0].id ?? (result[0] as Record)[0]; + domainMap.set(domain, Number(domainId)); + } + } + + for (const cert of existingCerts) { + const domain = String(cert.domain ?? (cert as Record)[1]); + const domainId = domainMap.get(domain); + + query( + `INSERT INTO certificates ( + domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, + expiry_date, issuer, is_valid, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + domainId, + domain, + 0, + String(cert.cert_path ?? (cert as Record)[2]), + String(cert.key_path ?? (cert as Record)[3]), + String(cert.full_chain_path ?? (cert as Record)[4]), + Number(cert.expiry_date ?? (cert as Record)[5]), + String(cert.issuer ?? (cert as Record)[6]), + 1, + Number(cert.created_at ?? (cert as Record)[7]), + Number(cert.updated_at ?? (cert as Record)[8]), + ], + ); + } + + query('DROP TABLE ssl_certificates'); + query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)'); + query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)'); + query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)'); + query( + 'CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)', + ); + } +} diff --git a/ts/database/migrations/migration-004-registry-columns.ts b/ts/database/migrations/migration-004-registry-columns.ts new file mode 100644 index 0000000..f435cfc --- /dev/null +++ b/ts/database/migrations/migration-004-registry-columns.ts @@ -0,0 +1,16 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration004RegistryColumns extends BaseMigration { + readonly version = 4; + readonly description = 'Add Onebox Registry columns to services table'; + + up(query: TQueryFunction): void { + query(`ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0`); + query(`ALTER TABLE services ADD COLUMN registry_repository TEXT`); + query(`ALTER TABLE services ADD COLUMN registry_token TEXT`); + query(`ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'`); + query(`ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0`); + query(`ALTER TABLE services ADD COLUMN image_digest TEXT`); + } +} diff --git a/ts/database/migrations/migration-005-registry-tokens.ts b/ts/database/migrations/migration-005-registry-tokens.ts new file mode 100644 index 0000000..c89a5a1 --- /dev/null +++ b/ts/database/migrations/migration-005-registry-tokens.ts @@ -0,0 +1,30 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration005RegistryTokens extends BaseMigration { + readonly version = 5; + readonly description = 'Registry tokens table'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE registry_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_type TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at REAL, + created_at REAL NOT NULL, + last_used_at REAL, + created_by TEXT NOT NULL + ) + `); + + query( + 'CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)', + ); + } +} diff --git a/ts/database/migrations/migration-006-drop-registry-token.ts b/ts/database/migrations/migration-006-drop-registry-token.ts new file mode 100644 index 0000000..ce7684d --- /dev/null +++ b/ts/database/migrations/migration-006-drop-registry-token.ts @@ -0,0 +1,48 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration006DropRegistryToken extends BaseMigration { + readonly version = 6; + readonly description = 'Drop registry_token column from services table'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE services_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + use_onebox_registry INTEGER DEFAULT 0, + registry_repository TEXT, + registry_image_tag TEXT DEFAULT 'latest', + auto_update_on_push INTEGER DEFAULT 0, + image_digest TEXT + ) + `); + + query(` + INSERT INTO services_new ( + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + ) + SELECT + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + FROM services + `); + + query('DROP TABLE services'); + query('ALTER TABLE services_new RENAME TO services'); + query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); + query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); + } +} diff --git a/ts/database/migrations/migration-007-platform-services.ts b/ts/database/migrations/migration-007-platform-services.ts new file mode 100644 index 0000000..4c7252b --- /dev/null +++ b/ts/database/migrations/migration-007-platform-services.ts @@ -0,0 +1,49 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration007PlatformServices extends BaseMigration { + readonly version = 7; + readonly description = 'Platform services tables'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE platform_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'stopped', + container_id TEXT, + config TEXT NOT NULL DEFAULT '{}', + admin_credentials_encrypted TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + query(` + CREATE TABLE platform_resources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_service_id INTEGER NOT NULL, + service_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + resource_name TEXT NOT NULL, + credentials_encrypted TEXT NOT NULL, + created_at REAL NOT NULL, + FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + query(`ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'`); + + query( + 'CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)', + ); + } +} diff --git a/ts/database/migrations/migration-008-cert-pem-content.ts b/ts/database/migrations/migration-008-cert-pem-content.ts new file mode 100644 index 0000000..e5e16a5 --- /dev/null +++ b/ts/database/migrations/migration-008-cert-pem-content.ts @@ -0,0 +1,41 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration008CertPemContent extends BaseMigration { + readonly version = 8; + readonly description = 'Convert certificates table to store PEM content'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE certificates_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL, + cert_domain TEXT NOT NULL, + is_wildcard INTEGER NOT NULL DEFAULT 0, + cert_pem TEXT NOT NULL DEFAULT '', + key_pem TEXT NOT NULL DEFAULT '', + fullchain_pem TEXT NOT NULL DEFAULT '', + expiry_date REAL NOT NULL, + issuer TEXT NOT NULL, + is_valid INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE + ) + `); + + query(` + INSERT INTO certificates_new (id, domain_id, cert_domain, is_wildcard, cert_pem, key_pem, fullchain_pem, expiry_date, issuer, is_valid, created_at, updated_at) + SELECT id, domain_id, cert_domain, is_wildcard, '', '', '', expiry_date, issuer, 0, created_at, updated_at FROM certificates + `); + + query('DROP TABLE certificates'); + query('ALTER TABLE certificates_new RENAME TO certificates'); + query( + 'CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)', + ); + } +} diff --git a/ts/database/migrations/migration-009-backup-system.ts b/ts/database/migrations/migration-009-backup-system.ts new file mode 100644 index 0000000..1e9941d --- /dev/null +++ b/ts/database/migrations/migration-009-backup-system.ts @@ -0,0 +1,29 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration009BackupSystem extends BaseMigration { + readonly version = 9; + readonly description = 'Backup system tables'; + + up(query: TQueryFunction): void { + query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`); + + query(` + CREATE TABLE backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + service_name TEXT NOT NULL, + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + includes_image INTEGER NOT NULL, + platform_resources TEXT NOT NULL DEFAULT '[]', + checksum TEXT NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)'); + query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)'); + } +} diff --git a/ts/database/migrations/migration-010-backup-schedules.ts b/ts/database/migrations/migration-010-backup-schedules.ts new file mode 100644 index 0000000..d1f1e88 --- /dev/null +++ b/ts/database/migrations/migration-010-backup-schedules.ts @@ -0,0 +1,39 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration010BackupSchedules extends BaseMigration { + readonly version = 10; + readonly description = 'Backup schedules table'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE backup_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + service_name TEXT NOT NULL, + cron_expression TEXT NOT NULL, + retention_tier TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at REAL, + next_run_at REAL, + last_status TEXT, + last_error TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)', + ); + + query('ALTER TABLE backups ADD COLUMN retention_tier TEXT'); + query( + 'ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL', + ); + } +} diff --git a/ts/database/migrations/migration-011-scope-columns.ts b/ts/database/migrations/migration-011-scope-columns.ts new file mode 100644 index 0000000..8940832 --- /dev/null +++ b/ts/database/migrations/migration-011-scope-columns.ts @@ -0,0 +1,54 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration011ScopeColumns extends BaseMigration { + readonly version = 11; + readonly description = 'Add scope columns to backup_schedules'; + + up(query: TQueryFunction): void { + query(` + CREATE TABLE backup_schedules_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL DEFAULT 'service', + scope_pattern TEXT, + service_id INTEGER, + service_name TEXT, + cron_expression TEXT NOT NULL, + retention_tier TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at REAL, + next_run_at REAL, + last_status TEXT, + last_error TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + query(` + INSERT INTO backup_schedules_new ( + id, scope_type, scope_pattern, service_id, service_name, cron_expression, + retention_tier, enabled, last_run_at, next_run_at, last_status, last_error, + created_at, updated_at + ) + SELECT + id, 'service', NULL, service_id, service_name, cron_expression, + retention_tier, enabled, last_run_at, next_run_at, last_status, last_error, + created_at, updated_at + FROM backup_schedules + `); + + query('DROP TABLE backup_schedules'); + query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules'); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)', + ); + } +} diff --git a/ts/database/migrations/migration-012-gfs-retention.ts b/ts/database/migrations/migration-012-gfs-retention.ts new file mode 100644 index 0000000..4a15c80 --- /dev/null +++ b/ts/database/migrations/migration-012-gfs-retention.ts @@ -0,0 +1,97 @@ +import { BaseMigration } from './base-migration.ts'; +import type { TQueryFunction } from '../types.ts'; + +export class Migration012GfsRetention extends BaseMigration { + readonly version = 12; + readonly description = 'GFS retention policy schema'; + + up(query: TQueryFunction): void { + // Recreate backup_schedules with GFS retention columns + query(` + CREATE TABLE backup_schedules_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL DEFAULT 'service', + scope_pattern TEXT, + service_id INTEGER, + service_name TEXT, + cron_expression TEXT NOT NULL, + retention_hourly INTEGER NOT NULL DEFAULT 0, + retention_daily INTEGER NOT NULL DEFAULT 7, + retention_weekly INTEGER NOT NULL DEFAULT 4, + retention_monthly INTEGER NOT NULL DEFAULT 12, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at REAL, + next_run_at REAL, + last_status TEXT, + last_error TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + // Migrate existing data - convert old retention_tier to new format + query(` + INSERT INTO backup_schedules_new ( + id, scope_type, scope_pattern, service_id, service_name, cron_expression, + retention_hourly, retention_daily, retention_weekly, retention_monthly, + enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at + ) + SELECT + id, scope_type, scope_pattern, service_id, service_name, cron_expression, + 0, + CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END, + CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END, + CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12 + WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END, + enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at + FROM backup_schedules + `); + + query('DROP TABLE backup_schedules'); + query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules'); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)', + ); + query( + 'CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)', + ); + + // Recreate backups table without retention_tier column + query(` + CREATE TABLE backups_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + service_name TEXT NOT NULL, + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at REAL NOT NULL, + includes_image INTEGER NOT NULL, + platform_resources TEXT NOT NULL DEFAULT '[]', + checksum TEXT NOT NULL, + schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + query(` + INSERT INTO backups_new ( + id, service_id, service_name, filename, size_bytes, created_at, + includes_image, platform_resources, checksum, schedule_id + ) + SELECT + id, service_id, service_name, filename, size_bytes, created_at, + includes_image, platform_resources, checksum, schedule_id + FROM backups + `); + + query('DROP TABLE backups'); + query('ALTER TABLE backups_new RENAME TO backups'); + query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)'); + query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)'); + query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)'); + } +} diff --git a/ts/database/migrations/migration-runner.ts b/ts/database/migrations/migration-runner.ts new file mode 100644 index 0000000..a62cad4 --- /dev/null +++ b/ts/database/migrations/migration-runner.ts @@ -0,0 +1,100 @@ +/** + * Migration runner - discovers, orders, and executes database migrations. + * Mirrors the pattern from @serve.zone/nupst. + */ + +import type { TQueryFunction } from '../types.ts'; +import { logger } from '../../logging.ts'; +import { getErrorMessage } from '../../utils/error.ts'; + +import { Migration001Initial } from './migration-001-initial.ts'; +import { Migration002TimestampsToReal } from './migration-002-timestamps-to-real.ts'; +import { Migration003DomainManagement } from './migration-003-domain-management.ts'; +import { Migration004RegistryColumns } from './migration-004-registry-columns.ts'; +import { Migration005RegistryTokens } from './migration-005-registry-tokens.ts'; +import { Migration006DropRegistryToken } from './migration-006-drop-registry-token.ts'; +import { Migration007PlatformServices } from './migration-007-platform-services.ts'; +import { Migration008CertPemContent } from './migration-008-cert-pem-content.ts'; +import { Migration009BackupSystem } from './migration-009-backup-system.ts'; +import { Migration010BackupSchedules } from './migration-010-backup-schedules.ts'; +import { Migration011ScopeColumns } from './migration-011-scope-columns.ts'; +import { Migration012GfsRetention } from './migration-012-gfs-retention.ts'; +import type { BaseMigration } from './base-migration.ts'; + +export class MigrationRunner { + private query: TQueryFunction; + private migrations: BaseMigration[]; + + constructor(query: TQueryFunction) { + this.query = query; + + // Register all migrations in order + this.migrations = [ + new Migration001Initial(), + new Migration002TimestampsToReal(), + new Migration003DomainManagement(), + new Migration004RegistryColumns(), + new Migration005RegistryTokens(), + new Migration006DropRegistryToken(), + new Migration007PlatformServices(), + new Migration008CertPemContent(), + new Migration009BackupSystem(), + new Migration010BackupSchedules(), + new Migration011ScopeColumns(), + new Migration012GfsRetention(), + ].sort((a, b) => a.version - b.version); + } + + /** Run all pending migrations */ + run(): void { + try { + const currentVersion = this.getMigrationVersion(); + logger.info(`Current database migration version: ${currentVersion}`); + + let applied = 0; + for (const migration of this.migrations) { + if (migration.version <= currentVersion) continue; + + logger.info(`Running ${migration.getName()}...`); + migration.up(this.query); + this.setMigrationVersion(migration.version); + logger.success(`${migration.getName()} completed`); + applied++; + } + + if (applied > 0) { + logger.success(`Applied ${applied} migration(s)`); + } + } catch (error) { + logger.error(`Migration failed: ${getErrorMessage(error)}`); + if (error instanceof Error && error.stack) { + logger.error(`Stack: ${error.stack}`); + } + throw error; + } + } + + /** Get current migration version from the migrations table */ + private getMigrationVersion(): number { + try { + const result = this.query<{ version?: number | null; [key: number]: unknown }>( + 'SELECT MAX(version) as version FROM migrations', + ); + if (result.length === 0) return 0; + + const versionValue = result[0].version ?? (result[0] as Record)[0]; + return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; + } catch { + // Table might not exist yet on fresh databases + return 0; + } + } + + /** Record a migration version as applied */ + private setMigrationVersion(version: number): void { + this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [ + version, + Date.now(), + ]); + } +}