← 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
A tk10873/retire_dupes.mjsA tk10873/retire_dupes.restore_map.preview.json
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 →