← 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
A scripts/hold-orphan-438.js
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 →