← 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
A apply.mjsA rollback.mjs
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 →