[object Object]

← back to Hollywood Optc

Option C HWC remap: apply.mjs + rollback.mjs (reversibility-first, idempotent, throttle-aware)

f1a72a39b56c96f8db17738bb0c24170d7f85acd · 2026-08-20 11:08:21 -0700 · Steve Abrams

Files touched

Diff

commit f1a72a39b56c96f8db17738bb0c24170d7f85acd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 20 11:08:21 2026 -0700

    Option C HWC remap: apply.mjs + rollback.mjs (reversibility-first, idempotent, throttle-aware)
---
 apply.mjs    | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 rollback.mjs |  68 ++++++++++++++++++++++++++++
 2 files changed, 211 insertions(+)

diff --git a/apply.mjs b/apply.mjs
new file mode 100644
index 0000000..49ae8b1
--- /dev/null
+++ b/apply.mjs
@@ -0,0 +1,143 @@
+#!/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); });
diff --git a/rollback.mjs b/rollback.mjs
new file mode 100644
index 0000000..c92c2c5
--- /dev/null
+++ b/rollback.mjs
@@ -0,0 +1,68 @@
+#!/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); });

← 2f60c00 TK-10633 Option C: contrarian FIX-FIRST addressed — canary-1  ·  back to Hollywood Optc  ·  Option C HWC remap FIRED: 124/124 applied, reversibility + r c5b6fe2 →