← back to Wolfgordon Crawl

push-shopify.js

691 lines

#!/usr/bin/env node
// ============================================================================
// Wolf Gordon -> Shopify pusher  (LIVE store: designer-laboratory-sandbox)
//
// Catalog-driven, idempotent-by-SKU, image-ADD-only (never deletes), throttled,
// resumable. DEFAULTS TO DRY-RUN — writes nothing unless --apply is passed.
//
//   node push-shopify.js                      # dry-run, whole catalog plan
//   node push-shopify.js --canary             # dry-run, the 10-item canary set
//   node push-shopify.js --canary --apply     # LIVE writes, 10-item canary
//   node push-shopify.js --limit 50           # dry-run, first 50 actionable
//
// Source of truth = local dw_unified.wolf_gordon_catalog (PostgreSQL BEFORE
// Shopify). Records the returned shopify_product_id back into the catalog.
//
// DW standing rules enforced:
//  - word "Wallpaper" BANNED -> "Wallcovering(s)".
//  - title format: "Pattern Real-Color | Wolf Gordon Wallcoverings", Title Case.
//  - NEVER "Unknown" in a title; fall back mfr_sku -> color -> skip.
//  - every product gets a Yard variant + a {DW_SKU}-Sample variant ($4.25, no
//    inventory tracking).
//  - NEVER ACTIVE without image AND width metafield (else draft + Needs-* tag).
//  - Discontinued = ARCHIVE (never delete).
//  - price = price_retail (already = round(cost/0.65/0.85, 2)).
//  - throttle ~2 req/s; >=90s gap between bulk batches (canary is one batch).
// ============================================================================

const { pool } = require('./scraper-utils');
const os = require('os');
const fs = require('fs');

// ---------- shared cap coordination (DTD-C variant-budget ledger) ----------
// The net-new create path used to bulk-dump products outside the paced cadence,
// which is what blew the ~1,000/day Shopify variant cap on 2026-06-19 (10–11am).
// It now draws from the SAME shared daily ledger every other DW writer uses
// (budget.cjs, category 'upload' — the ACTIVE phase), so creates-per-run can
// never exceed the day's remaining grant. Honors the shared kill-switch too.
// Pattern mirrors shopify/scripts/cadence/run-cadence-hourly.sh.
const KILL_SWITCH = os.homedir() + '/.dw-fixer-stop';
let budget = null;
try {
  budget = require(os.homedir() + '/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs');
} catch (e) {
  console.error('  [budget] WARN: ledger not loadable (' + e.message + ') — net-new creates will be BLOCKED (fail-CLOSED on the cap).');
}
const VARIANTS_PER_PRODUCT = 2; // every product ships Yard + Sample

// ---------- config ----------
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
const REST = `https://${SHOP}/admin/api/${API}`;
const VENDOR = 'Wolf Gordon';
const SAMPLE_PRICE = '4.25';
const THROTTLE_MS = 500; // ~2 req/s

// ---------- args ----------
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const CANARY = argv.includes('--canary');
// Phase 1 = updates + archives only, and VARIANT-FREE (no Sample variant added
// to existing products) so it never touches the daily variant-create cap.
const PHASE1 = argv.includes('--phase1');
// Phase 2 = create net-new products (new + draft) only; staggered by variant cap.
const NEW_ONLY = argv.includes('--new-only');
const MAX = (() => {
  const i = argv.indexOf('--max');
  return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
})();
const LIMIT = (() => {
  const i = argv.indexOf('--limit');
  return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
})();

// ---------- token (master .env via secrets manager) ----------
function loadToken() {
  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
  const fs = require('fs');
  const p = require('os').homedir() + '/Projects/secrets-manager/.env';
  const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
  if (!line) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets .env');
  return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
}
const TOKEN = loadToken();

const wait = (ms) => new Promise(r => setTimeout(r, ms));

