← back to Dw Data Repair

scripts/rollback.mjs

174 lines

#!/usr/bin/env node
/**
 * TK-10875: Rollback script using prestate map
 *
 * Reads a prestate-*.json file and restores every product's variants
 * to their exact pre-repair state by:
 *   1. Deleting any variants NOT in the prestate
 *   2. Restoring price/sku/title on variants that existed in prestate
 *
 * Usage:
 *   node scripts/rollback.mjs --prestate data/prestate-2026-08-28T04-00-00-000Z.json --dry-run
 *   node scripts/rollback.mjs --prestate data/prestate-2026-08-28T04-00-00-000Z.json --apply
 */

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
  const envPath = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
  if (fs.existsSync(envPath)) {
    const line = fs.readFileSync(envPath, 'utf8')
      .split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
    if (line) return line.split('=')[1].trim();
  }
  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
})();

const args = process.argv.slice(2);
const prestateIdx = args.indexOf('--prestate');
if (prestateIdx < 0 || !args[prestateIdx + 1]) {
  console.error('Usage: node rollback.mjs --prestate <path> [--dry-run|--apply]');
  process.exit(1);
}
const PRESTATE_FILE = args[prestateIdx + 1];
const DRY_RUN = !args.includes('--apply');

if (!fs.existsSync(PRESTATE_FILE)) {
  console.error(`Prestate file not found: ${PRESTATE_FILE}`);
  process.exit(1);
}

const prestate = JSON.parse(fs.readFileSync(PRESTATE_FILE, 'utf8'));
const productIds = Object.keys(prestate);

console.log(`\nTK-10875 rollback.mjs`);
console.log(`Mode: ${DRY_RUN ? 'DRY-RUN' : 'APPLY'}`);
console.log(`Prestate: ${PRESTATE_FILE}`);
console.log(`Products: ${productIds.length}`);
console.log(`---`);

async function shopifyGet(path) {
  const resp = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/${path}`, {
    headers: { 'X-Shopify-Access-Token': TOKEN }
  });
  if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}`);
  return resp.json();
}

async function shopifyPut(path, body) {
  const resp = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/${path}`, {
    method: 'PUT',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!resp.ok) {
    const t = await resp.text();
    throw new Error(`PUT ${path} → ${resp.status}: ${t.slice(0, 200)}`);
  }
  return resp.json();
}

async function shopifyDelete(path) {
  const resp = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/${path}`, {
    method: 'DELETE',
    headers: { 'X-Shopify-Access-Token': TOKEN },
  });
  if (!resp.ok) {
    const t = await resp.text();
    throw new Error(`DELETE ${path} → ${resp.status}: ${t.slice(0, 200)}`);
  }
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

async function main() {
  let restored = 0, deleted = 0, errors = 0;

  for (const productId of productIds) {
    const snap = prestate[productId];
    console.log(`\n[${snap.vendor}] ${productId} — ${snap.title?.slice(0, 50)}`);

    // Fetch current live state
    let live;
    try {
      const data = await shopifyGet(`products/${productId}.json`);
      live = data.product;
    } catch (e) {
      console.error(`  ERROR fetching live: ${e.message}`);
      errors++;
      continue;
    }

    const liveVariantIds = new Set(live.variants.map(v => String(v.id)));
    const snapVariantIds = new Set(snap.variants.map(v => String(v.id)));

    // Delete variants that didn't exist in prestate (were added by repair)
    const toDelete = live.variants.filter(v => !snapVariantIds.has(String(v.id)));
    for (const v of toDelete) {
      console.log(`  DELETE variant ${v.id} (${v.sku} @ $${v.price}) — not in prestate`);
      if (!DRY_RUN) {
        try {
          await shopifyDelete(`products/${productId}/variants/${v.id}.json`);
          deleted++;
          await sleep(300);
        } catch (e) {
          console.error(`    ERROR deleting: ${e.message}`);
          errors++;
        }
      }
    }

    // Restore variants that exist in both (price/sku may have changed)
    for (const snapV of snap.variants) {
      if (!liveVariantIds.has(String(snapV.id))) {
        console.log(`  WARN: prestate variant ${snapV.id} (${snapV.sku}) not found live — was it deleted?`);
        continue;
      }
      const liveV = live.variants.find(v => String(v.id) === String(snapV.id));
      if (liveV.price === snapV.price && liveV.sku === snapV.sku && liveV.option1 === snapV.option1) {
        console.log(`  OK: variant ${snapV.id} unchanged`);
        continue;
      }
      console.log(`  RESTORE variant ${snapV.id}: price ${liveV.price}→${snapV.price} sku ${liveV.sku}→${snapV.sku}`);
      if (!DRY_RUN) {
        try {
          await shopifyPut(`variants/${snapV.id}.json`, {
            variant: {
              id: snapV.id,
              price: snapV.price,
              sku: snapV.sku,
              option1: snapV.option1,
              inventory_management: snapV.inventory_management,
            }
          });
          restored++;
          await sleep(300);
        } catch (e) {
          console.error(`    ERROR restoring: ${e.message}`);
          errors++;
        }
      }
    }

    await sleep(200);
  }

  console.log(`\n=== ROLLBACK COMPLETE ===`);
  console.log(`Variants deleted: ${DRY_RUN ? '(dry)' : deleted}`);
  console.log(`Variants restored: ${DRY_RUN ? '(dry)' : restored}`);
  console.log(`Errors: ${errors}`);
  if (DRY_RUN) console.log(`\nRe-run with --apply to execute.`);
}

main().catch(e => {
  console.error(`FATAL: ${e.message}`);
  process.exit(1);
});