← back to Designerwallcoverings
TK-11307: GATED-0 applier — stamp exact ShowroomOnly tag on 4728 MDC products (tagsAdd, dry-run default, reversible restore map, resumable)
ce697b69aede2dca03bd8523d507482a14430ade · 2026-09-10 12:04:22 -0700 · vp-dw-commerce
Files touched
A scripts/tk11307-showroom-tag/apply-showroomonly.mjs
Diff
commit ce697b69aede2dca03bd8523d507482a14430ade
Author: vp-dw-commerce <steve@designerwallcoverings.com>
Date: Thu Sep 10 12:04:22 2026 -0700
TK-11307: GATED-0 applier — stamp exact ShowroomOnly tag on 4728 MDC products (tagsAdd, dry-run default, reversible restore map, resumable)
---
.../tk11307-showroom-tag/apply-showroomonly.mjs | 227 +++++++++++++++++++++
1 file changed, 227 insertions(+)
diff --git a/scripts/tk11307-showroom-tag/apply-showroomonly.mjs b/scripts/tk11307-showroom-tag/apply-showroomonly.mjs
new file mode 100644
index 0000000..30ac583
--- /dev/null
+++ b/scripts/tk11307-showroom-tag/apply-showroomonly.mjs
@@ -0,0 +1,227 @@
+#!/usr/bin/env node
+/**
+ * TK-11307 GATED-0 — stamp the EXACT tag `ShowroomOnly` on the 4,728 MDC product IDs
+ * so the TK-11186/TK-11307 theme hide (product-list-item.liquid + hide-browse-hidden.liquid)
+ * suppresses them from every browse/search grid. The theme hides on the EXACT downcased
+ * tag 'showroomonly' — so until this tag lands, GATED-1 (theme push) is a NO-OP.
+ *
+ * Authoritative ID list: ~/.claude/yolo-queue/pending-approval/TK-11305-pr-fleet-restore-map.json
+ * (4,728 entries; field `legacy` = numeric product id; 3,630 ACTIVE / 1,098 DRAFT; unique-verified.)
+ *
+ * SAFETY / DESIGN
+ * - tagsAdd ONLY (never tagsSet): additive + idempotent. Adding a tag that is already
+ * present is a Shopify no-op; other tags on the product are NEVER touched/clobbered.
+ * - EXACT tag string 'ShowroomOnly'. NEVER 'Showroom' (false-matches 18,518 'Showroom Line'
+ * sellable products) and NEVER 'Showroom Line'.
+ * - REVERSIBLE: a per-product pre-image restore map is written BEFORE any write. It records,
+ * for every target, whether 'ShowroomOnly' (any casing) was ALREADY present. --rollback
+ * removes the tag ONLY from products where we added it (had_tag=false) — so rollback can
+ * never strip a tag that pre-existed.
+ * - RESUMABLE: every applied product id is appended to data/applied-<runtag>.jsonl; a re-run
+ * skips ids already recorded, so a mid-run interruption resumes cleanly.
+ * - THROTTLING: uses the shared lib/shopify.mjs gql() (prefers SHOPIFY_FULL_ACCESS_TOKEN,
+ * THROTTLED backoff + cost-throttle sleep baked in). Writes are batched (aliased mutations)
+ * with a pause between batches.
+ *
+ * MODES
+ * (default) DRY-RUN — pre-scans live tags, reports target=4728, already-tagged, would-write.
+ * Writes NOTHING to Shopify. Still writes the restore-map (pre-image) so a
+ * subsequent --apply has an authoritative baseline; dry-run is safe/read-only on Shopify.
+ * --apply Pre-scan → write restore map → tagsAdd 'ShowroomOnly' on every target missing it.
+ * --rollback Read the newest restore map → tagsRemove 'ShowroomOnly' from products we added it to.
+ * --verify Count live products carrying the EXACT tag via query:"tag:ShowroomOnly"; compare to 4728.
+ *
+ * Cost: $0 (Admin API has no per-call charge).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql, SHOP } from '../lib/shopify.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(__dirname, 'data');
+const MAP_FILE = process.env.HOME + '/.claude/yolo-queue/pending-approval/TK-11305-pr-fleet-restore-map.json';
+const TAG = 'ShowroomOnly';
+const TAG_LC = TAG.toLowerCase();
+const LOG_EXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const VERIFY = args.includes('--verify');
+const READ_BATCH = 250; // nodes() max ids per query
+const WRITE_BATCH = 20; // aliased tagsAdd per gql call
+const BATCH_PAUSE_MS = 300;
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const ledger = rec => { try { execFileSync('node', [LOG_EXEC], { input: JSON.stringify(rec) }); } catch (e) { console.error(' [ledger WARN]', e.message); } };
+
+function loadTargets() {
+ const raw = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8'));
+ if (!Array.isArray(raw)) throw new Error('restore-map is not an array');
+ const seen = new Set();
+ const targets = [];
+ for (const e of raw) {
+ const legacy = String(e.legacy);
+ if (!/^\d+$/.test(legacy)) throw new Error('non-numeric legacy id: ' + legacy);
+ if (seen.has(legacy)) continue; // dedupe defensively
+ seen.add(legacy);
+ targets.push({ gid: `gid://shopify/Product/${legacy}`, legacy, status: e.status || '?' });
+ }
+ return targets;
+}
+
+async function prescan(targets) {
+ // Fetch current tags for every target so we know had_tag (reversibility) + missing count.
+ const byGid = new Map();
+ for (let i = 0; i < targets.length; i += READ_BATCH) {
+ const chunk = targets.slice(i, i + READ_BATCH);
+ const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id tags } } }`;
+ const d = await gql(q, { ids: chunk.map(t => t.gid) });
+ if (d?.__err) throw new Error('prescan gql err: ' + JSON.stringify(d.__err).slice(0, 300));
+ for (const n of (d.nodes || [])) {
+ if (!n) continue;
+ const tagsLc = (n.tags || []).map(t => String(t).trim().toLowerCase());
+ byGid.set(n.id, { had_tag: tagsLc.includes(TAG_LC), tags_before: n.tags || [] });
+ }
+ process.stdout.write(`\r prescan ${Math.min(i + READ_BATCH, targets.length)}/${targets.length}`);
+ }
+ process.stdout.write('\n');
+ return byGid;
+}
+
+function writeRestoreMap(targets, byGid, runtag) {
+ const rows = targets.map(t => {
+ const info = byGid.get(t.gid);
+ return {
+ gid: t.gid, legacy: t.legacy, status: t.status,
+ present_in_store: !!info,
+ had_tag: info ? info.had_tag : null, // null = product not found in store at prescan
+ tags_before: info ? info.tags_before : null,
+ };
+ });
+ const file = path.join(DATA, `restore-map-${runtag}.json`);
+ fs.writeFileSync(file, JSON.stringify({
+ ticket: 'TK-11307', tag: TAG, shop: SHOP, created_at: new Date().toISOString(),
+ source_id_list: MAP_FILE, target_count: targets.length, rows,
+ }, null, 2));
+ return { file, rows };
+}
+
+function newestRestoreMap() {
+ const files = fs.readdirSync(DATA).filter(f => /^restore-map-.*\.json$/.test(f)).sort();
+ if (!files.length) throw new Error('no restore-map found in data/ — run --apply (or dry-run) first');
+ return path.join(DATA, files[files.length - 1]);
+}
+
+async function batchTagOp(op, gids) {
+ // op = 'tagsAdd' | 'tagsRemove'; returns array of {gid, ok, err}
+ const results = [];
+ for (let i = 0; i < gids.length; i += WRITE_BATCH) {
+ const chunk = gids.slice(i, i + WRITE_BATCH);
+ const m = 'mutation{' + chunk.map((g, j) =>
+ `m${j}: ${op}(id:"${g}", tags:["${TAG}"]){ userErrors{ field message } }`).join(' ') + '}';
+ const d = await gql(m);
+ if (d?.__err) { chunk.forEach(g => results.push({ gid: g, ok: false, err: JSON.stringify(d.__err).slice(0, 200) })); }
+ else chunk.forEach((g, j) => {
+ const ue = d[`m${j}`]?.userErrors || [];
+ results.push({ gid: g, ok: ue.length === 0, err: ue.map(e => e.message).join('; ') || null });
+ });
+ process.stdout.write(`\r ${op} ${Math.min(i + WRITE_BATCH, gids.length)}/${gids.length}`);
+ await sleep(BATCH_PAUSE_MS);
+ }
+ process.stdout.write('\n');
+ return results;
+}
+
+async function doVerify(targets) {
+ // EXACT-tag live count. 'tag:ShowroomOnly' measures 0 collisions today (never 'tag:Showroom').
+ let count = 0;
+ const d = await gql(`query{ productsCount(query:"tag:'${TAG}'"){ count } }`);
+ if (d && !d.__err && d.productsCount) count = d.productsCount.count;
+ else {
+ // fallback: paginate
+ let after = null, more = true;
+ while (more) {
+ const q = `query($after:String){ products(first:250, query:"tag:'${TAG}'", after:$after){ pageInfo{hasNextPage endCursor} nodes{id} } }`;
+ const r = await gql(q, { after });
+ if (r?.__err) throw new Error('verify gql err: ' + JSON.stringify(r.__err).slice(0, 200));
+ count += r.products.nodes.length;
+ more = r.products.pageInfo.hasNextPage; after = r.products.pageInfo.endCursor;
+ }
+ }
+ console.log(`\n live products with EXACT tag '${TAG}': ${count}`);
+ console.log(` expected (target set): ${targets.length}`);
+ console.log(` ${count === targets.length ? 'MATCH ✓' : 'MISMATCH — investigate'}`);
+ return count;
+}
+
+(async () => {
+ const targets = loadTargets();
+ const active = targets.filter(t => t.status === 'ACTIVE').length;
+ const draft = targets.filter(t => t.status === 'DRAFT').length;
+ console.log(`TK-11307 GATED-0 apply '${TAG}' — ${ROLLBACK ? 'ROLLBACK' : VERIFY ? 'VERIFY' : APPLY ? 'APPLY' : 'DRY-RUN'} → ${SHOP}`);
+ console.log(` target set: ${targets.length} (ACTIVE ${active} / DRAFT ${draft})`);
+
+ if (VERIFY) { await doVerify(targets); return; }
+
+ if (ROLLBACK) {
+ const file = newestRestoreMap();
+ console.log(` reading restore map: ${file}`);
+ const map = JSON.parse(fs.readFileSync(file, 'utf8'));
+ // remove ONLY from products we added it to (had_tag === false)
+ const toRemove = map.rows.filter(r => r.present_in_store && r.had_tag === false).map(r => r.gid);
+ const preserved = map.rows.filter(r => r.had_tag === true).length;
+ console.log(` will tagsRemove '${TAG}' from ${toRemove.length} products (preserving ${preserved} that had it before).`);
+ if (!APPLY) { console.log('\n ROLLBACK is a preview unless combined with --apply. Re-run: --rollback --apply'); return; }
+ const res = await batchTagOp('tagsRemove', toRemove);
+ const fails = res.filter(r => !r.ok);
+ console.log(` removed OK: ${res.length - fails.length} failed: ${fails.length}`);
+ if (fails.length) fs.writeFileSync(path.join(DATA, `rollback-failures-${Date.now()}.json`), JSON.stringify(fails, null, 2));
+ ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11307',
+ action: `ROLLBACK tagsRemove '${TAG}' from ${res.length - fails.length} products`, blast_radius: toRemove.length,
+ undo_cmd: `node ${path.join(__dirname, 'apply-showroomonly.mjs')} --apply`, verify: `--verify` });
+ return;
+ }
+
+ // DRY-RUN or APPLY: prescan → restore map → (apply)
+ const runtag = new Date().toISOString().replace(/[:.]/g, '-');
+ console.log(' prescanning live tags (reversibility baseline)...');
+ const byGid = await prescan(targets);
+ const found = byGid.size;
+ const alreadyTagged = [...byGid.values()].filter(v => v.had_tag).length;
+ const missing = targets.filter(t => byGid.has(t.gid) && !byGid.get(t.gid).had_tag);
+ const notFound = targets.length - found;
+ const { file } = writeRestoreMap(targets, byGid, runtag);
+ console.log(` restore map written: ${file}`);
+ console.log(` found in store: ${found} not found: ${notFound}`);
+ console.log(` already carry '${TAG}': ${alreadyTagged}`);
+ console.log(` WOULD tagsAdd '${TAG}' on: ${missing.length}`);
+
+ if (!APPLY) {
+ console.log(`\nDRY-RUN only — no Shopify writes. Re-run with --apply to stamp the tag.`);
+ return;
+ }
+
+ // resumability: skip ids already recorded as applied this runtag lineage
+ const appliedFile = path.join(DATA, `applied-${runtag}.jsonl`);
+ const alreadyApplied = new Set();
+ for (const f of fs.readdirSync(DATA).filter(f => /^applied-.*\.jsonl$/.test(f))) {
+ for (const line of fs.readFileSync(path.join(DATA, f), 'utf8').split('\n').filter(Boolean)) {
+ try { const r = JSON.parse(line); if (r.ok) alreadyApplied.add(r.gid); } catch {}
+ }
+ }
+ const toApply = missing.filter(t => !alreadyApplied.has(t.gid)).map(t => t.gid);
+ console.log(` applying to ${toApply.length} (skipping ${missing.length - toApply.length} already-applied from prior run)`);
+ const res = await batchTagOp('tagsAdd', toApply);
+ const okRes = res.filter(r => r.ok), fails = res.filter(r => !r.ok);
+ fs.appendFileSync(appliedFile, res.map(r => JSON.stringify({ ...r, ts: Date.now() })).join('\n') + '\n');
+ console.log(` tagged OK: ${okRes.length} failed: ${fails.length}`);
+ if (fails.length) { const ff = path.join(DATA, `apply-failures-${runtag}.json`); fs.writeFileSync(ff, JSON.stringify(fails, null, 2)); console.log(` failures → ${ff} (re-run --apply to resume)`); }
+ ledger({ agent: process.env.TK_AGENT || 'vp-dw-commerce', ticket: 'TK-11307',
+ action: `tagsAdd '${TAG}' on ${okRes.length} MDC products (GATED-0)`, blast_radius: toApply.length,
+ undo_cmd: `node ${path.join(__dirname, 'apply-showroomonly.mjs')} --rollback --apply`,
+ verify: `node ${path.join(__dirname, 'apply-showroomonly.mjs')} --verify` });
+ console.log(`\nAPPLY complete. Verify: node apply-showroomonly.mjs --verify`);
+})().catch(e => { console.error('\nFATAL', e.message); process.exit(1); });
← 24d79fd auto-data-snapshot: 2026-09-10T11:43:04 (3 data files) — dat
·
back to Designerwallcoverings
·
chore: lint, refactor, v0.1.13 (session close) — TK-10895 b319c8f →