← back to Dwjs Consolidation 2026 04 23

yb_qty_backfill.js

69 lines

#!/usr/bin/env node
// GATED backfill — sets double-roll MOQ on the audited York/Brewster drifted ROLL products.
//   product-level: global.v_prods_quantity_order_min=2, global.v_prods_quantity_order_units=2
//   variant-level: custom.v_prod_quantity_order_min / custom.v_prods_quantity_order_units
//                  = 2 on bolt variants, 1 on the -Sample variant
// Input  = yb-audit/GATE_list.json (the 139 verified drifted rolls; paste/peel&stick already removed).
// DRY-RUN by default (prints the exact write plan). Writes to LIVE Shopify ONLY with --apply.
// Status filter: default ACTIVE,DRAFT. Add --include-archived to also touch the 1 archived row.
// DO NOT RUN without Steve's explicit go — this is a live customer-facing catalog write (~139 SKUs).
const https=require('https'), fs=require('fs');
const STORE='designer-laboratory-sandbox.myshopify.com';
const os=require('os');
function loadToken(){const k=process.env.SHOPIFY_ADMIN_TOKEN;if(k)return k;try{const e=fs.readFileSync(os.homedir()+'/Projects/secrets-manager/.env','utf8');const m=e.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);if(m)return m[1].trim();}catch{}throw new Error('SHOPIFY_ADMIN_TOKEN not found (export it or set in secrets-manager/.env)');}
let TOKEN; // set below via loadToken()
TOKEN=loadToken();
const DIR='/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/yb-audit';
const LOG=`${DIR}/yb_qty_backfill.log`;
const APPLY=process.argv.includes('--apply');
const INCLUDE_ARCHIVED=process.argv.includes('--include-archived');
const SL='single_line_text_field';
const log=m=>{const l=`[${new Date().toISOString().slice(0,19)}] ${m}\n`;process.stdout.write(l);fs.appendFileSync(LOG,l);};

function gql(b,retry=0){return new Promise((res,rej)=>{const d=JSON.stringify(b);const r=https.request({hostname:STORE,path:'/admin/api/2024-10/graphql.json',method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(d)}},R=>{let c='';R.on('data',x=>c+=x);R.on('end',async()=>{try{const j=JSON.parse(c);const isThr=j.errors&&/throttle/i.test(JSON.stringify(j.errors));if(isThr&&retry<6){await new Promise(r=>setTimeout(r,2000*(retry+1)));return res(gql(b,retry+1))}const tri=j?.extensions?.cost?.throttleStatus;if(tri&&tri.currentlyAvailable<300)await new Promise(r=>setTimeout(r,800));res(j)}catch{if(retry<3)setTimeout(()=>res(gql(b,retry+1)),2000);else res({error:c.slice(0,400)})}})});r.on('error',e=>{if(retry<3)setTimeout(()=>res(gql(b,retry+1)),2000);else rej(e)});r.setTimeout(45000,()=>{r.destroy();if(retry<3)res(gql(b,retry+1));else rej(new Error('t'))});r.write(d);r.end()});}

(async()=>{
  let gate=JSON.parse(fs.readFileSync(`${DIR}/GATE_list.json`,'utf8'));
  const before=gate.length;
  gate=gate.filter(g=> INCLUDE_ARCHIVED ? true : g.status!=='ARCHIVED');
  log(`gate products: ${before} loaded, ${gate.length} in-scope for this run (include-archived=${INCLUDE_ARCHIVED})`);
  log(`MODE: ${APPLY?'*** APPLY (LIVE WRITE) ***':'DRY-RUN (no writes)'}`);

  const needed=[];
  let i=0;
  for(const g of gate){
    i++;
    // fetch variants for this product
    const r=await gql({query:`{ product(id:${JSON.stringify(g.product_id)}){ id
      m1: metafield(namespace:"global", key:"v_prods_quantity_order_min"){value}
      m2: metafield(namespace:"global", key:"v_prods_quantity_order_units"){value}
      variants(first:50){ edges{ node{ id sku title } } } } }`});
    const p=r?.data?.product;
    if(!p){log(`  [${i}/${gate.length}] ${g.dw} NO PRODUCT (${r?.errors?JSON.stringify(r.errors).slice(0,120):''})`);continue;}
    if(p.m1?.value!=='2') needed.push({ownerId:p.id,namespace:'global',key:'v_prods_quantity_order_min',type:SL,value:'2'});
    if(p.m2?.value!=='2') needed.push({ownerId:p.id,namespace:'global',key:'v_prods_quantity_order_units',type:SL,value:'2'});
    for(const e of (p.variants?.edges||[])){
      const v=e.node; const isSample=/sample/i.test((v.sku||'')+(v.title||''));
      const want=isSample?'1':'2';
      needed.push({ownerId:v.id,namespace:'custom',key:'v_prod_quantity_order_min',type:SL,value:want});
      needed.push({ownerId:v.id,namespace:'custom',key:'v_prods_quantity_order_units',type:SL,value:want});
    }
    if(i%25===0) log(`  prepared ${i}/${gate.length} products`);
  }
  log(`metafields to write: ${needed.length} (across ${gate.length} products)`);
  fs.writeFileSync(`${DIR}/yb_backfill_plan.json`,JSON.stringify(needed,null,2));

  if(!APPLY){ log('DRY-RUN complete. Plan written to yb_backfill_plan.json. Re-run with --apply (Steve-gated) to write.'); return; }

  let ok=0,fail=0,b=0;
  for(let j=0;j<needed.length;j+=25){
    const batch=needed.slice(j,j+25); b++;
    const r=await gql({query:`mutation($mfs:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mfs){ metafields{id} userErrors{field message} } }`,variables:{mfs:batch}});
    const errs=r?.data?.metafieldsSet?.userErrors||[];
    if(errs.length){fail+=errs.length;log(`  batch ${b} errs: ${JSON.stringify(errs).slice(0,200)}`);}
    ok+=(r?.data?.metafieldsSet?.metafields?.length||0);
    if(b%10===0) log(`  batch ${b} ok=${ok} fail=${fail}`);
  }
  log(`DONE — wrote ok=${ok} fail=${fail} batches=${b}`);
})().catch(e=>{log('FATAL '+e.message);process.exit(1);});