← back to Tk10661 Product Seo
TK-10661: variant-reorder runner + rollback + scan tooling (Sample->last, clears 4.25 JSON-LD leak)
6b0c29b355a817229e08d395715b2c9721c3dc85 · 2026-08-18 10:30:39 -0700 · Steve
Files touched
A exact-scan.mjsA prove-one.mjsA reorder-all.mjsA rollback-all.mjs
Diff
commit 6b0c29b355a817229e08d395715b2c9721c3dc85
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 18 10:30:39 2026 -0700
TK-10661: variant-reorder runner + rollback + scan tooling (Sample->last, clears 4.25 JSON-LD leak)
---
exact-scan.mjs | 31 +++++++++++++++++++++++++++++++
prove-one.mjs | 29 +++++++++++++++++++++++++++++
reorder-all.mjs | 38 ++++++++++++++++++++++++++++++++++++++
rollback-all.mjs | 18 ++++++++++++++++++
4 files changed, 116 insertions(+)
diff --git a/exact-scan.mjs b/exact-scan.mjs
new file mode 100644
index 0000000..d7dad52
--- /dev/null
+++ b/exact-scan.mjs
@@ -0,0 +1,31 @@
+// TK-10661 EXACT-SCAN (read-only): paginate ALL products, record those where the FIRST
+// variant (position 1) is "Sample" = the leaking set. Capture full variant id+position list
+// per target for rollback. Writes data/targets.jsonl (one product/line) + count summary.
+import {readFileSync, writeFileSync, appendFileSync} from 'node:fs';
+const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
+const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
+const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(q,v={}){for(let a=0;a<8;a++){try{const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(2000*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}return j.data;}catch(e){if(a<7){await sleep(1500*(a+1));continue;}throw e;}}}
+const OUT='data/targets.jsonl'; writeFileSync(OUT,'');
+let cursor=null, scanned=0, targets=0, statusCount={};
+while(true){
+ const d=await gql(`query($c:String){ products(first:100, after:$c){ pageInfo{hasNextPage endCursor} nodes{ id status variants(first:15){ nodes{ id title position } } } } }`,{c:cursor});
+ const buf=[];
+ for(const p of d.products.nodes){ scanned++;
+ const vs=p.variants.nodes.slice().sort((a,b)=>a.position-b.position);
+ if(vs.length && /^sample$/i.test((vs[0].title||'').trim())){
+ // target: first variant is Sample. Only reorder if there IS a non-sample variant to promote.
+ const hasReal = vs.some(v=>!/^sample$/i.test((v.title||'').trim()));
+ if(hasReal){ targets++; statusCount[p.status]=(statusCount[p.status]||0)+1;
+ buf.push(JSON.stringify({id:p.id,status:p.status,variants:vs.map(v=>({id:v.id,title:v.title,position:v.position}))}));
+ }
+ }
+ }
+ if(buf.length) appendFileSync(OUT,buf.join('\n')+'\n');
+ if(scanned%5000===0) process.stderr.write(` scanned ${scanned}, targets ${targets}\n`);
+ if(!d.products.pageInfo.hasNextPage) break;
+ cursor=d.products.pageInfo.endCursor;
+}
+writeFileSync('data/scan-summary.json',JSON.stringify({scanned,targets,by_status:statusCount},null,2));
+console.log(JSON.stringify({scanned,targets,by_status:statusCount},null,2));
diff --git a/prove-one.mjs b/prove-one.mjs
new file mode 100644
index 0000000..b5e2813
--- /dev/null
+++ b/prove-one.mjs
@@ -0,0 +1,29 @@
+// TK-10661 PROVE-ON-ONE: reorder ONE product's variants (Sample -> last) and verify the
+// storefront JSON-LD offer flips from $4.25 to the real price. Records rollback data first.
+import {readFileSync, writeFileSync} from 'node:fs';
+const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
+const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
+const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
+async function gql(q,v={}){const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});return await r.json();}
+const HANDLE='feather-wallcovering-malibu-wallpaper-2';
+// 1. read current variant order
+const rd=await gql(`query($h:String!){productByHandle(handle:$h){id variants(first:50){nodes{id title position price}}}}`,{h:HANDLE});
+const prod=rd.data.productByHandle; const vars=prod.variants.nodes;
+console.log('BEFORE:', vars.map(v=>`${v.title}@${v.position}($${v.price})`).join(', '));
+// record rollback (original id->position)
+writeFileSync('data/rollback/'+prod.id.split('/').pop()+'.json', JSON.stringify({productId:prod.id,original:vars.map(v=>({id:v.id,position:v.position,title:v.title}))},null,2));
+// 2. compute new order: non-Sample first (preserve relative order), Sample(s) last
+const nonSample=vars.filter(v=>!/^sample$/i.test(v.title.trim()));
+const sample=vars.filter(v=>/^sample$/i.test(v.title.trim()));
+const ordered=[...nonSample,...sample];
+const positions=ordered.map((v,i)=>({id:v.id,position:i+1}));
+console.log('TARGET:', ordered.map((v,i)=>`${v.title}@${i+1}`).join(', '));
+// 3. apply reorder
+const mut=`mutation($pid:ID!,$pos:[ProductVariantPositionInput!]!){ productVariantsBulkReorder(productId:$pid,positions:$pos){ product{id} userErrors{field message} } }`;
+const res=await gql(mut,{pid:prod.id,pos:positions});
+const ue=res.data?.productVariantsBulkReorder?.userErrors||res.errors;
+if(ue&&ue.length){ console.log('WRITE ERROR:', JSON.stringify(ue)); process.exit(1); }
+console.log('REORDER APPLIED ok');
+// 4. re-read admin to confirm
+const rd2=await gql(`query($h:String!){productByHandle(handle:$h){variants(first:50){nodes{title position price}}}}`,{h:HANDLE});
+console.log('AFTER(admin):', rd2.data.productByHandle.variants.nodes.map(v=>`${v.title}@${v.position}`).join(', '));
diff --git a/reorder-all.mjs b/reorder-all.mjs
new file mode 100644
index 0000000..ad4e9b0
--- /dev/null
+++ b/reorder-all.mjs
@@ -0,0 +1,38 @@
+// TK-10661 REORDER-ALL: for every target product (variants[0]==Sample), reorder so Sample is
+// LAST via productVariantsBulkReorder. DRY-RUN default; --apply writes. Resumable (done-log).
+// Rate-limited + THROTTLED backoff. Rollback: targets.jsonl holds original id->position; the
+// companion rollback-all.mjs restores from the done-log.
+import {readFileSync, appendFileSync, existsSync} from 'node:fs';
+const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
+const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
+const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
+const APPLY=process.argv.includes('--apply');
+const LIMIT=Number((process.argv.find(a=>a.startsWith('--limit='))||'').split('=')[1]||Infinity);
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(q,v={}){for(let a=0;a<10;a++){try{const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(2500*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}return j;}catch(e){if(a<9){await sleep(1500*(a+1));continue;}throw e;}}}
+const DONE='data/done-reorder.jsonl';
+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 TFILE=process.argv.find(a=>a.endsWith('.jsonl'))||'data/targets.jsonl';
+const targets=readFileSync(TFILE,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+const MUT=`mutation($pid:ID!,$pos:[ProductVariantPositionInput!]!){ productVariantsBulkReorder(productId:$pid,positions:$pos){ userErrors{field message} } }`;
+let done_n=0, skipped=0, err=0, applied=0;
+for(const t of targets){
+ if(done.has(t.id)) { continue; }
+ const vs=t.variants.slice().sort((a,b)=>a.position-b.position);
+ const nonSample=vs.filter(v=>!/^sample$/i.test((v.title||'').trim()));
+ const sample=vs.filter(v=>/^sample$/i.test((v.title||'').trim()));
+ if(!nonSample.length){ skipped++; continue; } // nothing to promote
+ const ordered=[...nonSample,...sample];
+ const positions=ordered.map((v,i)=>({id:v.id,position:i+1}));
+ if(APPLY){
+ const res=await gql(MUT,{pid:t.id,pos:positions});
+ const ue=res.data?.productVariantsBulkReorder?.userErrors||[];
+ if(ue.length){ err++; appendFileSync(DONE,JSON.stringify({id:t.id,error:ue})+'\n'); }
+ else { applied++; appendFileSync(DONE,JSON.stringify({id:t.id,reordered:positions.map(p=>p.id),ok:true})+'\n'); }
+ await sleep(220); // ~4-5/s
+ }
+ done_n++;
+ if(done_n%500===0) process.stderr.write(` processed ${done_n}/${targets.length}, applied ${applied}, err ${err}\n`);
+ if(applied>=LIMIT){ process.stderr.write(`hit --limit=${LIMIT}\n`); break; }
+}
+console.log(JSON.stringify({mode:APPLY?'APPLY':'DRY-RUN',targets:targets.length,processed:done_n,applied,skipped_no_real_variant:skipped,errors:err},null,2));
diff --git a/rollback-all.mjs b/rollback-all.mjs
new file mode 100644
index 0000000..ddb81fe
--- /dev/null
+++ b/rollback-all.mjs
@@ -0,0 +1,18 @@
+// TK-10661 ROLLBACK: restore ORIGINAL variant positions from targets.jsonl for products in
+// the done-log. DRY-RUN default; --apply writes. Undoes reorder-all.
+import {readFileSync, existsSync} from 'node:fs';
+const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
+const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
+const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
+const APPLY=process.argv.includes('--apply');
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(q,v={}){for(let a=0;a<10;a++){try{const r=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(2500*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}return j;}catch(e){if(a<9){await sleep(1500*(a+1));continue;}throw e;}}}
+const TFILE=process.argv.find(a=>a.endsWith('.jsonl'))||'data/targets.jsonl';
+const targets=new Map(readFileSync(TFILE,'utf8').trim().split('\n').filter(Boolean).map(l=>{const t=JSON.parse(l);return [t.id,t];}));
+const done=existsSync('data/done-reorder.jsonl')?readFileSync('data/done-reorder.jsonl','utf8').trim().split('\n').filter(Boolean).map(JSON.parse).filter(x=>x.ok):[];
+const MUT=`mutation($pid:ID!,$pos:[ProductVariantPositionInput!]!){ productVariantsBulkReorder(productId:$pid,positions:$pos){ userErrors{message} } }`;
+let restored=0,err=0;
+for(const d of done){ const t=targets.get(d.id); if(!t) continue;
+ const positions=t.variants.map(v=>({id:v.id,position:v.position}));
+ if(APPLY){ const res=await gql(MUT,{pid:d.id,pos:positions}); if(res.data?.productVariantsBulkReorder?.userErrors?.length) err++; else restored++; await sleep(220);} }
+console.log(JSON.stringify({mode:APPLY?'APPLY':'DRY-RUN',in_done_log:done.length,restored,errors:err},null,2));
← a686abe auto-data-snapshot: 2026-08-18T10:14:32 (1 data files) — dat
·
back to Tk10661 Product Seo
·
auto-data-snapshot: 2026-08-18T10:46:23 (1 data files) — dat fe8d1b0 →