[object Object]

← back to Hollywood Optc

apply-tk10634: cap the 429 retry + stop writing the reversibility ledger in dry-run

dbe09cde30ddbccc6ad9ed1a37201ff64e6dbfde · 2026-09-24 11:32:26 -0700 · Steve Abrams

The 429 branch re-called graphql() with tries UNCHANGED and no cap, so a sustained
Shopify throttle recursed forever and the live-store run neither finished nor hit
the failure path. Now capped (tries<10) with a thrown error on exhaustion, matching
the 502/503 and THROTTLED branches. Also moved both appendFileSync(REV_PATH) calls
to AFTER the idempotent-skip and !APPLY checks, so a dry-run no longer emits a
reversibility ledger that rollback could later replay as if writes had happened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit dbe09cde30ddbccc6ad9ed1a37201ff64e6dbfde
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 11:32:26 2026 -0700

    apply-tk10634: cap the 429 retry + stop writing the reversibility ledger in dry-run
    
    The 429 branch re-called graphql() with tries UNCHANGED and no cap, so a sustained
    Shopify throttle recursed forever and the live-store run neither finished nor hit
    the failure path. Now capped (tries<10) with a thrown error on exhaustion, matching
    the 502/503 and THROTTLED branches. Also moved both appendFileSync(REV_PATH) calls
    to AFTER the idempotent-skip and !APPLY checks, so a dry-run no longer emits a
    reversibility ledger that rollback could later replay as if writes had happened.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apply-tk10634.mjs | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 144 insertions(+)

