[object Object]

← back to Designerwallcoverings

TK-11564: phased title_tag delete executor (dry-default, race-safe, reversible)

2fbec9081d8f6d8cdcaaef421c42b2f48778a7fe · 2026-09-13 14:59:49 -0700 · Steve Abrams

Deletes corrupt global.title_tag by class (B-generic / C template-prefix) so Shopify
falls back to the real product title. --apply is GATED. Live-verifies each product
still carries the corrupt value before deleting (skips already-gone / manual fixes),
asserts fallback title non-empty, ledgers a per-product rollback map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2fbec9081d8f6d8cdcaaef421c42b2f48778a7fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 14:59:49 2026 -0700

    TK-11564: phased title_tag delete executor (dry-default, race-safe, reversible)
    
    Deletes corrupt global.title_tag by class (B-generic / C template-prefix) so Shopify
    falls back to the real product title. --apply is GATED. Live-verifies each product
    still carries the corrupt value before deleting (skips already-gone / manual fixes),
    asserts fallback title non-empty, ledgers a per-product rollback map.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/tk11564-titletag-fix.mjs | 84 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 84 insertions(+)

diff --git a/scripts/tk11564-titletag-fix.mjs b/scripts/tk11564-titletag-fix.mjs
new file mode 100644
index 0000000..05e846a
--- /dev/null
+++ b/scripts/tk11564-titletag-fix.mjs
@@ -0,0 +1,84 @@
+// TK-11564 phased title_tag corruption FIX (delete global.title_tag -> Shopify falls back to real title).
+// DRY-RUN BY DEFAULT. --apply is the GATED live customer-facing write (Steve go only).
+// Re-derives A/B/C from the sweep snapshot, then LIVE-verifies each product before acting:
+//   - confirms the corrupt title_tag is STILL present (skip if changed since sweep = don't clobber a manual fix)
+//   - asserts the real product title is non-empty/clean (fallback safety)
+// On --apply: metafieldsDelete + append each to the executed-reversible ledger (rollback = re-set saved value).
+import { gql } from './lib/shopify.mjs';
+import fs from 'node:fs';
+
+const args = process.argv.slice(2);
+const PHASE = (args.find(a=>a.startsWith('--phase='))||'--phase=A').split('=')[1]; // A|B|C|all
+const APPLY = args.includes('--apply');
+const ALL_STATUSES = args.includes('--all-statuses'); // default: ACTIVE only
+
+const snap = JSON.parse(fs.readFileSync('/tmp/tk11564_titletag_sweep.json','utf8')).rows;
+const norm = s => (s||'').toLowerCase().replace(/[^a-z0-9 ]+/g,' ').replace(/\s+/g,' ').trim();
+const stop = new Set(['the','and','by','for','of','a','an','walls','wallcovering','wallpaper','fabric','vinyl','velvet','sample','samples','purchasing','available','free','designer','wallcoverings','collection','color','colour']);
+const sig = s => norm(s).split(' ').filter(w=>w.length>2 && !stop.has(w));
+const prefixRe=/^Free Samples & Purchasing Available for\s+(.+?)\s+By\s+/i;
+const reGeneric=/Samples and Purchasing at Designer Wallcoverings/i, reAuth=/Authorized Dealer of .* Samples and Purchasing/i;
+function classify(r){
+  const pm=r.seo_title_tag.match(prefixRe);
+  if(pm){ const pw=sig(pm[1]); const hay=norm(r.real_title)+' '+norm(r.handle);
+    const ov=pw.length?pw.filter(w=>hay.includes(w)).length:1; return ov===0?'A':'C'; }
+  if(reGeneric.test(r.seo_title_tag)||reAuth.test(r.seo_title_tag)) return 'B';
+  return null;
+}
+let set = snap.map(r=>({...r,cls:classify(r)})).filter(r=>r.cls);
+if(PHASE!=='all') set = set.filter(r=>r.cls===PHASE);
+if(!ALL_STATUSES) set = set.filter(r=>r.status==='ACTIVE');
+
+console.error(`Phase=${PHASE} statuses=${ALL_STATUSES?'all':'ACTIVE'} candidates=${set.length}  mode=${APPLY?'APPLY (LIVE WRITE)':'DRY-RUN'}`);
+
+// LIVE verify in batches of 100
+const byId = new Map(set.map(r=>[`gid://shopify/Product/${r.pid}`, r]));
+const ids = [...byId.keys()];
+const verified=[], changed=[], badFallback=[];
+for(let i=0;i<ids.length;i+=100){
+  const batch=ids.slice(i,i+100);
+  const q=`query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id title status metafield(namespace:"global",key:"title_tag"){ id value } } } }`;
+  const d=await gql(q,{ids:batch});
+  for(const n of d.nodes){
+    if(!n) continue; const r=byId.get(n.id);
+    const cur=(n.metafield?.value||'').trim();
+    if(!cur || cur!==r.seo_title_tag.trim()){ changed.push({pid:r.pid,handle:r.handle,was:r.seo_title_tag,now:cur||'(deleted)'}); continue; }
+    const fb=(n.title||'').trim();
+    if(fb.length<3){ badFallback.push({pid:r.pid,handle:r.handle,title:fb}); continue; }
+    verified.push({pid:r.pid,gid:n.id,handle:r.handle,vendor:r.vendor,cls:r.cls,mf_id:n.metafield.id,old_value:cur,fallback_title:fb});
+  }
+  process.stderr.write(`\r  verified ${verified.length}  changed-since-sweep ${changed.length}  bad-fallback ${badFallback.length}   `);
+}
+process.stderr.write('\n');
+
+const manifest={ phase:PHASE, generated_at:new Date().toISOString(), will_delete:verified.length,
+  skipped_changed_since_sweep:changed.length, skipped_bad_fallback:badFallback.length,
+  rows:verified, skipped_changed:changed, skipped_bad_fallback_rows:badFallback };
+const mpath=`/tmp/tk11564_fix_phase${PHASE}.json`; fs.writeFileSync(mpath,JSON.stringify(manifest,null,1));
+
+console.log(`\nWILL DELETE title_tag on:      ${verified.length}`);
+console.log(`SKIP (changed since sweep):    ${changed.length}  (don't clobber a manual fix / already gone)`);
+console.log(`SKIP (fallback title too short):${badFallback.length}  (would expose a bad title — held)`);
+if(badFallback.length) console.log('  bad-fallback held:', JSON.stringify(badFallback.slice(0,10)));
+console.log('\nSample before -> after (fallback title Google will show):');
+for(const v of verified.slice(0,6)) console.log(`  [${v.cls}] ${v.handle}\n      was: ${JSON.stringify(v.old_value.slice(0,70))}\n      ->:  ${JSON.stringify(v.fallback_title.slice(0,70))}`);
+console.log('\nmanifest + rollback map ->', mpath);
+
+if(!APPLY){ console.log('\nDRY-RUN. No write fired. Re-run with --apply (GATED) to delete.'); process.exit(0); }
+
+// ---- LIVE WRITE PATH (gated) ----
+console.error('APPLYING: deleting title_tag on', verified.length, 'products ...');
+const ledger=process.env.HOME+'/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+let ok=0,err=0;
+for(let i=0;i<verified.length;i+=25){
+  const chunk=verified.slice(i,i+25);
+  const mf=chunk.map(v=>({ownerId:v.gid,namespace:'global',key:'title_tag'}));
+  const m=`mutation($mf:[MetafieldIdentifierInput!]!){ metafieldsDelete(metafields:$mf){ deletedMetafields{ ownerId } userErrors{ field message } } }`;
+  const d=await gql(m,{mf});
+  const ue=d.metafieldsDelete.userErrors; if(ue.length){ console.error('userErrors',ue); err+=chunk.length; continue; }
+  ok+=d.metafieldsDelete.deletedMetafields.length;
+  for(const v of chunk) fs.appendFileSync(ledger, JSON.stringify({ts:new Date().toISOString(),agent:'claude-run-11564',ticket:'TK-11564',action:`delete global.title_tag (${v.cls})`,pid:v.pid,handle:v.handle,blast_radius:1,undo_cmd:`re-set global.title_tag=${JSON.stringify(v.old_value)} on product ${v.pid}`,old_value:v.old_value,verify:`GET product ${v.pid} title_tag == null`})+'\n');
+  process.stderr.write(`\r  deleted ${ok}/${verified.length}  err ${err}   `);
+}
+process.stderr.write('\n');
+console.log(`APPLY done. deleted=${ok} err=${err}. Rollback map in ${mpath} + executed-reversible ledger.`);

← 0c45db2 TK-11076: Hollywood colorway retag allow-list (282 clean) +  ·  back to Designerwallcoverings  ·  TK-11076: harden the undo the gated memo rests on 102998b →