← 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); });