← back to Hollywood Optc

rollback.mjs

69 lines

#!/usr/bin/env node
// TK-10633 Option C — ROLLBACK. Replays a reversibility jsonl written by apply.mjs,
// re-setting each variant's inventoryItem.sku back to from_sku.
// Usage: node rollback.mjs data/apply-reversibility-<ts>.jsonl
import { readFileSync } from 'node:fs';

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const env = readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN=')) || '')
  .replace('SHOPIFY_ADMIN_TOKEN=', '').replace(/["'\r]/g, '').trim();
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const jsonlPath = process.argv[2];
if (!jsonlPath) { console.error('usage: node rollback.mjs <reversibility.jsonl>'); process.exit(1); }
const lines = readFileSync(jsonlPath, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
if (!lines.length) { console.error('empty reversibility file'); process.exit(1); }

const GQL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function graphql(query, variables, tries = 0) {
  const res = await fetch(GQL, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  if (res.status === 429) { await sleep(2000); return graphql(query, variables, tries); }
  if ((res.status === 502 || res.status === 503) && tries < 4) { await sleep(1500 * (tries + 1)); return graphql(query, variables, tries + 1); }
  const j = await res.json();
  if (j.errors && j.errors.some(e => /THROTTLED|throttl/i.test(JSON.stringify(e))) && tries < 6) {
    await sleep(2500 * (tries + 1)); return graphql(query, variables, tries + 1);
  }
  return j;
}

// The reversibility line carries variant_id but not productId; productVariantsBulkUpdate
// needs the productId. Fetch it from the variant.
const OWNER = `query owner($id: ID!) { productVariant(id: $id) { id product { id } inventoryItem { sku } } }`;
const MUT = `
mutation setSku($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id inventoryItem { sku } }
    userErrors { field message code }
  }
}`;

async function main() {
  let ok = 0, fail = 0;
  for (const [i, rec] of lines.entries()) {
    const variantGid = `gid://shopify/ProductVariant/${rec.variant_id}`;
    const oj = await graphql(OWNER, { id: variantGid });
    const productGid = oj?.data?.productVariant?.product?.id;
    if (!productGid) { fail++; console.error(`[${i + 1}/${lines.length}] FAIL owner-lookup ${rec.variant_id}`); continue; }
    const j = await graphql(MUT, { productId: productGid, variants: [{ id: variantGid, inventoryItem: { sku: rec.from_sku } }] });
    const ue = j?.data?.productVariantsBulkUpdate?.userErrors || [];
    const confirmed = j?.data?.productVariantsBulkUpdate?.productVariants?.[0]?.inventoryItem?.sku ?? null;
    if (ue.length || (j.errors || []).length) {
      fail++; console.error(`[${i + 1}/${lines.length}] FAIL ${rec.to_sku} -> ${rec.from_sku}: ${JSON.stringify(ue.length ? ue : j.errors)}`);
    } else {
      ok++; console.log(`[${i + 1}/${lines.length}] OK ${rec.to_sku} -> ${confirmed}`);
    }
    await sleep(350);
  }
  console.log(`\nrollback done: ok=${ok} fail=${fail}`);
  if (fail) process.exit(2);
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });