[object Object]

← back to Japan Enrich

carnegie: rollout-draft.cjs writes custom.Width+global.Width metafield (TK-11016 gate-fix)

58e9ec287d98a7bd2005e6c94cd57aff6a34ffe7 · 2026-08-31 09:51:49 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 58e9ec287d98a7bd2005e6c94cd57aff6a34ffe7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 09:51:49 2026 -0700

    carnegie: rollout-draft.cjs writes custom.Width+global.Width metafield (TK-11016 gate-fix)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 carnegie-split/backfill-width-drafts.cjs | 86 ++++++++++++++++++++++++++++++++
 carnegie-split/rollout-draft.cjs         | 60 ++++++++++++++++++++++
 2 files changed, 146 insertions(+)

diff --git a/carnegie-split/backfill-width-drafts.cjs b/carnegie-split/backfill-width-drafts.cjs
new file mode 100644
index 0000000..bb14dec
--- /dev/null
+++ b/carnegie-split/backfill-width-drafts.cjs
@@ -0,0 +1,86 @@
+// STEP B (TK-11016): Backfill custom.Width + global.Width metafields onto the 53 held
+// Carnegie DRAFT products so they become gate-ELIGIBLE. Products STAY DRAFT — this does
+// NOT activate or publish anything. Reversible: writes a restore-map BEFORE any write.
+//
+// Convention verified against LIVE ACTIVE Carnegie products (e.g. "Carnegie Abbey 61",
+// PID 7923576045619): width is stored as BOTH custom.Width AND global.Width, capital "W",
+// type single_line_text_field, value like `54" (137 cm)`. We match that exactly.
+//
+// Join key = dw_sku (same key derive-pattern.py / rollout-draft.cjs build from):
+// each draft's non-sample ("Fabric") variant SKU == carnegie_catalog.dw_sku.
+//
+// Usage:  node backfill-width-drafts.cjs           (dry-run: report only)
+//         node backfill-width-drafts.cjs --apply    (writes metafields, stays draft)
+const fs=require("fs"),os=require("os"),path=require("path"),https=require("https"),{execSync}=require("child_process");
+const APPLY=process.argv.includes("--apply");
+const env=fs.readFileSync(path.join(os.homedir(),"Projects/secrets-manager/.env"),"utf8");
+const g=k=>{const m=env.match(new RegExp("^"+k+"=(.*)$","m"));return m?m[1].trim().replace(/^["']|["']$/g,""):null};
+const shop="designer-laboratory-sandbox.myshopify.com",tok=g("SHOPIFY_ADMIN_TOKEN")||g("SHOPIFY_ADMIN_API_TOKEN");
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function req(method,p,body){return new Promise((res)=>{const b=body?JSON.stringify(body):null;const o={host:shop,path:p,method,headers:{"X-Shopify-Access-Token":tok,"Content-Type":"application/json"},timeout:30000};if(b)o.headers["Content-Length"]=Buffer.byteLength(b);const r=https.request(o,x=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>res({status:x.statusCode,body:d}))});r.on("error",e=>res({status:0,body:String(e&&e.message||e)}));r.on("timeout",()=>{r.destroy();res({status:0,body:"timeout"})});if(b)r.write(b);r.end()})}
+async function reqRetry(method,p,body){for(let a=0;a<6;a++){const r=await req(method,p,body);if(r.status===429||r.status===0||r.status>=500){await sleep(1500*(a+1));continue;}return r;}return {status:0,body:"retries-exhausted"};}
+
+// psql helper via temp query file (avoids shell-quoting hell)
+function psql(sql){const f=path.join(os.tmpdir(),`q_${Date.now()}_${Math.random().toString(36).slice(2)}.sql`);fs.writeFileSync(f,sql);try{return execSync(`psql "host=/tmp dbname=dw_unified" -tA -F"|" -f "${f}"`,{encoding:"utf8"});}finally{fs.unlinkSync(f);}}
+
+(async()=>{
+  // 1. enumerate all DRAFT Carnegie products
+  let all=[], since=0;
+  while(true){
+    const r=await reqRetry("GET",`/admin/api/2024-10/products.json?vendor=Carnegie&status=draft&limit=250&since_id=${since}&fields=id,title,status,tags,variants`);
+    const j=JSON.parse(r.body); const ps=j.products||[];
+    if(!ps.length)break; all=all.concat(ps); since=ps[ps.length-1].id; if(ps.length<250)break;
+  }
+  const drafts=all.map(p=>{const fab=(p.variants||[]).find(v=>v.sku && !/sample/i.test(v.sku));return {id:p.id,title:p.title,sku:fab?fab.sku:null};});
+  console.log(`DRAFT Carnegie products: ${drafts.length}`);
+
+  // 2. join to carnegie_catalog width by dw_sku
+  const skus=[...new Set(drafts.map(d=>d.sku).filter(Boolean))];
+  const inlist=skus.map(s=>"'"+s.replace(/'/g,"''")+"'").join(",");
+  const out=psql(`select dw_sku, coalesce(width,'') from carnegie_catalog where dw_sku in (${inlist});`).trim();
+  const wmap={}; if(out) out.split("\n").forEach(l=>{const i=l.indexOf("|");wmap[l.slice(0,i)]=l.slice(i+1);});
+
+  const toWrite=[], skips=[];
+  for(const d of drafts){
+    if(!d.sku){skips.push({...d,reason:"no-fabric-variant-sku"});continue;}
+    if(!(d.sku in wmap)){skips.push({...d,reason:"no-carnegie_catalog-row"});continue;}
+    if(!wmap[d.sku]){skips.push({...d,reason:"catalog-width-empty"});continue;}
+    toWrite.push({...d,width:wmap[d.sku]});
+  }
+  console.log(`will-backfill: ${toWrite.length}  | skip: ${skips.length}`);
+  skips.forEach(s=>console.log("  SKIP",s.id,"|",s.title,"|",s.sku||"-","|",s.reason));
+
+  if(!APPLY){ console.log("\nDRY-RUN — pass --apply to write. No metafields written.");
+    fs.writeFileSync("width-backfill-plan.json",JSON.stringify({toWrite,skips},null,2));
+    console.log("Plan written: width-backfill-plan.json"); return; }
+
+  // 3. build restore map BEFORE writing (capture pre-write width-metafield state per product)
+  const restore=[];
+  const ts=new Date().toISOString().replace(/[:.]/g,"-");
+  const restorePath=`width-backfill-restore-${ts}.json`;
+  for(const d of toWrite){
+    const m=await reqRetry("GET",`/admin/api/2024-10/products/${d.id}/metafields.json`);
+    const existing=(JSON.parse(m.body).metafields||[]).filter(x=>/^width$/i.test(x.key)&&["custom","global"].includes(x.namespace));
+    restore.push({id:d.id,title:d.title,sku:d.sku,width:d.width,pre_existing_width_metafields:existing.map(x=>({id:x.id,namespace:x.namespace,key:x.key,value:x.value}))});
+    await sleep(120);
+  }
+  fs.writeFileSync(restorePath,JSON.stringify({ticket:"TK-11016",created:new Date().toISOString(),note:"pre-write width-metafield state; to undo, DELETE any metafield id created after this run (or restore listed values). Products were and stay DRAFT.",restore},null,2));
+  console.log(`Restore map written (BEFORE writes): ${restorePath}`);
+
+  // 4. write custom.Width + global.Width per product (matches ACTIVE convention)
+  let ok=0,fail=0; const results=[];
+  for(const d of toWrite){
+    let pok=true;
+    for(const ns of ["custom","global"]){
+      const r=await reqRetry("POST",`/admin/api/2024-10/products/${d.id}/metafields.json`,{metafield:{namespace:ns,key:"Width",value:d.width,type:"single_line_text_field"}});
+      const okr=r.status===201; if(!okr)pok=false;
+      let mid=null; try{mid=JSON.parse(r.body).metafield.id;}catch(e){}
+      results.push({id:d.id,namespace:ns,key:"Width",value:d.width,status:r.status,metafield_id:mid,body:okr?undefined:r.body.slice(0,120)});
+      await sleep(400);
+    }
+    if(pok){ok++;process.stdout.write(".");}else{fail++;process.stdout.write("x");}
+  }
+  fs.writeFileSync(`width-backfill-results-${ts}.json`,JSON.stringify(results,null,2));
+  console.log(`\nDONE — ${ok} products backfilled (custom.Width+global.Width), ${fail} failed. Products remain DRAFT.`);
+  console.log(`Results: width-backfill-results-${ts}.json  | Restore: ${restorePath}`);
+})();
diff --git a/carnegie-split/rollout-draft.cjs b/carnegie-split/rollout-draft.cjs
new file mode 100644
index 0000000..420de56
--- /dev/null
+++ b/carnegie-split/rollout-draft.cjs
@@ -0,0 +1,60 @@
+// DRAFT-FIRST rollout for all remaining Carnegie patterns.
+// For each pattern: derive colors (derive-pattern.py) -> create standalone per-colorway
+// products as status:DRAFT (not customer-visible), own DW SKU + image + Fabric/Memo Sample
+// variants, disambiguated color-name titles, tag split-batch:TK-10686 + draft. RESUMABLE
+// (skips parent_skus already in done-ledger). Rate-safe (retry on 429). Does NOT flip to
+// active and does NOT archive the old products — that customer-facing step stays gated.
+const fs=require("fs"),os=require("os"),path=require("path"),https=require("https"),{execSync}=require("child_process");
+const env=fs.readFileSync(path.join(os.homedir(),"Projects/secrets-manager/.env"),"utf8");
+const g=k=>{const m=env.match(new RegExp("^"+k+"=(.*)$","m"));return m?m[1].trim().replace(/^["']|["']$/g,""):null};
+const shop="designer-laboratory-sandbox.myshopify.com",tok=g("SHOPIFY_ADMIN_TOKEN")||g("SHOPIFY_ADMIN_API_TOKEN");
+const RETAIL="136.00"; // TODO-per-pattern: verify vs carnegie retail; $136 held on Siltech Grain
+const DONE="rollout-done-patterns.txt", LEDGER="rollout-draft-ledger.jsonl";
+const doneSet=new Set(fs.existsSync(DONE)?fs.readFileSync(DONE,"utf8").split("\n").filter(Boolean):[]);
+const slug=s=>String(s).toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function req(method,p,body){return new Promise((res)=>{const b=body?JSON.stringify(body):null;const o={host:shop,path:p,method,headers:{"X-Shopify-Access-Token":tok,"Content-Type":"application/json"},timeout:30000};if(b)o.headers["Content-Length"]=Buffer.byteLength(b);const r=https.request(o,x=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>res({status:x.statusCode,body:d}))});r.on("error",e=>res({status:0,body:String(e&&e.message||e)}));r.on("timeout",()=>{r.destroy();res({status:0,body:"timeout"})});if(b)r.write(b);r.end()})}
+async function createWithRetry(product){for(let a=0;a<8;a++){let r;try{r=await req("POST","/admin/api/2024-10/products.json",{product});}catch(e){r={status:0,body:String(e)};}if(r.status===201){try{return JSON.parse(r.body).product;}catch(e){return{error:"parse",body:r.body.slice(0,120)};}}if(r.status===429||r.status===0||r.status>=500){await sleep(1600*(a+1));continue;}return {error:r.status,body:r.body.slice(0,120)};}return {error:"retries-exhausted"};}
+(async()=>{
+  const LIMIT=parseInt(process.env.PATTERN_LIMIT||"0"); // 0 = all
+  // pattern list: all Carnegie parents except the Siltech Grain pilot, not already done
+  const patterns=execSync(`psql "host=/tmp dbname=dw_unified" -tA -c "select distinct parent_sku from carnegie_catalog where parent_sku is not null and parent_sku<>'6300-upholstery' order by 1"`,{encoding:"utf8"}).trim().split("\n").filter(Boolean).filter(p=>!doneSet.has(p));
+  const todo = LIMIT>0 ? patterns.slice(0,LIMIT) : patterns;
+  console.log(`patterns to roll (draft): ${todo.length} (of ${patterns.length} remaining; ${doneSet.size} done)`);
+  let pat=0, prod=0, fail=0;
+  for(const parent of todo){
+    let rows;
+    try{ rows=execSync(`python3 derive-pattern.py '${parent}'`,{encoding:"utf8",timeout:180000}).trim().split("\n").filter(Boolean).map(l=>l.split("\t")); }
+    catch(e){ console.error("derive fail",parent,e.message.slice(0,80)); continue; }
+    if(!rows.length){continue;}
+    try{
+    let patternName; try{ patternName=execSync(`psql "host=/tmp dbname=dw_unified" -tA -c "select coalesce(max(pattern_name),'') from carnegie_catalog where parent_sku='${parent.replace(/'/g,"''")}'"`,{encoding:"utf8"}).trim()||parent; }catch(e){ patternName=parent; }
+    // width map by dw_sku for this parent — carnegie_catalog.width (e.g. '54" (137 cm)').
+    // Needed for the custom.Width/global.Width metafield: the store's NEVER-ACTIVE-without-width
+    // gate blocks activation when Width is absent (root cause of the 53 held drafts, TK-11016).
+    const widthMap={}; try{ execSync(`psql "host=/tmp dbname=dw_unified" -tA -F"\t" -c "select dw_sku, coalesce(width,'') from carnegie_catalog where parent_sku='${parent.replace(/'/g,"''")}'"`,{encoding:"utf8"}).trim().split("\n").filter(Boolean).forEach(l=>{const i=l.indexOf("\t");if(i>0)widthMap[l.slice(0,i)]=l.slice(i+1);}); }catch(e){}
+    const nameCount={}; rows.forEach(([,,c])=>nameCount[c]=(nameCount[c]||0)+1);
+    for(const [dw,mfr,color,price,img] of rows){
+      let cn=color; if(nameCount[color]>1) cn=`${color} ${dw.replace(/^DWAG-/,'').slice(-2)}`;
+      const title=`Carnegie ${patternName} ${cn}`.replace(/\s+/g," ").trim();
+      const width=widthMap[dw]||"";
+      const metafields=[{namespace:"custom",key:"manufacturer_sku",value:mfr,type:"single_line_text_field"},{namespace:"dwc",key:"manufacturer_sku",value:mfr,type:"single_line_text_field"},{namespace:"global",key:"Brand",value:"Carnegie",type:"single_line_text_field"}];
+      // Width metafields (custom.Width + global.Width) — matches the store's ACTIVE-Carnegie
+      // convention; required so the product clears the NEVER-ACTIVE-without-width gate (TK-11016).
+      if(width){metafields.push({namespace:"custom",key:"Width",value:width,type:"single_line_text_field"},{namespace:"global",key:"Width",value:width,type:"single_line_text_field"});}
+      const product={title,handle:slug(title),vendor:"Carnegie",product_type:"Upholstery",status:"draft",
+        tags:["Carnegie","split-batch:TK-10686","carnegie-split-draft"].join(", "),
+        options:[{name:"Type"}], images: img?[{src:img}]:[],
+        variants:[{option1:"Fabric",sku:dw,price:price&&Number(price)>50?price:RETAIL},{option1:"Memo Sample",sku:`${dw}-sample`,price:"4.25"}],
+        metafields};
+      const r=await createWithRetry(product);
+      if(r&&r.id){prod++;fs.appendFileSync(LEDGER,JSON.stringify({parent,dw,new_id:r.id,handle:r.handle,title})+"\n");process.stdout.write(".");}
+      else{fail++;fs.appendFileSync(LEDGER,JSON.stringify({parent,dw,error:r&&r.error})+"\n");process.stdout.write("x");}
+      await sleep(700);
+    }
+    }catch(e){ console.error("pattern error",parent,String(e).slice(0,100)); }
+    fs.appendFileSync(DONE,parent+"\n"); pat++;
+    console.log(` [${pat}/${todo.length}] ${parent} done — ${rows.length} colorways`);
+  }
+  console.log(`\nROLLOUT (draft) pass: ${pat} patterns, ${prod} draft products created, ${fail} fail. Ledger: ${LEDGER}`);
+})();

← 30f9db1 auto-data-snapshot: 2026-08-24T19:34:57 (5 data files) — gov  ·  back to Japan Enrich  ·  carnegie: add width-backfill revert tool (TK-11016 undo) a3d377e →