← back to Designerwallcoverings

scripts/anna-french-rolls/af-build.js

82 lines

'use strict';
/* Anna French roll/yard variant builder — works off LIVE plan (af-build-plan.jsonl),
   NOT the stale mirror SKU. Thibaut price structure (per Steve 2026-06-17).
   Per product: re-verify LIVE it is still sample-only (1 variant <= $4.255), then add ONE
   variant — "Sold Per Roll" (wallcovering) or "Sold Per Yard" (fabric) — at the resolved price.
   Roll/yard variant sku = base (sample sku minus -SAMPLE). Never touches the $4.25 sample,
   never flips ACTIVE. Guards: PRICE_CAP $2000 hold, daily-limit abort, resumable done-file,
   ROLL_CAP ceiling (default 5 — bump to scale). exit 3 if cap hit (resumable). */
const fs = require('fs');
const ENV = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const EP = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PLAN = __dirname + '/out/af-build-plan.jsonl';
const DONE = __dirname + '/out/af-build-done.txt';
const CREATED = __dirname + '/out/af-build-created.jsonl';
const SKIPPED = __dirname + '/out/af-build-skipped.jsonl';
const CAP = parseInt(process.env.ROLL_CAP || '5', 10);
const PRICE_CAP = parseFloat(process.env.ROLL_PRICE_CAP || '2000');
const LIMIT_RE = /exceed|daily.*limit|limit.*reached|throttl|too many/i;
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function g(q, v) {
  for (let i = 0; i < 7; i++) {
    let r; const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 25000);
    try { r = await fetch(EP, { method: 'POST', signal: ac.signal, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
    catch (e) { clearTimeout(to); await sleep(1500 * (i + 1)); continue; }
    clearTimeout(to);
    if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
    const j = await r.json();
    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
    return j;
  }
  throw new Error('exhausted');
}
const VERIFY = `query($id:ID!){ product(id:$id){ status options{name} variants(first:5){nodes{sku price}} } }`;
const MUT = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$pid, variants:$variants){ productVariants{ id price sku } userErrors{ message } } }`;
const MFSET = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ message } } }`;

(async () => {
  // Only build clean Size-option products. Default-"Title" products migrate the lone default
  // variant when a named value is added (it ate one $4.25 sample) — they need a separate
  // option-migration path, so hard-skip them here.
  const rows = fs.readFileSync(PLAN, 'utf8').split('\n').filter(Boolean).map(JSON.parse)
    .filter(r => r.buildable && r.price > 0 && r.price < PRICE_CAP && r.optName === 'Size');
  const done = new Set(fs.existsSync(DONE) ? fs.readFileSync(DONE, 'utf8').split('\n').filter(Boolean) : []);
  const todo = rows.filter(r => !done.has(r.id));
  console.log(`buildable<cap=${rows.length} done=${done.size} todo=${todo.length} ROLL_CAP=${CAP}`);
  const doneS = fs.createWriteStream(DONE, { flags: 'a' }), crS = fs.createWriteStream(CREATED, { flags: 'a' }), skS = fs.createWriteStream(SKIPPED, { flags: 'a' });
  let made = 0, skipped = 0, aborted = false;
  for (const r of todo) {
    const lk = await g(VERIFY, { id: r.id });
    const p = lk.data && lk.data.product;
    if (!p || p.status !== 'ACTIVE') { skipped++; doneS.write(r.id + '\n'); skS.write(JSON.stringify({ id: r.id, reason: 'not-active-or-gone' }) + '\n'); continue; }
    const optName = (p.options[0] && p.options[0].name) || r.optName || 'Size';
    if (optName !== 'Size') { skipped++; doneS.write(r.id + '\n'); skS.write(JSON.stringify({ id: r.id, sku: r.sampleSku, reason: 'title-option-needs-migration' }) + '\n'); continue; }
    const vs = p.variants.nodes;
    if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.255)) { skipped++; doneS.write(r.id + '\n'); skS.write(JSON.stringify({ id: r.id, sku: r.sampleSku, reason: 'not-sample-only(has variant?)' }) + '\n'); continue; }
    const base = String(r.sampleSku || '').replace(/-SAMPLE$/i, '');
    const variants = [{ price: String(r.price), optionValues: [{ optionName: optName, name: r.unit }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: base, tracked: false } }];
    const rr = await g(MUT, { pid: r.id, variants });
    const res = rr.data && rr.data.productVariantsBulkCreate;
    const e = (res && res.userErrors) || [];
    if (e.length) {
      if (LIMIT_RE.test(JSON.stringify(e))) { console.log('daily-limit/throttle — aborting (resumable):', JSON.stringify(e)); aborted = true; break; }
      skipped++; skS.write(JSON.stringify({ id: r.id, sku: r.sampleSku, reason: 'err:' + JSON.stringify(e) }) + '\n'); continue;
    }
    const nv = res.productVariants[0];
    made++; doneS.write(r.id + '\n');
    // write custom.length (single-roll, e.g. "16.5 ft") if the plan resolved one
    if (r.lengthMf) { try { await g(MFSET, { m: [{ ownerId: r.id, namespace: 'custom', key: 'length', type: 'single_line_text_field', value: r.lengthMf }] }); } catch (e) {} }
    crS.write(JSON.stringify({ id: r.id, variantId: nv.id, sku: nv.sku, price: nv.price, unit: r.unit, lengthMf: r.lengthMf, title: r.title }) + '\n');
    console.log(`  +${made} ${nv.sku}  ${r.unit}  $${nv.price}  ${r.title.slice(0, 38)}`);
    if (made >= CAP) { console.log(`\nROLL_CAP ${CAP} reached — stopping (resumable).`); break; }
    await sleep(300);
  }
  doneS.end(); crS.end(); skS.end();
  console.log(`\nRun done: ${made} built, ${skipped} skipped, ${todo.length - made - skipped} untouched.`);
  if (made >= CAP) process.exit(3);
  process.exit(aborted ? 0 : 0);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });