#!/usr/bin/env node /** * migrate-to-postgres.js — Data Migration Script * * Copies all data from the SQLite database (cve_database.db) to PostgreSQL. * The SQLite file is opened READ-ONLY and is never modified. * * Special handling: * - ivanti_findings_cache.findings_json → individual rows in ivanti_findings * - ivanti_finding_notes → merged into ivanti_findings.note column * - ivanti_finding_overrides → merged into ivanti_findings.override_host_name / override_dns * - ivanti_sync_state and ivanti_counts_cache → populated from ivanti_findings_cache metadata * * Type conversions: * - SQLite 0/1 integers → Postgres boolean * - SQLite DATETIME strings → Postgres TIMESTAMPTZ (passed as-is) * - SQLite NULL → Postgres NULL * * Uses ON CONFLICT DO NOTHING for idempotency (safe to re-run). * * Usage: * node backend/scripts/migrate-to-postgres.js * * Requires: * - DATABASE_URL env var (or .env file in backend/) * - SQLite database at backend/cve_database.db */ const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); const { Pool } = require('pg'); // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- const SQLITE_PATH = path.join(__dirname, '..', 'cve_database.db'); const SCHEMA_PATH = path.join(__dirname, '..', 'db-schema.sql'); const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error('ERROR: DATABASE_URL environment variable is not set.'); console.error('Expected format: postgresql://user:password@host:port/database'); process.exit(1); } if (!fs.existsSync(SQLITE_PATH)) { console.error(`ERROR: SQLite database not found at ${SQLITE_PATH}`); process.exit(1); } // --------------------------------------------------------------------------- // SQLite helpers // --------------------------------------------------------------------------- function sqliteAll(db, sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } function sqliteGet(db, sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } // --------------------------------------------------------------------------- // Extract finding fields from raw JSON object (mirrors ivantiFindings.js) // --------------------------------------------------------------------------- function extractFinding(f) { const rawDueDate = f.statusEmbedded?.dueDate || f.dueDate || ''; const dueDate = rawDueDate ? rawDueDate.split('T')[0] : null; const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || f.buOwnership || f.bu_ownership || ''; const cves = Array.isArray(f.cves) ? f.cves : (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean); // Workflow extraction let workflow = null; if (f.workflow && typeof f.workflow === 'object') { workflow = { id: f.workflow.id || '', state: f.workflow.state || '', type: f.workflow.type || 'FP', }; } else if (f.workflowDistribution) { const wfDist = f.workflowDistribution || {}; const fpBuckets = [ ...(wfDist.actionableWorkflows || []), ...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []), ...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []), ...(wfDist.approvedWorkflows || []), ].filter(w => (w.generatedId || '').startsWith('FP#')); const fpEntry = fpBuckets[0] || null; if (fpEntry) { workflow = { id: fpEntry.generatedId || '', state: fpEntry.state || '', type: 'FP', }; } } return { id: String(f.id), hostId: f.hostId || f.host?.hostId || null, title: f.title || '', severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, vrrGroup: f.vrrGroup || f.severityGroup || f.vrr_group || '', hostName: f.hostName || f.host?.hostName || f.host_name || '', ipAddress: f.ipAddress || f.host?.ipAddress || f.ip_address || '', dns: f.dns || f.host?.fqdn || '', status: f.status || '', slaStatus: f.slaStatus || f.sla_status || '', dueDate: dueDate, lastFoundOn: f.lastFoundOn || f.last_found_on || null, buOwnership, cves, workflow, }; } // --------------------------------------------------------------------------- // Batch insert helper for Postgres // --------------------------------------------------------------------------- async function batchInsert(pool, tableName, columns, rows, conflictClause = 'DO NOTHING') { if (rows.length === 0) return 0; const BATCH_SIZE = 100; let inserted = 0; for (let i = 0; i < rows.length; i += BATCH_SIZE) { const batch = rows.slice(i, i + BATCH_SIZE); const values = []; const placeholders = []; batch.forEach((row, idx) => { const offset = idx * columns.length; const rowPlaceholders = columns.map((_, colIdx) => `$${offset + colIdx + 1}`); placeholders.push(`(${rowPlaceholders.join(', ')})`); values.push(...row); }); const sql = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES ${placeholders.join(', ')} ON CONFLICT ${conflictClause}`; await pool.query(sql, values); inserted += batch.length; } return inserted; } // --------------------------------------------------------------------------- // Table migration definitions // --------------------------------------------------------------------------- /** * Each entry defines how to copy a SQLite table to Postgres. * - sqliteTable: source table name * - pgTable: destination table name (defaults to sqliteTable) * - columns: array of { src, dest, transform } objects * - conflict: ON CONFLICT clause (default: DO NOTHING) * - selectSql: optional custom SELECT (defaults to SELECT * FROM sqliteTable) */ function getTableMigrations() { return [ { sqliteTable: 'users', columns: [ { src: 'id', dest: 'id' }, { src: 'username', dest: 'username' }, { src: 'email', dest: 'email' }, { src: 'password_hash', dest: 'password_hash' }, { src: 'role', dest: 'role' }, { src: 'is_active', dest: 'is_active', transform: v => v === 1 || v === true }, { src: 'created_at', dest: 'created_at' }, { src: 'last_login', dest: 'last_login' }, { src: 'user_group', dest: 'user_group', transform: v => v || 'Read_Only' }, { src: 'bu_teams', dest: 'bu_teams', transform: v => v || '' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'sessions', columns: [ { src: 'id', dest: 'id' }, { src: 'session_id', dest: 'session_id' }, { src: 'user_id', dest: 'user_id' }, { src: 'expires_at', dest: 'expires_at' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'cves', columns: [ { src: 'id', dest: 'id' }, { src: 'cve_id', dest: 'cve_id' }, { src: 'vendor', dest: 'vendor' }, { src: 'severity', dest: 'severity' }, { src: 'description', dest: 'description' }, { src: 'published_date', dest: 'published_date' }, { src: 'status', dest: 'status' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, { src: 'created_by', dest: 'created_by' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'documents', columns: [ { src: 'id', dest: 'id' }, { src: 'cve_id', dest: 'cve_id' }, { src: 'vendor', dest: 'vendor' }, { src: 'name', dest: 'name' }, { src: 'type', dest: 'type' }, { src: 'file_path', dest: 'file_path' }, { src: 'file_size', dest: 'file_size' }, { src: 'mime_type', dest: 'mime_type' }, { src: 'uploaded_at', dest: 'uploaded_at' }, { src: 'notes', dest: 'notes' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'required_documents', columns: [ { src: 'id', dest: 'id' }, { src: 'vendor', dest: 'vendor' }, { src: 'document_type', dest: 'document_type' }, { src: 'is_mandatory', dest: 'is_mandatory', transform: v => v === 1 || v === true }, { src: 'description', dest: 'description' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'jira_tickets', columns: [ { src: 'id', dest: 'id' }, { src: 'cve_id', dest: 'cve_id' }, { src: 'vendor', dest: 'vendor' }, { src: 'ticket_key', dest: 'ticket_key' }, { src: 'url', dest: 'url' }, { src: 'summary', dest: 'summary' }, { src: 'status', dest: 'status' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'archer_tickets', columns: [ { src: 'id', dest: 'id' }, { src: 'exc_number', dest: 'exc_number' }, { src: 'archer_url', dest: 'archer_url' }, { src: 'status', dest: 'status' }, { src: 'cve_id', dest: 'cve_id' }, { src: 'vendor', dest: 'vendor' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'knowledge_base', columns: [ { src: 'id', dest: 'id' }, { src: 'title', dest: 'title' }, { src: 'slug', dest: 'slug' }, { src: 'description', dest: 'description' }, { src: 'category', dest: 'category' }, { src: 'file_path', dest: 'file_path' }, { src: 'file_name', dest: 'file_name' }, { src: 'file_type', dest: 'file_type' }, { src: 'file_size', dest: 'file_size' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, { src: 'created_by', dest: 'created_by' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'audit_logs', columns: [ { src: 'id', dest: 'id' }, { src: 'user_id', dest: 'user_id' }, { src: 'username', dest: 'username' }, { src: 'action', dest: 'action' }, { src: 'entity_type', dest: 'entity_type' }, { src: 'entity_id', dest: 'entity_id' }, { src: 'details', dest: 'details' }, { src: 'ip_address', dest: 'ip_address' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'compliance_uploads', columns: [ { src: 'id', dest: 'id' }, { src: 'filename', dest: 'filename' }, { src: 'report_date', dest: 'report_date' }, { src: 'uploaded_by', dest: 'uploaded_by' }, { src: 'uploaded_at', dest: 'uploaded_at' }, { src: 'new_count', dest: 'new_count' }, { src: 'resolved_count', dest: 'resolved_count' }, { src: 'recurring_count', dest: 'recurring_count' }, { src: 'summary_json', dest: 'summary_json' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'compliance_items', columns: [ { src: 'id', dest: 'id' }, { src: 'upload_id', dest: 'upload_id' }, { src: 'hostname', dest: 'hostname' }, { src: 'ip_address', dest: 'ip_address' }, { src: 'device_type', dest: 'device_type' }, { src: 'team', dest: 'team' }, { src: 'metric_id', dest: 'metric_id' }, { src: 'metric_desc', dest: 'metric_desc' }, { src: 'category', dest: 'category' }, { src: 'extra_json', dest: 'extra_json' }, { src: 'status', dest: 'status' }, { src: 'first_seen_upload_id', dest: 'first_seen_upload_id' }, { src: 'resolved_upload_id', dest: 'resolved_upload_id' }, { src: 'seen_count', dest: 'seen_count' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'compliance_notes', columns: [ { src: 'id', dest: 'id' }, { src: 'hostname', dest: 'hostname' }, { src: 'metric_id', dest: 'metric_id' }, { src: 'note', dest: 'note' }, { src: 'group_id', dest: 'group_id' }, { src: 'created_by', dest: 'created_by' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_counts_history', columns: [ { src: 'id', dest: 'id' }, { src: 'open_count', dest: 'open_count' }, { src: 'closed_count', dest: 'closed_count' }, { src: 'recorded_at', dest: 'recorded_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_finding_archives', columns: [ { src: 'id', dest: 'id' }, { src: 'finding_id', dest: 'finding_id' }, { src: 'finding_title', dest: 'finding_title' }, { src: 'host_name', dest: 'host_name' }, { src: 'ip_address', dest: 'ip_address' }, { src: 'current_state', dest: 'current_state' }, { src: 'last_severity', dest: 'last_severity' }, { src: 'first_archived_at', dest: 'first_archived_at' }, { src: 'last_transition_at', dest: 'last_transition_at' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_archive_transitions', columns: [ { src: 'id', dest: 'id' }, { src: 'archive_id', dest: 'archive_id' }, { src: 'from_state', dest: 'from_state' }, { src: 'to_state', dest: 'to_state' }, { src: 'severity_at_transition', dest: 'severity_at_transition' }, { src: 'reason', dest: 'reason' }, { src: 'transitioned_at', dest: 'transitioned_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_sync_anomaly_log', columns: [ { src: 'id', dest: 'id' }, { src: 'sync_timestamp', dest: 'sync_timestamp' }, { src: 'open_count_delta', dest: 'open_count_delta' }, { src: 'closed_count_delta', dest: 'closed_count_delta' }, { src: 'newly_archived_count', dest: 'newly_archived_count' }, { src: 'returned_count', dest: 'returned_count' }, { src: 'classification_json', dest: 'classification_json' }, { src: 'return_classification_json', dest: 'return_classification_json' }, { src: 'is_significant', dest: 'is_significant', transform: v => v === 1 || v === true }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_finding_bu_history', columns: [ { src: 'id', dest: 'id' }, { src: 'finding_id', dest: 'finding_id' }, { src: 'finding_title', dest: 'finding_title' }, { src: 'host_name', dest: 'host_name' }, { src: 'previous_bu', dest: 'previous_bu' }, { src: 'new_bu', dest: 'new_bu' }, { src: 'detected_at', dest: 'detected_at' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'atlas_action_plans_cache', columns: [ { src: 'id', dest: 'id' }, { src: 'host_id', dest: 'host_id' }, { src: 'has_action_plan', dest: 'has_action_plan', transform: v => v === 1 || v === true }, { src: 'plan_count', dest: 'plan_count' }, { src: 'plans_json', dest: 'plans_json' }, { src: 'synced_at', dest: 'synced_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_fp_submissions', columns: [ { src: 'id', dest: 'id' }, { src: 'user_id', dest: 'user_id' }, { src: 'username', dest: 'username' }, { src: 'ivanti_workflow_batch_id', dest: 'ivanti_workflow_batch_id' }, { src: 'ivanti_generated_id', dest: 'ivanti_generated_id' }, { src: 'ivanti_workflow_batch_uuid', dest: 'ivanti_workflow_batch_uuid' }, { src: 'workflow_name', dest: 'workflow_name' }, { src: 'reason', dest: 'reason' }, { src: 'description', dest: 'description' }, { src: 'expiration_date', dest: 'expiration_date' }, { src: 'scope_override', dest: 'scope_override' }, { src: 'finding_ids_json', dest: 'finding_ids_json' }, { src: 'queue_item_ids_json', dest: 'queue_item_ids_json' }, { src: 'attachment_count', dest: 'attachment_count' }, { src: 'attachment_results_json', dest: 'attachment_results_json' }, { src: 'status', dest: 'status' }, { src: 'lifecycle_status', dest: 'lifecycle_status' }, { src: 'error_message', dest: 'error_message' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_fp_submission_history', columns: [ { src: 'id', dest: 'id' }, { src: 'submission_id', dest: 'submission_id' }, { src: 'user_id', dest: 'user_id' }, { src: 'username', dest: 'username' }, { src: 'change_type', dest: 'change_type' }, { src: 'change_details_json', dest: 'change_details_json' }, { src: 'created_at', dest: 'created_at' }, ], conflict: '(id) DO NOTHING', }, { sqliteTable: 'ivanti_todo_queue', columns: [ { src: 'id', dest: 'id' }, { src: 'user_id', dest: 'user_id' }, { src: 'finding_id', dest: 'finding_id' }, { src: 'finding_title', dest: 'finding_title' }, { src: 'cves_json', dest: 'cves_json' }, { src: 'ip_address', dest: 'ip_address' }, { src: 'hostname', dest: 'hostname' }, { src: 'vendor', dest: 'vendor' }, { src: 'workflow_type', dest: 'workflow_type' }, { src: 'status', dest: 'status' }, { src: 'created_at', dest: 'created_at' }, { src: 'updated_at', dest: 'updated_at' }, ], conflict: '(id) DO NOTHING', }, ]; } // --------------------------------------------------------------------------- // Main migration logic // --------------------------------------------------------------------------- async function migrate() { console.log('╔════════════════════════════════════════════════════════╗'); console.log('║ CVE Dashboard — SQLite → PostgreSQL Migration ║'); console.log('╚════════════════════════════════════════════════════════╝\n'); // Open SQLite in READ-ONLY mode const sqliteDb = new sqlite3.Database(SQLITE_PATH, sqlite3.OPEN_READONLY, (err) => { if (err) { console.error('ERROR: Failed to open SQLite database:', err.message); process.exit(1); } }); console.log(`✓ Opened SQLite database (read-only): ${SQLITE_PATH}`); // Connect to Postgres const pool = new Pool({ connectionString: DATABASE_URL, max: 5, idleTimeoutMillis: 30000, connectionTimeoutMillis: 10000, }); try { await pool.query('SELECT NOW()'); console.log('✓ Connected to PostgreSQL'); } catch (err) { console.error('ERROR: Failed to connect to PostgreSQL:', err.message); sqliteDb.close(); process.exit(1); } // Step 1: Run schema DDL console.log('\n── Step 1: Creating schema (idempotent) ──'); try { const schemaSql = fs.readFileSync(SCHEMA_PATH, 'utf8'); await pool.query(schemaSql); console.log('✓ Schema created/verified'); } catch (err) { console.error('ERROR: Schema creation failed:', err.message); await cleanup(sqliteDb, pool); process.exit(1); } // Step 2: Copy simple tables console.log('\n── Step 2: Copying tables ──'); const migrations = getTableMigrations(); const migrationResults = {}; for (const migration of migrations) { const tableName = migration.pgTable || migration.sqliteTable; try { // Check if table exists in SQLite const tableCheck = await sqliteGet( sqliteDb, `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [migration.sqliteTable] ); if (!tableCheck) { console.log(` ⚠ ${migration.sqliteTable} — table not found in SQLite, skipping`); migrationResults[tableName] = { source: 0, dest: 0, skipped: true }; continue; } // Read all rows from SQLite const selectSql = migration.selectSql || `SELECT * FROM ${migration.sqliteTable}`; const sourceRows = await sqliteAll(sqliteDb, selectSql); if (sourceRows.length === 0) { console.log(` ○ ${tableName} — 0 rows (empty table)`); migrationResults[tableName] = { source: 0, dest: 0 }; continue; } // Transform rows const destColumns = migration.columns.map(c => c.dest); const transformedRows = sourceRows.map(row => { return migration.columns.map(col => { let value = row[col.src]; if (value === undefined) value = null; if (col.transform) { value = col.transform(value); } return value; }); }); // Insert into Postgres const inserted = await batchInsert( pool, tableName, destColumns, transformedRows, migration.conflict ); console.log(` ✓ ${tableName} — ${inserted} rows copied`); migrationResults[tableName] = { source: sourceRows.length, dest: inserted }; } catch (err) { console.error(` ✗ ${tableName} — ERROR: ${err.message}`); migrationResults[tableName] = { source: 0, dest: 0, error: err.message }; } } // Reset sequences for SERIAL columns after bulk insert with explicit IDs console.log('\n── Step 2b: Resetting sequences ──'); const serialTables = [ 'users', 'sessions', 'cves', 'documents', 'required_documents', 'jira_tickets', 'archer_tickets', 'knowledge_base', 'audit_logs', 'compliance_uploads', 'compliance_items', 'compliance_notes', 'ivanti_counts_history', 'ivanti_finding_archives', 'ivanti_archive_transitions', 'ivanti_sync_anomaly_log', 'ivanti_finding_bu_history', 'atlas_action_plans_cache', 'ivanti_fp_submissions', 'ivanti_fp_submission_history', 'ivanti_todo_queue', ]; for (const table of serialTables) { try { await pool.query(` SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM ${table}), 0) + 1, false) `); } catch (err) { // Non-fatal — sequence may not exist for some tables console.log(` ⚠ Could not reset sequence for ${table}: ${err.message}`); } } console.log('✓ Sequences reset'); // Step 3: Migrate findings from JSON blob console.log('\n── Step 3: Migrating findings from JSON blob ──'); try { const cacheRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_findings_cache WHERE id = 1'); if (!cacheRow || !cacheRow.findings_json) { console.log(' ⚠ No findings_json data found in ivanti_findings_cache'); } else { let findings; try { findings = JSON.parse(cacheRow.findings_json); } catch (parseErr) { console.error(' ✗ Failed to parse findings_json:', parseErr.message); findings = []; } if (Array.isArray(findings) && findings.length > 0) { console.log(` Parsing ${findings.length} findings from JSON blob...`); // Extract and insert findings const BATCH_SIZE = 100; let insertedCount = 0; for (let i = 0; i < findings.length; i += BATCH_SIZE) { const batch = findings.slice(i, i + BATCH_SIZE); const values = []; const placeholders = []; batch.forEach((rawFinding, idx) => { const f = extractFinding(rawFinding); const offset = idx * 18; values.push( f.id, f.hostId, f.title, f.severity, f.vrrGroup, f.hostName, f.ipAddress, f.dns, f.status, f.slaStatus, f.dueDate, f.lastFoundOn, f.buOwnership, f.cves, f.workflow ? f.workflow.id : null, f.workflow ? f.workflow.state : null, f.workflow ? f.workflow.type : null, 'open' // state = open for all findings from cache ); placeholders.push( `($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` + `$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` + `$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` + `$${offset+16}, $${offset+17}, $${offset+18})` ); }); await pool.query(` INSERT INTO ivanti_findings ( id, host_id, title, severity, vrr_group, host_name, ip_address, dns, status, sla_status, due_date, last_found_on, bu_ownership, cves, workflow_id, workflow_state, workflow_type, state ) VALUES ${placeholders.join(', ')} ON CONFLICT (id) DO NOTHING `, values); insertedCount += batch.length; } console.log(` ✓ ivanti_findings — ${insertedCount} findings inserted (state='open')`); migrationResults['ivanti_findings'] = { source: findings.length, dest: insertedCount }; } else { console.log(' ○ findings_json is empty or not an array'); migrationResults['ivanti_findings'] = { source: 0, dest: 0 }; } } } catch (err) { console.error(` ✗ Findings migration ERROR: ${err.message}`); migrationResults['ivanti_findings'] = { source: 0, dest: 0, error: err.message }; } // Step 4: Merge notes into ivanti_findings.note console.log('\n── Step 4: Merging finding notes ──'); try { const notes = await sqliteAll(sqliteDb, 'SELECT finding_id, note FROM ivanti_finding_notes'); if (notes.length === 0) { console.log(' ○ No finding notes to merge'); } else { let mergedCount = 0; for (const { finding_id, note } of notes) { if (!finding_id || !note) continue; const result = await pool.query( `UPDATE ivanti_findings SET note = $1 WHERE id = $2`, [note, finding_id] ); if (result.rowCount > 0) mergedCount++; } console.log(` ✓ Merged ${mergedCount}/${notes.length} notes into ivanti_findings.note`); } } catch (err) { console.error(` ✗ Notes merge ERROR: ${err.message}`); } // Step 5: Merge overrides into ivanti_findings.override_host_name / override_dns console.log('\n── Step 5: Merging finding overrides ──'); try { const overrides = await sqliteAll( sqliteDb, 'SELECT finding_id, field, value FROM ivanti_finding_overrides' ); if (overrides.length === 0) { console.log(' ○ No finding overrides to merge'); } else { let mergedCount = 0; for (const { finding_id, field, value } of overrides) { if (!finding_id || !field) continue; let pgColumn; if (field === 'host_name' || field === 'hostName' || field === 'override_host_name') { pgColumn = 'override_host_name'; } else if (field === 'dns' || field === 'override_dns') { pgColumn = 'override_dns'; } else { // Unknown field — skip continue; } const result = await pool.query( `UPDATE ivanti_findings SET ${pgColumn} = $1 WHERE id = $2`, [value, finding_id] ); if (result.rowCount > 0) mergedCount++; } console.log(` ✓ Merged ${mergedCount}/${overrides.length} overrides into ivanti_findings`); } } catch (err) { console.error(` ✗ Overrides merge ERROR: ${err.message}`); } // Step 6: Populate ivanti_sync_state from ivanti_findings_cache metadata console.log('\n── Step 6: Populating sync state and counts cache ──'); try { const cacheRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_findings_cache WHERE id = 1'); if (cacheRow) { await pool.query(` UPDATE ivanti_sync_state SET total = $1, synced_at = $2, sync_status = $3, error_message = $4 WHERE id = 1 `, [ cacheRow.total || 0, cacheRow.synced_at || null, cacheRow.sync_status || 'never', cacheRow.error_message || null, ]); console.log(' ✓ ivanti_sync_state updated from ivanti_findings_cache metadata'); } // Populate ivanti_counts_cache const countsRow = await sqliteGet(sqliteDb, 'SELECT * FROM ivanti_counts_cache WHERE id = 1'); if (countsRow) { await pool.query(` UPDATE ivanti_counts_cache SET open_count = $1, closed_count = $2, synced_at = $3, fp_workflow_counts_json = $4, fp_id_counts_json = $5 WHERE id = 1 `, [ countsRow.open_count || 0, countsRow.closed_count || 0, countsRow.synced_at || null, countsRow.fp_workflow_counts_json || '{}', countsRow.fp_id_counts_json || '{}', ]); console.log(' ✓ ivanti_counts_cache updated'); } } catch (err) { console.error(` ✗ Sync state/counts migration ERROR: ${err.message}`); } // Step 7: Verification — compare row counts console.log('\n── Step 7: Verification ──'); console.log(''); console.log('┌─────────────────────────────────┬──────────┬──────────┬────────┐'); console.log('│ Table │ SQLite │ Postgres │ Status │'); console.log('├─────────────────────────────────┼──────────┼──────────┼────────┤'); let hasDiscrepancy = false; const verificationTables = [ ...migrations.map(m => ({ sqlite: m.sqliteTable, pg: m.pgTable || m.sqliteTable })), { sqlite: null, pg: 'ivanti_findings', special: true }, ]; for (const { sqlite: sqliteTable, pg: pgTable, special } of verificationTables) { let sqliteCount = 0; let pgCount = 0; try { if (special && pgTable === 'ivanti_findings') { // For findings, source count is from the JSON blob const cacheRow = await sqliteGet(sqliteDb, 'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1'); if (cacheRow && cacheRow.findings_json) { try { const parsed = JSON.parse(cacheRow.findings_json); sqliteCount = Array.isArray(parsed) ? parsed.length : 0; } catch (e) { sqliteCount = 0; } } } else if (sqliteTable) { const tableExists = await sqliteGet( sqliteDb, `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [sqliteTable] ); if (tableExists) { const countRow = await sqliteGet(sqliteDb, `SELECT COUNT(*) as cnt FROM ${sqliteTable}`); sqliteCount = countRow ? countRow.cnt : 0; } } const pgCountRow = await pool.query(`SELECT COUNT(*) as cnt FROM ${pgTable}`); pgCount = parseInt(pgCountRow.rows[0].cnt, 10); } catch (err) { // Table might not exist in one or both } const status = pgCount >= sqliteCount ? ' OK ' : ' WARN '; if (pgCount < sqliteCount) hasDiscrepancy = true; const tableDisplay = (pgTable || '').padEnd(31); const srcDisplay = String(sqliteCount).padStart(6); const destDisplay = String(pgCount).padStart(6); console.log(`│ ${tableDisplay} │ ${srcDisplay} │ ${destDisplay} │ ${status} │`); } console.log('└─────────────────────────────────┴──────────┴──────────┴────────┘'); if (hasDiscrepancy) { console.log('\n⚠ WARNING: Some tables have fewer rows in Postgres than SQLite.'); console.log(' This may be due to ON CONFLICT DO NOTHING skipping existing rows,'); console.log(' or foreign key constraints preventing insertion.'); } // Cleanup await cleanup(sqliteDb, pool); console.log('\n════════════════════════════════════════════════════════'); if (hasDiscrepancy) { console.log('Migration completed with warnings. Review discrepancies above.'); } else { console.log('✓ Migration completed successfully!'); } console.log('════════════════════════════════════════════════════════\n'); process.exit(hasDiscrepancy ? 0 : 0); // Exit 0 even with warnings (data is safe) } // --------------------------------------------------------------------------- // Cleanup helper // --------------------------------------------------------------------------- function cleanup(sqliteDb, pool) { return new Promise((resolve) => { sqliteDb.close((err) => { if (err) console.error('Warning: Error closing SQLite:', err.message); pool.end() .then(() => resolve()) .catch(() => resolve()); }); }); } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- migrate().catch((err) => { console.error('\n✗ FATAL ERROR:', err.message); console.error(err.stack); process.exit(1); });