← back to Carnegie Reprice

rebuild-gql.mjs

127 lines

// rebuild-gql — Carnegie per-color rebuild via Shopify GraphQL productSet.
// ONE atomic mutation per product = product + 2 variants + all metafields + images.
// Cost-throttled (reads throttleStatus), resumable (ledger), single-flight (lock), storage-guarded.
//   node rebuild-gql.mjs --one DWAG-379296   # build one, print URL (for verify)
//   node rebuild-gql.mjs --all               # full run (skips ledgered dw_skus)
//   node rebuild-gql.mjs --status
import { execFileSync } from 'node:child_process';
import fs from 'node:fs'; import path from 'node:path';
const DIR = new URL('.',import.meta.url).pathname;
const ENV = `${process.env.HOME}/Projects/secrets-manager/.env`;
const env = k => { const m=fs.readFileSync(ENV,'utf8').split('\n').find(l=>l.startsWith(k+'=')); return m?m.slice(k.length+1).trim().replace(/^["']|["']$/g,''):''; };
const TOKEN=env('SHOPIFY_ADMIN_TOKEN'); let SHOP=env('SHOPIFY_STORE_DOMAIN')||env('SHOPIFY_STORE'); if(SHOP&&!SHOP.includes('.'))SHOP+='.myshopify.com';
const GQL=`https://${SHOP}/admin/api/2024-10/graphql.json`;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const PSQL=['/opt/homebrew/opt/postgresql@14/bin/psql','/usr/local/opt/postgresql@14/bin/psql','psql'].find(p=>{try{execFileSync(p,['--version'],{stdio:'ignore'});return 1}catch{return 0}})||'psql';
const q1=sql=>execFileSync(PSQL,['postgresql:///dw_unified?host=/tmp','-At','-c',sql],{encoding:'utf8',maxBuffer:128*1024*1024}).trim();
const LEDGER=path.join(DIR,'rebuild-gql-ledger.jsonl'), LOCK=path.join(DIR,'.rebuild-gql.lock'), HALT=path.join(DIR,'rebuild-gql.halt');
const args=process.argv.slice(2); const argVal=f=>{const i=args.indexOf(f);return i>=0?args[i+1]:null;};

// cost-based throttle: keep the GraphQL bucket healthy
let bucket=1000;
async function gql(query,variables){
  for(let attempt=0;attempt<7;attempt++){
    if(bucket<350) await sleep(1200);          // let the bucket restore (~100/s)
    const r=await fetch(GQL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})});
    if(r.status===429||r.status===430){ await sleep(2000*(attempt+1)); continue; }
    const j=await r.json();
    const ts=j.extensions?.cost?.throttleStatus; if(ts) bucket=ts.currentlyAvailable;
    if(j.errors && /throttl/i.test(JSON.stringify(j.errors))){ await sleep(2000*(attempt+1)); continue; }
    return j;
  }
  throw new Error('gql throttled/exhausted');
}
// --- metafield-definition guard: skip any (ns,key) whose store definition is NOT single_line_text_field ---
let BADDEF=new Set();
async function loadBadDefs(){
  const j=await gql(`{ metafieldDefinitions(ownerType: PRODUCT, first: 250){ nodes{ namespace key type{ name } } } }`);
  for(const d of (j.data?.metafieldDefinitions?.nodes||[])) if(d.type?.name!=='single_line_text_field') BADDEF.add(d.namespace+''+d.key.toLowerCase());
  return BADDEF.size;
}
const mfSafe=(ns,key)=>!BADDEF.has(ns+''+String(key).toLowerCase());

const ledgerMap=()=>{const m=new Map();if(fs.existsSync(LEDGER))for(const l of fs.readFileSync(LEDGER,'utf8').split('\n')){if(!l.trim())continue;try{const r=JSON.parse(l);if(r.dw_sku)m.set(r.dw_sku,r);}catch{}}return m;};
const ledgerAppend=r=>fs.appendFileSync(LEDGER,JSON.stringify(r)+'\n');

