← back to Tk10630 Sku Suffix Canary
apply-versa-fix.mjs
44 lines
// Apply the Versa-leak fix from versa-fix-plan.json. DRY-RUN by default; --apply writes.
// Per product (serial): (1) create 301 redirect old->new, (2) rename handle,
// (3) rewrite leaking image alt text. Resumable via done-versa.jsonl.
// NOTE: needs write_products (+ write_content for urlRedirect). Customer-facing — Steve-gated.
import { gql } from './shopify.mjs';
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
const APPLY = process.argv.includes('--apply');
const CONC = Number((process.argv.find(a => a.startsWith('--conc=')) || '').split('=')[1] || 4);
const DONE = 'done-versa.jsonl';
const plan = JSON.parse(readFileSync('versa-fix-plan.json', 'utf8'));
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);
let work = plan.filter(p => !done.has(p.id));
console.log(`[versa-fix] mode=${APPLY ? 'LIVE-WRITE' : 'DRY-RUN'} products=${plan.length} todo=${work.length} conc=${CONC}`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function mut(q, v) { for (let a = 0; ; a++) { try { const { data } = await gql(q, v); return data; } catch (e) { if (/THROTTLED/i.test(e.message) && a < 8) { await sleep(1500 * (a + 1)); continue; } throw e; } } }
const REDIR = `mutation($path:String!,$target:String!){ urlRedirectCreate(urlRedirect:{path:$path,target:$target}){ userErrors{ field message } } }`;
const PUPD = `mutation($id:ID!,$handle:String!){ productUpdate(input:{id:$id,handle:$handle}){ product{ handle } userErrors{ field message } } }`;
const ALT = `mutation($files:[FileUpdateInput!]!){ fileUpdate(files:$files){ userErrors{ field message } } }`;
async function one(p) {
// HANDLE-ONLY pass (rename + 301 redirect). Alt text is a separate pass
// (apply-versa-alt.mjs) because alt lives on MediaImage, not ProductImage.
const errs = [];
if (p.from !== p.to && APPLY) {
// 301 redirect first; tolerate "already exists" (idempotent re-runs)
const r = await mut(REDIR, { path: `/products/${p.from}`, target: `/products/${p.to}` });
const rue = r.urlRedirectCreate.userErrors.filter(e => !/already|taken|exist/i.test(e.message));
if (rue.length) errs.push('redirect: ' + JSON.stringify(rue));
const u = await mut(PUPD, { id: p.id, handle: p.to });
if (u.productUpdate.userErrors.length) errs.push('handle: ' + JSON.stringify(u.productUpdate.userErrors));
}
if (APPLY && errs.length === 0) appendFileSync(DONE, JSON.stringify({ id: p.id, from: p.from, to: p.to }) + '\n');
return errs;
}
let idx = 0, ok = 0, err = 0;
async function worker() { while (idx < work.length) { const p = work[idx++]; try { const e = await one(p); if (e.length) { err++; console.log('✗', p.from, e.join('; ')); } else ok++; } catch (e) { err++; console.log('✗', p.from, e.message.slice(0, 100)); } if ((ok + err) % 50 === 0) process.stderr.write(` ${ok + err}/${work.length}\n`); } }
await Promise.all(Array.from({ length: Math.min(CONC, work.length) }, () => worker()));
console.log(`[versa-fix] DONE ok=${ok} err=${err} ${APPLY ? '(written)' : '(dry-run)'}`);