/ scripts / init-ops-db.js
init-ops-db.js
  1  #!/usr/bin/env node
  2  /**
  3   * init-ops-db.js — Populate ops.db and telemetry.db from sites.db
  4   *
  5   * Copies tables from sites.db into two new database files:
  6   *   ops.db       — cron_jobs, cron_job_logs, cron_locks, pipeline_control,
  7   *                  migrations, settings
  8   *   telemetry.db — llm_usage, agent_logs, agent_tasks, agent_messages,
  9   *                  agent_outcomes, agent_state, agent_llm_usage,
 10   *                  system_metrics, system_health, pipeline_metrics
 11   *
 12   * Safe to run while pipeline is running — uses INSERT OR IGNORE so it won't
 13   * duplicate rows, and does not delete anything from sites.db.
 14   *
 15   * Usage:
 16   *   node scripts/init-ops-db.js [--dry-run]
 17   */
 18  
 19  import Database from 'better-sqlite3';
 20  import { join, dirname } from 'path';
 21  import { fileURLToPath } from 'url';
 22  
 23  const __dirname = dirname(fileURLToPath(import.meta.url));
 24  const projectRoot = join(__dirname, '..');
 25  
 26  const sitesDbPath = process.env.DATABASE_PATH || join(projectRoot, 'db/sites.db');
 27  const opsDbPath = process.env.OPS_DB_PATH || join(projectRoot, 'db/ops.db');
 28  const telDbPath = process.env.TEL_DB_PATH || join(projectRoot, 'db/telemetry.db');
 29  
 30  const dryRun = process.argv.includes('--dry-run');
 31  
 32  if (dryRun) console.log('[init-ops-db] DRY RUN — databases will not be modified');
 33  
 34  console.log('[init-ops-db] Source:', sitesDbPath);
 35  console.log('[init-ops-db] ops.db:', opsDbPath);
 36  console.log('[init-ops-db] telemetry.db:', telDbPath);
 37  
 38  const OPS_TABLES = ['cron_jobs', 'cron_job_logs', 'cron_locks', 'pipeline_control', 'migrations', 'settings'];
 39  const TEL_TABLES = ['llm_usage', 'agent_logs', 'agent_tasks', 'agent_messages', 'agent_outcomes', 'agent_state', 'agent_llm_usage', 'system_metrics', 'system_health', 'pipeline_metrics'];
 40  
 41  /**
 42   * Strip REFERENCES and FOREIGN KEY clauses from a CREATE TABLE DDL.
 43   * This allows copying telemetry tables without needing sites.db.
 44   */
 45  function stripForeignKeys(ddl) {
 46    // Remove inline REFERENCES clauses: e.g. "col TYPE REFERENCES tbl(col)"
 47    let cleaned = ddl.replace(/\s+REFERENCES\s+\w+\s*\([^)]+\)/gi, '');
 48    // Remove full FOREIGN KEY lines
 49    cleaned = cleaned.replace(/,\s*FOREIGN KEY\s*\([^)]+\)\s*REFERENCES\s*\w+\s*\([^)]+\)(\s*ON\s+(DELETE|UPDATE)\s+\w+)*/gi, '');
 50    return cleaned;
 51  }
 52  
 53  /**
 54   * Copy tables from source DB to a target DB file.
 55   * Opens source once, attaches target, copies all tables, detaches.
 56   */
 57  function copyTables(sourceDb, tableNames, targetDbPath, label) {
 58    if (dryRun) {
 59      for (const tableName of tableNames) {
 60        const row = sourceDb.prepare(`SELECT COUNT(*) as n FROM main."${tableName}"`).get();
 61        console.log(`[init-ops-db] [DRY RUN] would copy ${tableName} → ${label} (${row.n} rows)`);
 62      }
 63      return;
 64    }
 65  
 66    // Open target DB directly for schema setup
 67    const targetDb = new Database(targetDbPath);
 68    targetDb.pragma('journal_mode = WAL');
 69    targetDb.pragma('busy_timeout = 30000');
 70    targetDb.pragma('foreign_keys = OFF');
 71  
 72    for (const tableName of tableNames) {
 73      try {
 74        // Get source row count
 75        const sourceCount = sourceDb.prepare(`SELECT COUNT(*) as n FROM main."${tableName}"`).get().n;
 76  
 77        // Get DDL
 78        const ddlRow = sourceDb.prepare(
 79          `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`
 80        ).get(tableName);
 81  
 82        if (!ddlRow) {
 83          console.warn(`[init-ops-db] WARNING: no DDL found for ${tableName}, skipping`);
 84          continue;
 85        }
 86  
 87        // Strip FK references (telemetry tables reference sites.id)
 88        let ddl = stripForeignKeys(ddlRow.sql);
 89  
 90        // Add IF NOT EXISTS
 91        ddl = ddl.replace(/^CREATE TABLE\s+(?!IF NOT EXISTS)/i, 'CREATE TABLE IF NOT EXISTS ');
 92  
 93        // Create table in target
 94        try {
 95          targetDb.exec(ddl);
 96        } catch (ddlErr) {
 97          console.warn(`[init-ops-db] DDL failed for ${tableName} (${ddlErr.message}), using SELECT fallback`);
 98          // Attach source to target temporarily for the AS SELECT approach
 99          targetDb.exec(`ATTACH DATABASE '${sitesDbPath}' AS src_tmp`);
100          try {
101            targetDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" AS SELECT * FROM src_tmp."${tableName}" WHERE 0`);
102          } finally {
103            targetDb.exec(`DETACH DATABASE src_tmp`);
104          }
105        }
106  
107        // Create indexes in target
108        const idxRows = sourceDb.prepare(
109          `SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL`
110        ).all(tableName);
111  
112        for (const idxRow of idxRows) {
113          try {
114            let idxDDL = idxRow.sql;
115            // Add IF NOT EXISTS
116            idxDDL = idxDDL.replace(
117              /^(CREATE\s+(?:UNIQUE\s+)?INDEX\s+)(?!IF NOT EXISTS)/i,
118              '$1IF NOT EXISTS '
119            );
120            targetDb.exec(idxDDL);
121          } catch (idxErr) {
122            // Non-critical
123            console.warn(`[init-ops-db] Index creation skipped for ${tableName}: ${idxErr.message}`);
124          }
125        }
126  
127        // Attach source to target for bulk copy
128        targetDb.exec(`ATTACH DATABASE '${sitesDbPath}' AS src`);
129        try {
130          const copied = targetDb.prepare(
131            `INSERT OR IGNORE INTO main."${tableName}" SELECT * FROM src."${tableName}"`
132          ).run().changes;
133  
134          const targetCount = targetDb.prepare(`SELECT COUNT(*) as n FROM main."${tableName}"`).get().n;
135          console.log(`[init-ops-db] ${label}.${tableName}: copied ${copied} rows (${sourceCount} in source, ${targetCount} in target)`);
136        } finally {
137          targetDb.exec(`DETACH DATABASE src`);
138        }
139      } catch (err) {
140        console.error(`[init-ops-db] ERROR copying ${tableName} to ${label}: ${err.message}`);
141      }
142    }
143  
144    targetDb.close();
145  }
146  
147  // Open source DB (readonly — we never write to it)
148  const sourceDb = new Database(sitesDbPath, { readonly: true });
149  
150  console.log('\n--- Phase 1: ops.db ---');
151  copyTables(sourceDb, OPS_TABLES, opsDbPath, 'ops');
152  
153  console.log('\n--- Phase 2: telemetry.db ---');
154  copyTables(sourceDb, TEL_TABLES, telDbPath, 'telemetry');
155  
156  sourceDb.close();
157  
158  console.log('\n[init-ops-db] Done.');
159  if (!dryRun) {
160    console.log('[init-ops-db] Verify with: sqlite3 db/ops.db ".tables" && sqlite3 db/telemetry.db ".tables"');
161    console.log('[init-ops-db] Next: run Step 6 to rename old tables in sites.db to _toDelete suffix.');
162  }