const SPEC_ALLOW=['Type','Warranty','Hydrolysis','Lightfastness','Additional Details','Standards and Certifications','Free of','ACT Symbols','IMO Certification Type'];
const bump=u=>u?u.replace(/height=\d*&width=\d*/,'height=1400&width=1400').replace(/height=&width=/,'height=1400&width=1400'):null;
const IMG=[/_puf\./i,/_pud\./i,/_pdp\./i,/_detail\./i,/_repeat\./i,/_web\./i,/lifestyle/i,/_swatch/i];
function selectImages(all){const c=(all||[]).filter(Boolean);if(!c.length)return[];const s=c.map(u=>{let i=IMG.findIndex(re=>re.test(u));if(i<0)i=IMG.length;return{u,i};});s.sort((a,b)=>a.i-b.i);const seen=new Set(),out=[];for(const{u}of s){const b=bump(u);if(!seen.has(b)){seen.add(b);out.push(b);if(out.length>=4)break;}}return out;}
const slug=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,60);

function buildInput(row){
  const specs=row.specs||{};
  const cost=+row.price, retail=Math.round(cost/0.65/0.85);
  const colorName=(row.color_tags&&row.color_tags[0])||row.color_bucket||('Color '+row.color_number);
  const ptype=row.product_type||'Upholstery';
  const imgs=selectImages(row.all_images);
  const spec={Width:row.width,Content:row.content,Contents:row.content,Durability:row.durability_wyzenbeek,
    'Cleaning Code':row.cleaning_code,Cleaning:row.cleaning_code,Finish:row.finish,Backing:row.backing,
    Flammability:row.flammability,Origin:row.origin,'Country of Origin':row.origin,
    Type:specs.Type,Warranty:specs.Warranty,Weight:specs['Weight per Linear Yard'],Use:ptype,
    Repeat:(row.repeat_v||row.repeat_h||'')||undefined};
  for(const k of SPEC_ALLOW){const v=specs[k];if(v&&!spec[k]&&String(v).length<600)spec[k]=v;}
  const ident={dw_sku:row.dw_sku,manufacturer_sku:row.mfr_sku,brand:'Carnegie',vendor:'Carnegie',
    pattern_name:row.pattern_name,color:colorName,color_number:String(row.color_number||''),
    product_class:'Fabric',collection_name:'Carnegie Textiles'};
  const metafields=[];
  for(const ns of ['global','custom','specifications','specs','dwc']) for(const[k,v]of Object.entries(spec)) if(v&&mfSafe(ns,k)) metafields.push({namespace:ns,key:k,type:'single_line_text_field',value:String(v).slice(0,900)});
  for(const ns of ['global','custom','dwc']) for(const[k,v]of Object.entries(ident)) if(v&&mfSafe(ns,k)) metafields.push({namespace:ns,key:k,type:'single_line_text_field',value:String(v)});
  if(row.primary_hex&&mfSafe('custom','color_hex')) metafields.push({namespace:'custom',key:'color_hex',type:'single_line_text_field',value:row.primary_hex});
  const tags=[colorName,row.color_bucket,'Carnegie','carnegie-textile','Fabric',ptype,'Carnegie Textiles','display_variant'].map(t=>String(t||'').trim()).filter(Boolean);
  const input={
    title:`Carnegie ${row.pattern_name} — ${colorName}`, handle:`carnegie-${slug(row.pattern_name)}-${slug(colorName)}-${slug(row.dw_sku)}`,
    descriptionHtml:row.description_text||'', vendor:'Carnegie', productType:ptype, status: imgs.length?'ACTIVE':'DRAFT',
    tags:[...new Set(tags)],
    productOptions:[{name:'Format', values:[{name:'Per Yard'},{name:'Memo Sample'}]}],
    variants:[
      {optionValues:[{optionName:'Format',name:'Per Yard'}], price:retail.toFixed(2), sku:row.dw_sku, inventoryPolicy:'CONTINUE', taxable:true},
      {optionValues:[{optionName:'Format',name:'Memo Sample'}], price:'4.25', sku:`${row.dw_sku}-Sample`, inventoryPolicy:'CONTINUE', taxable:true},
    ],
    files: imgs.map((u,i)=>({originalSource:u, contentType:'IMAGE', alt:`${row.pattern_name} ${colorName}`})),
    metafields,
  };
  return {input, colorName, retail, imgCount:imgs.length, mfCount:metafields.length};
}

