← back to Designerwallcoverings
Restore run-roll-scale.sh + build-roll-scale.js (recovered from migration commit fbd994f) — fixes com.steve.roll-scale-daily exit 127
2ad98951c0929cf405bfa793b48eee0842cee3b8 · 2026-08-09 00:32:49 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A build-roll-scale.jsA run-roll-scale.sh
Diff
commit 2ad98951c0929cf405bfa793b48eee0842cee3b8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 9 00:32:49 2026 -0700
Restore run-roll-scale.sh + build-roll-scale.js (recovered from migration commit fbd994f) — fixes com.steve.roll-scale-daily exit 127
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
build-roll-scale.js | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++
run-roll-scale.sh | 23 +++++++++++
2 files changed, 130 insertions(+)
diff --git a/build-roll-scale.js b/build-roll-scale.js
new file mode 100644
index 0000000..eaa987f
--- /dev/null
+++ b/build-roll-scale.js
@@ -0,0 +1,107 @@
+'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); });
diff --git a/run-roll-scale.sh b/run-roll-scale.sh
new file mode 100755
index 0000000..1a58fc6
--- /dev/null
+++ b/run-roll-scale.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# Daily Kravet roll-variant build — runs AFTER sample-split's window (Ops-amended).
+# Prices only from product_map 2026 MAP; aborts on variant daily-limit; resumable.
+cd /Users/macstudio3/Projects/designerwallcoverings || exit 1
+# launchd minimal-PATH fix (node lives in /usr/local/bin, not on launchd's PATH) — mirrors
+# run-cadence-hourly.sh so the bare `node`/budget.cjs calls below don't silently fail.
+export PATH="/usr/local/bin:/opt/homebrew/bin:/opt/homebrew/opt/postgresql@14/bin:$PATH"
+[ -f "$HOME/.dw-fixer-stop" ] && { echo "[$(date)] kill-switch present — abort" >> /tmp/roll-scale-daily.log; exit 0; }
+# Shared variant-budget gate (2026-06-19, roll-trap fix): this is a ROLL builder — its internal
+# loop gates on take('roll',1). In upload-priority mode roll cap=0, so the builder no-ops by
+# design. The earlier half-fix gated on take('upload',…) which DEBITED the cadence uploader's
+# slice for a slice this job can't use (~331 leaked upload slots/day across the roll wrappers).
+# FIX: do a NON-CONSUMING remaining('roll') read and exit 0 cleanly when roll is 0 — NO upload
+# debit. When roll caps are re-prioritized (>0), this falls through and the builder runs normally
+# (it does its own take('roll',…)). remaining() is read-only (never calls save()).
+BUDGET="$HOME/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs"
+ROLLBUD=$(node "$BUDGET" remaining roll 2>/dev/null || echo 0)
+if [ "${ROLLBUD:-0}" -lt 1 ]; then echo "[$(date)] roll budget 0 (upload-priority) — skip, no upload debit" >> /tmp/roll-scale-daily.log; exit 0; fi
+# roll budget exists → fall through to the builder, which does its own take('roll',…).
+GRANT="$ROLLBUD"
+export ROLL_CAP="$GRANT" # clamp the builder to the available roll slice (its own take('roll') is the real limiter)
+/opt/homebrew/bin/node build-roll-scale.js >> /tmp/roll-scale-daily.log 2>&1
+echo "[$(date)] exit=$? (cap=$ROLL_CAP, budget grant=$GRANT)" >> /tmp/roll-scale-daily.log
← e18878e auto-data-snapshot: 2026-08-08T18:20:18 (2 data files) — scr
·
back to Designerwallcoverings
·
auto-data-snapshot: 2026-08-09T01:01:19 (4 data files) — scr dc48079 →