← back to Dw Sku Integrity
apply-plan-gen.mjs
286 lines
#!/usr/bin/env node
// apply-plan-gen.mjs -- READ-ONLY generator of GATED apply-plan artifacts for the
// canonical dw_sku self-copy backlog (TK-10896).
//
// HARD CONSTRAINTS (do not relax):
// * NO database writes. NO minting. NO executing any SQL.
// * Opens ONLY a read-only connection to attach the stable DB key.
// * Emits per-vendor apply.sql / undo.sql / restore-map.json into apply-plans/.
// These are DRAFTS a human fires later -- the dw_sku write is Kamatera-canonical
// dw_unified and is HARD-GATED; this tool NEVER runs apply.sql.
//
// Recovers an EXISTING code already present in `sku` (self-copy). Never mints.
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { vendorAllowsMfrSelfCopy } from './classify.mjs';
// mfr_sku-sourced recovery classes. These recover dw_sku from mfr_sku and are
// permitted ONLY for verified-real-mfr vendors (Carnegie, Stout). This sku-keyed
// generator does NOT emit SQL for them (they have their own generator), but it
// HARD-ERRORS if a fabricated-mfr self-copy is ever hand-fed in — see the
// allowlist assertion in selectSelfCopyRows. DTD verdict A, 2026-09-02, TK-10900.
export const MFR_SELF_COPY_CLASSES = new Set(['SELF_COPY_MFR', 'SELF_COPY_MFR_COLLISION']);
const HERE = dirname(fileURLToPath(import.meta.url));
// Field separator for the (sku, vendor) map key AND the psql read. A single named
// constant is the only source of truth so load-bearing keys can never drift on an
// invisible-character re-encode. String.fromCharCode(1) = SOH: never in a SKU,
// vendor name, or handle.
const SEP = String.fromCharCode(1);
export const keyOf = (sku, vendor) => sku + SEP + vendor;
// psql prefix (parameterized like the scanner/preflight so this can target the
// canonical Kamatera DB, not just the local mirror):
// Mac2 mirror (default): node apply-plan-gen.mjs
// Kamatera (canonical): DWSKU_PSQL='ssh root@<kam> psql dw_unified' node apply-plan-gen.mjs
const PSQL = (process.env.DWSKU_PSQL || 'psql -h /tmp -d dw_unified').split(/\s+/);
// Self-copy classes that recover an existing code from `sku`. These + a non-null
// candidate are the ONLY rows that produce SQL. Everything else is excluded.
export const SELF_COPY_CLASSES = new Set([
'SELF_COPY_DW',
'SELF_COPY_DW_PROVEN_NATIVE',
'SELF_COPY_SOURCE',
'SELF_COPY_CORK',
]);
// Classes that must NEVER leak into an apply plan (asserted below). Note the
// collision classes literally start with "SELF_COPY", so a naive prefix filter
// would leak them -- the allow-set above is exact-match, and this set is the
// belt-and-suspenders guard.
export const FORBIDDEN_CLASSES = new Set([
'MINT_RESIDUE_RESCRAPE',
'RESCRAPE',
'MFR_FABRICATED_RESCRAPE',
'SELF_COPY_COLLISION',
'SELF_COPY_DW_COLLISION',
'SELF_COPY_MFR', // mfr recovery has its own generator (mfr-selfcopy-gen)
'SELF_COPY_MFR_COLLISION',
'STAGING_LINK',
'PROVENANCE_REVIEW',
]);
// The two sets must be disjoint (an allow class is never a forbidden class).
for (const c of SELF_COPY_CLASSES) {
if (FORBIDDEN_CLASSES.has(c)) throw new Error(`class ${c} is both allowed and forbidden`);
}
// ---- helpers ---------------------------------------------------------------
function readJsonLines(buffer, label) {
const text = buffer.toString('utf8').trim();
if (!text) return [];
return text.split('\n').map((line, index) => {
try { return JSON.parse(line); }
catch (error) { throw new Error(`${label}:${index + 1}: invalid JSON: ${error.message}`); }
});
}
// SQL single-quote escape (double any apostrophe). Candidates are DW SKU codes;
// this keeps a stray apostrophe from breaking (or injecting into) the statement.
export function sqlEscape(value) {
return String(value).replace(/'/g, "''");
}
export function vendorSlug(vendor) {
const slug = String(vendor || 'unknown')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'unknown';
}
// A candidate must LOOK like a code, not a product title or a garbage-suffixed
// string. classify.mjs's SELF_COPY_SOURCE path admits any non-DW/non-Cork sku
// verbatim, so a leaked title ("Bouncing Bubbles Mural - Cream") or a malformed
// code ("DWKK-152210-Sold Per Bolt (20.5in x 33ft)") could otherwise be written
// into the canonical dw_sku identity field. Reject anything with whitespace,
// parentheses, or over 40 chars — these need re-scrape/manual, never a self-copy.
export const CODE_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,39}$/;
// Keep ONLY self-copy classes with a non-null, code-shaped candidate. Assert no
// forbidden class ever slips through. Returns { kept, rejectedShape }.
export function selectSelfCopyRows(planRows) {
const kept = [];
const rejectedShape = [];
for (const row of planRows) {
// HARD MACHINE GATE (DTD verdict A, TK-10900): an mfr_sku-sourced self-copy
// for a vendor NOT on the verified-real allowlist is a fabricated-code
// laundering attempt. Refuse to proceed at all — even a hand-fed plan
// physically cannot slip a fabricated mfr_sku into a canonical write.
if (MFR_SELF_COPY_CLASSES.has(row.class) && !vendorAllowsMfrSelfCopy(row.vendor)) {
throw new Error(
`mfr self-copy blocked: vendor '${row.vendor}' is NOT on MFR_SKU_REAL_ALLOWLIST ` +
`(class ${row.class}, candidate ${row.candidate}). Route to re-scrape; never self-copy a fabricated mfr_sku.`,
);
}
if (FORBIDDEN_CLASSES.has(row.class)) continue; // rescrape / collision / staging / residue / mfr -> never
if (!SELF_COPY_CLASSES.has(row.class)) continue; // any other class -> no SQL
const cand = row.candidate == null ? '' : String(row.candidate).trim();
if (cand === '') continue; // require an existing code
if (!CODE_SHAPE.test(cand)) { rejectedShape.push(row); continue; } // title/garbage -> not a self-copy
kept.push(row);
}
kept.rejectedShape = rejectedShape;
return kept;
}
// ---- read-only DB key attachment ------------------------------------------
// One bulk psql read of all still-blank rows. Builds Map key(sku,vendor) ->
// [{ id, shopify_id, handle }]. NEVER opens a write connection.
export function loadBlankKeyMap(psql = defaultPsqlReader) {
const rows = psql();
const map = new Map();
for (const r of rows) {
const key = keyOf(r.sku, r.vendor);
if (!map.has(key)) map.set(key, []);
map.get(key).push({ id: r.id, shopify_id: r.shopify_id, handle: r.handle });
}
return map;
}
function defaultPsqlReader() {
// Read-only SELECT. No write clause anywhere. Emits SEP-delimited rows for
// every product whose dw_sku is STILL blank (idempotence at read time).
const sql =
"SELECT id, coalesce(shopify_id,''), coalesce(handle,''), coalesce(sku,''), coalesce(vendor,'') " +
"FROM shopify_products WHERE dw_sku IS NULL OR btrim(dw_sku)='';";
// Uses DWSKU_PSQL (default local mirror) + feeds SQL via STDIN, not -c, so it
// works identically local and over `ssh <host> psql ...` (no remote-shell quoting).
const out = execFileSync(PSQL[0], [...PSQL.slice(1), '-tA', '-F', SEP], {
input: sql,
maxBuffer: 1024 * 1024 * 512,
encoding: 'utf8',
});
const rows = [];
for (const line of out.split('\n')) {
if (!line) continue;
const [id, shopify_id, handle, sku, vendor] = line.split(SEP);
rows.push({ id: Number(id), shopify_id, handle, sku, vendor });
}
return rows;
}
// ---- core plan build -------------------------------------------------------
export function buildPlans(planRows, blankKeyMap) {
const selfCopy = selectSelfCopyRows(planRows);
const byVendor = new Map(); // vendor -> [{ id, shopify_id, handle, candidate }]
let unmatched = 0;
let noShopifyId = 0; // rows we cannot safely target on Kamatera (missing stable key)
const unmatchedRows = [];
for (const row of selfCopy) {
const dbRows = blankKeyMap.get(keyOf(row.sku, row.vendor));
if (!dbRows || dbRows.length === 0) { unmatched += 1; unmatchedRows.push(row); continue; }
if (!byVendor.has(row.vendor)) byVendor.set(row.vendor, []);
const bucket = byVendor.get(row.vendor);
for (const db of dbRows) {
// shopify_id is the stable, machine-independent key. A row missing it cannot
// be safely targeted on the canonical Kamatera DB -> exclude + report.
if (!db.shopify_id || String(db.shopify_id).trim() === '') { noShopifyId += 1; continue; }
bucket.push({ id: db.id, shopify_id: db.shopify_id, handle: db.handle, candidate: String(row.candidate) });
}
}
return { byVendor, unmatched, unmatchedRows, noShopifyId, selfCopyCount: selfCopy.length, rejectedShape: selfCopy.rejectedShape || [] };
}
const HEADER =
'-- GATED -- canonical Kamatera dw_unified write. Do NOT run automatically.\n' +
'-- Recovers existing code (no mint). Undo via undo.sql / restore-map.json. TK-10896.\n';
// Key on shopify_id (the stable Shopify GID, identical on Mac2 + Kamatera), NOT
// the local `id` (a per-machine nextval serial that maps to a DIFFERENT product
// on the canonical Kamatera DB). The blank-guard keeps a re-run / concurrent fill
// from clobbering.
function applyStmt(shopify_id, candidate) {
const c = sqlEscape(candidate);
const sid = sqlEscape(shopify_id);
return `UPDATE shopify_products SET dw_sku='${c}' WHERE shopify_id='${sid}' AND (dw_sku IS NULL OR btrim(dw_sku)='');`;
}
function undoStmt(shopify_id, candidate) {
const c = sqlEscape(candidate);
const sid = sqlEscape(shopify_id);
return `UPDATE shopify_products SET dw_sku=NULL WHERE shopify_id='${sid}' AND dw_sku='${c}';`;
}
export function renderApplySql(entries) {
return HEADER + entries.map((e) => applyStmt(e.shopify_id, e.candidate)).join('\n') + '\n';
}
export function renderUndoSql(entries) {
return HEADER + entries.map((e) => undoStmt(e.shopify_id, e.candidate)).join('\n') + '\n';
}
export function renderRestoreMap(entries) {
return entries.map((e) => ({
id: e.id,
shopify_id: e.shopify_id,
handle: e.handle,
column: 'dw_sku',
// old is the pre-apply canonical value: blank (NULL or ''); undo restores to
// NULL. null (not '') is the honest record — undo targets `dw_sku='<new>'` so
// it only ever reverts what this apply set.
old: null,
new: e.candidate,
}));
}
// ---- writer ----------------------------------------------------------------
export function writePlans({ byVendor, unmatched, selfCopyCount, noShopifyId = 0, rejectedShape = [] }, outDir) {
if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
const perVendor = {};
let grandTotal = 0;
for (const [vendor, entries] of [...byVendor.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const slug = vendorSlug(vendor);
const dir = join(outDir, slug);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'restore-map.json'), JSON.stringify(renderRestoreMap(entries), null, 2) + '\n');
writeFileSync(join(dir, 'apply.sql'), renderApplySql(entries));
writeFileSync(join(dir, 'undo.sql'), renderUndoSql(entries));
perVendor[vendor] = { slug, statements: entries.length };
grandTotal += entries.length;
}
const summary = {
ticket: 'TK-10896',
note: 'NOTHING was executed. apply.sql files are GATED drafts for a human to run. dw_sku is Kamatera-canonical dw_unified -- hard-gated.',
generated_at: new Date().toISOString(),
key_column: 'shopify_id',
self_copy_plan_rows: selfCopyCount,
vendors: Object.keys(perVendor).length,
grand_total_statements: grandTotal,
unmatched_plan_rows: unmatched,
excluded_missing_shopify_id: noShopifyId,
excluded_bad_candidate_shape: rejectedShape.length,
per_vendor: perVendor,
};
writeFileSync(join(outDir, 'SUMMARY.json'), JSON.stringify(summary, null, 2) + '\n');
return summary;
}
// ---- CLI -------------------------------------------------------------------
function arg(name, fallback = null) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
}
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
const planPath = arg('--plan', '/tmp/joined3.jsonl');
const outDir = arg('--out', join(HERE, 'apply-plans'));
const planRows = readJsonLines(readFileSync(planPath), 'plan');
const blankKeyMap = loadBlankKeyMap();
const plans = buildPlans(planRows, blankKeyMap);
const summary = writePlans(plans, outDir);
process.stdout.write(JSON.stringify({ ok: true, out: outDir, ...summary }, null, 2) + '\n');
}