← 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);
}