[object Object]

← back to Tk10630 Sku Suffix Canary

TK-10639: split versa fix (handle pass + alt pass), 507 applier

57606d9c0709bf2dec0affef71eb4fa08256fcaa · 2026-08-17 13:32:32 -0700 · steve

Files touched

Diff

commit 57606d9c0709bf2dec0affef71eb4fa08256fcaa
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 17 13:32:32 2026 -0700

    TK-10639: split versa fix (handle pass + alt pass), 507 applier
---
 apply-507.mjs       | 27 +++++++++++++++++++++++++++
 apply-versa-alt.mjs | 36 ++++++++++++++++++++++++++++++++++++
 apply-versa-fix.mjs | 22 +++++++++-------------
 3 files changed, 72 insertions(+), 13 deletions(-)

diff --git a/apply-507.mjs b/apply-507.mjs
new file mode 100644
index 0000000..f87264b
--- /dev/null
+++ b/apply-507.mjs
@@ -0,0 +1,27 @@
+// Apply the 507 clean-code recovery: set global/dwc/custom dw_sku = real code
+// (from recover-proposals.json). Metafield-only (write_products). Resumable.
+import { gql } from './shopify.mjs';
+import { readFileSync, appendFileSync, existsSync } from 'node:fs';
+const APPLY = process.argv.includes('--apply');
+const DONE = 'done-507.jsonl';
+const props = JSON.parse(readFileSync('recover-proposals.json', 'utf8'));
+const done = new Set();
+if (existsSync(DONE)) for (const l of readFileSync(DONE, 'utf8').split('\n')) if (l.trim()) done.add(JSON.parse(l).id);
+const work = props.filter(p => !done.has(p.id) && /^[A-Z]{2,6}-\d/i.test(p.code) && !/^DW/i.test(p.code));
+console.log(`[507] mode=${APPLY ? 'LIVE' : 'DRY'} total=${props.length} todo=${work.length}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const MFS = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`;
+let idx = 0, ok = 0, err = 0;
+async function worker() {
+  while (idx < work.length) {
+    const p = work[idx++];
+    const m = ['global', 'dwc', 'custom'].map(ns => ({ ownerId: p.id, namespace: ns, key: 'dw_sku', type: 'single_line_text_field', value: p.code }));
+    if (APPLY) {
+      try { for (let a = 0; ; a++) { try { const { data } = await gql(MFS, { m }); const ue = data.metafieldsSet.userErrors; if (ue.length) { err++; console.log('✗', p.handle, JSON.stringify(ue)); } else { ok++; appendFileSync(DONE, JSON.stringify({ id: p.id, code: p.code }) + '\n'); } break; } catch (e) { if (/THROTTLED/i.test(e.message) && a < 8) { await sleep(1500 * (a + 1)); continue; } throw e; } } }
+      catch (e) { err++; console.log('✗', p.handle, e.message.slice(0, 90)); }
+    } else ok++;
+    if ((ok + err) % 100 === 0) process.stderr.write(`  ${ok + err}/${work.length}\n`);
+  }
+}
+await Promise.all(Array.from({ length: Math.min(5, work.length) }, () => worker()));
+console.log(`[507] DONE ok=${ok} err=${err} ${APPLY ? '(written)' : '(dry)'}`);
diff --git a/apply-versa-alt.mjs b/apply-versa-alt.mjs
new file mode 100644
index 0000000..b25f89d
--- /dev/null
+++ b/apply-versa-alt.mjs
@@ -0,0 +1,36 @@
+// Fix image ALT text leaking "Versa" — the CORRECT way: productUpdateMedia on
+// MediaImage ids (fileUpdate rejects ProductImage ids). Iterates the versa product
+// IDs, fetches current media alt, rewrites 'Versa Designed Surfaces' -> 'Hollywood
+// Wallcoverings'. DRY by default; --apply writes. Resumable via done-versa-alt.jsonl.
+import { gql } from './shopify.mjs';
+import { readFileSync, appendFileSync, existsSync } from 'node:fs';
+const APPLY = process.argv.includes('--apply');
+const DONE = 'done-versa-alt.jsonl';
+const plan = JSON.parse(readFileSync('versa-fix-plan.json', 'utf8'));
+const done = new Set();
+if (existsSync(DONE)) for (const l of readFileSync(DONE, 'utf8').split('\n')) if (l.trim()) done.add(JSON.parse(l).id);
+const work = plan.filter(p => !done.has(p.id));
+console.log(`[versa-alt] mode=${APPLY ? 'LIVE' : 'DRY'} products=${plan.length} todo=${work.length}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function mut(q, v) { for (let a = 0; ; a++) { try { const { data } = await gql(q, v); return data; } catch (e) { if (/THROTTLED/i.test(e.message) && a < 8) { await sleep(1500 * (a + 1)); continue; } throw e; } } }
+const MEDIA = `query($id:ID!){ product(id:$id){ media(first:20){ nodes{ ... on MediaImage { id alt } } } } }`;
+const UPD = `mutation($pid:ID!,$media:[UpdateMediaInput!]!){ productUpdateMedia(productId:$pid, media:$media){ mediaUserErrors{ field message } } }`;
+let idx = 0, ok = 0, err = 0, fixed = 0;
+async function worker() {
+  while (idx < work.length) {
+    const p = work[idx++];
+    try {
+      const d = await mut(MEDIA, { id: p.id });
+      const media = (d.product?.media?.nodes || []).filter(m => m && m.id);
+      const changes = media.filter(m => /versa/i.test(m.alt || '')).map(m => ({ id: m.id, alt: m.alt.replace(/versa\s*designed\s*surfaces/ig, 'Hollywood Wallcoverings').replace(/\|\s*versa[^|]*/ig, '| Hollywood Wallcoverings') }));
+      if (changes.length && APPLY) {
+        const r = await mut(UPD, { pid: p.id, media: changes });
+        if (r.productUpdateMedia.mediaUserErrors.length) { err++; console.log('✗', p.id, JSON.stringify(r.productUpdateMedia.mediaUserErrors)); }
+        else { ok++; fixed += changes.length; appendFileSync(DONE, JSON.stringify({ id: p.id, n: changes.length }) + '\n'); }
+      } else { ok++; if (APPLY) appendFileSync(DONE, JSON.stringify({ id: p.id, n: 0 }) + '\n'); }
+    } catch (e) { err++; console.log('✗', p.id, e.message.slice(0, 90)); }
+    if ((ok + err) % 100 === 0) process.stderr.write(`  ${ok + err}/${work.length} (alt fixed=${fixed})\n`);
+  }
+}
+await Promise.all(Array.from({ length: 4 }, () => worker()));
+console.log(`[versa-alt] DONE ok=${ok} err=${err} alt_images_fixed=${fixed} ${APPLY ? '(written)' : '(dry)'}`);
diff --git a/apply-versa-fix.mjs b/apply-versa-fix.mjs
index 63f48f9..2994c5c 100644
--- a/apply-versa-fix.mjs
+++ b/apply-versa-fix.mjs
@@ -22,20 +22,16 @@ const PUPD  = `mutation($id:ID!,$handle:String!){ productUpdate(input:{id:$id,ha
 const ALT   = `mutation($files:[FileUpdateInput!]!){ fileUpdate(files:$files){ userErrors{ field message } } }`;
 
 async function one(p) {
+  // HANDLE-ONLY pass (rename + 301 redirect). Alt text is a separate pass
+  // (apply-versa-alt.mjs) because alt lives on MediaImage, not ProductImage.
   const errs = [];
-  if (p.from !== p.to) {
-    if (APPLY) {
-      // 301 redirect first (so the old URL keeps working)
-      const r = await mut(REDIR, { path: `/products/${p.from}`, target: `/products/${p.to}` });
-      if (r.urlRedirectCreate.userErrors.length) errs.push('redirect: ' + JSON.stringify(r.urlRedirectCreate.userErrors));
-      const u = await mut(PUPD, { id: p.id, handle: p.to });
-      if (u.productUpdate.userErrors.length) errs.push('handle: ' + JSON.stringify(u.productUpdate.userErrors));
-    }
-  }
-  if (p.altChanges.length && APPLY) {
-    const files = p.altChanges.map(c => ({ id: c.imageId, alt: c.to }));
-    const a = await mut(ALT, { files });
-    if (a.fileUpdate.userErrors.length) errs.push('alt: ' + JSON.stringify(a.fileUpdate.userErrors));
+  if (p.from !== p.to && APPLY) {
+    // 301 redirect first; tolerate "already exists" (idempotent re-runs)
+    const r = await mut(REDIR, { path: `/products/${p.from}`, target: `/products/${p.to}` });
+    const rue = r.urlRedirectCreate.userErrors.filter(e => !/already|taken|exist/i.test(e.message));
+    if (rue.length) errs.push('redirect: ' + JSON.stringify(rue));
+    const u = await mut(PUPD, { id: p.id, handle: p.to });
+    if (u.productUpdate.userErrors.length) errs.push('handle: ' + JSON.stringify(u.productUpdate.userErrors));
   }
   if (APPLY && errs.length === 0) appendFileSync(DONE, JSON.stringify({ id: p.id, from: p.from, to: p.to }) + '\n');
   return errs;

← f6cf95b TK-10639: Versa leak full-surface audit + gated fix draft (h  ·  back to Tk10630 Sku Suffix Canary  ·  auto-data-snapshot: 2026-08-17T13:58:50 (3 data files) — mfr c23bb5e →