← back to Hollywood Optc
apply.mjs
144 lines
#!/usr/bin/env node
// TK-10633 Option C — EXECUTE the Steve-approved HWC remap on the LIVE store.
// Drives off data/hwc-restore-map.json apply_set[] (62 products / 124 variants).
// For EACH variant, BEFORE the write, append {variant_id, from_sku, to_sku, ts} to
// data/apply-reversibility-<ts>.jsonl (reversibility FIRST). Then productVariantsBulkUpdate
// sets inventoryItem.sku = new_sku (standing DW rule: SKU lives on inventoryItem).
// Rate-limit aware (backoff on 429 + THROTTLED userErrors). Idempotent (skips a variant
// already == new_sku). Touches ONLY the 62 apply_set products / 124 variants — never the
// 584 held, never manufacturer_sku.
import { readFileSync, 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 map = JSON.parse(readFileSync(new URL('./data/hwc-restore-map.json', import.meta.url)));
const applySet = map.apply_set || [];
if (applySet.length !== 62) { console.error(`expected 62 apply_set rows, got ${applySet.length}`); process.exit(1); }
const TS = new Date().toISOString().replace(/[:.]/g, '-');
const REV = new URL(`./data/apply-reversibility-${TS}.jsonl`, import.meta.url);
const REV_PATH = REV.pathname;
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();
// GraphQL-level THROTTLED (top-level errors) → backoff + retry
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;
}
// productVariantsBulkUpdate — set inventoryItem.sku for a single variant under its product.
const MUT = `
mutation setSku($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id inventoryItem { sku } }
userErrors { field message code }
}
}`;
// Read current live SKU for a variant (for idempotency skip).
const CUR = `
query cur($id: ID!) {
productVariant(id: $id) { id inventoryItem { sku } }
}`;
async function currentSku(variantGid, tries = 0) {
const j = await graphql(CUR, { id: variantGid });
if (j.errors && tries < 4) { await sleep(1500 * (tries + 1)); return currentSku(variantGid, tries + 1); }
return j?.data?.productVariant?.inventoryItem?.sku ?? null;
}
async function setSku(productGid, variantGid, newSku) {
const j = await graphql(MUT, {
productId: productGid,
variants: [{ id: variantGid, inventoryItem: { sku: newSku } }],
});
const ue = j?.data?.productVariantsBulkUpdate?.userErrors || [];
const throttled = ue.some(e => /THROTTLED/i.test(e.code || '') || /throttl/i.test(e.message || ''));
const topErrs = j.errors || [];
const pv = j?.data?.productVariantsBulkUpdate?.productVariants || [];
const confirmedSku = pv[0]?.inventoryItem?.sku ?? null;
return { userErrors: ue, throttled, topErrors: topErrs, confirmedSku };
}
async function main() {
const results = { applied: 0, skipped_idempotent: 0, failed: 0, details: [] };
let variantIdx = 0;
const totalVariants = applySet.length * 2;
for (const row of applySet) {
const productGid = row.gid;
const jobs = [
{ kind: 'base', variant_id: row.base_variant.variant_id, from_sku: row.base_variant.old_sku, to_sku: row.new_base_sku },
{ kind: 'sample', variant_id: row.sample_variant.variant_id, from_sku: row.sample_variant.old_sku, to_sku: row.new_sample_sku },
];
for (const job of jobs) {
variantIdx++;
const variantGid = `gid://shopify/ProductVariant/${job.variant_id}`;
// reversibility FIRST — record intent before any write
appendFileSync(REV_PATH, JSON.stringify({
variant_id: job.variant_id, from_sku: job.from_sku, to_sku: job.to_sku, ts: new Date().toISOString(),
}) + '\n');
// idempotency — check live current sku
let live = null;
try { live = await currentSku(variantGid); } catch (e) { /* fall through, attempt write */ }
if (live && live.toUpperCase() === job.to_sku.toUpperCase()) {
results.skipped_idempotent++;
results.details.push({ variant_id: job.variant_id, kind: job.kind, status: 'skip_already_target', sku: live });
continue;
}
const r = await setSku(productGid, variantGid, job.to_sku);
if (r.topErrors.length || r.userErrors.length) {
results.failed++;
results.details.push({
variant_id: job.variant_id, kind: job.kind, handle: row.handle,
from: job.from_sku, to: job.to_sku, status: 'ERROR',
userErrors: r.userErrors, topErrors: r.topErrors,
});
console.error(`[${variantIdx}/${totalVariants}] FAIL ${job.from_sku} -> ${job.to_sku}: ` +
JSON.stringify(r.userErrors.length ? r.userErrors : r.topErrors));
} else if (r.confirmedSku && r.confirmedSku.toUpperCase() === job.to_sku.toUpperCase()) {
results.applied++;
results.details.push({ variant_id: job.variant_id, kind: job.kind, status: 'applied', from: job.from_sku, to: r.confirmedSku });
console.log(`[${variantIdx}/${totalVariants}] OK ${job.from_sku} -> ${r.confirmedSku}`);
} else {
// no errors but confirmedSku didn't echo target — treat as failure, keep rev record
results.failed++;
results.details.push({ variant_id: job.variant_id, kind: job.kind, status: 'UNCONFIRMED', from: job.from_sku, to: job.to_sku, confirmedSku: r.confirmedSku });
console.error(`[${variantIdx}/${totalVariants}] UNCONFIRMED ${job.from_sku} -> ${job.to_sku} (got ${r.confirmedSku})`);
}
await sleep(350); // gentle pacing between variant writes
}
}
console.log('\n=== SUMMARY ===');
console.log(`applied: ${results.applied}`);
console.log(`skipped_idempotent: ${results.skipped_idempotent}`);
console.log(`failed: ${results.failed}`);
console.log(`reversibility file: ${REV_PATH}`);
const outPath = new URL(`./data/apply-result-${TS}.json`, import.meta.url).pathname;
appendFileSync(outPath, JSON.stringify(results, null, 2));
console.log(`result detail: ${outPath}`);
if (results.failed) { console.error('\n!! FAILURES PRESENT — reversibility record intact, do NOT half-apply silently.'); process.exit(2); }
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });