← back to Dw Kravet Hires
scripts/apply-hires.mjs
368 lines
#!/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 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 TICKET = val('--ticket', 'TK-11658'); // override for subset re-runs (e.g. TK-11740); default = parent
const AGENT = process.env.TK_AGENT || 'night-batch1-dwcommerce';
const LIVE = has('--live');
const ROLLBACK = has('--rollback');
const ALL_TICKETS = has('--all-tickets'); // rollback: revert EVERY ticket in the shared ledger (old behavior; explicit only)
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_WIDTH = parseInt(val('--max-width', '400'), 10); // low-res threshold per batch: Kravet=400, Jeffrey Stevens=500 (etc.)
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();
}
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
// TK-12090 2b — T3 (this script) and T4 (shopify-room-mockup.py) share one
// SHOPIFY_ADMIN_TOKEN rate bucket, and this script previously threw hard on
// THROTTLED/429 with no retry, so a busy shared bucket silently killed a run.
// Bounded exponential backoff + jitter, honoring Shopify's own throttleStatus
// hint (extensions.cost.throttleStatus) or a Retry-After header when present.
// Concurrency is never raised — batches stay sequential; only the per-call
// wait grows.
const MAX_RETRIES = parseInt(val('--max-retries', '6'), 10);
async function backoffRetry(reason, query, variables, attempt, retryAfterHeader, throttleStatus) {
if (attempt >= MAX_RETRIES) {
throw new Error(`shopify gql: ${reason} — exhausted ${MAX_RETRIES} retries`);
}
let waitMs = 0;
if (throttleStatus && throttleStatus.currentlyAvailable != null && throttleStatus.restoreRate) {
// rough single-query cost guess (Shopify's typical simple-query cost ~50pts)
const need = Math.max(0, 50 - throttleStatus.currentlyAvailable);
waitMs = Math.ceil((need / throttleStatus.restoreRate) * 1000);
} else if (retryAfterHeader) {
waitMs = parseFloat(retryAfterHeader) * 1000 || 0;
}
const base = Math.min(30000, 1000 * Math.pow(2, attempt)); // 1s,2s,4s,8s,16s,30s(cap)
waitMs = Math.max(waitMs, base) + Math.floor(Math.random() * 500); // + jitter
console.log(` … ${reason}, backing off ${Math.round(waitMs)}ms (retry ${attempt + 1}/${MAX_RETRIES})`);
await sleep(waitMs);
return shopify(query, variables, attempt + 1);
}
async function shopify(query, variables, attempt = 0) {
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),
});
if (r.status === 429) {
return backoffRetry('HTTP 429', query, variables, attempt, r.headers.get('retry-after'), null);
}
const j = await r.json();
if (j.errors) {
const throttled = Array.isArray(j.errors) &&
j.errors.some((e) => e?.extensions?.code === 'THROTTLED' || /throttl/i.test(e?.message || ''));
if (throttled) {
return backoffRetry('THROTTLED', query, variables, attempt, null, j.extensions?.cost?.throttleStatus);
}
throw new Error('shopify gql: ' + JSON.stringify(j.errors));
}
return j.data;
}
// 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];
// TK-12090 2a — tracked OUTSIDE the try so the catch block can see whether
// productCreateMedia already succeeded this iteration before a LATER step
// (poll/reorder/ledger) throws. Without this, a post-create throw left an
// orphaned media on the product with no ledger row, so --rollback was
// blind to it (rollback is ledger-driven; an unledgered media is invisible).
let newMediaId = null;
let oldMediaId = null;
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 low-res media (threshold per batch), skip.
if (curUrl !== anchorUrl || curWidth > MAX_WIDTH) {
skipped++;
console.log(` ⤫ SKIP (featured already moved: live=${curWidth}px limit=${MAX_WIDTH} ${curUrl === anchorUrl ? 'same-url' : 'diff-url'}) ${r.mfr_sku}`);
continue;
}
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 }] });
newMediaId = cm.productCreateMedia.media?.[0]?.id;
const errs = cm.productCreateMedia.mediaUserErrors || [];
if (!newMediaId || errs.length) { newMediaId = null; 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++ < 25) { // 75s (was 45s) — large valid images were false-healing on slow processing
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`);
newMediaId = null; // cleanly removed — not an orphan, catch below must not re-record it
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 --ticket ${TICKET} --ledger ${path.relative(PROJ, LEDGER_PATH)} --live`,
verify: `product ${gid} featured media == ${newMediaId} (hi-res); old ${oldMediaId} retained` });
applied++;
newMediaId = null; // fully recorded — nothing left for the catch below to worry about
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 (newMediaId) {
// Media WAS created on Shopify this iteration but a later step threw
// before it got ledgered. Prefer a ledger row over a best-effort
// delete (a delete attempt here can itself throw, e.g. on the exact
// network error that caused the original failure) — a row rollback
// can act on beats a delete that silently doesn't happen.
const orphanRec = { ts: new Date().toISOString(), ticket: TICKET, shopify_id: gid, vendor: r.vendor, mfr_sku: r.mfr_sku,
old_media_id: oldMediaId, old_url: anchorUrl, orphaned_media_id: newMediaId, new_url: r.proposed_hires_url,
orphaned: true, error: String(e).slice(0, 200) };
try {
fs.appendFileSync(LEDGER_PATH, JSON.stringify(orphanRec) + '\n');
appendExecLedger({ ts: orphanRec.ts, agent: AGENT, ticket: TICKET,
action: `ORPHANED media on error ${r.mfr_sku}: created ${newMediaId}, never featured/ledgered (${orphanRec.error})`,
blast_radius: 1,
undo_cmd: `node scripts/apply-hires.mjs --rollback --ticket ${TICKET} --ledger ${path.relative(PROJ, LEDGER_PATH)} --live`,
verify: `product ${gid} media list no longer contains ${newMediaId}` });
console.log(` ⚠ ${r.mfr_sku} orphaned media ${newMediaId} recorded for rollback cleanup`);
} catch (e2) {
console.log(` ✗✗ ${r.mfr_sku} ORPHAN LEDGER WRITE ALSO FAILED — media ${newMediaId} exists on ${gid} with NO recorded undo: ${String(e2).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 all = fs.readFileSync(LEDGER_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
.filter((r) => r.shopify_id);
// SCOPED BY DEFAULT: the shared apply-ledger.jsonl accumulates every ticket's swaps
// (TK-11658 + TK-11740 + TK-11742 + …), so an unscoped rollback over-reverts sibling
// phases. Reverse only THIS ticket's entries unless --all-tickets is explicitly passed.
const scoped = ALL_TICKETS ? all : all.filter((r) => r.ticket === TICKET);
// TK-12090 2a — an ORPHANED row (media created, never featured/ledgered as a
// completed swap because a later step threw) is a different action than a
// completed swap: there is nothing to re-point (the old media was never
// displaced), just extra media to remove. Handle the two classes separately
// so an orphan row is never mistaken for "already applied" and reordered.
const recs = scoped.filter((r) => !r.orphaned && r.old_media_id && r.new_media_id);
const orphans = scoped.filter((r) => r.orphaned && r.orphaned_media_id);
const scope = ALL_TICKETS ? 'ALL tickets' : `--ticket ${TICKET} only`;
console.log(`\n=== ${TICKET} apply-hires — ROLLBACK ${LIVE ? '(LIVE)' : '(dry-run)'} ===`);
console.log(`ledger: ${LEDGER_PATH} (scope: ${scope} — ${recs.length} swap(s) to reverse, ${orphans.length} orphaned media to clean up, of ${all.length} total rows)`);
if (!ALL_TICKETS && recs.length === 0 && orphans.length === 0) {
console.error(`no ledger entries for ticket ${TICKET}. Pass --ticket <TK-...> or --all-tickets.`); process.exit(2);
}
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 …`);
orphans.slice(0, 15).forEach((r) => console.log(` would DELETE orphaned media ${r.orphaned_media_id} (${r.mfr_sku}, never featured) — ${r.error || ''}`));
if (orphans.length > 15) console.log(` … +${orphans.length - 15} more orphans …`);
console.log(`\nadd --live to actually re-point/delete. 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)}`); }
}
let orphansCleaned = 0, orphanErr = 0;
for (const r of orphans) {
const gid = String(r.shopify_id).startsWith('gid://') ? r.shopify_id : `gid://shopify/Product/${r.shopify_id}`;
try {
await shopify(
`mutation($ids:[ID!]!,$pid:ID!){ productDeleteMedia(mediaIds:$ids,productId:$pid){ deletedMediaIds mediaUserErrors{ message } } }`,
{ ids: [r.orphaned_media_id], pid: gid });
appendExecLedger({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET,
action: `ROLLBACK cleaned up orphaned media ${r.mfr_sku}: deleted ${r.orphaned_media_id}`,
blast_radius: 1, undo_cmd: 'n/a (media deleted; nothing left to undo)',
verify: `product ${gid} media list no longer contains ${r.orphaned_media_id}` });
orphansCleaned++;
console.log(` 🗑 ${r.mfr_sku} orphaned media ${r.orphaned_media_id} deleted`);
} catch (e) { orphanErr++; console.error(` ✗ ${r.mfr_sku} orphan cleanup failed: ${String(e).slice(0, 120)}`); }
}
console.log(`\n=== rollback done: restored=${done} errors=${err} | orphans cleaned=${orphansCleaned} errors=${orphanErr} ===`);
}
// --------------------------------- 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); });