Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
140 lines
11 KiB
JavaScript
140 lines
11 KiB
JavaScript
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 });
|
|
}
|