async function gql(query, variables) {
  await wait(THROTTLE_MS);
  const res = await fetch(GQL, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const j = await res.json();
  if (j.errors) {
    const throttled = JSON.stringify(j.errors).includes('VARIANT_THROTTLE_EXCEEDED');
    const err = new Error('GraphQL: ' + JSON.stringify(j.errors));
    err.variantThrottled = throttled;
    throw err;
  }
  return j.data;
}

async function rest(method, path, body) {
  await wait(THROTTLE_MS);
  const res = await fetch(REST + path, {
    method,
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const txt = await res.text();
  let j; try { j = JSON.parse(txt); } catch { j = { _raw: txt }; }
  if (!res.ok) throw new Error(`REST ${method} ${path} -> ${res.status}: ${txt.slice(0, 300)}`);
  return j;
}

// ---------- normalization helpers (shared w/ reconcile.js) ----------
function norm(s) {
  if (!s) return '';
  return String(s).toLowerCase().replace(/[®™©]/g, '').replace(/&/g, '&')
    .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function nsku(s) {
  if (!s) return '';
  return String(s).toLowerCase().replace(/_8$/, '').replace(/[^a-z0-9]+/g, '');
}
function legacyColorFromTitle(title) {
  if (!title) return null;
  const head = (title || '').split('|')[0].trim();
  const idx = head.indexOf(' - ');
  return idx === -1 ? null : head.slice(idx + 3).trim();
}
function titleCase(s) {
  return String(s || '').toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase());
}
function banWallpaper(s) {
  return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering');
}

// Build the DW title: "Pattern Real-Color | Wolf Gordon Wallcoverings".
// Color from catalog ONLY; never "Unknown"; fall back mfr_sku -> color -> skip.
function buildTitle(row) {
  const pat = titleCase((row.pattern_name || '').trim());
  let color = (row.color_name || '').trim();
  if (!color || /^unknown$/i.test(color)) color = (row.mfr_sku || '').trim();
  if (!color || /^unknown$/i.test(color)) return null; // skip — no usable color
  const left = `${pat} ${titleCase(color)}`.trim();
  if (!left || /unknown/i.test(left)) return null;
  return banWallpaper(`${left} | Wolf Gordon Wallcoverings`);
}

// ---------- store-side index (idempotent-by-SKU) ----------
// Map: variant SKU (lower) -> { productId, handle, status, variantId, price }.
// Also a nsku index of live WG products for pattern/sku reconciliation.
async function loadStoreIndex() {
  const bySku = new Map();
  let cursor = null, pages = 0;
  do {
    const data = await gql(`
      query($cursor:String){
        products(first:200, query:"vendor:'Wolf Gordon'", after:$cursor){
          pageInfo{ hasNextPage endCursor }
          edges{ node{
            id handle title status
            variants(first:5){ edges{ node{ id sku price title
              inventoryItem{ id } } } }
            media(first:50){ edges{ node{ ... on MediaImage { image{ url } } } } }
          } }
        }
      }`, { cursor });
    const conn = data.products;
    for (const e of conn.edges) {
      const n = e.node;
      const imgs = (n.media.edges || []).map(m => m.node?.image?.url).filter(Boolean);
      for (const ve of n.variants.edges) {
        const v = ve.node;
        if (!v.sku) continue;
        bySku.set(v.sku.toLowerCase(), {
          productId: n.id, handle: n.handle, status: n.status,
          variantId: v.id, price: v.price, vTitle: v.title,
          invItemId: v.inventoryItem?.id, images: imgs,
        });
      }
    }
    cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
    pages++;
  } while (cursor && pages < 60);
  return bySku;
}

// ---------- plan builder ----------
// Decide per catalog row + per live WG product what action applies.
async function buildPlan(storeBySku) {
  const cat = (await pool.query(`
    SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
           width, width_inches, material, price_retail, price_trade, image_url,
           all_images, product_url, discontinued, shopify_product_id,
           -- full crawled enrichment so createProduct ships COMPLETE products
           repeat_v, repeat_h, match_type, fire_rating, finish, design,
           color_hex, dominant_color_hex, ai_description, ai_styles, ai_patterns
    FROM wolf_gordon_catalog`)).rows;

  // Live WG legacy products (the reconcile source) for UPDATE/ARCHIVE matching.
  const leg = (await pool.query(`
    SELECT sku, mfr_sku, pattern_name, title, status, handle
    FROM shopify_products WHERE vendor = 'Wolf Gordon'`)).rows;

  // Catalog indexes for legacy->catalog join.
  const catByNsku = new Map(), catByPC = new Map();
  for (const r of cat) {
    const k = nsku(r.mfr_sku); if (k && !catByNsku.has(k)) catByNsku.set(k, r);
    const pc = norm(r.pattern_name) + '|' + norm(r.color_name);
    if (!catByPC.has(pc)) catByPC.set(pc, []); catByPC.get(pc).push(r);
  }
  // Which catalog rows are covered by a live legacy peer (so NEW = uncovered).
  const coveredNsku = new Set(), coveredPC = new Set();
  for (const r of leg) {
    coveredNsku.add(nsku(r.mfr_sku));
    coveredPC.add(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
  }

  const plan = { new: [], update: [], draft: [], archive: [] };

  // NEW + DRAFT: catalog rows with no live peer AND no existing SKU on store.
  for (const r of cat) {
    const skuLower = (r.dw_sku || '').toLowerCase();
    const onStore = skuLower && storeBySku.has(skuLower);
    const hasPeer = coveredNsku.has(nsku(r.mfr_sku)) ||
                    coveredPC.has(norm(r.pattern_name) + '|' + norm(r.color_name));
    if (onStore || hasPeer) continue; // idempotent-by-SKU + has a legacy peer => not a fresh NEW
    if (r.price_retail == null) plan.draft.push(r);
    else plan.new.push(r);
  }

  // UPDATE: live WG products currently at $4.25 that match a catalog row (by
  // normalized mfr_sku, else unique pattern+color) WITH a real price.
  for (const r of leg) {
    const liveSku = (r.sku || '').toLowerCase();
    const live = storeBySku.get(liveSku);
    if (!live) continue;
    if (parseFloat(live.price) !== 4.25) continue; // only fix the $4.25 placeholders
    const sh = catByNsku.get(nsku(r.mfr_sku));
    const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
    const tgt = sh || (pc && pc.length === 1 ? pc[0] : null);
    if (tgt && tgt.price_retail != null) {
      plan.update.push({ live, legacy: r, target: tgt });
    }
  }

  // ARCHIVE: live WG products with NO catalog match at all (discontinued).
  for (const r of leg) {
    const live = storeBySku.get((r.sku || '').toLowerCase());
    if (!live) continue;
    if (live.status === 'ARCHIVED') continue;
    const sh = catByNsku.get(nsku(r.mfr_sku));
    const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
    if (!sh && !(pc && pc.length)) plan.archive.push({ live, legacy: r });
  }

  return plan;
}

// ---------- writers ----------
async function recordPid(mfrSku, pid) {
  try {
    await pool.query(
      `UPDATE wolf_gordon_catalog SET shopify_product_id=$1, on_shopify=true, updated_at=NOW() WHERE mfr_sku=$2`,
      [pid, mfrSku]);
  } catch (e) { console.error('  recordPid fail:', e.message); }
}

function imageList(row) {
  const imgs = [];
  if (row.image_url) imgs.push(row.image_url);
  if (row.all_images) {
    try {
      const arr = typeof row.all_images === 'string' ? JSON.parse(row.all_images) : row.all_images;
      if (Array.isArray(arr)) for (const u of arr) if (u && !imgs.includes(u)) imgs.push(u);
    } catch {/* all_images may be a comma string */
      String(row.all_images).split(',').map(s => s.trim()).filter(Boolean)
        .forEach(u => { if (!imgs.includes(u)) imgs.push(u); });
    }
  }
  return imgs;
}

function widthInches(row) {
  if (row.width_inches != null) return String(row.width_inches);
  const m = String(row.width || '').match(/([\d.]+)\s*("|in|inch)/i);
  return m ? m[1] : null;
}

// ----------------------------------------------------------------------------
// FULL-ENRICHMENT builders. ROOT-CAUSE FIX (2026-06-19): the original
// createProduct() shipped only a `custom.width` metafield and NO description,
// producing 479 ACTIVE bare-shell products even though the full crawled record
// (ai_description, material, fire_rating, collection, mfr_sku, colors, specs)
// was already in wolf_gordon_catalog. These builders pull that crawled data so
// every product is created COMPLETE. They mirror backfill-enrichment.js so the
// repair path and the create path can never diverge again.
// Steve's standing rules honored: never AI-invent (description comes from the
// crawled ai_description only), word "Wallpaper" banned, never "Unknown".
function buildDescriptionHtml(row) {
  let d = (row.ai_description || '').trim();
  if (!d || d.length < 20) return null;            // no crawled description -> none (never AI-invent)
  d = banWallpaper(d).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  return `<p>${d}</p>`;
}
function firstOfJson(j) { try { const a = typeof j === 'string' ? JSON.parse(j) : j; return Array.isArray(a) && a.length ? String(a[0]) : null; } catch { return null; } }
function buildFullMetafields(row) {
  const mf = [];
  const add = (namespace, key, type, value) => {
    if (value == null) return;
    const v = String(value).trim();
    if (!v || /^unknown$/i.test(v)) return;
    mf.push({ namespace, key, type, value: banWallpaper(v) });
  };
  const wIn = widthInches(row);
  const widthDisp = row.width ? String(row.width).trim() : (wIn ? `${wIn}"` : null);
  // global.* (store convention for WG)
  add('global', 'width', 'single_line_text_field', widthDisp);
  add('global', 'material', 'single_line_text_field', row.material);
  add('global', 'color', 'single_line_text_field', row.color_name);
  add('global', 'Collection', 'single_line_text_field', row.collection);
  add('global', 'finish', 'single_line_text_field', row.finish);
  if (row.repeat_v) add('global', 'repeat', 'single_line_text_field', row.repeat_v);
  add('global', 'title_tag', 'single_line_text_field',
    `${titleCase(row.pattern_name || '')} - ${titleCase(row.color_name || '')} Wallcovering by Wolf Gordon Wallcoverings`.trim());
  add('global', 'description_tag', 'single_line_text_field',
    `Authorized Dealer of ${row.mfr_sku || row.dw_sku} by WOLF GORDON WALLCOVERINGS at Designer Wallcoverings`);
  // custom.*
  add('custom', 'width', 'single_line_text_field', wIn ? `${wIn} Inches` : widthDisp);
  add('custom', 'pattern_name', 'single_line_text_field', row.pattern_name);
  add('custom', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
  add('custom', 'material', 'multi_line_text_field', row.material);
  add('custom', 'collection_name', 'single_line_text_field', row.collection);
  add('custom', 'color', 'single_line_text_field', row.color_name);
  add('custom', 'fire_rating', 'single_line_text_field', row.fire_rating);
  add('custom', 'color_hex', 'single_line_text_field', row.color_hex || row.dominant_color_hex);
  if (row.match_type) add('custom', 'pattern_repeat', 'single_line_text_field', row.match_type);
  // dwc.* (manufacturer_sku backup + real vendor + pattern/color)
  add('dwc', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
  add('dwc', 'pattern_name', 'single_line_text_field', row.pattern_name);
  add('dwc', 'color', 'single_line_text_field', row.color_name);
  add('dwc', 'brand', 'single_line_text_field', 'Wolf Gordon');
  // specs.*
  add('specs', 'style', 'single_line_text_field', firstOfJson(row.ai_styles));
  add('specs', 'pattern', 'single_line_text_field', firstOfJson(row.ai_patterns) || row.design);
  return mf;
}

// Create a NEW product (active+published if image+width present, else draft+tag).
async function createProduct(row, { draft } = {}) {
  const title = buildTitle(row);
  if (!title) { console.log(`    SKIP (no usable color) mfr=${row.mfr_sku}`); return null; }
  const imgs = imageList(row);
  const width = widthInches(row);
  const descriptionHtml = buildDescriptionHtml(row);
  const fullMetafields = buildFullMetafields(row);

  // Activation gate (Steve's hard rules): a product goes ACTIVE only with an
  // image AND a width AND a non-trivial description. Missing any -> DRAFT + the
  // matching Needs-* tag, never a bare live product. (feedback_all_new_products_
  // must_have_description, 2026-06-19.)
  const hasDescription = !!descriptionHtml;
  const isDraft = draft || imgs.length === 0 || !width || !hasDescription;
  const tags = [VENDOR, row.collection].filter(Boolean);
  if (imgs.length === 0) tags.push('Needs-Image');
  if (!width) tags.push('Needs-Width');
  if (!hasDescription) tags.push('Needs-Description');
  if (draft) tags.push('Quote-Only');

  const productSet = {
    title,
    vendor: VENDOR,
    productType: row.product_type || 'Wallcovering',
    status: isDraft ? 'DRAFT' : 'ACTIVE',
    tags,
    ...(descriptionHtml ? { descriptionHtml } : {}),
    productOptions: [{ name: 'Variant', values: [{ name: 'Yard' }, { name: 'Sample' }] }],
    variants: [
      {
        optionValues: [{ optionName: 'Variant', name: 'Yard' }],
        price: draft ? '0.00' : String(row.price_retail),
        inventoryItem: { sku: row.dw_sku, tracked: false },
        inventoryPolicy: 'CONTINUE',
      },
      {
        optionValues: [{ optionName: 'Variant', name: 'Sample' }],
        price: SAMPLE_PRICE,
        inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: false },
        inventoryPolicy: 'CONTINUE',
      },
    ],
    // FULL metafield set from the crawled record (was: only custom.width).
    metafields: fullMetafields,
    files: imgs.map(u => ({ originalSource: u, contentType: 'IMAGE' })),
  };

  const data = await gql(`
    mutation($p:ProductSetInput!){
      productSet(input:$p, synchronous:true){
        product{ id handle status }
        userErrors{ field message }
      }
    }`, { p: productSet });
  const ue = data.productSet.userErrors;
  if (ue && ue.length) throw new Error('productSet: ' + JSON.stringify(ue));
  const prod = data.productSet.product;
  if (!isDraft) {
    // Steve 2026-06-20 (memory activation-all-channels-and-2026-inventory): every
    // ACTIVE product must be published to ALL sales channels AND carry inventory
    // 2026 at Ventura Blvd on its sellable variant. Mirrors cadence-import.js.
    await publish(prod.id);                       // ALL channels (was: Online Store only)
    await setInventory2026([row.dw_sku]);         // on_hand=2026 on the sellable (non-Sample) variant
  }
  await recordPid(row.mfr_sku, prod.id.split('/').pop());
  return prod;
}

// Publish a product to ALL sales channels (was: Online Store only — the bug that
// left 419 active WG products on ~6 of 13 channels). "already published" userErrors
// are benign. Copies cadence-import.js publishToChannels().
let _PUBLICATION_IDS = null;
async function loadPublications() {
  if (_PUBLICATION_IDS) return _PUBLICATION_IDS;
  const r = await gql(`{ publications(first:50){ edges{ node{ id name } } } }`);
  _PUBLICATION_IDS = (r.publications?.edges || []).map(e => e.node);
  return _PUBLICATION_IDS;
}
async function publish(productId) {
  try {
    const pubs = await loadPublications();
    if (!pubs.length) return;
    const input = pubs.map(p => ({ publicationId: p.id }));
    await gql(`
      mutation($id:ID!,$input:[PublicationInput!]!){
        publishablePublish(id:$id, input:$input){ userErrors{ field message } }
      }`, { id: productId, input });
  } catch (e) { console.error('  publish warn:', e.message); }
}

// Inventory on_hand=2026 at the Ventura Blvd location on the given (sellable) SKUs.
// Cap-free (touches existing variants). Copies cadence-import.js setInventory2026().
const INV_LOCATION_2026 = 'gid://shopify/Location/5795643504';  // 15442 Ventura Blvd.
async function setInventory2026(skus) {
  if (!skus.length) return;
  try {
    const map = new Map();
    for (let i = 0; i < skus.length; i += 40) {
      const batch = skus.slice(i, i + 40);
      const q = batch.map(s => `sku:"${String(s).replace(/"/g, '\\"')}"`).join(' OR ');
      const r = await gql(`query($q:String!){ productVariants(first:250,query:$q){ edges{ node{ sku inventoryItem{ id } } } } }`, { q });
      for (const e of (r.productVariants?.edges || [])) {
        if (e.node.sku && e.node.inventoryItem?.id) map.set(e.node.sku, e.node.inventoryItem.id);
      }
    }
    const pairs = skus.filter(s => map.has(s)).map(s => ({ inventoryItemId: map.get(s), locationId: INV_LOCATION_2026, quantity: 2026 }));
    for (let i = 0; i < pairs.length; i += 250) {
      await gql(`mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{ field message code } } }`,
        { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: pairs.slice(i, i + 250) } });
    }
  } catch (e) { console.error('  inventory-2026 warn:', e.message); }
}

// UPDATE: fix the $4.25 placeholder -> catalog retail price + replace SKU with
// the sequential DW SKU. Images ADD-only (never delete).
async function updateProduct(item) {
  const { live, target } = item;
  // 1. price + SKU on the existing variant.
  const data = await gql(`
    mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
      productVariantsBulkUpdate(productId:$pid, variants:$variants){
        productVariants{ id sku price }
        userErrors{ field message }
      }
    }`, {
    pid: live.productId,
    variants: [{ id: live.variantId, price: String(target.price_retail),
                 inventoryItem: { sku: target.dw_sku } }],
  });
  const ue = data.productVariantsBulkUpdate.userErrors;
  if (ue && ue.length) throw new Error('variantsBulkUpdate: ' + JSON.stringify(ue));

  // 2. ensure a Sample variant exists ({DW_SKU}-Sample @ $4.25, untracked).
  //    PHASE 1 is VARIANT-FREE: creating a Sample variant consumes the daily
  //    variant-create cap, which Phase 2's ~10,400-variant rollout needs. So
  //    skip the sample-create entirely in Phase 1 (Steve's explicit rule).
  if (!PHASE1) {
    const sampleSku = `${target.dw_sku}-Sample`;
    await gql(`
      mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
        productVariantsBulkCreate(productId:$pid, variants:$variants){
          productVariants{ id sku } userErrors{ field message }
        }
      }`, {
      pid: live.productId,
      variants: [{ price: SAMPLE_PRICE, inventoryItem: { sku: sampleSku, tracked: false },
                   inventoryPolicy: 'CONTINUE',
                   optionValues: [{ optionName: 'Title', name: 'Sample' }] }],
    }).catch(e => console.error('    sample-variant warn:', e.message));
  }

  // 3. images ADD/diff — add any catalog image not already on the product.
  const have = new Set((live.images || []).map(u => u.split('?')[0]));
  const want = imageList(target).filter(u => u && !have.has(u.split('?')[0]));
  if (want.length) {
    await gql(`
      mutation($pid:ID!,$media:[CreateMediaInput!]!){
        productCreateMedia(productId:$pid, media:$media){
          media{ ... on MediaImage { id } } mediaUserErrors{ message }
        }
      }`, { pid: live.productId,
            media: want.map(u => ({ originalSource: u, mediaContentType: 'IMAGE' })) })
      .catch(e => console.error('    image-add warn:', e.message));
  }

  await recordPid(target.mfr_sku, live.productId.split('/').pop());
  return { handle: live.handle, sku: target.dw_sku, price: String(target.price_retail) };
}

// ARCHIVE: set status archived (discontinued — never delete).
async function archiveProduct(item) {
  const data = await gql(`
    mutation($id:ID!){
      productUpdate(input:{ id:$id, status:ARCHIVED }){
        product{ id handle status } userErrors{ message }
      }
    }`, { id: item.live.productId });
  const ue = data.productUpdate.userErrors;
  if (ue && ue.length) throw new Error('archive: ' + JSON.stringify(ue));
  return { handle: item.live.handle, sku: item.legacy.sku };
}

// ---------- canary subset ----------
// Deterministic 3 new / 3 update / 2 draft / 1 archive picked from the plan.
function pickCanary(plan) {
  const NEW_IDS = [5, 7, 8];                              // Macao Melon/Pumpkin/Ginger
  const ARCHIVE_SKU = 'DWWG-KRN 5611_8';                  // Keren Maize (gone from catalog)
  const newAdds = plan.new.filter(r => NEW_IDS.includes(r.id)).slice(0, 3);
  const updates = plan.update
    .filter(u => /^(GSG|DVTS|CCT)/i.test(u.target.mfr_sku)).slice(0, 3);
  const DRAFT_SKUS = ['DWWG-530101', 'DWWG-530113']; // Marker Strokes / Wonder World (real names)
  let drafts = plan.draft.filter(r => DRAFT_SKUS.includes(r.dw_sku)).slice(0, 2);
  if (drafts.length < 2) drafts = plan.draft.slice(0, 2); // fallback
  const archive = plan.archive.filter(a => a.legacy.sku === ARCHIVE_SKU).slice(0, 1);
  const fallbackArch = archive.length ? archive : plan.archive.slice(0, 1);
  return { new: newAdds, update: updates, draft: drafts, archive: fallbackArch };
}

// ---------- main ----------
async function main() {
  console.log('='.repeat(74));
  const phaseTag = PHASE1 ? '  [PHASE1: update+archive, variant-free]'
                  : NEW_ONLY ? `  [PHASE2: new-only${MAX != null ? `, max ${MAX}` : ''}]` : '';
  console.log(`  WOLF GORDON -> SHOPIFY PUSHER   mode=${APPLY ? 'APPLY (LIVE WRITES)' : 'DRY-RUN'}` +
              `${CANARY ? '  [CANARY]' : ''}${phaseTag}${LIMIT ? `  [limit ${LIMIT}]` : ''}`);
  console.log(`  store=${SHOP}  api=${API}  token=...${TOKEN.slice(-4)}`);
  console.log('='.repeat(74));

  // token sanity
  const shop = await rest('GET', '/shop.json');
  console.log(`  shop: ${shop.shop.name}  (${shop.shop.myshopify_domain})`);

  console.log('  loading live WG store index...');
  const storeBySku = await loadStoreIndex();
  console.log(`  live WG variants indexed: ${storeBySku.size}`);

  let plan = await buildPlan(storeBySku);
  console.log(`  plan: new=${plan.new.length} update=${plan.update.length} draft=${plan.draft.length} archive=${plan.archive.length}`);

  let work;
  if (CANARY) {
    work = pickCanary(plan);
  } else if (PHASE1) {
    // Phase 1: re-price/re-SKU matched live products + archive discontinued.
    // ZERO new variants (updates touch existing variants; sample-create skipped).
    work = { new: [], update: plan.update, draft: [], archive: plan.archive };
  } else if (NEW_ONLY) {
    // Phase 2: create net-new only, capped to today's variant budget (--max
    // products; each = 2 variants). draft (quote-only) counts toward the cap too.
    const cap = MAX != null ? MAX : (LIMIT != null ? LIMIT : plan.new.length + plan.draft.length);
    const newAdds = plan.new.slice(0, cap);
    const draftAdds = plan.draft.slice(0, Math.max(0, cap - newAdds.length));
    work = { new: newAdds, update: [], draft: draftAdds, archive: [] };
  } else if (LIMIT) {
    work = { new: plan.new.slice(0, LIMIT), update: [], draft: [], archive: [] };
  } else {
    work = plan;
  }

  // ── shared-cap gate on the NET-NEW create path ──────────────────────────────
  // Updates/archives create NO new variants, so they are never cap-gated. Only
  // the create path (work.new + work.draft, 2 variants each) draws the budget.
  if (APPLY && (work.new.length || work.draft.length)) {
    // 1. shared kill-switch — if present, drop all creates this run.
    if (fs.existsSync(KILL_SWITCH)) {
      console.log(`  KILL-SWITCH ${KILL_SWITCH} present — skipping ALL net-new creates this run.`);
      work.new = []; work.draft = [];
    } else {
      // 2. draw from the shared daily ledger ('upload' = the ACTIVE-phase
      //    category). The grant is in VARIANTS; each product = 2 variants.
      //    Cap creates-per-run to floor(grant/2) so this trickles within the
      //    18/hr paced budget instead of dumping hundreds.
      const wantProducts = work.new.length + work.draft.length;
      const wantVariants = wantProducts * VARIANTS_PER_PRODUCT;
      let grantVariants;
      if (!budget) {
        // ledger unreachable -> fail-CLOSED on the create path (the cap is the
        // whole point of this rewire; never burst it on a missing ledger).
        grantVariants = 0;
        console.log('  [budget] ledger unavailable — creating 0 net-new products this run (fail-closed on the cap).');
      } else {
        grantVariants = budget.take('upload', wantVariants);
      }
      const grantProducts = Math.floor(grantVariants / VARIANTS_PER_PRODUCT);
      console.log(`  [budget] net-new requested ${wantProducts} products (${wantVariants} variants) -> granted ${grantVariants} variants = ${grantProducts} products this run.`);
      // Spend the grant on NEW first, then DRAFT (quote-only).
      const newKeep = Math.min(work.new.length, grantProducts);
      const draftKeep = Math.min(work.draft.length, Math.max(0, grantProducts - newKeep));
      if (newKeep < work.new.length || draftKeep < work.draft.length) {
        console.log(`  [budget] capping creates: new ${work.new.length}->${newKeep}, draft ${work.draft.length}->${draftKeep} (deferred to a later paced run).`);
      }
      work.new = work.new.slice(0, newKeep);
      work.draft = work.draft.slice(0, draftKeep);
    }
  }

  const total = work.new.length + work.update.length + work.draft.length + work.archive.length;
  console.log(`  ACTIONING: new=${work.new.length} update=${work.update.length} draft=${work.draft.length} archive=${work.archive.length} (total ${total})`);
  console.log('-'.repeat(74));

  const results = { new: [], update: [], draft: [], archive: [] };
  const deferred = []; // items blocked by Shopify daily variant-create quota

  // NEW
  for (const r of work.new) {
    const t = buildTitle(r);
    console.log(`  [NEW]   ${r.dw_sku} ${t}  $${r.price_retail}`);
    if (APPLY) {
      try {
        const p = await createProduct(r);
        if (p) results.new.push({ handle: p.handle, sku: r.dw_sku, price: String(r.price_retail), status: p.status });
      } catch (e) {
        if (e.variantThrottled) { console.log('    DEFERRED — daily variant-create quota reached'); deferred.push({ action: 'new', sku: r.dw_sku }); }
        else throw e;
      }
    }
  }
  // DRAFT (quote-only, null price)
  for (const r of work.draft) {
    const t = buildTitle(r);
    console.log(`  [DRAFT] ${r.dw_sku} ${t}  (quote-only)`);
    if (APPLY) {
      try {
        const p = await createProduct(r, { draft: true });
        if (p) results.draft.push({ handle: p.handle, sku: r.dw_sku, price: 'quote-only', status: p.status });
      } catch (e) {
        if (e.variantThrottled) { console.log('    DEFERRED — daily variant-create quota reached'); deferred.push({ action: 'draft', sku: r.dw_sku }); }
        else throw e;
      }
    }
  }
  // UPDATE
  for (const u of work.update) {
    console.log(`  [UPD]   ${u.live.handle}  ${u.live.price}->$${u.target.price_retail}  sku->${u.target.dw_sku}`);
    if (APPLY) results.update.push(await updateProduct(u));
  }
  // ARCHIVE
  for (const a of work.archive) {
    console.log(`  [ARCH]  ${a.live.handle}  (${a.legacy.sku})`);
    if (APPLY) results.archive.push(await archiveProduct(a));
  }

  console.log('-'.repeat(74));
  if (!APPLY) {
    console.log('  DRY-RUN — nothing written. Re-run with --apply to execute.');
  } else {
    console.log('  APPLIED. Results:');
    console.log(JSON.stringify(results, null, 2));
    if (deferred.length) {
      console.log(`  DEFERRED (Shopify daily variant-create quota, retry tomorrow): ${deferred.length}`);
      console.log('  ' + JSON.stringify(deferred));
    }
  }
  await pool.end();
}

main().catch(e => { console.error('FATAL:', e); process.exit(1); });