← back to Sanderson Onboard

tk10873/reprice_live_runbook.mjs

353 lines

#!/usr/bin/env node
// reprice_live_runbook.mjs — TK-10873 Zoffany Option-A LIVE reprice runbook.
// Memo APPROVED by Steve 2026-08-31 (pending-approval/TK-10873-zoffany-optionA-livepublish-2026-08-30.md).
//
// ─────────────────────────────────────────────────────────────────────────────
// DESIGN CONTRACT — restore-map-FIRST, fail-closed, reversible, ledgered.
// The LIVE reprice (customer-facing Shopify money write) is GATED: it only fires
// under `--apply --i-understand-this-is-live`, which Steve runs as a separate `!`
// step. The DEFAULT run is a zero-network DRY-RUN that resolves what it can from
// the local mirror and PROVES the plan. No mode writes anything to Shopify unless
// BOTH live flags are present.
// ─────────────────────────────────────────────────────────────────────────────
//
// Scope: the 277 action=REPRICE rows of zoffany_optionA_draft_v3.json — 239 feed-
// verified US retail + 38 trade-verified (retail == trade/0.65/0.85). Sample ($4.25)
// is NEVER touched. Only the sellable "Sold Per {unit}" variant's price moves.
//
// Modes
//   (default)  plan   — DRY-RUN. Mirror-only resolution (no network). Writes
//                        reprice_live_plan.json + reprice_restore_map.preview.json.
//                        Reports how many have a cached variant_id vs need a live GET.
//   --apply    LIVE   — Phase 1 RESOLVE+SNAPSHOT: for every row, read the live
//                        product variants, pick the unique sellable variant, capture
//                        {product_id, variant_id, old_price, new_price}. Persist the
//                        COMPLETE restore map (+fsync) BEFORE any write. ABORT the
//                        whole run if ANY row fails to resolve to exactly one sellable
//                        variant (zero writes happen on a resolution failure).
//                        Phase 2 WRITE: productVariantsBulkUpdate per product in
//                        batches, verify-readback each, append to the reversible ledger.
//   --revert <map.json> LIVE — read a restore map and set every variant back to
//                        old_price (the undo). Verify-readback + ledger.
//
// Safety rails
//   • --apply requires the second flag --i-understand-this-is-live (footgun guard).
//   • ACTIVE-only (mirror status='ACTIVE'); exactly-one-sellable-variant per product.
//   • Sample variant is identified and EXCLUDED from every write (price==4.25 / sku
//     endsWith '-Sample' / title contains 'Sample').
//   • Every new price must pass the sanity classifier (PASS/WARN, never FAIL) or the
//     row is dropped from the write set with a loud report.
//   • Restore map path is printed BEFORE the first write; the run aborts if it can't
//     be written+fsync'd.
//   • Batches pace to respect Shopify's GraphQL cost throttle.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { SAMPLE_PRICE, classifyPrice } from './reprice_lib.mjs';

const DIR = new URL('.', import.meta.url).pathname;
// The reprice set defaults to the v3 draft (the 277). A follow-up set can be driven by
// pointing REPRICE_DRAFT at another REPRICE-shaped JSON (e.g. the v4 promotions) — output
// filenames derive from the draft basename so a v4 run NEVER clobbers the v3 live restore map.
const V3_IN = process.env.REPRICE_DRAFT ? `${DIR}${process.env.REPRICE_DRAFT}` : `${DIR}zoffany_optionA_draft_v3.json`;
const BASE = V3_IN.replace(/\.json$/, '').split('/').pop();
const PLAN_OUT = `${DIR}${BASE}.live_plan.json`;
const PREVIEW_OUT = `${DIR}${BASE}.restore_map.preview.json`;
const RESTORE_OUT = `${DIR}${BASE}.restore_map.live.json`; // populated ONLY by --apply, before writes
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const BATCH = Number(process.env.REPRICE_BATCH || 25);

