[object Object]

← back to Designerwallcoverings

TK-10895: fix silent reorder failure leaking 4.25 on 226 live products

5bef4e33ce88a8f49c316ff59b0a1a06d6fcb732 · 2026-09-10 10:12:45 -0700 · Steve

Files touched

Diff

commit 5bef4e33ce88a8f49c316ff59b0a1a06d6fcb732
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 10:12:45 2026 -0700

    TK-10895: fix silent reorder failure leaking 4.25 on 226 live products
---
 scripts/tk10895-fix-position1.mjs       | 91 +++++++++++++++++++++++++++++++++
 scripts/tk10895-trancheB-golive-269.mjs | 16 ++++--
 2 files changed, 104 insertions(+), 3 deletions(-)

diff --git a/scripts/tk10895-fix-position1.mjs b/scripts/tk10895-fix-position1.mjs
new file mode 100644
index 0000000..31467e9
--- /dev/null
+++ b/scripts/tk10895-fix-position1.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// TK-10895 — repair the $4.25 position-1 leak on already-shipped Tranche B products.
+//
+// ROOT CAUSE (proven by introspection against API 2024-10):
+//   VariantPositionInput        -> __type: null   (DOES NOT EXIST)
+//   ProductVariantPositionInput -> exists
+// The go-live used the nonexistent type, so productVariantsBulkReorder failed at QUERY level.
+// That makes res.data null, and `res?.data?.productVariantsBulkReorder?.userErrors || []`
+// collapses to [] — so the run logged reorderWarnings=0 while EVERY reorder silently failed.
+// Lesson: a `?.` chain over a null data object turns a hard API error into a silent success.
+//
+// This script: for every product with >=2 variants where position 1 is the $4.25 sample,
+// reorder so the real sellable variant is position 1. Verifies AFTER, and fails LOUDLY.
+// DRY-RUN by default; --apply writes.
+
+import fs from 'fs';
+const SHOP='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const TOK=fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g,'');
+const APPLY=process.argv.includes('--apply');
+const LIMIT=(()=>{const i=process.argv.indexOf('--limit');return i>-1?+process.argv[i+1]:0;})();
+const PIDS=process.env.HOME+'/.claude/yolo-queue/evidence/TK-10895/leak-pids.txt';
+const MAP=process.env.HOME+'/.claude/yolo-queue/executed-reversible/TK-10895-position-fix-269.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+
+async function api(path,opts={},tries=6){
+  for(let a=1;a<=tries;a++){
+    try{const r=await fetch(`https://${SHOP}/admin/api/${VER}${path}`,{...opts,
+      headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json',...(opts.headers||{})}});
+      if(r.status===429||r.status>=500){await sleep(1500*a);continue;}
+      const t=await r.text(); try{return{status:r.status,body:JSON.parse(t)};}catch{await sleep(1200*a);}
+    }catch{await sleep(1200*a);} }
+  return {status:0,body:null};
+}
+// NOTE: returns the FULL envelope so query-level errors cannot be swallowed.
+async function gql(query,variables,tries=6){
+  for(let a=1;a<=tries;a++){
+    try{const r=await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`,{method:'POST',
+      headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},
+      body:JSON.stringify({query,variables})});
+      if(r.status===429||r.status>=500){await sleep(1500*a);continue;}
+      return await r.json();
+    }catch{await sleep(1200*a);} }
+  return null;
+}
+const REORDER=`mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
+  productVariantsBulkReorder(productId: $productId, positions: $positions) { userErrors { field message } } }`;
+
+const pids=fs.readFileSync(PIDS,'utf8').split('\n').map(s=>s.trim()).filter(Boolean);
+const work=LIMIT?pids.slice(0,LIMIT):pids;
+console.log(`${APPLY?'*** APPLY — LIVE WRITES ***':'DRY-RUN'} — ${work.length} products\n`);
+let fixed=0,ok=0,skip=0,hard=0;
+
+for(const [i,pid] of work.entries()){
+  const tag=`[${i+1}/${work.length}] pid=${pid}`;
+  const g=await api(`/products/${pid}.json`);
+  if(g.status!==200||!g.body?.product){console.log(`${tag} FAIL GET ${g.status}`);hard++;continue;}
+  const p=g.body.product, vs=[...p.variants].sort((a,b)=>a.position-b.position);
+  if(vs.length<2){console.log(`${tag} skip — ${vs.length} variant`);skip++;continue;}
+  const sell=vs.filter(v=>parseFloat(v.price)>50);
+  if(!sell.length){console.log(`${tag} SKIP — no sellable variant >$50 (nothing to promote)`);skip++;continue;}
+  if(parseFloat(vs[0].price)>50){console.log(`${tag} already OK — pos1 $${vs[0].price}`);ok++;continue;}
+  const target=sell[0];
+  if(!APPLY){console.log(`${tag} would promote "${target.title}" $${target.price} to position 1 (currently $${vs[0].price})`);fixed++;continue;}
+
+  const ordered=[target.id,...vs.filter(v=>v.id!==target.id).map(v=>v.id)];
+  const res=await gql(REORDER,{productId:`gid://shopify/Product/${pid}`,
+    positions:ordered.map((id,idx)=>({id:`gid://shopify/ProductVariant/${id}`,position:idx+1}))});
+  // LOUD: query-level errors, transport failure, and userErrors are ALL hard failures.
+  if(!res){console.log(`${tag} HARD FAIL — no response from GraphQL`);hard++;continue;}
+  if(res.errors){console.log(`${tag} HARD FAIL — GraphQL query error: ${JSON.stringify(res.errors).slice(0,200)}`);hard++;continue;}
+  const ue=res.data?.productVariantsBulkReorder?.userErrors;
+  if(ue===undefined){console.log(`${tag} HARD FAIL — mutation returned no payload: ${JSON.stringify(res).slice(0,200)}`);hard++;continue;}
+  if(ue.length){console.log(`${tag} HARD FAIL — userErrors ${JSON.stringify(ue)}`);hard++;continue;}
+
+  // VERIFY AFTER — do not trust the mutation's own success report
+  await sleep(400);
+  const v2=await api(`/products/${pid}.json`);
+  const nv=[...(v2.body?.product?.variants||[])].sort((a,b)=>a.position-b.position);
+  if(!nv.length||!(parseFloat(nv[0].price)>50)){
+    console.log(`${tag} HARD FAIL — after reorder pos1 is still $${nv[0]?.price}`);hard++;continue;}
+  fs.appendFileSync(MAP,JSON.stringify({ts:new Date().toISOString(),ticket:'TK-10895',
+    action:'position1-fix',product_id:pid,handle:p.handle,
+    before_pos1:{id:vs[0].id,price:vs[0].price,title:vs[0].title},
+    after_pos1:{id:nv[0].id,price:nv[0].price,title:nv[0].title},
+    undo:`productVariantsBulkReorder back to [${vs.map(v=>v.id).join(',')}]`})+'\n');
+  fixed++; console.log(`${tag} ✓ pos1 now $${nv[0].price} "${nv[0].title}"  (${p.handle})`);
+  await sleep(350);
+}
+console.log(`\nDONE  fixed=${fixed} alreadyOK=${ok} skipped=${skip} HARDFAIL=${hard}`);
+if(hard) { console.log('*** HARD FAILURES PRESENT — do not report success ***'); process.exitCode=1; }
diff --git a/scripts/tk10895-trancheB-golive-269.mjs b/scripts/tk10895-trancheB-golive-269.mjs
index 75b076e..409b11d 100644
--- a/scripts/tk10895-trancheB-golive-269.mjs
+++ b/scripts/tk10895-trancheB-golive-269.mjs
@@ -102,7 +102,7 @@ async function gql(query, variables, tries = 5) {
   }
   return null;
 }
