← back to Newmor Onboard
scripts/apply-a-bucket-sku.mjs
72 lines
// TK-10670 A-bucket Sample-variant SKU normalization — APPROVED by Steve 2026-09-09.
// Rewrites the single Sample variant SKU on 125 live Newmor products to {DWNM-58xxxx}-Sample.
// Drift-guard per product (re-read live sku; STOP whole run on mismatch), verify-after-write,
// batches of <=100 with >=90s inter-batch gap, retry on transient 5xx. Reversible via restore-map.
import { gql, ROOT } from './shopify.mjs';
import fs from 'node:fs';
const MAP = JSON.parse(fs.readFileSync(`${ROOT}/artifacts/A-BUCKET-SKU-RESTORE-MAP.json`, 'utf8'));
const rows = MAP.rows;
const JOURNAL = `${ROOT}/data/a-bucket-apply-journal.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gqlRetry(q, v, tries = 4) {
let last;
for (let i = 0; i < tries; i++) {
try { return await gql(q, v); }
catch (e) { last = e; if (/50[0-9]|throttl|Timeout|ETIMEDOUT|fetch failed/i.test(e.message)) { await sleep(2000 * (i + 1)); continue; } throw e; }
}
throw last;
}
const MUT = `mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid,variants:$vars){
productVariants{id inventoryItem{sku}}
userErrors{field message code}
}
}`;
const results = [];
let aborted = null;
async function processRow(r) {
// Drift guard: re-read current live sku for THIS variant
const d = await gqlRetry(`query($id:ID!){product(id:$id){status variants(first:5){nodes{id sku}}}}`, { id: r.gid });
const p = d.product;
if (!p) return { ...r, result: 'ABORT', reason: 'product-missing' };
const v = p.variants.nodes.find(x => x.id === r.variant_id);
if (!v) return { ...r, result: 'ABORT', reason: 'variant-missing' };
if (v.sku === r.new_sku) return { handle: r.handle, gid: r.gid, variant_id: r.variant_id, old_sku: r.old_sku, new_sku: r.new_sku, status: p.status, result: 'ALREADY-NEW' };
if (v.sku !== r.old_sku) return { ...r, live_sku: v.sku, result: 'ABORT', reason: `drift: live sku ${JSON.stringify(v.sku)} != recorded old ${JSON.stringify(r.old_sku)}` };
// Write
const w = await gqlRetry(MUT, { pid: r.gid, vars: [{ id: r.variant_id, inventoryItem: { sku: r.new_sku } }] });
const res = w.productVariantsBulkUpdate;
if (res.userErrors && res.userErrors.length) return { ...r, result: 'FAIL', errors: res.userErrors };
const got = res.productVariants?.[0]?.inventoryItem?.sku;
if (got !== r.new_sku) return { ...r, got, result: 'FAIL', reason: 'post-write sku mismatch' };
return { handle: r.handle, gid: r.gid, variant_id: r.variant_id, status: p.status, old_sku: r.old_sku, new_sku: r.new_sku, result: 'OK' };
}
const batches = [rows.slice(0, 100), rows.slice(100)];
for (let b = 0; b < batches.length; b++) {
if (aborted) break;
for (const r of batches[b]) {
const out = await processRow(r);
results.push(out);
fs.writeFileSync(JOURNAL, JSON.stringify({ started: MAP.generated_at, ts: new Date().toISOString(), batch: b + 1, done: results.length, results }, null, 2) + '\n');
if (out.result === 'ABORT') { aborted = out; break; }
await sleep(250);
}
if (aborted) break;
if (b === 0 && batches[1].length) { console.log(`batch 1 done (${results.length}); sleeping 90s before batch 2...`); await sleep(90000); }
}
const tally = results.reduce((a, r) => (a[r.result] = (a[r.result] || 0) + 1, a), {});
const summary = { finished: new Date().toISOString(), total: rows.length, processed: results.length, tally, aborted: aborted || null };
fs.writeFileSync(JOURNAL, JSON.stringify({ ...summary, results }, null, 2) + '\n');
console.log('DONE', JSON.stringify(summary));
if (aborted) { console.error('ABORTED:', aborted.reason); process.exit(3); }
const clean = (tally.OK || 0) + (tally['ALREADY-NEW'] || 0);
if (clean !== rows.length) { console.error(`INCOMPLETE: ${clean}/${rows.length} clean (tally ${JSON.stringify(tally)})`); process.exit(4); }
console.log(`ALL CLEAN: ${clean}/${rows.length}`);