[object Object]

← 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

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 →