import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; const APP = resolve(fileURLToPath(new URL('..', import.meta.url))); const BASE = process.argv[2] ? resolve(process.argv[2]) : null; function execDbExecBlocks(db, sourcePath) { const text = readFileSync(sourcePath, 'utf8'); const blocks = [...text.matchAll(/db\.exec\(`([\s\S]*?)`\)/g)]; let applied = 0; for (const [, sql] of blocks) { if (sql.includes('${')) continue; try { db.exec(sql); applied += 1; } catch { /* additive ALTER blocks intentionally tolerate existing columns */ } } if (text.includes('operations-center-1.30.0')) { db.prepare("INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('operations-center-1.30.0','1.30.0','Backup, Restore, Update Center und Extension-Diagnose')").run(); } return applied; } function columns(db, table) { return new Set(db.prepare(`PRAGMA table_info(${JSON.stringify(table)})`).all().map((row) => row.name)); } function verify(db) { const requiredTables = new Set([ 'flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions', 'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports', 'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings', 'update_operations', 'system_migrations', 'extension_clients', 'users', 'sessions', 'audit_log', 'resource_locks', 'theme_profiles', 'user_theme_preferences', ]); const existingTables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name)); const missingTables = [...requiredTables].filter((name) => !existingTables.has(name)); const checks = { flux_images: ['favorite', 'job_id', 'variant_index', 'prompt_mode', 'parameters_json'], flux_prompt_jobs: ['archived_at'], image_edit_versions: ['root_path', 'source_path', 'output_path', 'context', 'operations_json', 'width', 'height', 'format', 'quality', 'file_size', 'user_id', 'created_at'], image_batch_jobs: ['name', 'status', 'phase', 'source_count', 'profile_count', 'total_items', 'completed_items', 'failed_items', 'cancelled_items', 'pause_requested', 'cancel_requested', 'settings_json', 'created_at', 'updated_at'], image_batch_job_items: ['job_id', 'source_path', 'profile_id', 'profile_key', 'profile_name', 'status', 'phase', 'settings_json', 'output_path', 'error_message', 'created_at', 'updated_at'], quality_reports: ['listing_id', 'score', 'grade', 'ready', 'blockers', 'warnings', 'recommendations', 'report_json', 'analyzed_at'], publishing_jobs: ['id', 'listing_id', 'platform', 'mode', 'action', 'status', 'phase', 'idempotency_key', 'external_id', 'external_url', 'offer_id', 'retries', 'max_retries', 'next_attempt_at', 'claimed_by', 'claimed_at', 'error_code', 'error_message', 'payload_json', 'result_json', 'created_at', 'started_at', 'completed_at', 'updated_at'], publishing_events: ['id', 'job_id', 'listing_id', 'platform', 'level', 'event_type', 'message', 'details_json', 'created_at'], publish_log: ['offer_id', 'sync_status', 'last_synced_at', 'ended_at', 'updated_at'], operation_backups: ['id', 'kind', 'label', 'file_name', 'file_path', 'file_size', 'sha256', 'status', 'verified_at', 'options_json', 'manifest_json', 'created_at'], operation_settings: ['key', 'value', 'updated_at'], update_operations: ['id', 'version_from', 'version_to', 'source_name', 'archive_path', 'sha256', 'status', 'details_json', 'created_at', 'applied_at', 'error_message'], system_migrations: ['id', 'migration_key', 'app_version', 'description', 'applied_at'], extension_clients: ['client_id', 'browser', 'version', 'platform', 'server_url', 'user_agent', 'capabilities_json', 'first_seen', 'last_seen'], users: ['id', 'email', 'name', 'password_hash', 'role', 'active', 'avatar_color', 'last_active_at', 'failed_login_count', 'locked_until', 'password_changed_at', 'created_at', 'invited_by'], sessions: ['id', 'user_id', 'token_hash', 'ip', 'user_agent', 'expires_at', 'last_seen_at', 'revoked_at', 'created_at'], audit_log: ['id', 'user_id', 'user_email', 'action', 'target_type', 'target_id', 'details', 'ip', 'created_at'], resource_locks: ['id', 'resource_type', 'resource_id', 'user_id', 'token_hash', 'acquired_at', 'renewed_at', 'expires_at', 'client_label'], theme_profiles: ['id', 'name', 'base_theme', 'values_json', 'created_by', 'created_at', 'updated_at'], user_theme_preferences: ['user_id', 'theme_id', 'density', 'reduced_motion', 'updated_at'], }; const missingColumns = {}; for (const [table, required] of Object.entries(checks)) { const existing = columns(db, table); const missing = required.filter((name) => !existing.has(name)); if (missing.length) missingColumns[table] = missing.sort(); } if (missingTables.length || Object.keys(missingColumns).length) { throw new Error(JSON.stringify({ missing_tables: missingTables.sort(), missing_columns: missingColumns })); } } const tempRoot = mkdtempSync(join(tmpdir(), 'vendoo-db-migrations-')); let fresh; let existing; try { fresh = new DatabaseSync(join(tempRoot, 'fresh.db')); fresh.exec(readFileSync(join(APP, 'db/schema.sql'), 'utf8')); const freshBlocks = execDbExecBlocks(fresh, join(APP, 'lib/db.mjs')); verify(fresh); existing = new DatabaseSync(join(tempRoot, 'existing.db')); existing.exec(readFileSync(join(APP, 'db/schema.sql'), 'utf8')); if (BASE) execDbExecBlocks(existing, join(BASE, 'lib/db.mjs')); existing.prepare("INSERT INTO flux_images (prompt,image_path,width,height,seed,steps,style) VALUES ('Bestand','ai-generated/existing.png',768,768,42,4,'none')").run(); const existingBlocks = execDbExecBlocks(existing, join(APP, 'lib/db.mjs')); verify(existing); const row = existing.prepare("SELECT prompt,image_path,favorite FROM flux_images WHERE image_path='ai-generated/existing.png'").get(); if (!row || row.prompt !== 'Bestand' || row.image_path !== 'ai-generated/existing.png' || row.favorite !== 0) { throw new Error(`Bestandsdaten verändert: ${JSON.stringify(row)}`); } const insertJob = existing.prepare(`INSERT INTO flux_prompt_jobs (id,prompt,final_prompt,width,height,steps,style,profile,prompt_mode,assistant,variants,seed_base,seed_lock,status,phase,settings_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); for (const [jobId, status] of [['job-complete', 'completed'], ['job-failed', 'failed'], ['job-active', 'running']]) { insertJob.run(jobId, 'Test', 'Test', 768, 768, 6, 'none', 'balanced', 'precise', 1, 1, 42, 0, status, status, '{}'); } existing.prepare("UPDATE flux_prompt_jobs SET archived_at=datetime('now') WHERE id IN ('job-complete','job-failed','job-active') AND status NOT IN ('queued','running','cancelling')").run(); const archived = new Set(existing.prepare('SELECT id FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').all().map((item) => item.id)); if (archived.size !== 2 || !archived.has('job-complete') || !archived.has('job-failed')) { throw new Error(`Archivschutz inkorrekt: ${JSON.stringify([...archived])}`); } existing.prepare("INSERT INTO listings (title, platform, ai_provider, photos, tags) VALUES ('Publishing Test', 'ebay-de', 'openai', '[]', '[]')").run(); const listingId = existing.prepare('SELECT last_insert_rowid() AS id').get().id; existing.prepare("INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) VALUES ('pub-job-1', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')").run(listingId); existing.prepare("INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message) VALUES ('pub-job-1', ?, 'ebay-de', 'info', 'created', 'Auftrag erstellt')").run(listingId); let duplicateGuard = false; try { existing.prepare("INSERT INTO publishing_jobs (id, listing_id, platform, mode, action, status, phase, idempotency_key) VALUES ('pub-job-2', ?, 'ebay-de', 'api', 'publish', 'queued', 'queued', 'key-1')").run(listingId); } catch { duplicateGuard = true; } if (!duplicateGuard) throw new Error('Idempotency-Key verhindert keinen doppelten Auftrag'); existing.prepare("UPDATE publishing_jobs SET status='running', phase='publish' WHERE id='pub-job-1'").run(); existing.prepare("UPDATE publishing_jobs SET status='queued', phase='recovered' WHERE status='running'").run(); const recovered = existing.prepare("SELECT status, phase FROM publishing_jobs WHERE id='pub-job-1'").get(); if (recovered.status !== 'queued' || recovered.phase !== 'recovered') throw new Error(`Publishing-Recovery fehlgeschlagen: ${JSON.stringify(recovered)}`); existing.prepare("INSERT INTO operation_settings (key,value) VALUES ('auto_backup_enabled','1')").run(); existing.prepare("INSERT INTO operation_backups (id,kind,file_name,file_path,status) VALUES ('backup-1','manual','test.zip','/tmp/test.zip','verified')").run(); existing.prepare("INSERT INTO update_operations (id,version_from,version_to,archive_path,status) VALUES ('update-1','1.29.0','1.30.0','/tmp/update.zip','staged')").run(); existing.prepare("INSERT INTO extension_clients (client_id,browser,version,platform) VALUES ('client-1','chrome','1.9.0','popup')").run(); const migration = existing.prepare("SELECT app_version FROM system_migrations WHERE migration_key='operations-center-1.30.0'").get(); if (!migration || migration.app_version !== '1.30.0') throw new Error(`Operations-Migration fehlt: ${JSON.stringify(migration)}`); const extension = existing.prepare("SELECT browser,version FROM extension_clients WHERE client_id='client-1'").get(); if (!extension || extension.browser !== 'chrome' || extension.version !== '1.9.0') throw new Error(`Extension-Client fehlerhaft: ${JSON.stringify(extension)}`); console.log(JSON.stringify({ ok: true, fresh_db_exec_blocks: freshBlocks, existing_db_exec_blocks: existingBlocks, existing_row_preserved: true, publishing_hardening: { tables: ['publishing_jobs', 'publishing_events'], idempotency_key_unique: true, restart_recovery: true, events_persisted: true }, operations_center: { backup_tables: true, update_tables: true, extension_heartbeat: true, migration_recorded: true }, tables: ['flux_images', 'flux_prompt_jobs', 'flux_prompt_job_variants', 'image_edit_versions', 'image_batch_profiles', 'image_batch_jobs', 'image_batch_job_items', 'quality_reports', 'publishing_jobs', 'publishing_events', 'operation_backups', 'operation_settings', 'update_operations', 'system_migrations', 'extension_clients', 'theme_profiles', 'user_theme_preferences'], }, null, 2)); } finally { try { fresh?.close(); } catch {} try { existing?.close(); } catch {} rmSync(tempRoot, { recursive: true, force: true }); }