← back to Designerwallcoverings
build-roll-scale.js
108 lines
'use strict';
/**
* Tier-A roll-variant SCALE — driven DIRECTLY by product_map (efficient).
* For each buildable row (2026 spreadsheet MAP, unit set, price < cap), resolve the
* Shopify product by its sample SKU and add a "Sold Per Roll"/"Sold Per Yard" variant
* at the 2026 MAP. No catalog scanning. Never overwrites the $4.25 Sample; never flips ACTIVE.
*
* Guards: 25s fetch timeout, HTML-response guard, variant daily-limit abort (no cap fight
* with sample-split), price-sanity cap (>= $2000/roll held for unit review), resumable
* (done-file), ROLL_CAP ceiling (default 350), exit 3 if cap hit.
*/
const fs = require('fs');
const { execSync } = require('child_process');
const budget = require('./scripts/variant-budget/budget.cjs'); // shared daily variant ledger (DTD-C)
const ENV = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const ENDPOINT = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PGENV = { PGHOST: '/tmp', PGPORT: '5432', PGUSER: 'stevestudio2', PGDATABASE: 'dw_unified', PATH: '/opt/homebrew/opt/postgresql@14/bin:/usr/bin:/bin' };
const DONE = __dirname + '/roll-scale-done2.txt'; // dw_sku-keyed resume (new architecture)
const CREATED = __dirname + '/roll-scale-created.jsonl';
const SKIPPED = __dirname + '/roll-scale-skipped.jsonl';
const CAP = parseInt(process.env.ROLL_CAP || '350', 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(ENDPOINT, { 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; }
let j; try { j = await r.json(); } catch (e) { await sleep(2000 * (i + 1)); continue; }
if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
return j;
}
throw new Error('exhausted retries');
}
// buildable rows: dw_sku, map, unit (2026 sheet, unit set, under price cap)
function loadBuildable() {
// optional vendor scope (e.g. ROLL_VENDOR=Kravet) so an approved run touches only that vendor.
const vendorFilter = process.env.ROLL_VENDOR ? `AND vendor = '${process.env.ROLL_VENDOR.replace(/'/g, "''")}' ` : '';
// panels are legitimately expensive (wide-format murals) → exempt from the per-roll price cap; rolls/yards still capped.
const raw = execSync(`psql -tAc "SELECT dw_sku||'~~'||map_price||'~~'||map_unit FROM product_map WHERE map_source IN ('auth_new_map_2026','auth_new_map_2026_prefix','tier_b_retail_div085','kravet_site_2026') AND map_unit IS NOT NULL AND (map_price < ${PRICE_CAP} OR map_unit='Sold Per Panel') ${vendorFilter}ORDER BY (vendor='Kravet') DESC, vendor, dw_sku"`, { env: PGENV, encoding: 'utf8' });
return raw.split('\n').filter(Boolean).map(l => { const [dw, map, unit] = l.split('~~'); return { dw, map, unit }; });
}
const LOOKUP = `query($q:String!){ products(first:1, query:$q){ nodes{ id options{name} variants(first:3){nodes{id price sku selectedOptions{value}}} } } }`;
const MUT = `mutation($pid: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $pid, variants: $variants) { productVariants { id price sku } userErrors { message } } }`;
const UPD = `mutation($pid: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $pid, variants: $variants) { productVariants { id } userErrors { message } } }`;
(async () => {
const rows = loadBuildable();
const done = new Set(fs.existsSync(DONE) ? fs.readFileSync(DONE, 'utf8').split('\n').filter(Boolean) : []);
const todo = rows.filter(r => !done.has(r.dw));
console.log(`buildable=${rows.length} done=${done.size} todo=${todo.length} cap=${CAP}`);
const doneStream = fs.createWriteStream(DONE, { flags: 'a' });
const createdStream = fs.createWriteStream(CREATED, { flags: 'a' });
const skipStream = fs.createWriteStream(SKIPPED, { flags: 'a' });
let made = 0, skipped = 0, aborted = false;
for (const r of todo) {
const lk = await g(LOOKUP, { q: `sku:${r.dw}-Sample status:active` });
const p = lk.data && lk.data.products.nodes[0];
if (!p) { skipped++; doneStream.write(r.dw + '\n'); skipStream.write(JSON.stringify({ dw_sku: r.dw, reason: 'product-not-found' }) + '\n'); continue; }
const vs = p.variants.nodes;
if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.255)) { skipped++; doneStream.write(r.dw + '\n'); skipStream.write(JSON.stringify({ dw_sku: r.dw, reason: 'not-sample-only(has roll?)' }) + '\n'); continue; }
// shared daily-budget gate (DTD-C): stop cleanly when the roll share is spent,
// BEFORE hitting Shopify's hard ~1k cap — so sample-split keeps its share.
if (budget.take('roll', 1) < 1) { console.log('\nDaily variant budget for roll spent — stopping (resumable).'); break; }
const optName = (p.options[0] && p.options[0].name) || 'Size';
// CRITICAL (default-variant sample-preservation, 2026-06-17 canary): a product whose only
// variant is Shopify's implicit "Default Title" cannot coexist with a named-option variant —
// bulkCreate would REPLACE the lone $4.25 sample. First RENAME that default variant's option
// value to "Sample" (UPDATE, not create — bypasses the daily create cap and preserves the
// sample, under the SAME option name so both values coexist); only then does creating the
// named roll/yard variant ADD a 2nd variant. Mirrors build-romo-rolls / build-sandberg-rolls.
const v0 = vs[0];
const v0opt = (v0.selectedOptions && v0.selectedOptions[0] && v0.selectedOptions[0].value) || '';
if (/^default title$/i.test(v0opt)) {
const ur = await g(UPD, { pid: p.id, variants: [{ id: v0.id, optionValues: [{ optionName: optName, name: 'Sample' }] }] });
const ue = (ur.data && ur.data.productVariantsBulkUpdate && ur.data.productVariantsBulkUpdate.userErrors) || [];
if (ue.length) { budget.refund('roll', 1); skipped++; skipStream.write(JSON.stringify({ dw_sku: r.dw, reason: 'rename-default-err:' + JSON.stringify(ue) }) + '\n'); continue; }
}
const variants = [{ price: r.map, optionValues: [{ optionName: optName, name: r.unit }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: r.dw, tracked: false } }];
const rr = await g(MUT, { pid: p.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('Variant daily-limit/throttle — aborting (no headroom):', JSON.stringify(e)); aborted = true; break; }
skipped++; skipStream.write(JSON.stringify({ dw_sku: r.dw, reason: 'err:' + JSON.stringify(e) }) + '\n'); continue;
}
const nv = res.productVariants[0];
made++; done.add(r.dw); doneStream.write(r.dw + '\n');
createdStream.write(JSON.stringify({ pid: p.id, variantId: nv.id, sku: nv.sku, price: nv.price, unit: r.unit }) + '\n');
if (made % 25 === 0) console.log(` made ${made} / todo ${todo.length} (skipped ${skipped})`);
if (made >= CAP) { console.log(`\nCap ${CAP} reached — stopping (resumable).`); break; }
await sleep(300);
}
doneStream.end(); createdStream.end(); skipStream.end();
console.log(`\nRun done: ${made} built, ${skipped} skipped, ${todo.length - made - skipped} untouched.`);
if (made >= CAP) process.exit(3);
if (aborted) process.exit(0);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });