← back to Rebel Walls Push
scripts/rollback-relabel.js
66 lines
#!/usr/bin/env node
/**
* TK-10029 ROLLBACK: reverse the Roll->"Mural (per m²)" relabel using a durable
* pre-apply rollback ledger. For each recorded rename, renames the canonical option
* value BACK to its captured old_value. Dry-run by default; --apply to execute.
*
* Usage:
* node scripts/rollback-relabel.js --ledger data/relabel-rollback-map-<ISO>.json
* node scripts/rollback-relabel.js --ledger <file> --apply
*/
const https = require('https');
const fs = require('fs');
const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';
function getToken() {
const env = fs.readFileSync(SECRETS_ENV, 'utf8');
const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m);
if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
return m[1].trim();
}
const TOKEN = getToken();
const args = process.argv.slice(2);
const DRY = !args.includes('--apply');
const CANONICAL = 'Mural (per m²)';
const li = args.indexOf('--ledger');
if (li < 0) { console.error('need --ledger <file>'); process.exit(1); }
const ledger = JSON.parse(fs.readFileSync(args[li + 1], 'utf8'));
function gql(query, vars) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables: vars || {} });
const req = https.request({
hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>{try{resolve(JSON.parse(d))}catch(e){reject(e)}}); });
req.on('error', reject); req.write(body); req.end();
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
console.log(`[rollback] mode=${DRY?'DRY-RUN':'LIVE'} rows=${ledger.renames.length}`);
let done=0, skip=0, err=0;
for (const row of ledger.renames) {
const detailQ = `query { product(id: "${row.productId}") { options { id name optionValues { id name } } } }`;
const detail = await gql(detailQ);
const opt = detail.data?.product?.options?.find(o => o.id === row.optionId);
if (!opt) { console.log(` SKIP ${row.title} — option gone`); skip++; continue; }
const ov = opt.optionValues.find(v => v.name === CANONICAL);
if (!ov) { console.log(` SKIP ${row.title} — canonical not present (already reverted?)`); skip++; continue; }
console.log(` REVERT ${row.title}: "${CANONICAL}" -> "${row.old_value}"`);
if (DRY) { done++; continue; }
const mutation = `mutation($productId: ID!, $option: OptionUpdateInput!, $ovs: [OptionValueUpdateInput!]!) {
productOptionUpdate(productId:$productId, option:$option, optionValuesToUpdate:$ovs) { userErrors { message } } }`;
const res = await gql(mutation, { productId: row.productId,
option: { id: row.optionId, name: row.optionName }, ovs: [{ id: ov.id, name: row.old_value }] });
const errs = res.data?.productOptionUpdate?.userErrors;
if (errs && errs.length) { console.log(` ERR ${row.title}: ${errs.map(e=>e.message).join('; ')}`); err++; }
else { done++; await sleep(500); }
}
console.log(`[rollback] done=${done} skip=${skip} err=${err} dry=${DRY}`);
})().catch(e => { console.error('[rollback] FATAL', e.message); process.exit(1); });