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