← back to Sanderson Onboard

tk10873/retire_dupes.mjs

138 lines

#!/usr/bin/env node
// retire_dupes.mjs — TK-10873. Reversibly retire the 2 confirmed Zoffany duplicate ORPHANS
// (Tigers Eye ZDAR312884 / DWZF-187020, and Papaver Stripe 313114 / DWWC-502880) by setting each
// product status ACTIVE → DRAFT (removes from storefront, fully reversible). Keeps the canonical
// siblings (ZOW0075-03 / ZOW0146-01) untouched.
//
// Same DESIGN CONTRACT as reprice_live_runbook.mjs: restore-map-FIRST, fail-closed, reversible, ledgered.
//   (default)          PLAN — DRY-RUN, resolve products, write preview map, ZERO network writes.
//   --apply            LIVE — capture COMPLETE restore map (old status) +fsync BEFORE any write,
//                      then productUpdate status→DRAFT per product, readback-verify. Requires
//                      --i-understand-this-is-live.
//   --revert <map>     LIVE — read a restore map, set every product status back to its old value.
import fs from 'node:fs';
const DIR = new URL('.', import.meta.url).pathname;
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const PREVIEW_OUT = `${DIR}retire_dupes.restore_map.preview.json`;
const RESTORE_OUT = `${DIR}retire_dupes.restore_map.live.json`;

// The 2 orphans to retire (by sellable/handle SKU). Canonical siblings are NOT touched.
const ORPHANS = [
  { sku: 'DWZF-187020', label: 'Darnley Tigers Eye orphan (ZDAR312884) — keep ZOW0075-03' },
  { sku: 'DWWC-502880', label: 'Papaver Stripe 313114 orphan (sample-only) — keep ZOW0146-01' },
];
const RETIRE_TO = 'DRAFT'; // reversible; restore sets back to ACTIVE

const MAX_RETRIES = 5;
const RETRY_BACKOFF_MS = 1200;

const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const REVERT_IDX = args.indexOf('--revert');
const LIVE_OK = args.includes('--i-understand-this-is-live');

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 a live run');
  return t;
}
async function gql(query, variables) {
  let attempt = 0;
  while (true) {
    const res = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': token(), 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    if (res.status === 429 && attempt++ < MAX_RETRIES) { await new Promise(r => setTimeout(r, RETRY_BACKOFF_MS * attempt)); continue; }
    const j = await res.json();
    if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
    return j.data;
  }
}
const Q_RESOLVE = `query($q:String!){ products(first:2, query:$q){edges{node{id title status handle}}} }`;
const M_UPDATE = `mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`;

async function resolveOne(sku) {
  const d = await gql(Q_RESOLVE, { q: `sku:${sku}` });
  const nodes = d.products.edges.map(e => e.node);
  if (nodes.length !== 1) throw new Error(`FAIL-CLOSED: sku ${sku} resolved to ${nodes.length} products (need exactly 1)`);
  return nodes[0];
}
function toRestoreRows(resolved) {
  return resolved.map(r => ({ sku: r.sku, product_id: r.product_id, old_status: r.old_status, new_status: RETIRE_TO }));
}
function persist(path, obj) {
  const fd = fs.openSync(path, 'w');
  fs.writeSync(fd, JSON.stringify(obj, null, 2));
  fs.fsyncSync(fd);
  fs.closeSync(fd);
}

// ── REVERT ────────────────────────────────────────────────────────────────────
if (REVERT_IDX !== -1) {
  const mapPath = args[REVERT_IDX + 1];
  if (!mapPath) throw new Error('--revert needs a restore-map path');
  if (!LIVE_OK) throw new Error('revert is LIVE — pass --i-understand-this-is-live');
  const map = JSON.parse(fs.readFileSync(mapPath, 'utf8')).rows;
  if (!Array.isArray(map) || !map.length) throw new Error(`restore map has no rows: ${mapPath}`);
  for (const r of map) {
    if (!r.product_id || !r.sku || !r.old_status) throw new Error(`restore map row missing required field (product_id/sku/old_status): ${JSON.stringify(r)}`);
  }
  let ok = 0, errs = [];
  for (const r of map) {
    try {
      const d = await gql(M_UPDATE, { input: { id: r.product_id, status: r.old_status } });
      const ue = d.productUpdate.userErrors;
      if (ue.length) { errs.push({ sku: r.sku, ue }); continue; }
      ok++;
      console.log(`[revert] ${r.sku} status → ${r.old_status}`);
    } catch (e) { errs.push({ sku: r.sku, err: String(e) }); }
  }
  console.log(`[revert] DONE. restored=${ok} errors=${errs.length}`);
  if (errs.length) console.log(JSON.stringify(errs, null, 2));
  process.exit(errs.length ? 1 : 0);
}

// ── resolve all orphans (needed by plan + apply) ───────────────────────────────
const resolved = [];
for (const o of ORPHANS) {
  const p = await resolveOne(o.sku);
  resolved.push({ ...o, product_id: p.id, title: p.title, old_status: p.status });
}

// ── PLAN (default, DRY-RUN) ────────────────────────────────────────────────────
if (!APPLY) {
  console.log('=== retire_dupes — PLAN (DRY-RUN, no writes) ===');
  console.log(`orphans resolved: ${resolved.length}/${ORPHANS.length}`);
  for (const r of resolved) console.log(`  ${r.sku}  "${r.title}"  ${r.old_status} → ${RETIRE_TO}   (${r.label})`);
  persist(PREVIEW_OUT, { generated: 'preview', retire_to: RETIRE_TO, rows: toRestoreRows(resolved) });
  console.log(`\npreview restore map → ${PREVIEW_OUT}`);
  console.log('LIVE run: node retire_dupes.mjs --apply --i-understand-this-is-live  (Steve, token sourced)');
  process.exit(0);
}

// ── APPLY (LIVE — restore-map FIRST) ───────────────────────────────────────────
if (!LIVE_OK) throw new Error('--apply is LIVE — pass --i-understand-this-is-live');
const restore = { generated: 'live', retire_to: RETIRE_TO, rows: toRestoreRows(resolved) };
persist(RESTORE_OUT, restore); // COMPLETE restore map + fsync BEFORE any write — this IS the undo
console.log(`[apply] restore map persisted BEFORE any write → ${RESTORE_OUT}`);
console.log(`[apply] undo: node retire_dupes.mjs --revert ${RESTORE_OUT} --i-understand-this-is-live`);
let ok = 0, errs = [];
for (const r of resolved) {
  try {
    const d = await gql(M_UPDATE, { input: { id: r.product_id, status: RETIRE_TO } });
    const ue = d.productUpdate.userErrors;
    if (ue.length) { errs.push({ sku: r.sku, ue }); continue; }
    // readback-verify
    const rb = await gql(Q_RESOLVE, { q: `sku:${r.sku}` });
    const st = rb.products.edges[0]?.node.status;
    if (st !== RETIRE_TO) { errs.push({ sku: r.sku, err: `readback status ${st} != ${RETIRE_TO}` }); continue; }
    ok++;
    console.log(`[apply] ${r.sku} "${r.title}" ${r.old_status} → ${st}  ✓`);
  } catch (e) { errs.push({ sku: r.sku, err: String(e) }); }
}
console.log(`[apply] DONE. retired+verified=${ok} errors=${errs.length}`);
if (errs.length) { console.log(JSON.stringify(errs, null, 2)); process.exit(1); }