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