← back to Designer Wallcoverings
All-day uploader: cadence-import --max + --activate (readiness-gated ACTIVE + inventory 2026) + defensive denylist; hourly runner (budget-aware, kill-switch, resumable) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87a9e7d6e2ac11f6ffe4ffeec390f18bf45a0bfc · 2026-06-17 14:40:18 -0700 · SteveStudio2
Files touched
M shopify/scripts/cadence/cadence-import.jsA shopify/scripts/cadence/run-cadence-hourly.sh
Diff
commit 87a9e7d6e2ac11f6ffe4ffeec390f18bf45a0bfc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed Jun 17 14:40:18 2026 -0700
All-day uploader: cadence-import --max + --activate (readiness-gated ACTIVE + inventory 2026) + defensive denylist; hourly runner (budget-aware, kill-switch, resumable)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
shopify/scripts/cadence/cadence-import.js | 92 +++++++++++++++++++++++----
shopify/scripts/cadence/run-cadence-hourly.sh | 37 +++++++++++
2 files changed, 116 insertions(+), 13 deletions(-)
diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 92de05c0..4368a6af 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -40,7 +40,15 @@ const SLOT = val('--slot', 'am');
const ONLY = val('--only', null); // restrict to a single vendor (e.g. --only Thibaut)
const VENDORS_PER_SLOT = parseInt(val('--vendors', '10'), 10);
const SKUS_PER_VENDOR = parseInt(val('--skus', '20'), 10);
+const MAX_PRODUCTS = parseInt(val('--max', '0'), 10); // 0 = unlimited; total products this run (budget-bound by the hourly runner)
+const ACTIVATE = flag('--activate'); // auto-activate readiness-passing products to ACTIVE + inventory=2026 (Steve 2026-06-17)
const TODAY = new Date().toISOString().slice(0, 10);
+// Defensive vendor denylist — even if a vendor is in vendors.js + discount_confirmed, NEVER
+// import these (belt-and-suspenders for sub-brand/registry drift). Today vendors.js already
+// excludes them by omission; this hard-blocks a future accidental add. (compliance officer, 2026-06-17)
+const DENY_VENDORS = new Set(['Nicolette Mayer','Phillip Jeffries','Et Cie','Spoonflower','Colefax & Fowler','Carlisle','Paul Montgomery','Cowtan & Tout','Rebel Walls','Koroseal','Newmor','Wolf Gordon','Innovations','Scalamandre','Desima',
+ 'Romo']); // Romo: vendors.js note "confirm tariff treatment w/ Steve before bulk import" → hold from the auto-uploader until confirmed
+const INV_LOCATION = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd. (inventory-set-2026.js)
const RETAIL = c => Math.round((c / 0.65 / 0.85) * 100) / 100;
const SAMPLE_PRICE = '4.25'; // correct price for the Sample variant (Steve 2026-06-11)
@@ -150,6 +158,7 @@ function gate() {
const confirmed = confirmedDiscountVendors();
const rows = [];
for (const [vendor, cfg] of Object.entries(VENDORS)) {
+ if (DENY_VENDORS.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'DENYLISTED', netnew: 0 }); continue; }
if (!confirmed.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'discount-not-confirmed', netnew: 0 }); continue; }
let netnew = 0, why = 'READY';
try {
@@ -187,7 +196,7 @@ function selectSkus(cfg, limit) {
}
// ---- build the Shopify productSet input (generic across standard-schema vendors) ----
-function buildInput(vendor, cfg, row, retail) {
+function buildInput(vendor, cfg, row, retail, activate) {
const stripCodes = s => String(s||'').replace(/\(\s*[A-Za-z]?\d{3,}[^)]*\)/g, '').replace(/\s{2,}/g,' ').trim(); // kill (T457) etc
const pattern = titleCase(stripCodes(row.pattern_name || ''));
const rawColor = cleanColor(row.color_name, row.pattern_name, row.mfr_sku);
@@ -215,8 +224,15 @@ function buildInput(vendor, cfg, row, retail) {
m('custom','cost',row.cost.toFixed(2),ND); // REAL cost
m('custom','price_updated_at',TODAY,'date'); // 30-day refresh audit
m('global','unit_of_measure',`Priced Per ${cfg.soldBy}`,SL);
+ // Auto-activate-to-live readiness gate (Steve 2026-06-17 "auto-activate to live"): a product
+ // goes ACTIVE only if it's complete — real image + width + pattern (cost is already gated by
+ // costExpr>0 upstream; title is banned-word-clean by construction). Anything incomplete stays
+ // DRAFT (still "uploaded", fixable later) — never a broken product on the storefront.
+ const ready = !!(row.image_url && String(row.width || '').trim() && pattern);
+ const willActivate = !!(activate && ready);
const input = {
- title, handle, vendor, productType: cfg.productType, status: 'DRAFT', // never ACTIVE on create (Steve rule)
+ title, handle, vendor, productType: cfg.productType,
+ status: willActivate ? 'ACTIVE' : 'DRAFT', // DRAFT unless --activate AND readiness passes
tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[])],
descriptionHtml: `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`,
metafields: mf,
@@ -227,11 +243,46 @@ function buildInput(vendor, cfg, row, retail) {
],
};
if (row.image_url) input.files = [{ originalSource: webImage(row.image_url), contentType:'IMAGE', alt: core }];
- return { input, title, retail };
+ return { input, title, retail, willActivate, ready };
}
const PRODUCTSET = `mutation push($input: ProductSetInput!){productSet(synchronous:true,input:$input){product{id handle status} userErrors{field message}}}`;
+// ---- restore map (one batch_id per slot → a bad hour is selectable + revertible) ----
+const BATCH_ID = `upload-${SLOT}-${new Date().toISOString().replace(/[:.]/g,'-')}`;
+const RESTORE_FILE = path.join(DATADIR, `upload-restore-${TODAY}.jsonl`);
+function appendRestore(rec) {
+ try { fs.mkdirSync(DATADIR,{recursive:true}); fs.appendFileSync(RESTORE_FILE, JSON.stringify({ ...rec, batch_id: BATCH_ID, ts: new Date().toISOString() }) + '\n'); }
+ catch (e) { /* never let audit logging break a create */ }
+}
+
+// ---- inventory=2026 on BOTH variants at activation (hard rule new-products-inventory-2026) ----
+// Reuses inventory-set-2026.js's exact mechanism: look up inventoryItem ids by SKU, then
+// inventorySetQuantities on_hand=2026 at the Ventura Blvd location. Inventory sets do NOT
+// consume the variant-creation cap (they touch existing variants), so this is cap-free.
+const INV_LOOKUP = `query($q:String!){productVariants(first:250,query:$q){edges{node{sku inventoryItem{id}}}}}`;
+const INV_SET = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{field message code}}}`;
+async function setInventory2026(skus) {
+ if (!skus.length) return { set: 0, errors: [] };
+ 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 gqlRetry(INV_LOOKUP, { q });
+ for (const e of (r.json?.data?.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, quantity: 2026 }));
+ const errors = [];
+ for (let i=0;i<pairs.length;i+=250) {
+ const r = await gqlRetry(INV_SET, { input: { name:'on_hand', reason:'correction', ignoreCompareQuantity:true, quantities: pairs.slice(i,i+250) } });
+ const ue = r.json?.data?.inventorySetQuantities?.userErrors || [];
+ if (ue.length) errors.push(...ue);
+ }
+ return { set: pairs.length, mapped: map.size, errors };
+}
+
// ---- PRE-PUSH DEDUP GUARD (Steve 2026-06-12, DTD-picked structural fix) ----
// The net-new gate selects rows where shopify_product_id='' and blindly CREATEs. If that link
// is empty due to drift (a Mac2-created product whose id never reached this DB, a late sync, a
@@ -264,6 +315,7 @@ async function createProduct(table, row, payload) {
// log our computed price to the DB (the hard rule) but DO NOT overwrite the live Shopify product.
logPriceToDb(table, row.dw_sku, payload.retail);
psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}' AND coalesce(shopify_product_id::text,'')='';`);
+ appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'linked-existing', activated: false });
return { ok: true, pid: existing.id, action: 'linked-existing', status: existing.status };
}
// dw_unified FIRST
@@ -279,8 +331,11 @@ async function createProduct(table, row, payload) {
const pid = r.json?.data?.productSet?.product?.id;
// CRITICAL: only count as created when a real product id comes back (else net-new → retried).
if (!pid) return { ok:false, err: (topErr || r.raw || 'no-pid (empty response)').slice(0,160) };
- psql(`UPDATE ${table} SET shopify_product_id='${String(pid).replace(/.*\//,'')}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
- return { ok:true, pid };
+ const num = String(pid).replace(/.*\//,'');
+ psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
+ const activated = !!payload.willActivate;
+ appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, status: activated ? 'ACTIVE' : 'DRAFT' });
+ return { ok:true, pid, activated, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
}
// ---- main ----
@@ -302,30 +357,40 @@ async function createProduct(table, row, payload) {
for (let i=0;i<Math.min(VENDORS_PER_SLOT, ready.length);i++) picked.push(ready[(start+i)%ready.length]);
console.log(`\n=== ${COMMIT?'LIVE COMMIT':'DRY-RUN'} — slot ${SLOT}: ${picked.length} vendor(s) × up to ${SKUS_PER_VENDOR} SKUs ===`);
- const plan = []; let created=0, failed=0, linked=0;
+ const plan = []; let created=0, failed=0, linked=0; const activatedSkus=[]; let hitMax=false, hitCap=false;
+ if (MAX_PRODUCTS) console.log(`(--max ${MAX_PRODUCTS} products this run)${ACTIVATE?' --activate: readiness-passing → ACTIVE + inventory 2026':''}`);
for (const r of picked) {
+ if (hitMax || hitCap) break;
if (COMMIT) ensureOurPriceCol(r.cfg.table);
const skus = selectSkus(r.cfg, SKUS_PER_VENDOR);
console.log(`\n${r.vendor} — ${skus.length} SKU(s) (cost ${r.cfg.costExpr}):`);
for (const row of skus) {
// MAP-priced vendors (sellExpr) sell at MAP; everyone else at cost/0.65/0.85.
const retail = (r.cfg.sellExpr && row.sell > 0) ? Math.round(row.sell * 100) / 100 : RETAIL(row.cost);
- const payload = buildInput(r.vendor, r.cfg, row, retail);
- plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title });
- if (!COMMIT) { console.log(` · ${row.dw_sku} cost $${row.cost.toFixed(2)} → roll $${retail} sample $${SAMPLE_PRICE} | ${payload.title.slice(0,52)}`); continue; }
+ const payload = buildInput(r.vendor, r.cfg, row, retail, ACTIVATE);
+ plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title, willActivate:payload.willActivate });
+ if (!COMMIT) { console.log(` · ${row.dw_sku} cost $${row.cost.toFixed(2)} → roll $${retail} sample $${SAMPLE_PRICE} ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''} | ${payload.title.slice(0,48)}`); continue; }
const res = await createProduct(r.cfg.table, row, payload);
if (res.ok && res.action === 'linked-existing') { linked++; console.log(` ↪ ${row.dw_sku} already on Shopify (${res.status}) → backfilled link ${String(res.pid).replace(/.*\//,'')}, skipped create (dedup guard)`); }
- else if (res.ok) { created++; if(created%10===0) process.stdout.write(`\r created ${created}…`); }
+ else if (res.ok) { created++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if(created%10===0) process.stdout.write(`\r created ${created}…`); }
else { failed++; console.log(` ✗ ${row.dw_sku} ${JSON.stringify(res.err).slice(0,140)}`); }
- if (res.stop) { console.log(`\n⛔ DAILY_VARIANT_LIMIT reached — stopping (resets in ~24h). created ${created} this run.`); break; }
+ if (res.stop) { console.log(`\n⛔ DAILY_VARIANT_LIMIT reached — stopping (resets in ~24h). created ${created} this run.`); hitCap=true; break; }
+ if (MAX_PRODUCTS && (created + linked) >= MAX_PRODUCTS) { console.log(`\n· --max ${MAX_PRODUCTS} reached — stopping this slot (resumes next slot via cursor).`); hitMax=true; break; }
}
- if (failed && /* bail across vendors too once the daily cap is hit */ false) break;
+ }
+ // auto-activate-to-live: set inventory=2026 on BOTH variants of every product that went ACTIVE.
+ if (COMMIT && ACTIVATE && activatedSkus.length) {
+ console.log(`\nsetting inventory=2026 on ${activatedSkus.length} variant(s) (${activatedSkus.length/2} activated products) at Ventura Blvd…`);
+ try { const inv = await setInventory2026(activatedSkus); console.log(` inventory set: ${inv.set} pairs, mapped ${inv.mapped}, errors ${inv.errors.length}`); if (inv.errors.length) console.log(' inv errors:', JSON.stringify(inv.errors).slice(0,200)); }
+ catch (e) { console.warn(' inventory-set failed (products are ACTIVE but inventory not 2026 — re-run inventory-set-2026.js):', e.message); }
}
// advance rotation
saveCursor({ idx: (start + picked.length) % ready.length, lastSlot: SLOT, lastRun: TODAY });
const planFile = path.join(DATADIR, `cadence-plan-${SLOT}-${TODAY}.json`);
fs.mkdirSync(DATADIR,{recursive:true}); fs.writeFileSync(planFile, JSON.stringify(plan,null,1));
- console.log(`\n\n${COMMIT?`CREATED ${created}, linked-existing ${linked} (dedup guard), failed ${failed}`:`DRY-RUN planned ${plan.length} products`} → ${planFile}`);
+ const activatedN = activatedSkus.length / 2;
+ console.log(`\n\n${COMMIT?`CREATED ${created} (${ACTIVATE?activatedN+' ACTIVE / '+(created-activatedN)+' DRAFT':'all DRAFT'}), linked-existing ${linked} (dedup guard), failed ${failed}`:`DRY-RUN planned ${plan.length} products${ACTIVATE?` (${plan.filter(p=>p.willActivate).length} would activate)`:''}`} → ${planFile}`);
+ if (COMMIT) console.log(`restore map: ${RESTORE_FILE} (batch ${BATCH_ID})`);
if (!COMMIT) console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
// refresh the CNCP "Price Coverage" goal panel now that new cost may have landed.
@@ -334,4 +399,5 @@ async function createProduct(table, row, payload) {
execFileSync('node', [path.join(__dirname, 'compute-price-coverage.js'), '--quiet'], { stdio: 'ignore' });
console.log('price-coverage recomputed.');
} catch (e) { console.warn('price-coverage recompute skipped:', e.message); }
+ if (hitCap) process.exit(3); // resumable signal: daily variant cap hit, runner resumes next slot
})();
diff --git a/shopify/scripts/cadence/run-cadence-hourly.sh b/shopify/scripts/cadence/run-cadence-hourly.sh
new file mode 100755
index 00000000..9e457ffb
--- /dev/null
+++ b/shopify/scripts/cadence/run-cadence-hourly.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# DW all-day new-product uploader — fires hourly 6am–5pm PT. Drains the already-staged vendor
+# backlog (cadence-import.js), AUTO-ACTIVATING readiness-passing products to live + inventory=2026
+# (Steve 2026-06-17). Cooperates with the shared ~1,000/day variant cap via budget.cjs ('upload'
+# category, leftover-aware) and the importer's own VARIANT_THROTTLE_EXCEEDED abort (exit 3).
+# Resumable: the firm rotation cursor (data/cadence-cursor.json) advances each slot so all
+# eligible firms get coverage across the day; the staged backlog drains continuously.
+set -u
+SHOP="$HOME/Projects/Designer-Wallcoverings/shopify"
+BUDGET="$HOME/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs"
+KILL="$HOME/.dw-fixer-stop"
+LOG="/tmp/dw-cadence-hourly.log"
+exec >>"$LOG" 2>&1
+TS="$(date '+%F %T')"
+echo "═══════ cadence-hourly $TS ═══════"
+
+# global kill-switch (shared with roll-scale/other builders)
+[ -f "$KILL" ] && { echo "kill-switch $KILL present — skip slot"; exit 0; }
+
+# slot: am (hours 6-11) / pm (12-17), local time
+H=$(date +%H); H=${H#0}; SLOT=am; [ "${H:-0}" -ge 12 ] && SLOT=pm
+
+# ask the shared variant-budget ledger for this slot's grant. We aim for ~40 variants
+# (~20 products) per slot; budget.take returns min(request, upload-cap-remaining, global-remaining)
+# so on heavy days it hands back less (leftover-aware) and on light days up to the upload slice.
+REQ="${UPLOAD_SLOT_VARIANTS:-40}"
+GRANT=$(node "$BUDGET" take upload "$REQ" 2>/dev/null || echo "$REQ")
+MAXP=$(( GRANT / 2 )) # 2 variants per product (Single Roll + Sample)
+echo "slot=$SLOT budget grant=$GRANT variants → max $MAXP products"
+if [ "$MAXP" -lt 1 ]; then echo "no upload budget this slot — exit clean"; exit 0; fi
+
+cd "$SHOP" || { echo "FATAL: no $SHOP"; exit 1; }
+export $(grep -E "^SHOPIFY_ADMIN_TOKEN=" "$HOME/Projects/secrets-manager/.env" | xargs) 2>/dev/null
+node scripts/cadence/cadence-import.js --slot "$SLOT" --vendors 8 --skus 10 --max "$MAXP" --activate --commit
+RC=$?
+echo "cadence-import exit $RC (3 = daily variant cap hit, resumes next slot)"
+echo "═══════ done $TS ═══════"
← 5964ee51 Add Artmura real logo (artmura.it) for brands page
·
back to Designer Wallcoverings
·
Wire Innovations into cadence vendors.js (cost=price_trade=l edced97e →