const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const LIVE_OK = argv.includes('--i-understand-this-is-live');
const REVERT_IDX = argv.indexOf('--revert');
const REVERT_MAP = REVERT_IDX >= 0 ? argv[REVERT_IDX + 1] : null;

// ── mirror read (read-only, local, $0) ───────────────────────────────────────
function psql(q) {
  return execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAF\t', '-c', q], { encoding: 'utf8' });
}
function loadMirror(mfrSkus) {
  const inlist = mfrSkus.map(s => `'${String(s).replace(/'/g, "''")}'`).join(',');
  const q = `SELECT mfr_sku, vendor, shopify_id, variant_id, sku, variant_sku, variant_count,
             has_product_variant, has_sample_variant, status
             FROM shopify_products WHERE mfr_sku IN (${inlist})`;
  const m = new Map();
  for (const line of psql(q).trim().split('\n').filter(Boolean)) {
    const [mfr_sku, vendor, shopify_id, variant_id, sku, variant_sku, variant_count,
           has_product_variant, has_sample_variant, status] = line.split('\t');
    m.set(mfr_sku, {
      mfr_sku, vendor, shopify_id, variant_id: variant_id || null, sku, variant_sku,
      variant_count: Number(variant_count),
      has_product_variant: has_product_variant === 't',
      has_sample_variant: has_sample_variant === 't',
      status,
    });
  }
  return m;
}

// ── the reprice set: 277 REPRICE rows, sanity-gated ───────────────────────────
function loadRepriceRows() {
  const v3 = JSON.parse(fs.readFileSync(V3_IN, 'utf8'));
  const reprice = v3.filter(r => r.action === 'REPRICE');
  const rows = [], dropped = [];
  for (const r of reprice) {
    const price = Number(r.proposed_sellable_price);
    const floor = Number.isFinite(Number(r.price_trade)) ? Number(r.price_trade) : null;
    const { verdict, reasons } = classifyPrice(price, SAMPLE_PRICE, floor);
    if (verdict === 'FAIL') { dropped.push({ mfr_sku: r.mfr_sku, price, reasons }); continue; }
    rows.push({
      mfr_sku: r.mfr_sku,
      new_price: Number(price.toFixed(2)),
      price_source: r.price_source,
      sanity: verdict, // PASS or WARN (WARN = out-of-band outlier, still written; logged)
      sanity_reasons: reasons,
    });
  }
  return { rows, dropped };
}

// ── Shopify Admin GraphQL (used ONLY by --apply / --revert) ───────────────────
function token() {
  const t = process.env.SHOPIFY_ADMIN_TOKEN;
  if (!t) throw new Error('SHOPIFY_ADMIN_TOKEN not set — source ~/Projects/secrets-manager/.env before --apply');
  return t;
}
async function gql(query, variables) {
  const res = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token() },
    body: JSON.stringify({ query, variables }),
  });
  const j = await res.json();
  if (j.errors) throw new Error('GraphQL errors: ' + JSON.stringify(j.errors));
  // pace against the throttle: if we're low on the cost bucket, breathe.
  const throttle = j.extensions?.cost?.throttleStatus;
  if (throttle && throttle.currentlyAvailable < 200) await new Promise(r => setTimeout(r, 1200));
  return j.data;
}

// Live-read a product's variants and pick the UNIQUE sellable (non-sample) variant.
const PRODUCT_VARIANTS_Q = `query($id: ID!) {
  product(id: $id) {
    id status
    variants(first: 20) { nodes { id sku title price } }
  }
}`;
function pickSellable(variants) {
  const sellable = variants.filter(v =>
    !(String(v.sku || '').endsWith('-Sample')) &&
    !/sample/i.test(v.title || '') &&
    Number(v.price) !== SAMPLE_PRICE);
  return sellable; // caller enforces exactly-one
}

