[object Object]

← back to Sanderson Onboard

TK-10873: build retire_dupes.mjs (restore-map-first, reversible) — dry-run 2/2 clean; DUPES card now one-keystroke (retire Tigers Eye + Papaver orphans ACTIVE->DRAFT)

c12e702e05190d8d6d38f4101456cfb6f607d095 · 2026-08-31 05:12:06 -0700 · Steve Abrams

Files touched

Diff

commit c12e702e05190d8d6d38f4101456cfb6f607d095
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 05:12:06 2026 -0700

    TK-10873: build retire_dupes.mjs (restore-map-first, reversible) — dry-run 2/2 clean; DUPES card now one-keystroke (retire Tigers Eye + Papaver orphans ACTIVE->DRAFT)
---
 tk10873/retire_dupes.mjs                      | 127 ++++++++++++++++++++++++++
 tk10873/retire_dupes.restore_map.preview.json |  18 ++++
 2 files changed, 145 insertions(+)

diff --git a/tk10873/retire_dupes.mjs b/tk10873/retire_dupes.mjs
new file mode 100644
index 0000000..87cf6ef
--- /dev/null
+++ b/tk10873/retire_dupes.mjs
@@ -0,0 +1,127 @@
+#!/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 args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const REVERT_I = 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++ < 5) { await new Promise(r => setTimeout(r, 1200 * 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 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_I !== -1) {
+  const mapPath = args[REVERT_I + 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;
+  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: resolved.map(r => ({ sku: r.sku, product_id: r.product_id, old_status: r.old_status, new_status: RETIRE_TO })) });
+  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: resolved.map(r => ({ sku: r.sku, product_id: r.product_id, old_status: r.old_status, new_status: RETIRE_TO })) };
+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); }
diff --git a/tk10873/retire_dupes.restore_map.preview.json b/tk10873/retire_dupes.restore_map.preview.json
new file mode 100644
index 0000000..713389b
--- /dev/null
+++ b/tk10873/retire_dupes.restore_map.preview.json
@@ -0,0 +1,18 @@
+{
+  "generated": "preview",
+  "retire_to": "DRAFT",
+  "rows": [
+    {
+      "sku": "DWZF-187020",
+      "product_id": "gid://shopify/Product/7866101661747",
+      "old_status": "ACTIVE",
+      "new_status": "DRAFT"
+    },
+    {
+      "sku": "DWWC-502880",
+      "product_id": "gid://shopify/Product/7694202011699",
+      "old_status": "ACTIVE",
+      "new_status": "DRAFT"
+    }
+  ]
+}
\ No newline at end of file

← 9b37d1b TK-10873: record 313114 resolution in decisions doc — mappin  ·  back to Sanderson Onboard  ·  TK-10873: add restore-map-first apply_labeling.mjs (dry-run b7e94de →