← 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)