← 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
A preflight-check.mjsA test/preflight-check.test.mjs
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 →