[object Object]

← back to Dw Sku Integrity

TK-10896: read-only preflight GO/NO-GO checker for the gated apply run

600aa8314ab41750c97842cc5d8872d4f2c339de · 2026-08-31 00:13:50 -0700 · codex-10896

preflight-check.mjs (parameterized target via DWSKU_PSQL, like the scanner) confirms
on the TARGET DB that each planned shopify_id still EXISTS + is still BLANK before a
human fires apply.sql — classifies READY / ALREADY_TARGET (idempotent) / ALREADY_OTHER
(conflict) / MISSING (drift), verdict GO iff 0 missing + 0 other, exit code reflects it.
READ-ONLY (SELECT only). Verified against the mirror: Carnegie GO, 10,707 READY. 45
tests green (+5). Completes the gated-run kit: apply.sql + undo.sql/restore-map + preflight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 600aa8314ab41750c97842cc5d8872d4f2c339de
Author: codex-10896 <steve@designerwallcoverings.com>
Date:   Mon Aug 31 00:13:50 2026 -0700

    TK-10896: read-only preflight GO/NO-GO checker for the gated apply run
    
    preflight-check.mjs (parameterized target via DWSKU_PSQL, like the scanner) confirms
    on the TARGET DB that each planned shopify_id still EXISTS + is still BLANK before a
    human fires apply.sql — classifies READY / ALREADY_TARGET (idempotent) / ALREADY_OTHER
    (conflict) / MISSING (drift), verdict GO iff 0 missing + 0 other, exit code reflects it.
    READ-ONLY (SELECT only). Verified against the mirror: Carnegie GO, 10,707 READY. 45
    tests green (+5). Completes the gated-run kit: apply.sql + undo.sql/restore-map + preflight.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 preflight-check.mjs           | 115 ++++++++++++++++++++++++++++++++++++++++++
 test/preflight-check.test.mjs |  57 +++++++++++++++++++++
 2 files changed, 172 insertions(+)

diff --git a/preflight-check.mjs b/preflight-check.mjs
new file mode 100644
index 0000000..4d9d50a
--- /dev/null
+++ b/preflight-check.mjs
@@ -0,0 +1,115 @@
+#!/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});`;
+  const out = execFileSync(PSQL[0], [...PSQL.slice(1), '-tA', '-F', US, '-R', RS, '-c', 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.
+export function classifyTargets(planned, targetLookup) {
+  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; 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 = stats.MISSING === 0 && stats.ALREADY_OTHER === 0;
+  return { stats, flags, go };
+}
+
+// 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 planned = loadPlanned(planDir, vendor);
+  console.error(`[preflight] 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));
+  const report = {
+    ticket: 'TK-10896', target: PSQL.join(' '), scope: vendor || 'ALL',
+    verdict: result.go ? 'GO' : 'NO_GO',
+    note: 'READ-ONLY preflight. GO = every planned row exists + is still blank on target. NO_GO = MISSING or ALREADY_OTHER present (inspect flags). 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);
+}
diff --git a/test/preflight-check.test.mjs b/test/preflight-check.test.mjs
new file mode 100644
index 0000000..88ce753
--- /dev/null
+++ b/test/preflight-check.test.mjs
@@ -0,0 +1,57 @@
+// Unit tests for the read-only preflight checker. Pure (injected target lookup).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { classifyTargets } from '../preflight-check.mjs';
+
+const planned = [
+  { shopify_id: 'gid/ready', candidate: 'DWAG-376000' },   // blank on target -> READY
+  { shopify_id: 'gid/done', candidate: 'DWAG-376001' },    // already == candidate -> ALREADY_TARGET
+  { shopify_id: 'gid/other', candidate: 'DWAG-376002' },   // holds a DIFFERENT code -> ALREADY_OTHER
+  { shopify_id: 'gid/gone', candidate: 'DWAG-376003' },    // not on target -> MISSING
+];
+const target = new Map([
+  ['gid/ready', ''],
+  ['gid/done', 'DWAG-376001'],
+  ['gid/other', 'DWAG-999999'],
+  // gid/gone absent
+]);
+const lookup = (sid) => (target.has(sid) ? target.get(sid) : undefined);
+
+test('classifyTargets buckets each planned row correctly', () => {
+  const r = classifyTargets(planned, lookup);
+  assert.deepEqual(r.stats, { total: 4, READY: 1, ALREADY_TARGET: 1, ALREADY_OTHER: 1, MISSING: 1 });
+});
+
+test('NO_GO when any MISSING or ALREADY_OTHER present', () => {
+  const r = classifyTargets(planned, lookup);
+  assert.equal(r.go, false);
+  const kinds = new Set(r.flags.map((f) => f.status));
+  assert.ok(kinds.has('MISSING'));
+  assert.ok(kinds.has('ALREADY_OTHER'));
+});
+
+test('GO only when every row is READY or ALREADY_TARGET (idempotent)', () => {
+  const clean = [
+    { shopify_id: 'a', candidate: 'DWX-1' },
+    { shopify_id: 'b', candidate: 'DWX-2' },
+  ];
+  const t = new Map([['a', ''], ['b', 'DWX-2']]); // a blank (ready), b already applied
+  const r = classifyTargets(clean, (s) => (t.has(s) ? t.get(s) : undefined));
+  assert.equal(r.go, true);
+  assert.equal(r.stats.READY, 1);
+  assert.equal(r.stats.ALREADY_TARGET, 1);
+  assert.equal(r.flags.length, 0);
+});
+
+test('ALREADY_OTHER flag carries the conflicting current value', () => {
+  const r = classifyTargets([{ shopify_id: 'x', candidate: 'DWX-1' }], () => 'DWX-CONFLICT');
+  assert.equal(r.stats.ALREADY_OTHER, 1);
+  assert.equal(r.flags[0].current_dw_sku, 'DWX-CONFLICT');
+  assert.equal(r.go, false);
+});
+
+test('whitespace-only target dw_sku counts as blank/READY', () => {
+  const r = classifyTargets([{ shopify_id: 'x', candidate: 'DWX-1' }], () => '   ');
+  assert.equal(r.stats.READY, 1);
+  assert.equal(r.go, true);
+});

← e5d7d09 TK-10896: Cody-gate fixes on apply-plan-gen — key on shopify  ·  back to Dw Sku Integrity  ·  TK-10896: add post-apply verify mode + turnkey per-vendor fi e941614 →