transceiver-db/scripts/migrate.ts
Rene Fichtmueller 60cffb3585 feat(tip): autonomous data-quality orchestrator (tip-dqo, plan-mode)
Close the missing feedback loop. Detection (v_scraper_health, v_data_quality_alerts,
check_switch_quality) was accurate but inert; dispatch was blind fixed-cadence
(boss.send() is never called from any health path). tip-dqo is a separate, read-mostly
control plane that closes DETECT -> PRIORITIZE -> DISPATCH -> VERIFY -> RECHECK against
observation-table freshness (never pg-boss job state), with DB-enforced guardrails.

- sql/129: source_registry (signal->queue map + per-source SLA + guardrail contract +
  global kill-switch), self_heal_dispatch append-only ledger, scraper_run_log,
  acknowledged_exceptions, v_dqo_targets ranked view.
- orchestrator/: control-loop, self-heal-guards (pure, unit-tested), escalation-sink
  (ntfy + no-op fallback), index entrypoint. Reuses the verification-robots dispatch
  idiom (extracted shared dispatchQueues) and the quality views as-is.
- Guardrails: kill-switch, plan-mode default, per-source cooldown/budget/backoff/
  circuit-breaker, load-guard, erik-safe profile cap, delta-gated escalation. Root-cause
  routing escalates code_fix/blocked (e.g. ATGBICS silent stock-write break) to a human
  instead of looping retries; only staleness is auto-dispatched.
- security: remove hardcoded 'tip_dev_2026' DB password fallback across 6 sites ->
  fail-closed requireDbPassword() / dbConnectionString().

SELF_HEAL_PLAN_ONLY defaults true (dispatches nothing; writes plan rows for review).
Verified against the live DB: detects ATGBICS-stock dead/54d -> escalate, Flexoptix-stock
stale/30d -> plan-dispatch; guards + ledger correct; 8/8 guard unit tests pass.
2026-07-06 11:38:19 +02:00

82 lines
2.3 KiB
TypeScript

import { readFileSync, readdirSync } from "fs";
import { join } from "path";
import { Pool } from "pg";
import { config } from "dotenv";
config();
const dbPassword = process.env.POSTGRES_PASSWORD || process.env.TIP_DB_PASS;
if (!dbPassword) {
throw new Error("POSTGRES_PASSWORD (or TIP_DB_PASS) must be set — refusing a hardcoded default.");
}
const pool = new Pool({
host: process.env.POSTGRES_HOST || "localhost",
port: parseInt(process.env.POSTGRES_PORT || "5432"),
database: process.env.POSTGRES_DB || "transceiver_db",
user: process.env.POSTGRES_USER || "tip",
password: dbPassword,
});
async function migrate(): Promise<void> {
const sqlDir = join(__dirname, "..", "sql");
const files = readdirSync(sqlDir)
.filter((f) => f.endsWith(".sql"))
.sort();
const client = await pool.connect();
try {
// Create migration tracker if it doesn't exist
await client.query(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Get already-applied migrations
const { rows } = await client.query(
"SELECT filename FROM _migrations ORDER BY filename"
);
const applied = new Set(rows.map((r: { filename: string }) => r.filename));
const pending = files.filter((f) => !applied.has(f));
if (pending.length === 0) {
console.log("All migrations already applied. Nothing to do.");
return;
}
console.log(
`${applied.size} already applied, running ${pending.length} pending migrations`
);
for (const file of pending) {
const sql = readFileSync(join(sqlDir, file), "utf-8");
console.log(`Running: ${file}...`);
await client.query("BEGIN");
try {
await client.query(sql);
await client.query(
"INSERT INTO _migrations (filename) VALUES ($1)",
[file]
);
await client.query("COMMIT");
console.log(` Done: ${file}`);
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
}
console.log("\nAll migrations completed successfully.");
} catch (err) {
console.error("Migration failed:", err);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
migrate();