← back to Dw Title Repair

apply.mjs

50 lines

#!/usr/bin/env node
// TK-11376 title repair. DRY-RUN BY DEFAULT. Live writes require APPLY=1 (Steve-gated).
import fs from 'node:fs';
const DOM=process.env.SHOPIFY_STORE_DOMAIN, TOK=process.env.SHOPIFY_ADMIN_TOKEN;
const APPLY=process.env.APPLY==='1', DIR=process.env.UNDO==='1'?'undo':'apply';
const LIMIT=parseInt(process.env.LIMIT||'0',10);
if(!DOM||!TOK){console.error('missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_TOKEN');process.exit(1);}
const MAP=process.env.MAP||'data/restore-map.tsv';
// derive the tag from the map's own filename so supp and supp2 never share a ledger
const TAG=(MAP.match(/restore-map-([a-z0-9]+)\.tsv/)||[])[1] ? '-'+(MAP.match(/restore-map-([a-z0-9]+)\.tsv/))[1] : '';
const rows=fs.readFileSync(MAP,'utf8').trim().split('\n')
  .map(l=>{const [id,oldT,newT]=l.split('\t');return {id,from:DIR==='apply'?oldT:newT,to:DIR==='apply'?newT:oldT};});
const work=LIMIT?rows.slice(0,LIMIT):rows;
const ledger=fs.createWriteStream(`data/${DIR}${TAG}-progress.jsonl`,{flags:'a'});
async function gql(q,v){
  for(let a=0;a<4;a++){
    const r=await fetch(`https://${DOM}/admin/api/2024-10/graphql.json`,{method:'POST',
      headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},
      body:JSON.stringify({query:q,variables:v})});
    if(r.status===502||r.status===503||r.status===429){await new Promise(s=>setTimeout(s,1500*(a+1)));continue;}
    return r.json();
  }
  throw new Error('gql retries exhausted');
}
const Q=`mutation($id:ID!,$t:String!){productUpdate(input:{id:$id,title:$t}){product{id title} userErrors{field message}}}`;
let ok=0,skip=0,err=0;
for(const [i,r] of work.entries()){
  // VERIFY-BEFORE-WRITE: only write if the live title is still what we recorded
  // A null/undefined product can be a TRANSIENT read failure, not a deleted product —
  // observed mid-run on products that demonstrably exist and are ACTIVE. Re-read before
  // believing it, so a blip can never silently skip an unapplied product.
  let live;
  for(let a=0;a<3;a++){
    const cur=await gql(`{product(id:"${r.id}"){title}}`);
    live=cur?.data?.product?.title;
    if(live!==undefined) break;
    await new Promise(s=>setTimeout(s,800*(a+1)));
  }
  if(live===undefined){err++;ledger.write(JSON.stringify({id:r.id,status:'not_found_after_3_reads'})+'\n');continue;}
  if(live===r.to){skip++;ledger.write(JSON.stringify({id:r.id,status:'already_target'})+'\n');continue;}
  if(live!==r.from){skip++;ledger.write(JSON.stringify({id:r.id,status:'drifted',live,expected:r.from})+'\n');continue;}
  if(!APPLY){ok++;ledger.write(JSON.stringify({id:r.id,status:'DRYRUN',from:r.from,to:r.to})+'\n');continue;}
  const res=await gql(Q,{id:r.id,t:r.to});
  const ue=res?.data?.productUpdate?.userErrors||[];
  if(ue.length){err++;ledger.write(JSON.stringify({id:r.id,status:'error',ue})+'\n');}
  else{ok++;ledger.write(JSON.stringify({id:r.id,status:'APPLIED',from:r.from,to:r.to})+'\n');}
  if(i%200===0)console.log(`  ${i}/${work.length} ok=${ok} skip=${skip} err=${err}`);
}
console.log(`${APPLY?'APPLIED':'DRY-RUN'} ${DIR}: ok=${ok} skipped=${skip} errors=${err} of ${work.length}`);