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