← back to Tk10640 Redirect Prune
apply-remediation.mjs
79 lines
// TK-10653 — GATED APPLY of the broken-redirect remediation map.
// Reads data/full-scan/remediation-map.jsonl and, per row:
// REPOINT -> urlRedirectUpdate(id, {path, target:new_target}) (301 -> live product)
// PRUNE -> urlRedirectDelete(id) (301->404 becomes clean 404)
// DRY-RUN by default. --apply performs writes. --limit=N caps writes (canary / 500 tier).
// --only=REPOINT|PRUNE runs just one class. Resumable via data/full-scan/done-remediation.jsonl.
// GUARD: before touching a row, re-check the SOURCE path is NOT a live ACTIVE+published product
// (would be an inert redirect we must not disturb) — SKIP + flag if it is.
// Restore map (data/full-scan/restore-map.jsonl) already exists BEFORE any write; rollback
// script re-applies it. Uses SHOPIFY_FULL_ACCESS_TOKEN only. NEVER --apply without Steve's go.
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
const HOME=process.env.HOME;
const txt=readFileSync(`${HOME}/Projects/secrets-manager/.env`,'utf8');
const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
const STORE=env.SHOPIFY_STORE_DOMAIN||'designer-laboratory-sandbox.myshopify.com', TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
const APPLY=process.argv.includes('--apply');
const LIMIT=Number((process.argv.find(a=>a.startsWith('--limit='))||'').split('=')[1]||Infinity);
const SCAN_LIMIT=Number((process.argv.find(a=>a.startsWith('--scan-limit='))||'').split('=')[1]||Infinity);
const ONLY=(process.argv.find(a=>a.startsWith('--only='))||'').split('=')[1]||null;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v={}){for(let a=0;a<12;a++){try{
const res=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});
const tx=await res.text();let j;try{j=JSON.parse(tx);}catch{await sleep(1200*(a+1));continue;}
if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(1200*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}
return j.data;
}catch(e){if(a<11){await sleep(1200*(a+1));continue;}throw e;}}}
const DONE=`${HOME}/Projects/tk10640-redirect-prune/data/full-scan/done-remediation.jsonl`;
const done=new Set(); if(existsSync(DONE)) for(const l of readFileSync(DONE,'utf8').split('\n')) if(l.trim()) done.add(JSON.parse(l).id||JSON.parse(l).path);
const UPD=`mutation($id:ID!,$r:UrlRedirectInput!){ urlRedirectUpdate(id:$id, urlRedirect:$r){ userErrors{ message } } }`;
const DEL=`mutation($id:ID!){ urlRedirectDelete(id:$id){ deletedUrlRedirectId userErrors{ message } } }`;
// WRITE-TIME FRESHNESS GUARD (dump is captured hours/days earlier; the live redirect may have
// been repointed since). Read the redirect's CURRENT live target by id + whether a handle is a
// live ACTIVE+published product. A row is STALE and SKIPPED if reality no longer matches the map.
const REDIR=`query($id:ID!){ urlRedirect(id:$id){ id path target } }`;
const PBH=`query($h:String!){ productByHandle(handle:$h){ status onlineStoreUrl } }`;
const handleOf=t=>(t||'').replace(/^\/products\//,'').split('?')[0].split('#')[0];
async function liveTarget(id){ const d=await gql(REDIR,{id}); return d.urlRedirect? d.urlRedirect.target : null; }
async function isLiveProduct(target){ if(!/^\/products\//.test(target||'')) return false; const d=await gql(PBH,{h:handleOf(target)}); const p=d.productByHandle; return !!(p && p.status==='ACTIVE' && p.onlineStoreUrl); }
const MAP=(process.argv.find(a=>a.startsWith('--map='))||'').split('=')[1]||`${HOME}/Projects/tk10640-redirect-prune/data/full-scan/remediation-map.jsonl`;
const rows=readFileSync(MAP,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
.filter(r=> ONLY? r.action===ONLY : true);
let scanned=0,repointed=0,pruned=0,skipped_live=0,skipped_stale=0,err=0,writes=0;
for(const r of rows){
const key=r.id||r.path;
if(done.has(key)) continue;
scanned++;
if(/^\/products\//.test(r.path)){
const h=r.path.replace(/^\/products\//,'').split('?')[0].split('#')[0];
const d=await gql(`query($h:String!){productByHandle(handle:$h){status onlineStoreUrl}}`,{h});
const p=d.productByHandle;
if(p && p.status==='ACTIVE' && p.onlineStoreUrl){ skipped_live++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'LIVE_ACTIVE_PUBLISHED'})+'\n'); continue; }
}
// Freshness guard vs LIVE state (skip stale rows even in DRY-RUN so the dry-run count is honest)
const curTarget = await liveTarget(r.id);
if(curTarget===null){ skipped_stale++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'REDIRECT_GONE'})+'\n'); continue; }
if(r.action==='REPOINT'){
if(curTarget===r.new_target){ skipped_stale++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'ALREADY_REPOINTED'})+'\n'); continue; }
if(curTarget!==r.old_target){ skipped_stale++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'TARGET_DRIFTED',live:curTarget})+'\n'); continue; }
if(!(await isLiveProduct(r.new_target))){ skipped_stale++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'REPLACEMENT_NOT_LIVE'})+'\n'); continue; }
} else { // PRUNE
if(await isLiveProduct(curTarget)){ skipped_live++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'TARGET_NOW_LIVE'})+'\n'); continue; }
}
if(APPLY){
if(r.action==='REPOINT'){
const d=await gql(UPD,{id:r.id, r:{path:r.path, target:r.new_target}});
if(d.urlRedirectUpdate.userErrors.length){ err++; }
else { repointed++; writes++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,repointed:r.new_target})+'\n'); }
} else {
const d=await gql(DEL,{id:r.id});
if(d.urlRedirectDelete.userErrors.length){ err++; }
else { pruned++; writes++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,pruned:true})+'\n'); }
}
}
if(scanned%1000===0) process.stderr.write(` scanned ${scanned} repoint ${repointed} prune ${pruned} skip_live ${skipped_live}\n`);
if(writes>=LIMIT){ process.stderr.write(`hit --limit=${LIMIT} (writes=${writes})\n`); break; }
if(scanned>=SCAN_LIMIT){ process.stderr.write(`hit --scan-limit=${SCAN_LIMIT} (scanned=${scanned})\n`); break; }
}
console.log(JSON.stringify({mode:APPLY?'APPLY':'DRY-RUN', only:ONLY||'ALL', total_rows:rows.length, scanned, repointed, pruned, skipped_live, skipped_stale, errors:err, writes},null,2));