[object Object]

← back to Designer Wallcoverings

hold-orphan-438: reversible unpublish->draft for go-live-gate orphans (438 held, 0 failed)

c6296c081d3a909e1997e32b3051489bb4552a21 · 2026-07-06 20:06:41 -0700 · Steve

Files touched

Diff

commit c6296c081d3a909e1997e32b3051489bb4552a21
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 20:06:41 2026 -0700

    hold-orphan-438: reversible unpublish->draft for go-live-gate orphans (438 held, 0 failed)
---
 scripts/hold-orphan-438.js | 122 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 122 insertions(+)

diff --git a/scripts/hold-orphan-438.js b/scripts/hold-orphan-438.js
new file mode 100644
index 00000000..2a38dd86
--- /dev/null
+++ b/scripts/hold-orphan-438.js
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+/**
+ * hold-orphan-438.js — reversible HOLD (unpublish -> DRAFT) for the go-live-gate orphans.
+ *
+ * Context: Steve's HARD rule — any SKU created in the last 6 months must have a real
+ * vendor name AND a real mfr SKU number or it cannot go live. 438 ACTIVE live products
+ * (Phillipe Romano + Malibu Wallpaper) fail it: private-label alias vendor + blank/fake/
+ * reused mfr number, absent from FileMaker too. DTD verdict (3/3): HOLD now = set status
+ * DRAFT (reversible), then backfill real mfr #s from the WallQuest/Command54 scrapers and
+ * republish. Draft removes a product from ALL sales channels but preserves the object,
+ * variants, images, and order-history references.
+ *
+ * Reads the target list from ~/Projects/dw-golive-gate/kill-list.csv (col 1 = product GID).
+ * Writes an append-only result ledger to ~/Projects/dw-golive-gate/hold-results.jsonl.
+ *
+ * Usage:
+ *   node scripts/hold-orphan-438.js --dry [--limit N]   # no writes; GET current status only
+ *   node scripts/hold-orphan-438.js --limit 1           # hold ONE (canary), then verify
+ *   node scripts/hold-orphan-438.js                      # hold ALL rows in the CSV
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const https = require('https');
+require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
+
+const SHOPIFY_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = '2024-10';
+const CSV = path.join(os.homedir(), 'Projects/dw-golive-gate/kill-list.csv');
+const LEDGER = path.join(os.homedir(), 'Projects/dw-golive-gate/hold-results.jsonl');
+
+const DRY = process.argv.includes('--dry');
+const limIdx = process.argv.indexOf('--limit');
+const LIMIT = limIdx > -1 ? parseInt(process.argv[limIdx + 1], 10) : Infinity;
+
+if (!SHOPIFY_TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN missing from .env'); process.exit(1); }
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+function req(method, endpoint, body) {
+  return new Promise((resolve, reject) => {
+    const data = body ? JSON.stringify(body) : null;
+    const r = https.request({
+      hostname: SHOPIFY_DOMAIN,
+      path: `/admin/api/${API}${endpoint}`,
+      method,
+      headers: {
+        'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+        'Content-Type': 'application/json',
+        ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
+      },
+    }, (res) => {
+      let buf = '';
+      res.on('data', c => buf += c);
+      res.on('end', () => { let j; try { j = JSON.parse(buf); } catch { j = buf; } resolve({ status: res.statusCode, data: j }); });
+    });
+    r.on('error', reject);
+    if (data) r.write(data);
+    r.end();
+  });
+}
+
+// GET/PUT with 429 backoff (Shopify REST bucket = 40, leaks 2/s)
+async function call(method, endpoint, body) {
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    const res = await req(method, endpoint, body);
+    if (res.status !== 429) return res;
+    await sleep(1000 * attempt);
+  }
+  return { status: 429, data: 'rate-limited after retries' };
+}
+
+function loadTargets() {
+  const lines = fs.readFileSync(CSV, 'utf8').trim().split('\n').slice(1); // drop header
+  return lines.map(l => {
+    const cols = l.split(',');
+    const gid = cols[0];
+    const numeric = (gid.match(/(\d+)$/) || [])[1];
+    return { gid, id: numeric, handle: cols[2], vendor: cols[3] };
+  }).filter(t => t.id);
+}
+
+(async () => {
+  const targets = loadTargets().slice(0, LIMIT);
+  console.log(`${DRY ? '🔎 DRY RUN' : '✋ HOLD'} — ${targets.length} product(s) | store=${SHOPIFY_DOMAIN} | token …${SHOPIFY_TOKEN.slice(-4)}`);
+  let held = 0, already = 0, missing = 0, failed = 0;
+  for (let i = 0; i < targets.length; i++) {
+    const t = targets[i];
+    const tag = `[${i + 1}/${targets.length}] ${t.id} ${t.vendor} ${t.handle}`;
+    // read current status first
+    const cur = await call('GET', `/products/${t.id}.json?fields=id,status,handle,title`);
+    if (cur.status === 404 || !cur.data || !cur.data.product) {
+      console.log(`  ⚠️  ${tag} — NOT FOUND on Shopify (404)`); missing++;
+      fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), id: t.id, action: 'missing' }) + '\n');
+      await sleep(300); continue;
+    }
+    const before = cur.data.product.status;
+    if (before === 'draft') {
+      console.log(`  ⏭️  ${tag} — already DRAFT`); already++;
+      await sleep(300); continue;
+    }
+    if (DRY) {
+      console.log(`  🔎 ${tag} — would set ${before} -> draft`);
+      await sleep(250); continue;
+    }
+    const put = await call('PUT', `/products/${t.id}.json`, { product: { id: Number(t.id), status: 'draft' } });
+    const ok = put.status === 200 && put.data?.product?.status === 'draft';
+    if (ok) {
+      held++;
+      console.log(`  ✅ ${tag} — ${before} -> DRAFT`);
+      fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), id: t.id, handle: t.handle, vendor: t.vendor, before, after: 'draft', action: 'held' }) + '\n');
+    } else {
+      failed++;
+      console.log(`  ❌ ${tag} — FAILED (${put.status}) ${JSON.stringify(put.data).slice(0, 160)}`);
+      fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), id: t.id, action: 'failed', status: put.status }) + '\n');
+    }
+    await sleep(400); // ~2.5 req/s incl the GET — under the 2/s leak with bucket headroom
+  }
+  console.log(`\n── ${DRY ? 'DRY RUN' : 'HOLD'} complete ──`);
+  console.log(`held=${held}  already-draft=${already}  missing=${missing}  failed=${failed}  ($0 — Shopify Admin API is free)`);
+})();

← 7ff102ab chore: v1.1.10 (session close — bucketB TSV env override)  ·  back to Designer Wallcoverings  ·  backfill-malibu-mfr: recover real SKU+mfr+vendor for 61 held 51edfffd →