← back to Reid Witlin Onboarding
backfill-rwltd-mfr.mjs
148 lines
#!/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); });