const MUT=`mutation set($input: ProductSetInput!){ productSet(synchronous:true, input:$input){ product{ id handle status } userErrors{ field message } } }`;
async function createOne(sku){
  const row=JSON.parse(q1(`select row_to_json(t) from (select * from carnegie_catalog where dw_sku='${sku.replace(/'/g,"''")}') t`));
  const b=buildInput(row);
  const j=await gql(MUT,{input:b.input});
  const ue=j.data?.productSet?.userErrors||[]; const p=j.data?.productSet?.product;
  if(ue.length && /storage|FILE_STORAGE/i.test(JSON.stringify(ue))){ fs.writeFileSync(HALT,'storage: '+JSON.stringify(ue)); throw new Error('STORAGE HALT'); }
  if(!p){ const err=JSON.stringify(ue||j.errors||j).slice(0,240); ledgerAppend({dw_sku:sku,error:err,at:new Date().toISOString()}); return {sku,error:err}; }
  // publish to Online Store + Google&YouTube so it's actually visible / in the GMC feed
  let pubErr='';
  try{ const pj=await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id, input:$in){ userErrors{ message } } }`,
      {id:p.id, in:[{publicationId:'gid://shopify/Publication/22208643184'},{publicationId:'gid://shopify/Publication/29646651457'}]});
    pubErr=(pj.data?.publishablePublish?.userErrors||[]).map(e=>e.message).join(';'); }catch(e){ pubErr='pub-throw:'+e.message.slice(0,60); }
  const rec={dw_sku:sku,product_id:p.id,handle:p.handle,status:p.status,color:b.colorName,retail:b.retail,imgCount:b.imgCount,mfCount:b.mfCount,userErrors:ue.length,pubErr:pubErr||null,at:new Date().toISOString()};
  ledgerAppend(rec); return rec;
}

if(args.includes('--status')){ const m=ledgerMap(); const ok=[...m.values()].filter(r=>r.product_id).length; const errs=[...m.values()].filter(r=>r.error).length; const total=+q1(`select count(*) from carnegie_catalog where price>5`); console.log(`GQL ledger: ${ok} created, ${errs} errored, ${total-ok} remaining of ${total}. halt=${fs.existsSync(HALT)}`); process.exit(0); }
if(fs.existsSync(HALT)){ console.error('HALT flag present:',fs.readFileSync(HALT,'utf8')); process.exit(2); }

console.log('loading metafield definitions… skipping', await loadBadDefs(), 'non-text (ns,key) pairs');

if(argVal('--one')){ const r=await createOne(argVal('--one')); console.log(JSON.stringify(r,null,2)); if(r.handle) console.log('URL: https://www.designerwallcoverings.com/products/'+r.handle); process.exit(0); }

if(args.includes('--all')){
  if(fs.existsSync(LOCK)){ console.error('another rebuild-gql is running (lock present). exiting.'); process.exit(3); }
  fs.writeFileSync(LOCK,String(process.pid)); process.on('exit',()=>{try{fs.unlinkSync(LOCK)}catch{}});
  const done=ledgerMap();
  const skus=q1(`select dw_sku from carnegie_catalog where price>5 order by pattern_name,color_number`).split('\n').filter(Boolean);
  const todo=skus.filter(s=>!(done.get(s)&&done.get(s).product_id));
  console.log(`[gql] catalog ${skus.length} | created ${[...done.values()].filter(r=>r.product_id).length} | to create ${todo.length}`);
  let made=0,err=0,imgStarve=0;
  for(const sku of todo){
    let r; try{ r=await createOne(sku); }catch(e){ if(/STORAGE HALT/.test(e.message)){ console.error('!!! STORAGE HALT — stopping'); break; } ledgerAppend({dw_sku:sku,error:'throw:'+e.message.slice(0,150),at:new Date().toISOString()}); err++; continue; }
    if(r.product_id){ made++; if(r.imgCount>0 && r.status==='DRAFT') imgStarve++; else imgStarve=0; } else err++;
    if(imgStarve>=4){ fs.writeFileSync(HALT,'4 consecutive image-starved (DRAFT despite images) — storage cap suspected'); console.error('!!! image-starve halt'); break; }
    if((made+err)%100===0) console.log(`  ... ${made} created, ${err} err (bucket ${bucket})`);
  }
  console.log(`[gql] DONE this run: ${made} created, ${err} err.`);
}