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