-const REORDER = `mutation r($productId: ID!, $positions: [VariantPositionInput!]!) {
+const REORDER = `mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
   productVariantsBulkReorder(productId: $productId, positions: $positions) { userErrors { field message } } }`;
 
 console.log(`${APPLY ? '*** APPLY — LIVE CUSTOMER-FACING WRITES ***' : 'DRY-RUN (no writes)'} — ${work.length} products\n`);
@@ -132,8 +132,18 @@ for (const [i, w] of work.entries()) {
   const ordered = [nv.id, ...all.filter(v => v.id !== nv.id).map(v => v.id)];
   const res = await gql(REORDER, { productId: `gid://shopify/Product/${w.pid}`,
     positions: ordered.map((id, idx) => ({ id: `gid://shopify/ProductVariant/${id}`, position: idx + 1 })) });
-  const ue = res?.data?.productVariantsBulkReorder?.userErrors || [];
-  if (ue.length) { console.log(`${tag} ⚠ reorder userErrors ${JSON.stringify(ue)}`); warn++; }
+  let bad = null;
+  if (!res) bad = 'no response from GraphQL';
+  else if (res.errors) bad = `query error ${JSON.stringify(res.errors).slice(0,200)}`;
+  else if (res.data?.productVariantsBulkReorder?.userErrors === undefined) bad = `no payload ${JSON.stringify(res).slice(0,200)}`;
+  else if (res.data.productVariantsBulkReorder.userErrors.length) bad = `userErrors ${JSON.stringify(res.data.productVariantsBulkReorder.userErrors)}`;
+  if (bad) { console.log(`${tag} HARD FAIL reorder — ${bad}`); warn++; }
+  else {
+    await sleep(350);
+    const chk = await api(`/products/${w.pid}.json`);
+    const cv = [...(chk.body?.product?.variants || [])].sort((a,b)=>a.position-b.position);
+    if (!(parseFloat(cv[0]?.price) > 50)) { console.log(`${tag} HARD FAIL — after reorder pos1 is $${cv[0]?.price}`); warn++; }
+  }
   if (p.options?.[0]?.name && p.options[0].name !== 'Size') {
     await api(`/products/${w.pid}.json`, { method: 'PUT', body: JSON.stringify({ product: { id: +w.pid, options: [{ id: p.options[0].id, name: 'Size' }] } }) });
   }

← c825f58 auto-data-snapshot: 2026-09-10T10:01:38 (3 data files) — scr  ·  back to Designerwallcoverings  ·  TK-11357 Fix A: stop emitting the $0 sellable variant in quo 5ac31df →