const BULK_UPDATE_M = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id price sku }
    userErrors { field message }
  }
}`;

function ledger(entry) {
  fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
}
function writeFsync(path, data) {
  const fd = fs.openSync(path, 'w');
  fs.writeSync(fd, data);
  fs.fsyncSync(fd);
  fs.closeSync(fd);
}

// ── MODE: plan (DRY-RUN, zero network) ────────────────────────────────────────
function runPlan() {
  const { rows, dropped } = loadRepriceRows();
  const mirror = loadMirror(rows.map(r => r.mfr_sku));
  const plan = [], needLiveGet = [], notActive = [], noProduct = [];
  for (const r of rows) {
    const m = mirror.get(r.mfr_sku);
    if (!m) { noProduct.push(r.mfr_sku); continue; }
    if (m.status !== 'ACTIVE') { notActive.push(r.mfr_sku); continue; }
    if (!m.variant_id) needLiveGet.push(r.mfr_sku); // must resolve variant id via live GET in --apply
    plan.push({
      mfr_sku: r.mfr_sku,
      product_id: m.shopify_id,
      sellable_sku: m.sku,
      cached_variant_id: m.variant_id, // null for the 38 → live GET required
      new_price: r.new_price,
      old_price: '<LIVE-READ-REQUIRED before write>',
      price_source: r.price_source,
      sanity: r.sanity,
    });
  }
  // restore-map PREVIEW: same shape as the live map, old_price/variant_id as markers.
  const preview = plan.map(p => ({
    mfr_sku: p.mfr_sku, product_id: p.product_id, sellable_sku: p.sellable_sku,
    variant_id: p.cached_variant_id || '<LIVE-GET-REQUIRED>',
    old_price: '<LIVE-READ-REQUIRED>', new_price: p.new_price,
    _note: 'PREVIEW only — the live --apply run materializes old_price + variant_id from a live read BEFORE any write.',
  }));
  fs.writeFileSync(PLAN_OUT, JSON.stringify(plan, null, 2));
  fs.writeFileSync(PREVIEW_OUT, JSON.stringify(preview, null, 2));

  console.log('=== Zoffany Option-A LIVE reprice — PLAN (DRY-RUN, zero network) ===');
  console.log(`reprice rows (v3, sanity-passed): ${rows.length}   dropped by sanity FAIL: ${dropped.length}`);
  console.log(`resolved to an ACTIVE product in mirror: ${plan.length}`);
  console.log(`  ├─ have cached sellable variant_id: ${plan.length - needLiveGet.length}`);
  console.log(`  └─ need a LIVE GET to resolve variant_id: ${needLiveGet.length}`);
  if (notActive.length) console.log(`NOT active (excluded): ${notActive.length} → ${notActive.slice(0, 10).join(', ')}`);
  if (noProduct.length) console.log(`NO mirror product (excluded): ${noProduct.length} → ${noProduct.slice(0, 10).join(', ')}`);
  if (dropped.length) console.log(`SANITY-FAIL dropped: ${JSON.stringify(dropped.slice(0, 10))}`);
  const warns = plan.filter(p => p.sanity === 'WARN');
  if (warns.length) console.log(`out-of-band WARN prices (written but flagged): ${warns.length} → ${warns.slice(0, 8).map(w => w.mfr_sku + '=$' + w.new_price).join(', ')}`);
  console.log(`\nartifacts:\n  ${PLAN_OUT}\n  ${PREVIEW_OUT}`);
  console.log('\nThis run made NO network calls and NO writes. The live reprice is:');
  console.log('  node reprice_live_runbook.mjs --apply --i-understand-this-is-live');
  console.log('  (run by Steve via `!`, with SHOPIFY_ADMIN_TOKEN sourced.)');
}

// ── MODE: apply (LIVE — restore-map-first) ────────────────────────────────────
async function runApply() {
  if (!LIVE_OK) {
    console.error('REFUSING: --apply requires --i-understand-this-is-live (customer-facing money write).');
    process.exit(2);
  }
  const { rows, dropped } = loadRepriceRows();
  const mirror = loadMirror(rows.map(r => r.mfr_sku));
  console.log(`[apply] ${rows.length} candidate rows (${dropped.length} dropped by sanity). Resolving live variants…`);

  // ── PHASE 1: RESOLVE + SNAPSHOT (no writes) ─────────────────────────────────
  const restore = [], failures = [];
  for (const r of rows) {
    const m = mirror.get(r.mfr_sku);
    if (!m) { failures.push({ mfr_sku: r.mfr_sku, why: 'no mirror product' }); continue; }
    if (m.status !== 'ACTIVE') { failures.push({ mfr_sku: r.mfr_sku, why: `status ${m.status}` }); continue; }
    let data;
    try { data = await gql(PRODUCT_VARIANTS_Q, { id: m.shopify_id }); }
    catch (e) { failures.push({ mfr_sku: r.mfr_sku, why: 'GET failed: ' + e.message }); continue; }
    const prod = data.product;
    if (!prod) { failures.push({ mfr_sku: r.mfr_sku, why: 'product not found live' }); continue; }
    if (prod.status !== 'ACTIVE') { failures.push({ mfr_sku: r.mfr_sku, why: `live status ${prod.status}` }); continue; }
    const sellable = pickSellable(prod.variants.nodes);
    if (sellable.length !== 1) {
      failures.push({ mfr_sku: r.mfr_sku, why: `expected 1 sellable variant, found ${sellable.length}` });
      continue;
    }
    const v = sellable[0];
    restore.push({
      mfr_sku: r.mfr_sku, product_id: prod.id, variant_id: v.id, sellable_sku: v.sku,
      old_price: Number(v.price), new_price: r.new_price, sanity: r.sanity,
    });
  }

  // FAIL-CLOSED: any resolution failure aborts BEFORE a single write.
  if (failures.length) {
    console.error(`\n[apply] ABORTED — ${failures.length} rows failed to resolve. ZERO writes made.`);
    console.error(JSON.stringify(failures.slice(0, 30), null, 2));
    process.exit(3);
  }

  // Persist the COMPLETE restore map (+fsync) BEFORE the first write. This IS the undo.
  writeFsync(RESTORE_OUT, JSON.stringify({
    ticket: 'TK-10873', generated_at: new Date().toISOString(),
    shop: SHOP, count: restore.length, rows: restore,
  }, null, 2));
  console.log(`\n[apply] restore map persisted BEFORE any write → ${RESTORE_OUT} (${restore.length} rows)`);
  console.log(`[apply] undo at any time: node reprice_live_runbook.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`);

  // Cody HOLE 3: a WARN price (out-of-band $20–$2000) is a possible scraper unit error.
  // Fine to preview, but HARD-STOP a live money write unless --allow-warn is explicit.
  const warnRows = restore.filter(r => r.sanity === 'WARN');
  if (warnRows.length && !argv.includes('--allow-warn')) {
    console.error(`\n[apply] ABORTED — ${warnRows.length} out-of-band WARN prices in the write set; ZERO writes made.`);
    console.error(`  ${warnRows.map(r => r.mfr_sku + '=$' + r.new_price).join(', ')}`);
    console.error('  Review them, then re-run with --allow-warn to include, or fix v3 to exclude.');
    process.exit(4);
  }
  if (warnRows.length) console.warn(`[apply] --allow-warn set: writing ${warnRows.length} out-of-band prices → ${warnRows.map(r => r.mfr_sku + '=$' + r.new_price).join(', ')}`);

  // ── PHASE 2: WRITE (batched, verify-readback, ledger) ───────────────────────
  // Cody HOLE 1: EVERY network call is wrapped so a transient 5xx/timeout lands in
  // errs[] instead of throwing past the loop and skipping the ledger flush below.
  // The loop can never throw → the ledger always fires → we always have a durable
  // record of exactly which rows landed. The restore map already covers the undo.
  let ok = 0, errs = [];
  for (let i = 0; i < restore.length; i += BATCH) {
    const batch = restore.slice(i, i + BATCH);
    for (const row of batch) {
      try {
        const data = await gql(BULK_UPDATE_M, {
          productId: row.product_id,
          variants: [{ id: row.variant_id, price: row.new_price.toFixed(2) }],
        });
        const ue = data.productVariantsBulkUpdate.userErrors;
        if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, userErrors: ue }); continue; }
        const wrote = data.productVariantsBulkUpdate.productVariants?.[0];
        if (!wrote || Number(wrote.price) !== row.new_price) {
          errs.push({ mfr_sku: row.mfr_sku, why: `readback ${wrote?.price} != ${row.new_price}` });
          continue;
        }
        ok++;
      } catch (e) {
        errs.push({ mfr_sku: row.mfr_sku, why: 'write threw: ' + e.message });
        continue;
      }
    }
    console.log(`[apply] batch ${Math.floor(i / BATCH) + 1}: ${ok}/${restore.length} written  (errors so far: ${errs.length})`);
  }

  ledger({
    ts: new Date().toISOString(), agent: 'codex-10873', ticket: 'TK-10873',
    action: `Zoffany Option-A live reprice — set sellable variant price on ${ok} ACTIVE products (v3, feed+trade verified)`,
    blast_radius: ok,
    undo_cmd: `node ${DIR}reprice_live_runbook.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`,
    verify: `restore map ${RESTORE_OUT}; readback-verified each write`,
  });

  console.log(`\n[apply] DONE. written+verified=${ok}  errors=${errs.length}`);
  if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
}

// ── MODE: revert (LIVE undo) ──────────────────────────────────────────────────
async function runRevert() {
  if (!LIVE_OK) { console.error('REFUSING: --revert requires --i-understand-this-is-live.'); process.exit(2); }
  const map = JSON.parse(fs.readFileSync(REVERT_MAP, 'utf8'));
  const rows = map.rows || map;
  console.log(`[revert] restoring ${rows.length} variants to old_price from ${REVERT_MAP}…`);
  let ok = 0, errs = [];
  for (const row of rows) {
    try {
      const data = await gql(BULK_UPDATE_M, {
        productId: row.product_id,
        variants: [{ id: row.variant_id, price: Number(row.old_price).toFixed(2) }],
      });
      const ue = data.productVariantsBulkUpdate.userErrors;
      if (ue && ue.length) { errs.push({ mfr_sku: row.mfr_sku, userErrors: ue }); continue; }
      const wrote = data.productVariantsBulkUpdate.productVariants?.[0];
      if (!wrote || Number(wrote.price) !== Number(row.old_price)) {
        errs.push({ mfr_sku: row.mfr_sku, why: `readback ${wrote?.price} != ${row.old_price}` }); continue;
      }
      ok++;
    } catch (e) {
      errs.push({ mfr_sku: row.mfr_sku, why: 'revert threw: ' + e.message }); continue;
    }
  }
  ledger({
    ts: new Date().toISOString(), agent: 'codex-10873', ticket: 'TK-10873',
    action: `REVERT Zoffany Option-A reprice — restored ${ok} variants to pre-write price`,
    blast_radius: ok, undo_cmd: 're-run --apply to redo', verify: `from ${REVERT_MAP}`,
  });
  console.log(`[revert] DONE. restored=${ok}  errors=${errs.length}`);
  if (errs.length) console.log(JSON.stringify(errs.slice(0, 20), null, 2));
}

// ── dispatch ──────────────────────────────────────────────────────────────────
(async () => {
  if (REVERT_MAP) return runRevert();
  if (APPLY) return runApply();
  return runPlan();
})().catch(e => { console.error('FATAL:', e.message); process.exit(1); });