diff --git a/apply-tk10634.mjs b/apply-tk10634.mjs
new file mode 100644
index 0000000..c5e46bf
--- /dev/null
+++ b/apply-tk10634.mjs
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+// TK-10634 — Restore 859 Hollywood products to their EXISTING real manufacturer codes.
+// SELF-COPY ONLY (keep-old-never-mint): restore_to_canonical == the value already on file in the
+// custom.manufacturer_sku metafield. This script NEVER mints a code.
+//
+// For EACH product row {shopify_id, fabricated_dw_sku, restore_to_canonical}:
+//   1. Fetch live variants + the current dw_sku metafields.
+//   2. HARD SELF-COPY GUARD: refuse the row unless live custom.manufacturer_sku == restore_to_canonical.
+//   3. Reversibility FIRST: append {shopify_id, variant_id, from_sku, to_sku} and
+//      {ownerId, ns, key, from, to} metafield records to data/tk10634-reversibility-<ts>.jsonl BEFORE any write.
+//   4. Remap base variant SKU  fabricated_dw_sku       -> restore_to_canonical
+//      Remap sample variant SKU fabricated_dw_sku-Sample -> restore_to_canonical-Sample (case-insensitive match)
+//   5. Set global.dw_sku + dwc.dw_sku metafields (only the ones currently holding a fabricated code) -> restore_to_canonical.
+//   manufacturer_sku is LEFT UNTOUCHED (already the real code — it is the SOURCE of the self-copy).
+//
+// Idempotent (skips a variant/metafield already == target). Rate-limit aware. Never touches held/blocked sets.
+// ROLLBACK: node rollback-tk10634.mjs data/tk10634-reversibility-<ts>.jsonl
+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 args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const LIMIT = (() => { const a = args.find(x => x.startsWith('--limit=')); return a ? parseInt(a.split('=')[1], 10) : Infinity; })();
+const MAP_PATH = process.env.HOME + '/Projects/dw-add-sellable-variant-tk10902/tk10634-recon/tk10634-recovery-map.json';
+const map = JSON.parse(readFileSync(MAP_PATH, 'utf8'));
+let applySet = (map.apply_set || []);
+if (isFinite(LIMIT)) applySet = applySet.slice(0, LIMIT);
+if (!applySet.length) { console.error('empty apply_set'); process.exit(1); }
+
+const TS = new Date().toISOString().replace(/[:.]/g, '-');
+const REV_PATH = new URL(`./data/tk10634-reversibility-${TS}.jsonl`, import.meta.url).pathname;
+const OUT_PATH = new URL(`./data/tk10634-result-${TS}.json`, import.meta.url).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 && tries < 10) { await sleep(2000); return graphql(query, variables, tries + 1); }
+  if (res.status === 429) throw new Error('429 retry budget exhausted');
+  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;
+}
+
+const Q_PROD = `query p($id: ID!){ product(id:$id){ id status
+  mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
+  gdw: metafield(namespace:"global", key:"dw_sku"){ value }
+  dwc: metafield(namespace:"dwc", key:"dw_sku"){ value }
+  variants(first:20){ nodes{ id title inventoryItem{ sku } } } } }`;
+
+const MUT_SKU = `mutation setSku($productId: ID!, $variants: [ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$productId, variants:$variants){ productVariants{ id inventoryItem{ sku } } userErrors{ field message code } } }`;
+
+const MUT_MF = `mutation setMf($mf: [MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id namespace key value } userErrors{ field message code } } }`;
+
+async function setSku(productGid, variantGid, newSku) {
+  const j = await graphql(MUT_SKU, { productId: productGid, variants: [{ id: variantGid, inventoryItem: { sku: newSku } }] });
+  const ue = j?.data?.productVariantsBulkUpdate?.userErrors || [];
+  const confirmed = j?.data?.productVariantsBulkUpdate?.productVariants?.[0]?.inventoryItem?.sku ?? null;
+  return { ue, top: j.errors || [], confirmed };
+}
+async function setMf(ownerGid, ns, key, value) {
+  const j = await graphql(MUT_MF, { mf: [{ ownerId: ownerGid, namespace: ns, key, type: 'single_line_text_field', value }] });
+  const ue = j?.data?.metafieldsSet?.userErrors || [];
+  const confirmed = j?.data?.metafieldsSet?.metafields?.[0]?.value ?? null;
+  return { ue, top: j.errors || [], confirmed };
+}
+
+async function main() {
+  const R = { total: applySet.length, self_copy_ok: 0, sku_applied: 0, mf_applied: 0, skipped_idempotent: 0, self_copy_refused: 0, failed: 0, details: [] };
+  let idx = 0;
+  for (const row of applySet) {
+    idx++;
+    const productGid = row.shopify_id;
+    const fab = (row.fabricated_dw_sku || '').trim();
+    const restore = (row.restore_to_canonical || '').trim();
+    if (!fab || !restore) { R.failed++; R.details.push({ productGid, status: 'MAP_INCOMPLETE' }); continue; }
+
+    const pj = await graphql(Q_PROD, { id: productGid });
+    const p = pj?.data?.product;
+    if (!p) { R.failed++; R.details.push({ productGid, status: 'PRODUCT_NOT_FOUND' }); console.error(`[${idx}] NOT_FOUND ${productGid}`); continue; }
+
+    // HARD SELF-COPY GUARD — restore MUST equal the real code already on file.
+    const mfr = (p.mfr?.value || '').trim();
+    if (!mfr || mfr.toUpperCase() !== restore.toUpperCase()) {
+      R.self_copy_refused++;
+      R.details.push({ productGid, status: 'SELF_COPY_REFUSED', manufacturer_sku: mfr, restore });
+      console.error(`[${idx}] SELF-COPY REFUSED ${productGid}: mfr=${mfr} != restore=${restore}`);
+      continue;
+    }
+    R.self_copy_ok++;
+
+    // Map variants: base == fabricated_dw_sku, sample == fabricated_dw_sku + (-sample, any case)
+    const vs = p.variants.nodes;
+    const jobs = [];
+    for (const v of vs) {
+      const sku = (v.inventoryItem?.sku || '').trim();
+      if (!sku) continue;
+      const up = sku.toUpperCase();
+      if (up === fab.toUpperCase()) jobs.push({ variantGid: v.id, from: sku, to: restore, kind: 'base' });
+      else if (up === (fab + '-SAMPLE').toUpperCase() || up === (fab + '-Sample').toUpperCase()) jobs.push({ variantGid: v.id, from: sku, to: restore + '-Sample', kind: 'sample' });
+    }
+
+    for (const job of jobs) {
+      if (job.from.toUpperCase() === job.to.toUpperCase()) { R.skipped_idempotent++; continue; }
+      if (!APPLY) { R.details.push({ productGid, kind: job.kind, would: `${job.from} -> ${job.to}` }); continue; }
+      // Record the reversibility row only for a real write we're about to make —
+      // never in dry-run (a dry-run ledger could be replayed by rollback as truth).
+      appendFileSync(REV_PATH, JSON.stringify({ type: 'variant_sku', productGid, variant_id: job.variantGid.split('/').pop(), from_sku: job.from, to_sku: job.to, kind: job.kind, ts: new Date().toISOString() }) + '\n');
+      const r = await setSku(productGid, job.variantGid, job.to);
+      if (r.ue.length || r.top.length) { R.failed++; console.error(`[${idx}] FAIL sku ${job.from} -> ${job.to}: ${JSON.stringify(r.ue.length ? r.ue : r.top)}`); }
+      else if (r.confirmed && r.confirmed.toUpperCase() === job.to.toUpperCase()) { R.sku_applied++; console.log(`[${idx}] OK sku ${job.from} -> ${r.confirmed}`); }
+      else { R.failed++; console.error(`[${idx}] UNCONFIRMED sku ${job.from} -> ${job.to} (got ${r.confirmed})`); }
+      await sleep(300);
+    }
+
+    // Metafields: fix global.dw_sku + dwc.dw_sku only where they currently hold the fabricated code.
+    const mfJobs = [];
+    if ((p.gdw?.value || '').trim().toUpperCase() === fab.toUpperCase()) mfJobs.push({ ns: 'global', key: 'dw_sku', from: p.gdw.value });
+    if ((p.dwc?.value || '').trim().toUpperCase() === fab.toUpperCase()) mfJobs.push({ ns: 'dwc', key: 'dw_sku', from: p.dwc.value });
+    for (const mf of mfJobs) {
+      if (!APPLY) { R.details.push({ productGid, mf: `${mf.ns}.${mf.key}`, would: `${mf.from} -> ${restore}` }); continue; }
+      appendFileSync(REV_PATH, JSON.stringify({ type: 'metafield', ownerId: productGid, ns: mf.ns, key: mf.key, from: mf.from, to: restore, ts: new Date().toISOString() }) + '\n');
+      const r = await setMf(productGid, mf.ns, mf.key, restore);
+      if (r.ue.length || r.top.length) { R.failed++; console.error(`[${idx}] FAIL mf ${mf.ns}.${mf.key}: ${JSON.stringify(r.ue.length ? r.ue : r.top)}`); }
+      else if (r.confirmed === restore) { R.mf_applied++; console.log(`[${idx}] OK mf ${mf.ns}.${mf.key} ${mf.from} -> ${restore}`); }
+      else { R.failed++; console.error(`[${idx}] UNCONFIRMED mf ${mf.ns}.${mf.key} (got ${r.confirmed})`); }
+      await sleep(250);
+    }
+  }
+  console.log('\n=== TK-10634 SUMMARY ===');
+  console.log(JSON.stringify({ ...R, details: `(${R.details.length} rows -> ${OUT_PATH})` }, null, 2));
+  appendFileSync(OUT_PATH, JSON.stringify(R, null, 2));
+  console.log(`reversibility: ${APPLY ? REV_PATH : '(dry-run, no writes)'}`);
+  if (R.failed) process.exit(2);
+}
+main().catch(e => { console.error('FATAL', e); process.exit(1); });

← aa6d85f auto-data-snapshot: 2026-09-13T09:13:35 (1 data files) — pac  ·  back to Hollywood Optc  ·  TK-10634 rollback: compare-and-swap, dry-run ledger gate, pe 00618f1 →