← back to Dw Sku Integrity

preflight-check.mjs

135 lines

#!/usr/bin/env node
// preflight-check.mjs — READ-ONLY GO/NO-GO safety net for the gated apply plan.
//
// Before a human fires an apply.sql on the canonical (Kamatera) DB, this confirms
// on the TARGET DB that each planned shopify_id still EXISTS and its dw_sku is
// still BLANK — catching rows that were filled/archived/removed since the plan was
// generated on the Mac2 mirror. It issues ONLY SELECT; it never writes, never fires
// apply.sql. Parent ticket: TK-10896.
//
// Portability: point it at the canonical DB the SAME way as the scanner —
//   Mac2 mirror (default):  node preflight-check.mjs
//   Kamatera (canonical):   DWSKU_PSQL='ssh <kam> psql' node preflight-check.mjs
//
// Reads plan targets from apply-plans/<vendor>/restore-map.json (shopify_id + new
// candidate). Classifies each planned row on the target:
//   READY          exists + dw_sku blank            -> apply will set it (good)
//   ALREADY_TARGET exists + dw_sku == candidate      -> already applied (idempotent no-op)
//   ALREADY_OTHER  exists + dw_sku is some OTHER code -> conflict (guard skips it) — FLAG
//   MISSING        shopify_id not on target          -> anomaly (drift) — FLAG
// GO iff MISSING == 0 and ALREADY_OTHER == 0 for the scope.

import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';

const HERE = dirname(fileURLToPath(import.meta.url));
const US = '\x1f';
const RS = '\x1e';
const PSQL = (process.env.DWSKU_PSQL || 'psql -h /tmp -d dw_unified').split(/\s+/);

// Read the (shopify_id -> dw_sku) for a chunk of ids. READ-ONLY SELECT.
function readTargetChunk(shopifyIds, psql = defaultChunkReader) {
  return psql(shopifyIds);
}

function sqlEscape(v) { return String(v).replace(/'/g, "''"); }

function defaultChunkReader(shopifyIds) {
  const inList = shopifyIds.map((s) => `'${sqlEscape(s)}'`).join(',');
  const sql = `SELECT shopify_id, coalesce(dw_sku,'') FROM shopify_products WHERE shopify_id IN (${inList});`;
  // Feed SQL via STDIN, not -c: over `ssh <host> psql ...` the -c argument would be
  // re-parsed by the REMOTE shell and choke on the SQL's parens/quotes. psql with no
  // -c/-f reads from stdin, which works identically local and over ssh.
  const out = execFileSync(PSQL[0], [...PSQL.slice(1), '-tA', '-F', US, '-R', RS], {
    input: sql, maxBuffer: 1 << 30, encoding: 'utf8',
  });
  const rows = new Map();
  for (const rec of out.split(RS)) {
    const line = rec.replace(/\n$/, '');
    if (!line) continue;
    const [sid, dw] = line.split(US);
    rows.set(sid, dw);
  }
  return rows;
}

// Classify planned targets against a resolver (shopify_id -> dw_sku on target, or
// undefined if absent). Pure — testable without a DB.
//   mode 'preflight' (BEFORE firing): GO iff every row exists + is still blank
//     (READY) or already equals its candidate (ALREADY_TARGET, idempotent). A row
//     holding a DIFFERENT code (ALREADY_OTHER) or absent (MISSING) blocks.
//   mode 'verify' (AFTER firing): GO iff every row now equals its candidate
//     (ALREADY_TARGET). A still-blank row (READY) means the apply didn't land;
//     ALREADY_OTHER / MISSING also block.
export function classifyTargets(planned, targetLookup, mode = 'preflight') {
  const stats = { total: planned.length, READY: 0, ALREADY_TARGET: 0, ALREADY_OTHER: 0, MISSING: 0 };
  const flags = [];
  for (const p of planned) {
    const dw = targetLookup(p.shopify_id);
    if (dw === undefined) { stats.MISSING += 1; flags.push({ ...p, status: 'MISSING' }); continue; }
    const cur = String(dw || '').trim();
    if (cur === '') {
      stats.READY += 1;
      if (mode === 'verify') flags.push({ ...p, status: 'NOT_APPLIED' }); // should be set post-apply
      continue;
    }
    if (cur === String(p.candidate)) { stats.ALREADY_TARGET += 1; continue; }
    stats.ALREADY_OTHER += 1;
    flags.push({ ...p, status: 'ALREADY_OTHER', current_dw_sku: cur });
  }
  const go = mode === 'verify'
    ? (stats.ALREADY_TARGET === stats.total)
    : (stats.MISSING === 0 && stats.ALREADY_OTHER === 0);
  return { stats, flags, go, mode };
}

// Load planned targets from apply-plans/<vendor>/restore-map.json.
export function loadPlanned(planDir, vendorSlug = null) {
  const planned = [];
  const slugs = vendorSlug
    ? [vendorSlug]
    : readdirSync(planDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
  for (const slug of slugs) {
    const p = join(planDir, slug, 'restore-map.json');
    if (!existsSync(p)) continue;
    for (const e of JSON.parse(readFileSync(p, 'utf8'))) {
      if (e.shopify_id) planned.push({ vendor_slug: slug, shopify_id: e.shopify_id, candidate: e.new });
    }
  }
  return planned;
}

function chunk(arr, n) { const out = []; for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); return out; }

function arg(name, fb = null) { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : fb; }

if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
  const planDir = arg('--plan-dir', join(HERE, 'apply-plans'));
  const vendor = arg('--vendor', null);
  const outPath = arg('--out', null);
  const mode = arg('--mode', 'preflight'); // 'preflight' (before) | 'verify' (after)
  if (mode !== 'preflight' && mode !== 'verify') { console.error(`bad --mode: ${mode}`); process.exit(3); }
  const planned = loadPlanned(planDir, vendor);
  console.error(`[${mode}] target: ${PSQL.join(' ')} | planned rows: ${planned.length}`);

  // Resolve all target dw_sku values in chunks (READ-ONLY).
  const found = new Map();
  const ids = [...new Set(planned.map((p) => p.shopify_id))];
  for (const c of chunk(ids, 500)) for (const [k, v] of readTargetChunk(c)) found.set(k, v);

  const result = classifyTargets(planned, (sid) => (found.has(sid) ? found.get(sid) : undefined), mode);
  const report = {
    ticket: 'TK-10896', mode, target: PSQL.join(' '), scope: vendor || 'ALL',
    verdict: result.go ? 'GO' : 'NO_GO',
    note: mode === 'verify'
      ? 'READ-ONLY post-apply verify. GO = every planned row now equals its candidate. NO_GO = NOT_APPLIED / ALREADY_OTHER / MISSING present. Nothing was written.'
      : 'READ-ONLY preflight. GO = every planned row exists + is still blank (or already its candidate) on target. NO_GO = MISSING or ALREADY_OTHER present. Nothing was written.',
    ...result.stats, flags_sample: result.flags.slice(0, 50), flag_count: result.flags.length,
  };
  console.log(JSON.stringify(report, null, 2));
  if (outPath) { mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify(report, null, 2) + '\n'); }
  process.exit(result.go ? 0 : 2);
}