← back to Mfr Recovery 2026 08 23
TK-10677: add push-mfr-to-shopify.mjs (dry-run default; sets custom.+dwc.manufacturer_sku; restore-map; reversible)
31981a9cf64b36eb8586df311cd603fb0625d348 · 2026-08-24 21:21:09 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A push-mfr-to-shopify.mjs
Diff
commit 31981a9cf64b36eb8586df311cd603fb0625d348
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 21:21:09 2026 -0700
TK-10677: add push-mfr-to-shopify.mjs (dry-run default; sets custom.+dwc.manufacturer_sku; restore-map; reversible)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
push-mfr-to-shopify.mjs | 119 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 119 insertions(+)
diff --git a/push-mfr-to-shopify.mjs b/push-mfr-to-shopify.mjs
new file mode 100644
index 0000000..5de1301
--- /dev/null
+++ b/push-mfr-to-shopify.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+/**
+ * push-mfr-to-shopify.mjs — TK-10677 durable mfr_sku recovery, Shopify side.
+ *
+ * Reads the recovered {handle -> mfr_sku} maps for Zoffany (24, from the verified CSV)
+ * and Novasuede (recoverable set, derived live from dw_unified), resolves each handle
+ * to its live Product GID, and SETS the manufacturer-SKU metafields:
+ * custom.manufacturer_sku (single_line_text_field)
+ * dwc.manufacturer_sku (single_line_text_field)
+ * — the canonical DW mfr-metafield pattern (see knoll-onboard/build-payloads.mjs metafields()).
+ *
+ * DEFAULT is --dry-run: resolves handles, prints the plan, sets NOTHING.
+ * node push-mfr-to-shopify.mjs # DRY-RUN (default): resolve + plan
+ * node push-mfr-to-shopify.mjs --vendor=zoffany # scope to one vendor (zoffany|novasuede|all)
+ * node push-mfr-to-shopify.mjs --apply # GATED: actually write metafields
+ *
+ * Idempotent + reversible: on --apply it records a restore-map (ownerId, ns.key, old->new)
+ * to out/mfr-push-restore-<ts>.jsonl so every write can be reverted. Never touches variants,
+ * price, status, or images — metafields only.
+ *
+ * HARD RAIL: this is a customer-facing Shopify write. Do NOT run --apply without Steve's
+ * approval (see pending-approval/2026-08-25-TK-10677-durable-recovery-RUNBOOK.md).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql, SHOP, VER, TOKEN } from '../../Projects/designerwallcoverings/scripts/lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+fs.mkdirSync(OUT, { recursive: true });
+
+const APPLY = process.argv.includes('--apply');
+const vendorArg = (process.argv.find(a => a.startsWith('--vendor=')) || '--vendor=all').split('=')[1].toLowerCase();
+
+const NS = [
+ { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field' },
+ { namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field' },
+];
+
+// ── recovered maps ────────────────────────────────────────────────────────────
+// Zoffany: from the verified CSV (24 real ZxxxNNNNNN mfr codes).
+function loadZoffany() {
+ const csv = path.join(HERE, 'zoffany_24_verified_20260824.csv');
+ if (!fs.existsSync(csv)) return [];
+ const [head, ...rows] = fs.readFileSync(csv, 'utf8').trim().split('\n');
+ const cols = head.split(',');
+ const hi = cols.indexOf('handle'), mi = cols.indexOf('mfr_sku');
+ return rows.map(l => l.split(',')).map(c => ({ vendor: 'Zoffany', handle: c[hi], mfr_sku: c[mi] }))
+ .filter(r => r.handle && r.mfr_sku);
+}
+
+// Novasuede: derive live from dw_unified so we never push stale rows.
+// (The old SQL claimed 14; current truth = 1 recoverable. Derive, don't hardcode.)
+function loadNovasuede() {
+ const sql = `
+ WITH stripped AS (
+ SELECT handle, regexp_replace(handle, '-luxury-suede.*|(-fabric-wallcovering)', '', 'g') AS sh
+ FROM shopify_products
+ WHERE vendor='Novasuede' AND status='ACTIVE' AND (mfr_sku IS NULL OR mfr_sku='')
+ )
+ SELECT s.handle || '|' || nc.mfr_sku
+ FROM stripped s JOIN novasuede_catalog nc ON nc.mfr_sku = s.sh;`;
+ let out;
+ try {
+ out = execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tA', '-c', sql], { encoding: 'utf8' });
+ } catch { return []; }
+ return out.trim().split('\n').filter(Boolean).map(l => {
+ const [handle, mfr_sku] = l.split('|');
+ return { vendor: 'Novasuede', handle, mfr_sku };
+ });
+}
+
+async function productByHandle(handle) {
+ const q = `query($h:String!){ productByHandle(handle:$h){ id title status
+ metafields(first:50){ nodes { namespace key value } } } }`;
+ const d = await gql(q, { h: handle });
+ return d?.productByHandle || null;
+}
+
+async function main() {
+ let items = [];
+ if (vendorArg === 'all' || vendorArg === 'zoffany') items = items.concat(loadZoffany());
+ if (vendorArg === 'all' || vendorArg === 'novasuede') items = items.concat(loadNovasuede());
+
+ console.log(`Store ${SHOP} · API ${VER} · token …${TOKEN.slice(-4)} · scope=${vendorArg} · ${items.length} recovered rows · ${APPLY ? 'APPLY' : 'DRY-RUN (default)'}`);
+ if (!items.length) { console.log('No recovered rows for this scope. Nothing to do.'); return; }
+
+ const restoreFd = APPLY ? fs.openSync(path.join(OUT, `mfr-push-restore-${Date.now()}.jsonl`), 'a') : null;
+ let planned = 0, wrote = 0, skipped = 0, notFound = 0, errs = 0;
+
+ for (const it of items) {
+ const p = await productByHandle(it.handle);
+ if (!p) { console.error(` ✗ NOT-FOUND ${it.vendor} ${it.handle}`); notFound++; continue; }
+ const existing = Object.fromEntries((p.metafields?.nodes || []).map(m => [`${m.namespace}.${m.key}`, m.value]));
+ // already-correct?
+ const already = NS.every(t => existing[`${t.namespace}.${t.key}`] === it.mfr_sku);
+ if (already) { console.log(` = SKIP(set) ${it.handle} ${it.mfr_sku}`); skipped++; continue; }
+
+ console.log(` ${APPLY ? '→ SET' : '· PLAN'} ${it.handle} ${it.mfr_sku} (was custom=${existing['custom.manufacturer_sku'] ?? '∅'} dwc=${existing['dwc.manufacturer_sku'] ?? '∅'})`);
+ planned++;
+ if (!APPLY) continue;
+
+ const mf = NS.map(t => ({ ownerId: p.id, namespace: t.namespace, key: t.key, type: t.type, value: it.mfr_sku }));
+ const r = await gql(`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`, { m: mf });
+ const ue = r?.metafieldsSet?.userErrors || r?.__err || [];
+ if (ue.length) { console.error(` ⚠ error ${it.handle}: ${JSON.stringify(ue).slice(0,180)}`); errs++; continue; }
+ fs.writeSync(restoreFd, JSON.stringify({
+ ts: new Date().toISOString(), ownerId: p.id, handle: it.handle, vendor: it.vendor,
+ set: NS.map(t => ({ ns: t.namespace, key: t.key, old: existing[`${t.namespace}.${t.key}`] ?? null, new: it.mfr_sku })),
+ }) + '\n');
+ wrote++;
+ }
+ if (restoreFd) fs.closeSync(restoreFd);
+ console.log(`\nDone. planned=${planned} wrote=${wrote} skipped=${skipped} not-found=${notFound} errors=${errs}${APPLY ? ` · restore-map in out/` : ' · DRY-RUN — add --apply (GATED) to write'}`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
← fc2116f TK-10677: verify 24 Zoffany recoverable (/bin/zsh join), mar
·
back to Mfr Recovery 2026 08 23
·
TK-10827: durable two-surface mfr push (Kamatera DB + Shopif d390b67 →