← back to Designerwallcoverings
scripts/c34-osborne-gaston/generate-enqueue-rows.mjs
152 lines
#!/usr/bin/env node
/**
* c34 Osborne & Little + Gaston Y Daniela specs metafield ENQUEUE GENERATOR
* ============================================================================
* Reads the APPROVED worklist TSV and emits one shopify_api_queue row per
* non-SKIP / non-EXCLUDE cell (custom.width / custom.material blank-fill).
*
* THIS IS A DRAFT. It does NOT enqueue and does NOT push to Shopify.
* • DRY-RUN by default: live-reads each product's current custom.width /
* custom.material (read-only), prints every row it WOULD enqueue + every
* row it SKIPS (live already filled), writes the staging artifacts
* (queue-rows JSON + restore-map CSV), and inserts NOTHING.
* • --execute is GATED: it refuses to actually INSERT into shopify_api_queue
* unless C34_CONFIRM=I-AM-STEVE is set — mirroring the scrub-true-sku.mjs
* SCRUB_CONFIRM=I-AM-STEVE gate. Even with --execute, it only INSERTs into
* the LOCAL Mac2 mirror's shopify_api_queue, which has NO active runner
* (audited: zero 'done' rows ever) — so it is a no-op transport. The REAL
* canonical write is the Kamatera-side `sudo -u postgres psql -f <sql>`
* INSERT, which only Steve runs. This generator emits that SQL for him.
*
* SAFETY INVARIANTS (validated by vp-dw-commerce, 2026-06-18):
* 1. blank-fill ONLY — a write is emitted only where the LIVE product
* metafield is currently empty (live-blank re-verify at generate time).
* The Mac2 mirror is ~95% stale for this cohort, so the live read is the
* source of truth, NOT the worklist's mirror-derived (blank) flags.
* 2. format rules (DTD-unanimous 2026-06-17): width = "{n} inches" with
* trailing zeros trimmed; material canonicalized to "Non-Woven"; EXCLUDE
* Wallpaper / Holographic / N/A from material (their width still writes).
* These are pre-baked in the worklist's proposed_* columns; this generator
* only forwards them + enforces the SKIP/EXCLUDE filter.
* 3. NEVER overwrites a non-null live value (the live read makes false-blanks
* from a stale mirror harmless).
*
* Usage:
* node generate-enqueue-rows.mjs # DRY-RUN, full worklist
* node generate-enqueue-rows.mjs --limit 1 # DRY-RUN, canary (first product)
* C34_CONFIRM=I-AM-STEVE node generate-enqueue-rows.mjs --execute # gated local-mirror INSERT (no runner)
*
* Emits (always, even in DRY-RUN):
* queue-rows-<ts>.json exact shopify_api_queue rows that WOULD be inserted
* restore-map-<ts>.csv live width/material per product BEFORE any write
* enqueue-<ts>.sql NOT-EXISTS-guarded INSERT SQL for Kamatera (Steve runs it)
*/
import { rest } from '../lib/shopify.mjs';
import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const HERE = dirname(fileURLToPath(import.meta.url));
const EXECUTE = process.argv.includes('--execute');
const CONFIRMED = process.env.C34_CONFIRM === 'I-AM-STEVE';
const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : 0; })();
const SOURCE_AGENT = 'c34-osborne-gaston-specs';
const PRIORITY = 5; // low — interleaves behind live order/price traffic
const HOME = process.env.HOME;
const WORKLIST = join(HOME, '.claude/yolo-queue/pending-approval/done/c34-osborne-gaston-specs-worklist.tsv');
// A proposed value is a NO-WRITE sentinel if blank, EXCLUDE-*, SKIP-*, (blank), or N/A.
const isNoWrite = (v) =>
!v || v.startsWith('EXCLUDE') || v.startsWith('SKIP') || v === '(blank)' || v === 'N/A';
function loadWorklist() {
const lines = readFileSync(WORKLIST, 'utf8').trim().split('\n');
const hdr = lines[0].split('\t');
const rows = lines.slice(1).map((l) => {
const c = l.split('\t'); const o = {}; hdr.forEach((h, i) => (o[h] = c[i])); return o;
});
// one product per row in this worklist; keep unique product_ids, preserve order
const seen = new Set(); const products = [];
for (const r of rows) if (!seen.has(r.product_id)) { seen.add(r.product_id); products.push(r); }
return LIMIT ? products.slice(0, LIMIT) : products;
}
// Live read of a product's custom.width / custom.material (READ-ONLY).
async function liveCustomWidthMaterial(pid) {
const r = await rest(`/products/${pid}/metafields.json`);
const ms = (await r.json()).metafields || [];
const cur = {};
for (const m of ms) if (m.namespace === 'custom' && (m.key === 'width' || m.key === 'material')) cur[m.key] = m.value;
return cur;
}
const sqlLit = (s) => "'" + String(s).replace(/'/g, "''") + "'";
async function main() {
const products = loadWorklist();
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
console.log(`\n=== c34 enqueue generator — ${LIMIT ? `CANARY (first ${LIMIT})` : 'FULL worklist'} — ${EXECUTE ? 'EXECUTE' : 'DRY-RUN'} ===`);
console.log(`worklist: ${WORKLIST}`);
console.log(`products: ${products.length}\n`);
const restore = []; const inserts = []; const skipped = [];
for (const r of products) {
const pid = r.product_id;
let cur;
try { cur = await liveCustomWidthMaterial(pid); }
catch (e) { skipped.push(`${pid}: LIVE-READ-FAIL ${e.message} — row held, no enqueue`); continue; }
restore.push({ product_id: pid, handle: r.handle, vendor: r.vendor, live_width: cur.width || '', live_material: cur.material || '' });
for (const [key, proposed] of [['width', r.proposed_width], ['material', r.proposed_material]]) {
if (isNoWrite(proposed)) continue;
if (cur[key]) { skipped.push(`${pid} ${key}: live already = ${JSON.stringify(cur[key])} (proposed ${JSON.stringify(proposed)}) → SKIP`); continue; }
const endpoint = `/products/${pid}/metafields.json`;
const payload = { metafield: { namespace: 'custom', key, type: 'single_line_text_field', value: proposed } };
inserts.push({ endpoint, payload, key, pid, proposed });
}
}
// ---- print plan ----
for (const it of inserts) console.log(`ENQUEUE ${it.pid} custom.${it.key} = ${JSON.stringify(it.proposed)}`);
for (const s of skipped.slice(0, 20)) console.log(`skip ${s}`);
if (skipped.length > 20) console.log(` …${skipped.length - 20} more skips`);
console.log(`\nWOULD ENQUEUE: ${inserts.length} rows (width=${inserts.filter((i) => i.key === 'width').length}, material=${inserts.filter((i) => i.key === 'material').length})`);
console.log(`SKIPPED (live already filled / read-fail): ${skipped.length}`);
// ---- always write staging artifacts ----
const queueRows = inserts.map((it) => ({
method: 'POST', endpoint: it.endpoint, payload: it.payload,
token_alias: 'product', priority: PRIORITY, status: 'pending', source_agent: SOURCE_AGENT,
}));
const qrPath = join(HERE, `queue-rows-${ts}.json`);
writeFileSync(qrPath, JSON.stringify({ note: 'EXACT shopify_api_queue rows. NOT inserted unless --execute + C34_CONFIRM=I-AM-STEVE (local mirror only; Kamatera SQL is the canonical path).', queueRows }, null, 2));
const rmPath = join(HERE, `restore-map-${ts}.csv`);
writeFileSync(rmPath, 'product_id,handle,vendor,live_width,live_material\n' +
restore.map((r) => [r.product_id, r.handle, r.vendor, `"${(r.live_width || '').replace(/"/g, '""')}"`, `"${(r.live_material || '').replace(/"/g, '""')}"`].join(',')).join('\n') + '\n');
const sqlPath = join(HERE, `enqueue-${ts}.sql`);
const sql = ['-- c34 osborne/gaston specs metafield enqueue (live-blank-verified). Run on Kamatera as: sudo -u postgres psql -d dw_unified -f <this>.', 'BEGIN;']
.concat(inserts.map((it) => {
const p = JSON.stringify(it.payload);
return `INSERT INTO shopify_api_queue (method, endpoint, payload, token_alias, priority, status, source_agent)\n` +
`SELECT 'POST', ${sqlLit(it.endpoint)}, ${sqlLit(p)}::jsonb, 'product', ${PRIORITY}, 'pending', '${SOURCE_AGENT}'\n` +
`WHERE NOT EXISTS (SELECT 1 FROM shopify_api_queue q WHERE q.request_hash = md5(('POST'||${sqlLit(it.endpoint)})||COALESCE((${sqlLit(p)}::jsonb)::text,'')) AND q.status IN ('pending','processing'));`;
}))
.concat(['COMMIT;', '']).join('\n');
writeFileSync(sqlPath, sql);
console.log(`\nstaged → ${qrPath}\nrestore-map → ${rmPath}\nKamatera SQL (Steve runs) → ${sqlPath}`);
if (!EXECUTE) {
console.log('\nDRY-RUN: no INSERT. Re-run with --execute + C34_CONFIRM=I-AM-STEVE to insert into the LOCAL mirror (no runner). The CANONICAL push is the Kamatera SQL above, run by Steve.');
return;
}
if (!CONFIRMED) {
console.error('\n⛔ --execute refused: gated. Set C34_CONFIRM=I-AM-STEVE to confirm (Steve only).');
process.exit(2);
}
console.error('\n⛔ --execute is intentionally a NO-OP transport: the local Mac2 shopify_api_queue has NO runner (audited — zero done rows ever).');
console.error(' The canonical, gated write is the Kamatera SQL emitted above. Hand Steve:');
console.error(` scp ${sqlPath} my-server:/tmp/ && ssh my-server "sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -f /tmp/$(basename ${sqlPath})"`);
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });