← 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
A scripts/tk10895-fix-position1.mjsM scripts/tk10895-trancheB-golive-269.mjs
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 →