← 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); });