← 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
A carnegie-split/backfill-width-drafts.cjsA carnegie-split/rollout-draft.cjs
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 →