[object Object]

← back to Reid Witlin Onboarding

backfill-rwltd-mfr: namespace-scoped idempotency + HTTP status check & retry

bb0a80a4696720532322f5a86a88a2092a34890b · 2026-09-24 11:33:51 -0700 · Steve Abrams

Two live-Shopify-path fixes on the 1,195-row backfill: (1) liveHasMfr now matches
the EXACT target metafield custom.manufacturer_sku (namespace + key), not any
namespace with that key — a same-keyed metafield elsewhere could previously mask a
genuinely-missing custom one and get counted "already had"; (2) added a fetchRetry
wrapper (429/5xx backoff, 6 tries) and res.ok checks in gql() and liveHasMfr(), so
a transient throttle no longer kills the batch mid-run or makes liveHasMfr return
false on an error body (which would trigger a needless write). Re-runs resume
safely via the live idempotency read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit bb0a80a4696720532322f5a86a88a2092a34890b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 11:33:51 2026 -0700

    backfill-rwltd-mfr: namespace-scoped idempotency + HTTP status check & retry
    
    Two live-Shopify-path fixes on the 1,195-row backfill: (1) liveHasMfr now matches
    the EXACT target metafield custom.manufacturer_sku (namespace + key), not any
    namespace with that key — a same-keyed metafield elsewhere could previously mask a
    genuinely-missing custom one and get counted "already had"; (2) added a fetchRetry
    wrapper (429/5xx backoff, 6 tries) and res.ok checks in gql() and liveHasMfr(), so
    a transient throttle no longer kills the batch mid-run or makes liveHasMfr return
    false on an error body (which would trigger a needless write). Re-runs resume
    safely via the live idempotency read.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 backfill-rwltd-mfr.mjs | 147 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 147 insertions(+)

