← back to Hollywood Optc

rollback-tk10634.mjs

98 lines

#!/usr/bin/env node
// TK-10634 ROLLBACK — restore every variant SKU + metafield from a reversibility jsonl.
// Reads each {type,...,from_sku/from,to_sku/to} record and writes the value BACK to `from`.
// Usage: node rollback-tk10634.mjs data/tk10634-reversibility-<ts>.jsonl [--apply] [--no-result-ok]
//
// Safety:
//   - Ledger gate: refuses a ledger whose sibling tk10634-result-<ts>.json shows 0 applied writes
//     (pre-dbe09cd dry runs still wrote ledgers). A ledger with NO result file (run died mid-way)
//     needs --no-result-ok; the compare-and-swap below still protects every record.
//   - Compare-and-swap: each record is reverted ONLY if the live value still equals `to`
//     (what the run wrote). Anything else is drift (edited since, or never written) and is skipped.
//   - Read-back: the mutation selects the written value; a mismatch counts as a failure.
//   - Receipt: every outcome is appended to data/tk10634-rollback-<ts>.jsonl as it happens.
import { readFileSync, existsSync, appendFileSync } 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 path = process.argv[2];
const APPLY = process.argv.includes('--apply');
const NO_RESULT_OK = process.argv.includes('--no-result-ok');
if (!path || path.startsWith('--')) { console.error('usage: rollback-tk10634.mjs <jsonl> [--apply] [--no-result-ok]'); process.exit(1); }

// Ledger gate — tie the ledger to its run's result counts.
if (/DRYRUN/i.test(path)) { console.error('refusing: ledger is marked DRYRUN'); process.exit(2); }
const resultPath = path.replace('tk10634-reversibility-', 'tk10634-result-').replace(/\.jsonl$/, '.json');
if (existsSync(resultPath)) {
  const res = JSON.parse(readFileSync(resultPath, 'utf8'));
  const applied = (res.sku_applied ?? res.summary?.sku_applied ?? 0) + (res.mf_applied ?? res.summary?.mf_applied ?? 0);
  if (!applied) { console.error(`refusing: ${resultPath} shows 0 applied writes — this ledger is from a dry run`); process.exit(2); }
} else if (!NO_RESULT_OK) {
  console.error(`refusing: no result file ${resultPath} (run may have died mid-way). Re-run with --no-result-ok; compare-and-swap still guards each record.`);
  process.exit(2);
}

const GQL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables, t = 0) {
  let res, j;
  try {
    res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
  } catch (e) {
    if (t < 6) { await sleep(2000 * (t + 1)); return gql(query, variables, t + 1); }
    throw e;
  }
  if ((res.status === 429 || res.status >= 500) && t < 8) { await sleep(2000 * (t + 1)); return gql(query, variables, t + 1); }
  const text = await res.text();
  try { j = JSON.parse(text); } catch { throw new Error(`HTTP ${res.status} non-JSON: ${text.slice(0, 120)}`); }
  if (j.errors && /THROTTL/i.test(JSON.stringify(j.errors)) && t < 6) { await sleep(2500 * (t + 1)); return gql(query, variables, t + 1); }
  return j;
}
const Q_SKU = `query q($id:ID!){productVariant(id:$id){sku}}`;
const Q_MF = `query q($id:ID!,$ns:String!,$k:String!){node(id:$id){...on HasMetafields{metafield(namespace:$ns,key:$k){value}}}}`;
const MUT_SKU = `mutation s($p:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$p,variants:$v){productVariants{id sku} userErrors{message}}}`;
const MUT_MF = `mutation m($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){metafields{value} userErrors{message}}}`;

const recs = readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
const TS = new Date().toISOString().replace(/[:.]/g, '-');
const RECEIPT = new URL(`./data/tk10634-rollback-${TS}.jsonl`, import.meta.url).pathname;
const receipt = o => { if (APPLY) appendFileSync(RECEIPT, JSON.stringify({ ...o, ts: new Date().toISOString() }) + '\n'); };

let ok = 0, fail = 0, drift = 0, would = 0;
for (const r of recs) {
  const isSku = r.type === 'variant_sku';
  const from = isSku ? r.from_sku : r.from;
  const to = isSku ? r.to_sku : r.to;
  const id = isSku ? `gid://shopify/ProductVariant/${r.variant_id}` : r.ownerId;
  const tag = isSku ? `variant ${r.variant_id}` : `${r.ownerId} ${r.ns}.${r.key}`;
  if (!isSku && r.type !== 'metafield') { fail++; console.error('FAIL unknown type', r.type); receipt({ rec: r, outcome: 'unknown_type' }); continue; }
  try {
    const cur = isSku
      ? (await gql(Q_SKU, { id }))?.data?.productVariant?.sku
      : (await gql(Q_MF, { id, ns: r.ns, k: r.key }))?.data?.node?.metafield?.value;
    if (cur === from) { console.log(`[skip] ${tag} already ${from}`); receipt({ rec: r, outcome: 'already_reverted', live: cur }); continue; }
    if (cur !== to) { drift++; console.warn(`[drift] ${tag} live=${JSON.stringify(cur)} expected ${to} — skipped`); receipt({ rec: r, outcome: 'drift_skipped', live: cur ?? null }); continue; }
    if (!APPLY) { would++; console.log(`[dry] revert ${tag} ${to} -> ${from}`); continue; }

    const j = isSku
      ? await gql(MUT_SKU, { p: r.productGid, v: [{ id, inventoryItem: { sku: from } }] })
      : await gql(MUT_MF, { mf: [{ ownerId: r.ownerId, namespace: r.ns, key: r.key, type: 'single_line_text_field', value: from }] });
    const payload = j?.data?.productVariantsBulkUpdate || j?.data?.metafieldsSet;
    const ue = payload?.userErrors || [];
    const written = isSku ? payload?.productVariants?.find(v => v.id === id)?.sku : payload?.metafields?.[0]?.value;
    if (ue.length || j.errors || written !== from) {
      fail++; console.error('FAIL', tag, JSON.stringify(ue.length ? ue : j.errors || { written }));
      receipt({ rec: r, outcome: 'failed', errors: ue.length ? ue : j.errors || null, written: written ?? null });
    } else { ok++; receipt({ rec: r, outcome: 'reverted', written }); }
  } catch (e) {
    fail++; console.error('FAIL', tag, e.message); receipt({ rec: r, outcome: 'error', error: e.message });
  }
  await sleep(300);
}
console.log(APPLY
  ? `rollback done: ${ok} reverted, ${drift} drift-skipped, ${fail} failed — receipt ${RECEIPT}`
  : `dry-run: ${would} would revert, ${drift} drift-skipped, ${fail} errors of ${recs.length} records`);
process.exit(fail ? 1 : 0);