← back to Dw Image Shrink
files-delete.js
42 lines
// Gated bulk fileDelete of orphan images via the CONTENT token (has write_files).
// Reads orphans.jsonl, deletes in batches via GraphQL fileDelete(fileIds:[...]),
// audit-logs every deleted gid to files-deleted.jsonl (resumable, skips logged).
// DRY-RUN by default. --apply to execute. --limit N for a canary batch.
// node files-delete.js [--apply] [--limit 500] [--batch 100]
import fs from 'fs';
import https from 'https';
const SHOP='designer-laboratory-sandbox.myshopify.com';
const TOK=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_CONTENT_TOKEN=')).split('=').slice(1).join('=').replace(/["' ]/g,'');
const DIR='/Users/macstudio3/Projects/dw-image-shrink';
const APPLY=process.argv.includes('--apply');
const arg=(k,d)=>{const a=process.argv.find(x=>x.startsWith('--'+k+'='));return a?a.split('=').slice(1).join('='):d;};
const LIMIT=+arg('limit','0')||1e9, BATCH=+arg('batch','100');
const SOURCE=DIR+'/'+arg('source','orphans.jsonl');
const LOG=DIR+'/files-deleted.jsonl';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
function gql(query,vars){return new Promise((res,rej)=>{const body=JSON.stringify({query,variables:vars});const req=https.request({method:'POST',host:SHOP,path:'/admin/api/2024-10/graphql.json',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{try{res(JSON.parse(d))}catch(e){res({errors:[{message:d.slice(0,120)}]})}});});req.on('error',rej);req.write(body);req.end();});}
const done=new Set();
if(fs.existsSync(LOG)) for(const l of fs.readFileSync(LOG,'utf8').split('\n')) if(l){try{done.add(JSON.parse(l).id)}catch(e){}}
let cand=fs.readFileSync(SOURCE,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l)).filter(o=>!done.has(o.id)).slice(0,LIMIT);
const totGB=(cand.reduce((a,b)=>a+(b.size||0),0)/1073741824).toFixed(2);
console.log(`${APPLY?'APPLYING':'DRY-RUN'}: ${cand.length} orphans to delete (~${totGB} GB); already deleted ${done.size}`);
if(!APPLY){ console.log(' (dry-run — pass --apply to execute)'); process.exit(0); }
const M=`mutation($ids:[ID!]!){ fileDelete(fileIds:$ids){ deletedFileIds userErrors{ field message } } }`;
const out=fs.createWriteStream(LOG,{flags:'a'});
let ok=0,err=0,freed=0;
for(let i=0;i<cand.length;i+=BATCH){
const chunk=cand.slice(i,i+BATCH);
let r; for(let a=0;a<6;a++){ r=await gql(M,{ids:chunk.map(c=>c.id)}); if(r.errors&&/throttl/i.test(JSON.stringify(r.errors))){await sleep(2000);continue;} break; }
if(r.errors){ err+=chunk.length; console.log(' batch ERR:',JSON.stringify(r.errors).slice(0,120)); await sleep(1000); continue; }
const del=new Set(r.data.fileDelete.deletedFileIds||[]);
for(const c of chunk){ if(del.has(c.id)){ out.write(JSON.stringify({id:c.id,size:c.size,ts:new Date().toISOString()})+'\n'); ok++; freed+=c.size||0; } else err++; }
const ue=r.data.fileDelete.userErrors; if(ue&&ue.length) console.log(' userErrors:',JSON.stringify(ue).slice(0,120));
if((i/BATCH)%10===0) console.log(` deleted ${ok} / err ${err} | freed ~${(freed/1073741824).toFixed(1)}GB (${i+chunk.length}/${cand.length})`);
const c=r.extensions&&r.extensions.cost&&r.extensions.cost.throttleStatus;
await sleep(c && c.currentlyAvailable<600 ? 1200 : 350);
}
out.end();
console.log(`DONE: ${ok} deleted (~${(freed/1073741824).toFixed(2)}GB), ${err} errors. Space frees after Shopify GC.`);