← back to Carnegie Reprice
archive-redirect.mjs
169 lines
// archive-redirect.mjs — TK-10686 post-create step: archive the OLD Carnegie multi-variant
// products and 301-redirect each old handle -> its new first-colorway standalone product (target A).
// Steve APPROVED the plan 2026-08-18 (pending-approval/...-archive-redirect-gmc-GATED.md).
//
// SAFETY (mirrors reprice.mjs):
// - dry-run by default: enumerates, joins, writes archive-run-map.json + preview, touches NOTHING.
// - --apply : gated live write (archive old product + create redirect). Reversible.
// - --rollback archive-run-map.json : un-archive every product + delete every created redirect.
// - COMPLETENESS GATE: an old product is only archived if EVERY colorway of its pattern
// (carnegie_catalog rows) already exists as a created new product (rebuild-ledger.jsonl w/ product_id).
// Any pattern with missing replacements is SKIPPED and reported — never archived half-covered.
// - NEW split products (tagged split-batch:TK-10686) are never touched.
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
const DIR = new URL('.', import.meta.url).pathname;
const ENV = `${process.env.HOME}/Projects/secrets-manager/.env`;
function 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 API=`https://${SHOP}/admin/api/2024-10`;
const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const MODE=process.argv[2]||'dry'; // dry | apply | rollback
const ROLLBACK_FILE=process.argv[3];
const TAG='split-batch:TK-10686';
const LEDGER=`${DIR}rebuild-ledger.jsonl`;
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 true;}catch{return false;}})||'psql';
function q(sql){ const out=execFileSync(PSQL,['postgresql:///dw_unified?host=/tmp','-At','-F','|','-c',sql],{encoding:'utf8',maxBuffer:128*1024*1024}); return out.trim()?out.trim().split('\n').map(r=>r.split('|')):[]; }
async function shop(path,opts={},tries=5){
for(let i=0;i<tries;i++){ const res=await fetch(`${API}${path}`,{headers:H,...opts});
if(res.status===429){await sleep(2000*(i+1));continue;}
if(!res.ok)throw new Error(`HTTP ${res.status} ${path} :: ${(await res.text()).slice(0,200)}`);
return res; }
throw new Error(`exhausted retries ${path}`);
}
async function allCarnegie(){ const out=[]; let url=`/products.json?vendor=Carnegie&limit=250`;
while(url){ const res=await shop(url); const j=await res.json(); out.push(...(j.products||[]));
const link=res.headers.get('link')||''; const m=link.match(/<[^>]*[?&]page_info=([^>&]+)[^>]*>;\s*rel="next"/);
url=m?`/products.json?limit=250&page_info=${m[1]}`:null; await sleep(600); }
return out;
}
// ---- ROLLBACK ----
if(MODE==='rollback'){
if(!ROLLBACK_FILE||!fs.existsSync(ROLLBACK_FILE))throw new Error('rollback needs archive-run-map.json');
const map=JSON.parse(fs.readFileSync(ROLLBACK_FILE,'utf8'));
console.log(`[rollback] un-archiving ${map.archived.length} products + deleting ${map.redirects.length} redirects…`);
// TK-10792 note: rollback re-activates OLD multi-variant products that were intentionally
// archived. These are original Carnegie pattern pages with real vendor mfr_skus (pre-split),
// NOT new DWAG-* products. mfr_sku gate intentionally does not apply to this undo path.
// TK-11547 WEIGHT GO-LIVE GUARD (Steve's TK-11414 rule: never ACTIVE at zero/missing weight —
// zero weight collapses an order into the lowest weight tier / free-shipping band and mis-costs
// freight). Found by shopify-activator-register (TK-11498); this file was never in the
// TK-11414/TK-11471 field of view.
//
// HEAL-THEN-PROCEED-LOUDLY, deliberately NOT hold. Every other site gated under TK-11547 HOLDS,
// but this is an UNDO path: it restores products that were intentionally archived. A gate that
// can block a rollback is worse than the defect it prevents — it strands you mid-incident with
// no way back. So this never refuses to un-archive. It fills the approved default first, and if
// that fails it un-archives ANYWAY and says so loudly, leaving dw-active-weight-canary (daily)
// as the backstop. Silence is the only outcome ruled out.
//
// Heal goes through the REST variant field because this script authenticates with the NARROW
// SHOPIFY_ADMIN_TOKEN, which lacks write_inventory (the GraphQL inventoryItemUpdate path 403s).
const W_SAMPLE_LB = 0.25, W_DEFAULT_LB = 3.0; // TK-11414-approved defaults
const isSample = v => /sample|memo/i.test(String(v.title||v.option1||'')) || /-sample$/i.test(String(v.sku||''))
|| Math.abs(Number(v.price)-4.25) < 0.01;
let healed=0, unhealed=[];
for(const a of map.archived){
try{
const pr = await (await shop(`/products/${a.old_id}.json`)).json();
for(const v of (pr.product?.variants||[])){
const w = Number(v.weight);
const u = String(v.weight_unit||'lb').toLowerCase();
const lb = u.startsWith('kg') ? w*2.20462 : (u==='g'||u.startsWith('gram')) ? w/453.59237 : u==='oz' ? w/16 : w;
if(Number.isFinite(lb) && lb > 0) continue;
const target = isSample(v) ? W_SAMPLE_LB : W_DEFAULT_LB;
await shop(`/variants/${v.id}.json`,{method:'PUT',body:JSON.stringify({variant:{id:v.id,weight:target,weight_unit:'lb'}})});
await sleep(400);
// RE-VERIFY: the mutation's own 200 is not evidence the weight is set.
const re = await (await shop(`/variants/${v.id}.json`)).json();
const rw = Number(re.variant?.weight);
if(Number.isFinite(rw) && rw > 0) healed++; else unhealed.push(`${a.old_id}/${v.sku||v.id}`);
}
}catch(e){ unhealed.push(`${a.old_id}(read/heal error: ${e.message})`); }
await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550);
}
if(healed) console.log(`[rollback] TK-11547: healed ${healed} zero-weight variant(s) to the approved default before un-archiving`);
if(unhealed.length) console.error(`[rollback] ⚠️ TK-11547: ${unhealed.length} variant(s) are LIVE AT ZERO WEIGHT — un-archived anyway (an undo must never be blocked). dw-active-weight-canary will flag these within 24h; fix with scripts/tk11471-weight-backfill.py. ${unhealed.slice(0,15).join(', ')}`);
for(const r of map.redirects){ if(r.redirect_id){ try{await shop(`/redirects/${r.redirect_id}.json`,{method:'DELETE'});}catch{} await sleep(400);} }
console.log(`[rollback] done`); process.exit(0);
}
// ---- BUILD: pattern -> colorways expected (carnegie_catalog) ----
const patColorways=new Map(); // pattern_name -> [{dw_sku,color_number}]
for(const [pat,sku,cn] of q(`select pattern_name, dw_sku, color_number from carnegie_catalog order by pattern_name, dw_sku`)){
if(!patColorways.has(pat))patColorways.set(pat,[]); patColorways.get(pat).push({sku,cn});
}
// ---- BUILD: dw_sku -> new product (from ledger, only rows with a product_id) ----
const newBySku=new Map(); // dw_sku -> {product_id, handle}
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.product_id&&r.dw_sku)newBySku.set(r.dw_sku,{id:r.product_id,handle:r.handle});}catch{} }
// pattern -> first-colorway new handle (lowest color_number that exists as a new product)
function firstColorwayNewHandle(pat){ const cw=patColorways.get(pat)||[]; for(const c of cw){ const np=newBySku.get(c.sku); if(np)return np.handle; } return null; }
function patternComplete(pat){ const cw=patColorways.get(pat)||[]; if(!cw.length)return false; return cw.every(c=>newBySku.has(c.sku)); }
// ---- ENUMERATE old multi-variant Carnegie products (NOT tagged split-batch, has a "Color" option) ----
const products=await allCarnegie();
const old=products.filter(p=>{
const tags=(p.tags||'').includes(TAG); if(tags)return false; // exclude new split products
const hasColor=(p.options||[]).some(o=>/^colou?r$/i.test(o.name)); // original had a Color option
return hasColor;
});
console.log(`[enum] live Carnegie products: ${products.length} | old multi-variant (Color option, not split-tagged): ${old.length}`);
// map each old product to its pattern via its non-sample variant SKUs -> carnegie_catalog pattern
const skuPattern=new Map(); for(const [pat,sku] of q(`select pattern_name, dw_sku from carnegie_catalog`)) skuPattern.set(sku,pat);
const ready=[], blocked=[], nopattern=[];
for(const p of old){
const skus=(p.variants||[]).map(v=>v.sku).filter(Boolean).filter(s=>!/sample/i.test(s));
const pats=[...new Set(skus.map(s=>skuPattern.get(s)).filter(Boolean))];
const pat=pats[0];
if(!pat){ nopattern.push({handle:p.handle,id:p.id}); continue; }
const target=firstColorwayNewHandle(pat);
const complete=patternComplete(pat) && target;
const rec={old_id:p.id, old_handle:p.handle, pattern:pat, expected:(patColorways.get(pat)||[]).length,
present:(patColorways.get(pat)||[]).filter(c=>newBySku.has(c.sku)).length, target_handle:target};
(complete?ready:blocked).push(rec);
}
const stamp=q(`select to_char(now(),'YYYYMMDD-HH24MISS')`)[0][0];
const preview={ ts:new Date().toISOString(), mode:MODE, redirect_target:'A (old -> new first-colorway product)',
old_total:old.length, ready:ready.length, blocked:blocked.length, no_pattern:nopattern.length,
ready_sample:ready.slice(0,8), blocked_sample:blocked.slice(0,8), no_pattern_sample:nopattern.slice(0,5) };
fs.writeFileSync(`${DIR}archive-preview-${stamp}.json`,JSON.stringify(preview,null,2));
console.log(`\n=== ARCHIVE+REDIRECT PLAN (${MODE.toUpperCase()}) — target A ===`);
console.log(` old multi-variant products : ${old.length}`);
console.log(` READY (all colorways created): ${ready.length}`);
console.log(` BLOCKED (replacements incomplete): ${blocked.length}`);
console.log(` no-pattern (review): ${nopattern.length}`);
for(const r of ready.slice(0,8)) console.log(` ARCHIVE ${r.old_handle.padEnd(34)} -> /products/${r.target_handle}`);
if(blocked.length) console.log(` ⚠ ${blocked.length} blocked e.g. ${blocked.slice(0,3).map(b=>`${b.old_handle}(${b.present}/${b.expected})`).join(', ')}`);
if(MODE!=='apply'){ console.log(`\n[dry-run] nothing written. preview: archive-preview-${stamp}.json. Re-run 'apply' AFTER creates complete + Steve go.`); process.exit(0); }
// ---- APPLY (gated): archive READY old products + create redirect ----
if(!ready.length){ console.log('[apply] nothing READY yet (creates incomplete) — refusing to archive half-covered patterns.'); process.exit(0); }
console.log(`\n[apply] archiving ${ready.length} old products + creating redirects…`);
const runmap={ts:new Date().toISOString(),archived:[],redirects:[]};
let ok=0,err=0;
for(const r of ready){
try{
await shop(`/products/${r.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:r.old_id,status:'archived'}})});
runmap.archived.push({old_id:r.old_id,old_handle:r.old_handle});
const rr=await shop(`/redirects.json`,{method:'POST',body:JSON.stringify({redirect:{path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`}})});
const rid=(await rr.json())?.redirect?.id||null;
runmap.redirects.push({redirect_id:rid,path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`});
ok++;
}catch(e){ err++; console.log(` ERR ${r.old_handle}: ${String(e.message||e).slice(0,120)}`); }
if((ok+err)%25===0)console.log(` ${ok+err}/${ready.length} (ok ${ok}, err ${err})`);
await sleep(600);
}
fs.writeFileSync(`${DIR}archive-run-map.json`,JSON.stringify(runmap,null,2));
console.log(`[apply] done: ${ok} archived+redirected, ${err} err. Rollback: node archive-redirect.mjs rollback archive-run-map.json`);
console.log(`[apply] ${blocked.length} patterns still blocked — re-run apply after remaining creates land.`);