← back to Mfr Recovery 2026 08 23
TK-10827: durable two-surface mfr push (Kamatera DB + Shopify) + companion rollback; dry-run default, restore-map
d390b67ee6085a9e28ba302cf7a2f8240c72ec6d · 2026-08-24 22:04:23 -0700 · Steve Abrams
Files touched
A durable-push-mfr.mjsA rollback-durable-push.mjs
Diff
commit d390b67ee6085a9e28ba302cf7a2f8240c72ec6d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 22:04:23 2026 -0700
TK-10827: durable two-surface mfr push (Kamatera DB + Shopify) + companion rollback; dry-run default, restore-map
---
durable-push-mfr.mjs | 193 ++++++++++++++++++++++++++++++++++++++++++++++
rollback-durable-push.mjs | 60 ++++++++++++++
2 files changed, 253 insertions(+)
diff --git a/durable-push-mfr.mjs b/durable-push-mfr.mjs
new file mode 100644
index 0000000..92269e5
--- /dev/null
+++ b/durable-push-mfr.mjs
@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+/**
+ * durable-push-mfr.mjs — TK-10827 (follow-up to TK-10677)
+ * THE durable mfr_sku recovery push: writes BOTH authoritative surfaces so the
+ * recovery does NOT revert on the next Kamatera→Mac2 sync.
+ *
+ * Surface 1 (canonical, remote): Kamatera dw_unified `shopify_products.mfr_sku`
+ * — via ssh root@45.61.58.125 psql, one transaction, per-row.
+ * Surface 2 (customer-facing): Shopify product metafields
+ * custom.manufacturer_sku + dwc.manufacturer_sku (single_line_text_field).
+ *
+ * ORDER = Kamatera DB THEN Shopify (PostgreSQL-before-Shopify). The Mac2 mirror
+ * re-syncs FROM Kamatera, so we deliberately never write the Mac2 mirror here
+ * (that was the NON-durable bug: a mirror-only write reverts on next sync).
+ *
+ * SAFETY / RAILS:
+ * - DEFAULT is --dry-run: reads live state on BOTH surfaces, prints the plan,
+ * writes NOTHING. You must pass --apply to write.
+ * - --apply is a HARD-GATED, customer-facing + Kamatera-canonical write. Do NOT
+ * run it without Steve's approval (the harness classifier also blocks the ssh/
+ * Shopify write). See pending-approval/2026-08-25-TK-10827-durable-push-memo.md.
+ * - Every applied row records an old->new restore-map to
+ * out/durable-restore-<ts>.jsonl (both surfaces). Companion rollback:
+ * node rollback-durable-push.mjs out/durable-restore-<ts>.jsonl [--apply]
+ * - Idempotent: rows already correct on a surface are SKIPped, not rewritten.
+ * - Bounded: only the enumerated recovered rows (24 Zoffany, verified) are touched.
+ * - NEVER touches variants, price, status, images — mfr identity only.
+ *
+ * Usage:
+ * node durable-push-mfr.mjs # DRY-RUN (default), all vendors
+ * node durable-push-mfr.mjs --vendor=zoffany # scope (zoffany|novasuede|all)
+ * node durable-push-mfr.mjs --surface=shopify # scope (db|shopify|both, default both)
+ * node durable-push-mfr.mjs --apply # GATED write to both surfaces
+ *
+ * Novasuede note: the only "recoverable" Novasuede row resolves to a STRIPPED
+ * HANDLE (novasuede™-mist), not a real vendor code. It is EXCLUDED by default
+ * (writing a handle-string into mfr_sku is worse than blank). Pass
+ * --allow-novasuede-handle to include it anyway (still gated).
+ */
+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 '../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 ALLOW_NOVA_HANDLE = process.argv.includes('--allow-novasuede-handle');
+const arg = (k, d) => (process.argv.find(a => a.startsWith(`--${k}=`)) || `--${k}=${d}`).split('=')[1].toLowerCase();
+const vendorArg = arg('vendor', 'all');
+const surfaceArg = arg('surface', 'both'); // db | shopify | both
+const KAM = 'root@45.61.58.125';
+
+const NS = [
+ { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field' },
+ { namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field' },
+];
+
+// ── recovered rows (id, handle, mfr_sku, vendor) ────────────────────────────────
+// Zoffany: from the VERIFIED CSV (24 real Z####NNNNNN mfr codes; live-join confirmed 0 drift).
+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 ii = cols.indexOf('shopify_id'), hi = cols.indexOf('handle'), mi = cols.indexOf('mfr_sku');
+ return rows.map(l => l.split(',')).map(c => ({
+ vendor: 'Zoffany', id: Number(c[ii]), handle: c[hi], mfr_sku: c[mi],
+ })).filter(r => r.id && r.handle && r.mfr_sku);
+}
+
+// Novasuede: derived LIVE from the Mac2 mirror so we never push a stale set.
+// Excluded by default because the only match is a stripped handle, not a real code.
+function loadNovasuede() {
+ if (!ALLOW_NOVA_HANDLE) return [];
+ const sql = `
+ WITH stripped AS (
+ SELECT id, 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.id || '\t' || s.handle || '\t' || 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 [id, handle, mfr_sku] = l.split('\t');
+ return { vendor: 'Novasuede', id: Number(id), handle, mfr_sku };
+ });
+}
+
+// ── Kamatera DB surface (canonical shopify_products.mfr_sku) ─────────────────────
+function kamRead(ids) {
+ // returns {id: mfr_sku_or_empty} straight from Kamatera-canonical shopify_products
+ if (!ids.length) return {};
+ const sql = `SELECT id||'\t'||coalesce(mfr_sku,'') FROM shopify_products WHERE id IN (${ids.join(',')});`;
+ const out = execFileSync('ssh', [KAM, `psql -d dw_unified -tA -c "${sql.replace(/"/g, '\\"')}"`], { encoding: 'utf8' });
+ const m = {};
+ out.trim().split('\n').filter(Boolean).forEach(l => { const [id, v] = l.split('\t'); m[id] = v; });
+ return m;
+}
+function kamWrite(rows) {
+ // one transaction; per-row UPDATE keyed by id (id is safe/stable, no regex on Kamatera)
+ const stmts = rows.map(r =>
+ `UPDATE shopify_products SET mfr_sku='${r.mfr_sku.replace(/'/g, "''")}' WHERE id=${r.id} AND vendor='${r.vendor}';`
+ );
+ const body = `BEGIN; ${stmts.join(' ')} COMMIT;`;
+ execFileSync('ssh', [KAM, `psql -d dw_unified -c "${body.replace(/"/g, '\\"')}"`], { encoding: 'utf8', stdio: 'pipe' });
+}
+
+// ── Shopify surface ─────────────────────────────────────────────────────────────
+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());
+
+ const doDB = surfaceArg === 'db' || surfaceArg === 'both';
+ const doSH = surfaceArg === 'shopify' || surfaceArg === 'both';
+
+ console.log(`Store ${SHOP} · API ${VER} · token …${TOKEN.slice(-4)} · Kamatera ${KAM}`);
+ console.log(`scope: vendor=${vendorArg} surface=${surfaceArg} · ${items.length} recovered rows · ${APPLY ? 'APPLY (GATED)' : 'DRY-RUN (default)'}\n`);
+ if (!items.length) { console.log('No recovered rows for this scope. Nothing to do.'); return; }
+
+ const restore = APPLY ? fs.openSync(path.join(OUT, `durable-restore-${Date.now()}.jsonl`), 'a') : null;
+ const c = { dbPlan: 0, dbWrote: 0, dbSkip: 0, shPlan: 0, shWrote: 0, shSkip: 0, notFound: 0, errs: 0 };
+
+ // ── Surface 1: Kamatera DB (canonical) — do first (PG-before-Shopify) ──────────
+ if (doDB) {
+ let live;
+ try { live = kamRead(items.map(i => i.id)); }
+ catch (e) { console.error(`Kamatera read FAILED (ssh/psql): ${String(e.message || e).slice(0, 200)}`); process.exit(2); }
+ const need = [];
+ for (const it of items) {
+ const cur = live[String(it.id)];
+ if (cur === undefined) { console.error(` ✗ DB NOT-FOUND id=${it.id} ${it.handle}`); c.notFound++; continue; }
+ if (cur === it.mfr_sku) { console.log(` = DB SKIP id=${it.id} ${it.mfr_sku}`); c.dbSkip++; continue; }
+ console.log(` ${APPLY ? '→ DB SET' : '· DB PLAN'} id=${it.id} ${it.mfr_sku} (was '${cur}')`);
+ c.dbPlan++;
+ it._dbOld = cur; need.push(it);
+ }
+ if (APPLY && need.length) {
+ try {
+ kamWrite(need);
+ for (const it of need) {
+ fs.writeSync(restore, JSON.stringify({ ts: new Date().toISOString(), surface: 'kamatera_db',
+ id: it.id, handle: it.handle, vendor: it.vendor, old: it._dbOld ?? '', new: it.mfr_sku }) + '\n');
+ c.dbWrote++;
+ }
+ } catch (e) { console.error(` ⚠ DB WRITE FAILED (txn rolled back): ${String(e.message || e).slice(0, 200)}`); c.errs++; }
+ }
+ }
+
+ // ── Surface 2: Shopify metafields ─────────────────────────────────────────────
+ if (doSH) {
+ for (const it of items) {
+ const p = await productByHandle(it.handle);
+ if (!p) { console.error(` ✗ SH NOT-FOUND ${it.handle}`); c.notFound++; continue; }
+ const ex = Object.fromEntries((p.metafields?.nodes || []).map(m => [`${m.namespace}.${m.key}`, m.value]));
+ if (NS.every(t => ex[`${t.namespace}.${t.key}`] === it.mfr_sku)) {
+ console.log(` = SH SKIP ${it.handle} ${it.mfr_sku}`); c.shSkip++; continue;
+ }
+ console.log(` ${APPLY ? '→ SH SET' : '· SH PLAN'} ${it.handle} ${it.mfr_sku} (was custom=${ex['custom.manufacturer_sku'] ?? '∅'} dwc=${ex['dwc.manufacturer_sku'] ?? '∅'})`);
+ c.shPlan++;
+ 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(` ⚠ SH error ${it.handle}: ${JSON.stringify(ue).slice(0, 180)}`); c.errs++; continue; }
+ fs.writeSync(restore, JSON.stringify({ ts: new Date().toISOString(), surface: 'shopify_metafield',
+ ownerId: p.id, handle: it.handle, vendor: it.vendor,
+ set: NS.map(t => ({ ns: t.namespace, key: t.key, old: ex[`${t.namespace}.${t.key}`] ?? null, new: it.mfr_sku })) }) + '\n');
+ c.shWrote++;
+ }
+ }
+
+ if (restore) fs.closeSync(restore);
+ console.log(`\nDone. DB(plan=${c.dbPlan} wrote=${c.dbWrote} skip=${c.dbSkip}) SH(plan=${c.shPlan} wrote=${c.shWrote} skip=${c.shSkip}) not-found=${c.notFound} errors=${c.errs}`);
+ console.log(APPLY ? `restore-map written to out/ · rollback: node rollback-durable-push.mjs <that-file> --apply`
+ : `DRY-RUN — add --apply (GATED, needs Steve's go) to write both surfaces.`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/rollback-durable-push.mjs b/rollback-durable-push.mjs
new file mode 100644
index 0000000..3ef46ce
--- /dev/null
+++ b/rollback-durable-push.mjs
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+/**
+ * rollback-durable-push.mjs — TK-10827 companion rollback for durable-push-mfr.mjs
+ *
+ * Reads a restore-map produced by durable-push-mfr.mjs --apply
+ * (out/durable-restore-<ts>.jsonl) and REVERSES each recorded write on BOTH surfaces,
+ * restoring every field to its `old` value:
+ * - surface=kamatera_db → ssh psql UPDATE shopify_products SET mfr_sku=<old> WHERE id=..
+ * - surface=shopify_metafield→ metafieldsSet back to <old>, or metafieldDelete when old is null
+ *
+ * DEFAULT is --dry-run: prints exactly what it would revert, writes nothing.
+ *
+ * Usage:
+ * node rollback-durable-push.mjs out/durable-restore-1756...jsonl # DRY-RUN
+ * node rollback-durable-push.mjs out/durable-restore-1756...jsonl --apply # GATED revert
+ */
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../designerwallcoverings/scripts/lib/shopify.mjs';
+
+const file = process.argv[2];
+const APPLY = process.argv.includes('--apply');
+const KAM = 'root@45.61.58.125';
+if (!file || !fs.existsSync(file)) { console.error('usage: node rollback-durable-push.mjs <restore-map.jsonl> [--apply]'); process.exit(1); }
+
+const recs = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+const dbRecs = recs.filter(r => r.surface === 'kamatera_db');
+const shRecs = recs.filter(r => r.surface === 'shopify_metafield');
+console.log(`Rollback ${file} · db=${dbRecs.length} shopify=${shRecs.length} · ${APPLY ? 'APPLY (GATED)' : 'DRY-RUN'}\n`);
+
+// ── Kamatera DB revert (reverse order is irrelevant; keyed by id) ────────────────
+if (dbRecs.length) {
+ for (const r of dbRecs) console.log(` ${APPLY ? '→ DB REVERT' : '· DB PLAN'} id=${r.id} '${r.new}' -> '${r.old}'`);
+ if (APPLY) {
+ const stmts = dbRecs.map(r => `UPDATE shopify_products SET mfr_sku='${String(r.old ?? '').replace(/'/g, "''")}' WHERE id=${r.id} AND vendor='${r.vendor}';`);
+ const body = `BEGIN; ${stmts.join(' ')} COMMIT;`;
+ try { execFileSync('ssh', [KAM, `psql -d dw_unified -c "${body.replace(/"/g, '\\"')}"`], { encoding: 'utf8' }); console.log(' DB revert txn committed.'); }
+ catch (e) { console.error(` ⚠ DB revert FAILED: ${String(e.message || e).slice(0, 200)}`); }
+ }
+}
+
+// ── Shopify metafield revert ─────────────────────────────────────────────────────
+async function shopRevert() {
+ for (const r of shRecs) {
+ for (const s of r.set) {
+ console.log(` ${APPLY ? '→ SH REVERT' : '· SH PLAN'} ${r.handle} ${s.ns}.${s.key} '${s.new}' -> ${s.old === null ? 'DELETE' : `'${s.old}'`}`);
+ if (!APPLY) continue;
+ if (s.old === null) {
+ await gql(`mutation($mf:[MetafieldIdentifierInput!]!){ metafieldsDelete(metafields:$mf){ userErrors{ message } } }`,
+ { mf: [{ ownerId: r.ownerId, namespace: s.ns, key: s.key }] });
+ } else {
+ await gql(`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ message } } }`,
+ { m: [{ ownerId: r.ownerId, namespace: s.ns, key: s.key, type: 'single_line_text_field', value: s.old }] });
+ }
+ }
+ }
+}
+
+await shopRevert();
+console.log(`\n${APPLY ? 'Rollback complete.' : 'DRY-RUN — add --apply (GATED) to revert.'}`);
← 31981a9 TK-10677: add push-mfr-to-shopify.mjs (dry-run default; sets
·
back to Mfr Recovery 2026 08 23
·
auto-data-snapshot: 2026-08-25T07:39:06 (3 data files) — zof 8836003 →