← back to Designerwallcoverings
TK-11186: A2 GMC-channel unpublisher (PJ-scoped, publication restore-map, reversible) + shop showroom_vendors metafield setter
e45ffc0d5f26e2e570d1d64538cba08570a09198 · 2026-09-03 12:02:03 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXf9gMQQWRfNex6EestZhp
Files touched
A scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjsA scripts/tk11186-showroom-hide/set-showroom-metafield.mjs
Diff
commit e45ffc0d5f26e2e570d1d64538cba08570a09198
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 12:02:03 2026 -0700
TK-11186: A2 GMC-channel unpublisher (PJ-scoped, publication restore-map, reversible) + shop showroom_vendors metafield setter
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXf9gMQQWRfNex6EestZhp
---
.../a2-gmc-channel-unpublish.mjs | 170 +++++++++++++++++++++
.../set-showroom-metafield.mjs | 30 ++++
2 files changed, 200 insertions(+)
diff --git a/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs b/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs
new file mode 100644
index 0000000..9dfc8af
--- /dev/null
+++ b/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs
@@ -0,0 +1,170 @@
+#!/usr/bin/env node
+/**
+ * TK-11186 STEP A2 — UNPUBLISH all active showroom-only (Phillip Jeffries) products
+ * from the Google & YouTube sales channel (Publication 29646651457 → MC 146735262),
+ * so a showroom-only line stops advertising on PAID Google Shopping. Durable removal:
+ * a feed/offer delete alone would NOT stick (Shopify re-syncs the channel) — the fix
+ * is publishableUnpublish on the channel itself.
+ *
+ * SCOPE — vendor Phillip Jeffries (read LIVE from the canonical showroom list, never
+ * hardcoded) on the GOOGLE publication ONLY. Never touches Online Store or any other
+ * channel, never archives/deletes, never changes prices/tags.
+ *
+ * REVERSIBLE (tk11061 pattern): the restore-map records the EXACT set of products that
+ * are currently PUBLISHED on the Google channel (wasPublished:true). Undo = re-publish
+ * only those via `--rollback`. Products already unpublished get no restore entry (so
+ * rollback never publishes something that wasn't published).
+ *
+ * MIRROR NOTE: this is a sales-CHANNEL publish-state write, not a catalog-column write —
+ * the dw_unified mirror has no per-publication publish-state column, so there is no
+ * "mirror-first" row to write; the restore-map IS the reversibility record.
+ *
+ * DEFAULT = --dry-run. --apply executes. --rollback re-publishes from the restore-map.
+ * Ledgers each batch to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl. Cost: $0.
+ *
+ * Usage:
+ * node a2-gmc-channel-unpublish.mjs # DRY-RUN: build+validate restore-map, no writes
+ * node a2-gmc-channel-unpublish.mjs --apply # unpublish PJ from Google channel
+ * node a2-gmc-channel-unpublish.mjs --rollback --apply --map out/a2-restore-map-latest.json
+ * flags: --batch N (250) --conc N (5) --gap MS (90000)
+ */
+import { gql } from '../lib/shopify.mjs';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const { showroomVendors } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT_DIR = path.join(__dirname, 'out');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+const LOG_EXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+
+const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube -> MC 146735262
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const argV = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const argN = (k, d) => { const i = args.indexOf(k); return i >= 0 ? parseInt(args[i + 1], 10) : d; };
+const BATCH = argN('--batch', 250), CONC = argN('--conc', 5), GAP = argN('--gap', 90000);
+const MAP_PATH = argV('--map', path.join(OUT_DIR, 'a2-restore-map-latest.json'));
+
+const vendors = showroomVendors();
+if (!vendors.length) { console.error('showroom list empty — abort'); process.exit(1); }
+const vendorClause = '(' + vendors.map(v => `vendor:'${v.replace(/'/g, "\\'")}'`).join(' OR ') + ')';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const stamp = () => new Date().toISOString();
+function ledger(rec) { try { execFileSync('node', [LOG_EXEC], { input: JSON.stringify(rec) }); } catch (e) { console.error(' [ledger WARN]', e.message); } }
+
+// ---- read live PJ + Google-channel publish state ----
+async function scanPublishState() {
+ const rows = []; let cur = null;
+ const Q = `query($q:String!,$c:String,$pub:ID!){
+ products(first:100, query:$q, after:$c){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ id handle vendor status publishedOnPublication(publicationId:$pub) }
+ }
+ }`;
+ while (true) {
+ const d = await gql(Q, { q: `${vendorClause} AND status:active`, c: cur, pub: GOOGLE_PUB });
+ if (d?.__err || !d?.products) throw new Error('scan failed: ' + JSON.stringify(d).slice(0, 200));
+ for (const n of d.products.nodes) rows.push({ id: n.id, handle: n.handle, vendor: n.vendor, wasPublished: !!n.publishedOnPublication });
+ if (!d.products.pageInfo.hasNextPage) break;
+ cur = d.products.pageInfo.endCursor;
+ }
+ return rows;
+}
+
+async function unpublishOne(id) {
+ const d = await gql(`mutation($id:ID!,$pubs:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$pubs){ userErrors{ field message } } }`,
+ { id, pubs: [{ publicationId: GOOGLE_PUB }] });
+ if (d?.__err) throw new Error('gql ' + JSON.stringify(d.__err).slice(0, 150));
+ const ue = d?.publishableUnpublish?.userErrors; if (ue && ue.length) throw new Error('userErrors ' + JSON.stringify(ue));
+}
+async function publishOne(id) {
+ const d = await gql(`mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{ field message } } }`,
+ { id, pubs: [{ publicationId: GOOGLE_PUB }] });
+ if (d?.__err) throw new Error('gql ' + JSON.stringify(d.__err).slice(0, 150));
+ const ue = d?.publishablePublish?.userErrors; if (ue && ue.length) throw new Error('userErrors ' + JSON.stringify(ue));
+}
+
+async function runBatched(ids, fn, label, undoNote, verifyNote) {
+ let done = 0, failed = 0;
+ for (let b = 0; b < ids.length; b += BATCH) {
+ const batch = ids.slice(b, b + BATCH);
+ for (let i = 0; i < batch.length; i += CONC) {
+ const res = await Promise.allSettled(batch.slice(i, i + CONC).map(fn));
+ res.forEach(r => r.status === 'fulfilled' ? done++ : (failed++, failed <= 10 && console.error(` [${label} FAIL]`, r.reason?.message)));
+ }
+ console.log(` ${label} progress: ${Math.min(b + BATCH, ids.length)}/${ids.length} (ok ${done}, fail ${failed})`);
+ ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11186',
+ action: `STEP A2 ${label} Google channel (pub 29646651457) batch [${b}..${Math.min(b + BATCH, ids.length)}) — ok ${done} fail ${failed}`,
+ blast_radius: batch.length, undo_cmd: undoNote, verify: verifyNote });
+ if (b + BATCH < ids.length) { console.log(` ...inter-batch gap ${GAP}ms`); await sleep(GAP); }
+ }
+ return { done, failed };
+}
+
+// ================= ROLLBACK =================
+if (ROLLBACK) {
+ const map = JSON.parse(fs.readFileSync(MAP_PATH, 'utf8'));
+ const ids = map.rows.filter(r => r.wasPublished).map(r => r.id);
+ console.log(`[${stamp()}] A2 ROLLBACK ${APPLY ? 'APPLY' : 'DRY-RUN'} — re-publish ${ids.length} PJ to Google channel from ${MAP_PATH}`);
+ if (!APPLY) { console.log(' (dry-run) re-run with --apply --rollback'); process.exit(0); }
+ const r = await runBatched(ids, publishOne, 'publishablePublish',
+ `re-unpublish via: node a2-gmc-channel-unpublish.mjs --apply`,
+ `PJ published on Google channel back to ${ids.length}`);
+ console.log(`[${stamp()}] ROLLBACK done — republished ok ${r.done} fail ${r.failed}`);
+ process.exit(r.failed ? 1 : 0);
+}
+
+// ================= FORWARD =================
+console.log(`[${stamp()}] TK-11186 STEP A2 — ${APPLY ? 'APPLY' : 'DRY-RUN'} | vendors ${JSON.stringify(vendors)} | pub ${GOOGLE_PUB}`);
+console.log(' scanning live PJ Google-channel publish state...');
+const rows = await scanPublishState();
+const published = rows.filter(r => r.wasPublished);
+console.log(` active PJ total: ${rows.length} | currently PUBLISHED on Google channel: ${published.length} | already off: ${rows.length - published.length}`);
+
+// write + validate restore-map BEFORE any write
+const map = { ticket: 'TK-11186', step: 'A2', generated_at: stamp(), publication: GOOGLE_PUB, vendors,
+ active_total: rows.length, published_count: published.length, rows };
+const outFile = path.join(OUT_DIR, `a2-restore-map-${stamp().replace(/[:.]/g, '-')}.json`);
+fs.writeFileSync(outFile, JSON.stringify(map, null, 2));
+fs.writeFileSync(path.join(OUT_DIR, 'a2-restore-map-latest.json'), JSON.stringify(map, null, 2));
+console.log(` restore-map written: ${outFile} (+ a2-restore-map-latest.json)`);
+
+// VALIDATE restore-map: non-empty, all rows PJ, spot-check 2 ids re-queried live
+if (!rows.length) { console.error(' [ABORT] 0 active PJ found — refuse to proceed'); process.exit(1); }
+const allPJ = rows.every(r => vendors.map(v => v.toLowerCase()).includes((r.vendor || '').toLowerCase()));
+if (!allPJ) { console.error(' [ABORT] restore-map contains a non-showroom vendor — scope breach, refuse'); process.exit(1); }
+const spot = published.slice(0, 2);
+for (const s of spot) {
+ const d = await gql(`query($id:ID!,$pub:ID!){ product(id:$id){ vendor publishedOnPublication(publicationId:$pub) } }`, { id: s.id, pub: GOOGLE_PUB });
+ const ok = d?.product?.vendor && vendors.map(v => v.toLowerCase()).includes(d.product.vendor.toLowerCase()) && d.product.publishedOnPublication === true;
+ console.log(` spot-check ${s.id}: vendor=${d?.product?.vendor} published=${d?.product?.publishedOnPublication} => ${ok ? 'OK' : 'MISMATCH'}`);
+ if (!ok) { console.error(' [ABORT] spot-check mismatch — restore-map not trustworthy, refuse'); process.exit(1); }
+}
+console.log(' restore-map VALIDATED (non-empty, all PJ, spot-checks passed).');
+
+if (!APPLY) {
+ console.log(`\nDRY-RUN — would publishableUnpublish ${published.length} PJ from Google channel (batch ${BATCH}, conc ${CONC}, gap ${GAP}ms).`);
+ console.log(' Re-run with --apply to execute.');
+ process.exit(0);
+}
+
+// ---- APPLY: unpublish only the currently-published set ----
+console.log(`\n[${stamp()}] APPLY — unpublishing ${published.length} PJ from Google channel`);
+const r = await runBatched(published.map(x => x.id), unpublishOne, 'publishableUnpublish',
+ `node ~/Projects/designerwallcoverings/scripts/tk11186-showroom-hide/a2-gmc-channel-unpublish.mjs --rollback --apply --map ${path.join(OUT_DIR, 'a2-restore-map-latest.json')}`,
+ `re-scan publishedOnPublication(29646651457) for PJ => expect 0 published`);
+
+// verify
+await sleep(2000);
+const after = await scanPublishState();
+const stillPub = after.filter(x => x.wasPublished).length;
+console.log(`\n[VERIFY] PJ still published on Google channel: ${stillPub} (expect 0)`);
+console.log(`[${stamp()}] A2 APPLY done — unpublished ok ${r.done} fail ${r.failed}`);
+process.exit((r.failed || stillPub) ? 1 : 0);
diff --git a/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs b/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs
new file mode 100644
index 0000000..fc30e83
--- /dev/null
+++ b/scripts/tk11186-showroom-hide/set-showroom-metafield.mjs
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+/**
+ * TK-11186 — set shop metafield custom.showroom_vendors from the canonical
+ * showroom-vendors.json, so the (Steve-deployed) theme suppression is data-driven
+ * instead of relying on the snippet's hardcoded fallback. Reversible: prior value
+ * recorded to out/showroom-metafield-restore.json; undo = re-set or delete.
+ * DEFAULT dry-run; --apply writes. $0.
+ */
+import { gql } from '../lib/shopify.mjs';
+import { createRequire } from 'node:module';
+import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
+const require = createRequire(import.meta.url);
+const { showroomVendors } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const APPLY = process.argv.includes('--apply');
+const VALUE = showroomVendors().join(','); // "Phillip Jeffries"
+const NS = 'custom', KEY = 'showroom_vendors', TYPE = 'single_line_text_field';
+
+const shop = await gql(`{ shop { id } }`);
+const shopId = shop?.shop?.id; if (!shopId) { console.error('no shop id', JSON.stringify(shop)); process.exit(1); }
+const cur = await gql(`query($id:ID!){ shop { metafield(namespace:"${NS}", key:"${KEY}"){ id value type } } }`, { id: shopId });
+const prior = cur?.shop?.metafield || null;
+fs.writeFileSync(path.join(__dirname, 'out', 'showroom-metafield-restore.json'),
+ JSON.stringify({ ticket: 'TK-11186', shopId, namespace: NS, key: KEY, prior_value: prior?.value ?? null, prior_type: prior?.type ?? null, new_value: VALUE }, null, 2));
+console.log(`shop metafield ${NS}.${KEY} — prior: ${prior ? JSON.stringify(prior.value) : '(unset)'} | new: ${JSON.stringify(VALUE)}`);
+if (!APPLY) { console.log('DRY-RUN — re-run with --apply'); process.exit(0); }
+const d = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id namespace key value } userErrors{ field message } } }`,
+ { mf: [{ ownerId: shopId, namespace: NS, key: KEY, type: TYPE, value: VALUE }] });
+const ue = d?.metafieldsSet?.userErrors; if (d?.__err || (ue && ue.length)) { console.error('ERR', JSON.stringify(d?.__err || ue)); process.exit(1); }
+console.log('SET ok:', JSON.stringify(d.metafieldsSet.metafields[0]));
← 9f550a9 TK-10372: turnkey fix+rollback for final 3 dup-image cross-l
·
back to Designerwallcoverings
·
sanderson verify: suppress deleted-tombstone false-positive df3ec4b →