diff --git a/backfill-rwltd-mfr.mjs b/backfill-rwltd-mfr.mjs
new file mode 100644
index 0000000..a90b48a
--- /dev/null
+++ b/backfill-rwltd-mfr.mjs
@@ -0,0 +1,147 @@
+#!/usr/bin/env node
+/**
+ * TK-10066 follow-on — Reid Witlin (private-labeled "Architectural Fabrics")
+ * go-live-gate mfr# backfill.
+ *
+ * Discovered 2026-09-03 while verifying an unrelated fix (TK-11177): after a
+ * fleet-wide metafields re-sync fixed ~3 months of stale mirror data, the
+ * dw-golive-gate-canary live-verified 500 recently-created "Architectural
+ * Fabrics" products with NO custom.manufacturer_sku metafield at all — a real
+ * gap, not mirror lag. The batch-creation scripts under TK-10066 minted DW
+ * SKUs (DWKR-xxxxxx) and set shopify_product_id but never wrote the mfr#
+ * metafield.
+ *
+ * Source of truth for the value: rwltd_catalog.mfr_sku (the real Reid Witlin
+ * pattern/colorway slug, e.g. "no-chill-white"), exported to
+ * rwltd-mfr-mapping-clean.csv (1,195 rows — every catalog row with both a
+ * shopify_product_id and a source mfr_sku). Target metafield namespace/key
+ * matches every other vendor on the gate (custom.manufacturer_sku,
+ * single_line_text_field) — same pattern as the PJ backfill this was found
+ * alongside (~/Projects/designerwallcoverings/scripts/pj-mfr-backfill/).
+ *
+ * SAFETY / RAILS:
+ *   - DRY-RUN by default. Pass --apply to write to Shopify (customer-facing =
+ *     Steve-GATED; do NOT --apply without an approved memo).
+ *   - Idempotent: GETs each product's metafields first and SKIPs any that
+ *     already carry a non-empty manufacturer_sku.
+ *   - Records a rollback ledger (product_id -> had_before) so every write is
+ *     reversible.
+ *   - Batches of 25 with a >=90s gap between batches (DW bulk-push rule).
+ *   - Hard-fails on GraphQL top-level errors or userErrors (never a silent
+ *     no-op).
+ *
+ * COST: $0 (no AI). Pure Admin API GET + metafieldsSet.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dir = path.dirname(fileURLToPath(import.meta.url));
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const APPLY = process.argv.includes('--apply');
+const BATCH = 25;
+const GAP_MS = 90_000;
+const CSV = path.join(__dir, 'rwltd-mfr-mapping-clean.csv');
+const LEDGER = path.join(__dir, 'rwltd-mfr-rollback-ledger.jsonl');
+
+const TOKEN = (() => {
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+  return m.split('=').slice(1).join('=').replace(/["'\r ]/g, '');
+})();
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function parseCsv(txt) {
+  const [head, ...rows] = txt.trim().split('\n');
+  const cols = head.split(',');
+  return rows.map(line => {
+    const parts = line.split(',');
+    const rec = {};
+    cols.forEach((c, i) => (rec[c] = parts[i]));
+    return rec;
+  });
+}
+
+// Retry transient failures (429 rate-limit, 5xx) with backoff so a throttle
+// doesn't kill a 1,195-row live run mid-batch. Uses the existing sleep().
+async function fetchRetry(url, opts, tries = 0) {
+  const res = await fetch(url, opts);
+  if ((res.status === 429 || res.status >= 500) && tries < 6) {
+    await sleep(1500 * (tries + 1));
+    return fetchRetry(url, opts, tries + 1);
+  }
+  return res;
+}
+
+async function gql(query, variables) {
+  const res = await fetchRetry(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  if (!res.ok) throw new Error(`Shopify HTTP ${res.status} on graphql`);
+  const j = await res.json();
+  if (j.errors) throw new Error('GraphQL errors: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+async function liveHasMfr(numericId) {
+  const res = await fetchRetry(`https://${STORE}/admin/api/${API}/products/${numericId}/metafields.json`, {
+    headers: { 'X-Shopify-Access-Token': TOKEN },
+  });
+  // On a non-OK response we can't know the real state — throw rather than return
+  // false (returning false would trigger a needless/duplicate write). A re-run
+  // resumes safely because a written product then reads back as already-having it.
+  if (!res.ok) throw new Error(`Shopify HTTP ${res.status} reading metafields for ${numericId}`);
+  const d = await res.json();
+  // Match the EXACT target (custom.manufacturer_sku), not any namespace — a
+  // same-keyed metafield under another namespace must not mask a missing custom one.
+  return (d.metafields || []).some(m =>
+    m.namespace === 'custom' && m.key && m.key.toLowerCase() === 'manufacturer_sku' && (m.value || '').trim());
+}
+
+const SET = `mutation($mf:[MetafieldsSetInput!]!){
+  metafieldsSet(metafields:$mf){
+    metafields{ id namespace key value }
+    userErrors{ field message }
+  }
+}`;
+
+async function main() {
+  const recs = parseCsv(fs.readFileSync(CSV, 'utf8'));
+  const bad = recs.filter(r => !r.product_numeric_id || !r.new_manufacturer_sku);
+  if (bad.length) throw new Error(`${bad.length} rows missing product id or mfr# — aborting`);
+  console.log(`[${APPLY ? 'APPLY' : 'DRY-RUN'}] map=${path.basename(CSV)} — ${recs.length} Reid Witlin products to backfill custom.manufacturer_sku`);
+
+  let wrote = 0, skipped = 0, i = 0;
+  for (let b = 0; b < recs.length; b += BATCH) {
+    const batch = recs.slice(b, b + BATCH);
+    for (const r of batch) {
+      i++;
+      const pid = r.product_numeric_id;
+      const val = String(r.new_manufacturer_sku).trim();
+      const already = await liveHasMfr(pid);
+      if (already) { skipped++; continue; }
+      if (!APPLY) {
+        if (i <= 5 || i % 100 === 0) console.log(`  DRY ${r.dw_sku} (${pid}) -> custom.manufacturer_sku='${val}'`);
+        wrote++;
+        continue;
+      }
+      const data = await gql(SET, {
+        mf: [{ ownerId: `gid://shopify/Product/${pid}`, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: val }],
+      });
+      const ue = data.metafieldsSet.userErrors;
+      if (ue.length) throw new Error(`userErrors on ${pid}: ${JSON.stringify(ue)}`);
+      fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), product_id: pid, dw_sku: r.dw_sku, namespace: 'custom', key: 'manufacturer_sku', new_value: val, had_before: false }) + '\n');
+      wrote++;
+    }
+    console.log(`  batch ${b / BATCH + 1}: cumulative wrote=${wrote} skipped=${skipped}`);
+    if (APPLY && b + BATCH < recs.length) await sleep(GAP_MS);
+  }
+  console.log(`DONE. ${APPLY ? 'wrote' : 'would-write'}=${wrote} skipped(already-had)=${skipped} of ${recs.length}`);
+  if (APPLY) console.log('Next: re-run sync-shopify-metafields.js so the canary sees the metafield, then re-run dw-golive-gate-canary.');
+}
+main().catch(e => { console.error('FATAL', e); process.exit(1); });

← 97382b2 TK-11256: fix Reid Witlin/Architectural Fabrics onboarder dr  ·  back to Reid Witlin Onboarding  ·  (newest)