← back to Dw Title Repair
patternfill.mjs
53 lines
#!/usr/bin/env node
// TK-11240 part B — backfill dwc.pattern_name on ACTIVE Hollywood products that have none.
// DRY-RUN BY DEFAULT. APPLY=1 to write. UNDO=1 APPLY=1 deletes the metafields this created.
// Safe by construction: only touches products whose dwc.pattern_name is currently ABSENT,
// re-verified LIVE immediately before each write, so it can never overwrite an existing value.
import fs from 'node:fs';
const DOM=process.env.SHOPIFY_STORE_DOMAIN, TOK=process.env.SHOPIFY_ADMIN_TOKEN;
const APPLY=process.env.APPLY==='1', UNDO=process.env.UNDO==='1';
const LIMIT=parseInt(process.env.LIMIT||'0',10);
if(!DOM||!TOK){console.error('missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_TOKEN');process.exit(1);}
const rows=fs.readFileSync('data/patternfill.tsv','utf8').trim().split('\n')
.map(l=>{const [id,sku,pat]=l.split('\t');return {id,sku,pat};});
const work=LIMIT?rows.slice(0,LIMIT):rows;
const ledger=fs.createWriteStream(`data/patternfill-${UNDO?'undo':'apply'}-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([429,502,503].includes(r.status)){await new Promise(s=>setTimeout(s,1500*(a+1)));continue;}
return r.json();
}
throw new Error('gql retries exhausted');
}
const SET=`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{field message}}}`;
const DEL=`mutation($id:ID!){metafieldDelete(input:{id:$id}){deletedId userErrors{field message}}}`;
let ok=0,skip=0,err=0;
for(const [i,r] of work.entries()){
// VERIFY LIVE: read the current value before deciding anything
const cur=await gql(`{product(id:"${r.id}"){ metafield(namespace:"dwc",key:"pattern_name"){ id value } }}`);
const mf=cur?.data?.product?.metafield;
if(cur?.data?.product===undefined){err++;ledger.write(JSON.stringify({id:r.id,status:'not_found'})+'\n');continue;}
if(!UNDO){
if(mf&&mf.value){skip++;ledger.write(JSON.stringify({id:r.id,status:'already_set',live:mf.value})+'\n');continue;}
if(!APPLY){ok++;ledger.write(JSON.stringify({id:r.id,status:'DRYRUN',to:r.pat})+'\n');continue;}
const res=await gql(SET,{m:[{ownerId:r.id,namespace:'dwc',key:'pattern_name',type:'single_line_text_field',value:r.pat}]});
const ue=res?.data?.metafieldsSet?.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',to:r.pat})+'\n');}
}else{
// UNDO: only delete if the live value is exactly what we wrote (never touch a foreign value)
if(!mf){skip++;ledger.write(JSON.stringify({id:r.id,status:'already_absent'})+'\n');continue;}
if(mf.value!==r.pat){skip++;ledger.write(JSON.stringify({id:r.id,status:'drifted',live:mf.value,expected:r.pat})+'\n');continue;}
if(!APPLY){ok++;ledger.write(JSON.stringify({id:r.id,status:'DRYRUN_DELETE',value:mf.value})+'\n');continue;}
const res=await gql(DEL,{id:mf.id});
const ue=res?.data?.metafieldDelete?.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:'DELETED'})+'\n');}
}
if(i%50===0)console.log(` ${i}/${work.length} ok=${ok} skip=${skip} err=${err}`);
}
console.log(`${UNDO?'UNDO':'FILL'} ${APPLY?'APPLIED':'DRY-RUN'}: ok=${ok} skipped=${skip} errors=${err} of ${work.length}`);