← 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
A scripts/apply-hires.mjs
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 →