[object Object]

← back to Dw Kravet Hires

TK-11658: apply-hires.mjs executor (dry-run default, ledger, rollback) — dry-run proves 621-product Phase-1 plan, zero writes

05813b729c94611bcc972447b3802e3af03ed855 · 2026-09-14 08:37:51 -0700 · Steve

Files touched

Diff

commit 05813b729c94611bcc972447b3802e3af03ed855
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Sep 14 08:37:51 2026 -0700

    TK-11658: apply-hires.mjs executor (dry-run default, ledger, rollback) — dry-run proves 621-product Phase-1 plan, zero writes
---
 scripts/apply-hires.mjs | 264 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 264 insertions(+)

diff --git a/scripts/apply-hires.mjs b/scripts/apply-hires.mjs
new file mode 100644
index 0000000..58291f6
--- /dev/null
+++ b/scripts/apply-hires.mjs
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+// TK-11658 — Kravet-family hi-res featured-image swap EXECUTOR.
+//
+// Reads the reversible swap map (data/kravet-hires-swap-map.json, committed at d057cdc)
+// and, ON EXPLICIT --live ONLY, swaps each low-res (<=400px) featured image for the
+// verified vendor Brandfolder hi-res original — while KEEPING the old 400px media in the
+// product as a per-product rollback anchor.
+//
+// ================================ SAFETY MODEL ================================
+//   * DRY-RUN BY DEFAULT. Without --live this makes NO Shopify write of any kind: it
+//     prints the exact per-product plan (old featured 400px image -> new hi-res URL) from
+//     the MAP (map-only, $0, zero network) and exits. This is the state Steve approves.
+//   * --live is the ONLY switch that writes. It ADDS the hi-res as new media and moves it
+//     to featured (position 0). It NEVER deletes the old 400px media — that media stays in
+//     the product as the rollback anchor.
+//   * Idempotent / race-safe: before touching a product it reads the LIVE featured media;
+//     if the live featured image URL no longer matches the recorded 400px url (someone
+//     already swapped it, or a re-scrape moved it), it SKIPS that product.
+//   * Self-healing: if the newly-added hi-res media fails Shopify processing (e.g. an
+//     original >~25MP), it deletes the bad new media, leaves the old 400px featured intact,
+//     and logs it as not-applied. A product is never left with a broken featured image.
+//   * Every applied swap is recorded to data/apply-ledger.jsonl ({shopify_id, old_media_id,
+//     new_media_id, ...}) AND appended to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl
+//     so it is one-click reversible post-hoc.
+//   * --rollback re-points each product's featured image back to its old_media_id (still in
+//     the product); 100% reversible per product.
+//
+// ================================== USAGE ====================================
+//   # DRY-RUN (default) — prints the Phase-1 621-product plan, writes NOTHING:
+//   node scripts/apply-hires.mjs --map data/kravet-hires-swap-map.json --only-swappable --batch 200 --gap 90
+//
+//   # LIVE apply (GATED — run only on Steve's APPROVE):
+//   node scripts/apply-hires.mjs --map data/kravet-hires-swap-map.json --only-swappable --batch 200 --gap 90 --live
+//
+//   # ROLLBACK (re-point featured back to the retained 400px media):
+//   node scripts/apply-hires.mjs --rollback --ledger data/apply-ledger.jsonl --live
+//
+// cost: $0 (Shopify Admin API reads/writes on our own store + local file I/O).
+
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+// ------------------------------- config / args -------------------------------
+const SHOP = 'designer-laboratory-sandbox';
+const API = '2024-10';
+const TICKET = 'TK-11658';
+const AGENT = process.env.TK_AGENT || 'night-batch1-dwcommerce';
+
+const argv = process.argv.slice(2);
+const has = (f) => argv.includes(f);
+const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
+
+const LIVE = has('--live');
+const ROLLBACK = has('--rollback');
+const ONLY_SWAPPABLE = has('--only-swappable');
+const BATCH = parseInt(val('--batch', '200'), 10);
+const GAP = parseInt(val('--gap', '90'), 10);      // seconds between batches (live only)
+const LIMIT = parseInt(val('--limit', '0'), 10);   // optional hard cap on rows this run (0 = all)
+const MAX_HIRES = 5000;                            // originals >~5000px (>25MP) fail Shopify processing → self-heal skip
+
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+const PROJ = path.resolve(HERE, '..');
+const MAP_PATH = path.resolve(PROJ, val('--map', 'data/kravet-hires-swap-map.json'));
+const LEDGER_PATH = path.resolve(PROJ, val('--ledger', 'data/apply-ledger.jsonl'));
+const EXEC_LEDGER = path.join(os.homedir(), '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+// ------------------------------- shopify client ------------------------------
+function shopTok() {
+  const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+  // Memo §5 names SHOPIFY_ADMIN_TOKEN; fall back to FULL access if the narrow one is absent.
+  const admin = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
+  const full = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1];
+  const tok = admin || full;
+  if (!tok) { console.error('FATAL: no SHOPIFY_ADMIN_TOKEN / SHOPIFY_FULL_ACCESS_TOKEN in secrets .env'); process.exit(2); }
+  return tok.trim();
+}
+
+async function shopify(query, variables) {
+  const r = await fetch(`https://${SHOP}.myshopify.com/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': shopTok(), 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+    signal: AbortSignal.timeout(60000),
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error('shopify gql: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
+
+// Read the LIVE featured (position-0) image media: {id, url, width}. Null if no image media.
+async function liveFeatured(gid) {
+  const d = await shopify(
+    `query($id:ID!){ product(id:$id){ id title media(first:25){ nodes{ id mediaContentType ... on MediaImage { image { url width height } } } } } }`,
+    { id: gid });
+  const p = d.product; if (!p) return null;
+  const imgs = p.media.nodes.filter((n) => n.mediaContentType === 'IMAGE');
+  return { title: p.title, first: imgs[0] || null, count: imgs.length };
+}
+
+// ------------------------------- map loading ---------------------------------
+function loadRows() {
+  const doc = JSON.parse(fs.readFileSync(MAP_PATH, 'utf8'));
+  let rows = Array.isArray(doc) ? doc : doc.rows || [];
+  if (ONLY_SWAPPABLE) rows = rows.filter((r) => r.swappable_from_local_staging === true);
+  // must have a shopify id, a proposed hi-res URL, and the 400px rollback anchor url
+  rows = rows.filter((r) => r.shopify_id && r.proposed_hires_url && (r.rollback_url || r.current_400px_url));
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  return rows;
+}
+
+// ------------------------------- dry-run plan --------------------------------
+function dryRun(rows) {
+  console.log(`\n=== ${TICKET} apply-hires — DRY-RUN (no --live) ===`);
+  console.log(`map        : ${MAP_PATH}`);
+  console.log(`filter     : ${ONLY_SWAPPABLE ? 'only-swappable (Phase-1 local-staging hi-res)' : 'ALL rows in map'}`);
+  console.log(`batch/gap  : ${BATCH} per batch, ${GAP}s between batches (live only)`);
+  console.log(`plan rows  : ${rows.length}`);
+  console.log(`--- per-product plan (old featured 400px image -> new hi-res URL) ---`);
+  const batches = Math.ceil(rows.length / BATCH) || 0;
+  rows.forEach((r, i) => {
+    if (i < 12 || i >= rows.length - 3) {
+      const oldUrl = (r.rollback_url || r.current_400px_url).split('?')[0];
+      console.log(`  [${String(i + 1).padStart(4)}] ${r.vendor} ${r.mfr_sku}`);
+      console.log(`         product : ${r.shopify_id}  (cur_width=${r.cur_width}px)`);
+      console.log(`         OLD featured (rollback anchor, kept in product): ${oldUrl}`);
+      console.log(`         NEW hi-res featured                            : ${r.proposed_hires_url}`);
+      console.log(`         old_media_id: <resolved live at apply from the current featured media>`);
+    } else if (i === 12) {
+      console.log(`         … (${rows.length - 15} more rows omitted from console; full set is the map) …`);
+    }
+  });
+  console.log(`\n--- plan summary ---`);
+  console.log(`  products to swap : ${rows.length}`);
+  console.log(`  batches          : ${batches}  (@ ${BATCH}/batch)`);
+  console.log(`  WRITES FIRED     : 0  (dry-run — nothing sent to Shopify)`);
+  console.log(`  to execute live  : re-run with --live (GATED — Steve approval only)`);
+  console.log(`\ncost: $0 (map-only, zero network). Nothing fired.`);
+}
+
+// ------------------------------- live apply ----------------------------------
+function appendExecLedger(entry) {
+  try {
+    fs.mkdirSync(path.dirname(EXEC_LEDGER), { recursive: true });
+    fs.appendFileSync(EXEC_LEDGER, JSON.stringify(entry) + '\n');
+  } catch (e) { console.error('  (warn) exec-ledger append failed:', String(e).slice(0, 80)); }
+}
+
+async function applyLive(rows) {
+  console.log(`\n=== ${TICKET} apply-hires — LIVE APPLY ===`);
+  console.log(`swapping ${rows.length} products, ${BATCH}/batch, ${GAP}s between batches. Old 400px media KEPT as rollback anchor.`);
+  let applied = 0, skipped = 0, healed = 0, failed = 0;
+  const batches = Math.ceil(rows.length / BATCH);
+  for (let b = 0; b < batches; b++) {
+    const batch = rows.slice(b * BATCH, (b + 1) * BATCH);
+    console.log(`\n--- batch ${b + 1}/${batches} (${batch.length} products) ---`);
+    for (const r of batch) {
+      const gid = String(r.shopify_id).startsWith('gid://') ? r.shopify_id : `gid://shopify/Product/${r.shopify_id}`;
+      const anchorUrl = (r.rollback_url || r.current_400px_url).split('?')[0];
+      try {
+        // 1) read live featured media = old_media_id anchor + idempotency/race check
+        const lf = await liveFeatured(gid);
+        if (!lf || !lf.first) { skipped++; console.log(`  ⤫ SKIP (no image media) ${r.mfr_sku}`); continue; }
+        const curUrl = (lf.first.image.url || '').split('?')[0];
+        const curWidth = Math.max(lf.first.image.width || 0, lf.first.image.height || 0);
+        // race-safe: if the live featured image already moved off the recorded 400px media, skip.
+        if (curUrl !== anchorUrl || curWidth > 400) {
+          skipped++;
+          console.log(`  ⤫ SKIP (featured already moved: live=${curWidth}px ${curUrl === anchorUrl ? 'same-url' : 'diff-url'}) ${r.mfr_sku}`);
+          continue;
+        }
+        const oldMediaId = lf.first.id;
+
+        // 2) add the hi-res as new media
+        const cm = await shopify(
+          `mutation($pid:ID!,$media:[CreateMediaInput!]!){
+            productCreateMedia(productId:$pid, media:$media){ media{ id status } mediaUserErrors{ field message } } }`,
+          { pid: gid, media: [{ originalSource: r.proposed_hires_url, mediaContentType: 'IMAGE', alt: lf.title }] });
+        const newMediaId = cm.productCreateMedia.media?.[0]?.id;
+        const errs = cm.productCreateMedia.mediaUserErrors || [];
+        if (!newMediaId || errs.length) { failed++; console.log(`  ✗ ${r.mfr_sku} createMedia err: ${JSON.stringify(errs)}`); continue; }
+
+        // 3) poll processing; self-heal if it never reaches READY
+        let st = 'PROCESSING', tries = 0;
+        while ((st === 'PROCESSING' || st === 'UPLOADED') && tries++ < 15) {
+          await sleep(3000);
+          const q = await shopify(`query($id:ID!){ node(id:$id){ ... on MediaImage{ status } } }`, { id: newMediaId });
+          st = q.node?.status || st;
+        }
+        if (st !== 'READY') {
+          await shopify(`mutation($ids:[ID!]!,$pid:ID!){ productDeleteMedia(mediaIds:$ids,productId:$pid){ deletedMediaIds } }`, { ids: [newMediaId], pid: gid });
+          healed++;
+          console.log(`  ⚠ ${r.mfr_sku} new media ${st} (orig likely >${MAX_HIRES}px) → removed, old 400px kept featured`);
+          continue;
+        }
+
+        // 4) make the new hi-res the featured image (move to position 0). Old media stays in product.
+        await shopify(
+          `mutation($id:ID!,$moves:[MoveInput!]!){ productReorderMedia(id:$id, moves:$moves){ job{ id } userErrors{ message } } }`,
+          { id: gid, moves: [{ id: newMediaId, newPosition: '0' }] });
+
+        // 5) ledger (both the per-run ledger and the fleet reversible ledger)
+        const rec = { ts: new Date().toISOString(), ticket: TICKET, shopify_id: gid, vendor: r.vendor, mfr_sku: r.mfr_sku,
+          old_media_id: oldMediaId, old_url: anchorUrl, new_media_id: newMediaId, new_url: r.proposed_hires_url };
+        fs.appendFileSync(LEDGER_PATH, JSON.stringify(rec) + '\n');
+        appendExecLedger({ ts: rec.ts, agent: AGENT, ticket: TICKET,
+          action: `featured-image swap ${r.mfr_sku}: ${oldMediaId} -> ${newMediaId}`,
+          blast_radius: 1,
+          undo_cmd: `node scripts/apply-hires.mjs --rollback --ledger ${path.relative(PROJ, LEDGER_PATH)} --live`,
+          verify: `product ${gid} featured media == ${newMediaId} (hi-res); old ${oldMediaId} retained` });
+        applied++;
+        console.log(`  ✓ ${r.mfr_sku}  featured -> hi-res (old ${oldMediaId} kept)`);
+      } catch (e) {
+        failed++;
+        console.log(`  ✗ ${r.mfr_sku} ${String(e).slice(0, 140)}`);
+      }
+    }
+    if (b < batches - 1) { console.log(`  … pacing ${GAP}s before next batch (customer-facing) …`); await sleep(GAP * 1000); }
+  }
+  console.log(`\n=== done: applied=${applied} skipped=${skipped} self-healed=${healed} failed=${failed} ===`);
+  console.log(`ledger      : ${LEDGER_PATH}`);
+  console.log(`exec-ledger : ${EXEC_LEDGER}`);
+}
+
+// ------------------------------- rollback ------------------------------------
+async function rollback() {
+  if (!fs.existsSync(LEDGER_PATH)) { console.error(`no ledger at ${LEDGER_PATH}`); process.exit(2); }
+  const recs = fs.readFileSync(LEDGER_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
+    .filter((r) => r.old_media_id && r.shopify_id);
+  console.log(`\n=== ${TICKET} apply-hires — ROLLBACK ${LIVE ? '(LIVE)' : '(dry-run)'} ===`);
+  console.log(`ledger: ${LEDGER_PATH}  (${recs.length} swaps to reverse)`);
+  if (!LIVE) {
+    recs.slice(0, 15).forEach((r) => console.log(`  would re-point ${r.mfr_sku} featured -> old_media_id ${r.old_media_id}`));
+    if (recs.length > 15) console.log(`  … +${recs.length - 15} more …`);
+    console.log(`\nadd --live to actually re-point. Nothing fired.`);
+    return;
+  }
+  let done = 0, err = 0;
+  for (const r of recs) {
+    const gid = String(r.shopify_id).startsWith('gid://') ? r.shopify_id : `gid://shopify/Product/${r.shopify_id}`;
+    try {
+      await shopify(
+        `mutation($id:ID!,$moves:[MoveInput!]!){ productReorderMedia(id:$id, moves:$moves){ job{ id } userErrors{ message } } }`,
+        { id: gid, moves: [{ id: r.old_media_id, newPosition: '0' }] });
+      appendExecLedger({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET,
+        action: `ROLLBACK featured-image ${r.mfr_sku}: restored ${r.old_media_id}`,
+        blast_radius: 1, undo_cmd: 're-run --live (re-point to new_media_id)',
+        verify: `product ${gid} featured media == ${r.old_media_id} (original 400px)` });
+      done++;
+      console.log(`  ↩ ${r.mfr_sku} featured restored -> ${r.old_media_id}`);
+    } catch (e) { err++; console.error(`  ✗ ${r.mfr_sku} ${String(e).slice(0, 120)}`); }
+  }
+  console.log(`\n=== rollback done: restored=${done} errors=${err} ===`);
+}
+
+// --------------------------------- main --------------------------------------
+(async () => {
+  if (ROLLBACK) { await rollback(); return; }
+  const rows = loadRows();
+  if (!LIVE) { dryRun(rows); return; }
+  await applyLive(rows);
+})().catch((e) => { console.error('FATAL:', e); process.exit(1); });

← d057cdc TK-11658: Kravet-family hi-res image audit + reversible swap  ·  back to Dw Kravet Hires  ·  auto-data-snapshot: 2026-09-14T14:02:07 (1 data files) — dat 7db4573 →