← back to Designer Wallcoverings
cadence-import: auto-add newly-published products to Trending Wallcovering Collection 2026 (custom collection 298339205171), gated behind --collections
2e31cf351678b2b897854608912eecd4a9159cb0 · 2026-06-19 15:26:47 -0700 · SteveStudio2
New-Arrivals auto-flow fix (DTD 3/3 Option A). Custom collections don't
auto-populate from tags; membership written explicitly via collectionAddProducts
right after publish, atomic with creation. Prior out-of-band collections-agent
had added 0 members since Jun 12. Live collection write gated behind --collections
(OFF by default) so the scheduled --commit run never auto-writes until approved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M shopify/scripts/cadence/cadence-import.js
Diff
commit 2e31cf351678b2b897854608912eecd4a9159cb0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Fri Jun 19 15:26:47 2026 -0700
cadence-import: auto-add newly-published products to Trending Wallcovering Collection 2026 (custom collection 298339205171), gated behind --collections
New-Arrivals auto-flow fix (DTD 3/3 Option A). Custom collections don't
auto-populate from tags; membership written explicitly via collectionAddProducts
right after publish, atomic with creation. Prior out-of-band collections-agent
had added 0 members since Jun 12. Live collection write gated behind --collections
(OFF by default) so the scheduled --commit run never auto-writes until approved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
shopify/scripts/cadence/cadence-import.js | 96 +++++++++++++++++++++++++++++--
1 file changed, 91 insertions(+), 5 deletions(-)
diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 7486524b..ebe5f92f 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -42,6 +42,7 @@ 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 ADD_TO_COLLECTIONS = flag('--collections'); // GATED: add newly-published products to the "Trending Wallcovering Collection 2026" custom collection (DTD 3/3 Option A, 2026-06-19). OFF by default so the scheduled --commit run does NOT write to the live collection until Steve approves enabling this flag.
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
@@ -250,6 +251,66 @@ function buildInput(vendor, cfg, row, retail, activate) {
const PRODUCTSET = `mutation push($input: ProductSetInput!){productSet(synchronous:true,input:$input){product{id handle status} userErrors{field message}}}`;
+// ---- publish-to-sales-channels (the missing step — Steve 2026-06-19) ----
+// status:ACTIVE alone does NOT put a product on the Online Store; it stays
+// invisible to shoppers (published_at=null) until publishablePublish runs against
+// the channel publications. Every product the cadence/activate path made ACTIVE
+// since the --activate flag landed (2026-06-17) was created ACTIVE-but-unpublished.
+// This mirrors the proven rebel-walls-push publishAndStock() pattern.
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
+ publishablePublish(id:$id, input:$input){ userErrors{field message} }
+}`;
+let PUBLICATION_IDS = null; // [{id,name}], loaded once per run
+async function loadPublications() {
+ if (PUBLICATION_IDS) return PUBLICATION_IDS;
+ const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {});
+ PUBLICATION_IDS = (r.json?.data?.publications?.edges || []).map(e => e.node);
+ return PUBLICATION_IDS;
+}
+// Publish an ACTIVE product to ALL channel publications (Online Store first).
+// "already published" userErrors are benign (idempotent re-runs); real errors throw.
+async function publishToChannels(productId) {
+ const pubs = await loadPublications();
+ if (!pubs.length) return { published: false, why: 'no-publications-scope' };
+ const input = pubs.map(p => ({ publicationId: p.id }));
+ const r = await gqlRetry(M_PUBLISH, { id: productId, input });
+ const ue = (r.json?.data?.publishablePublish?.userErrors) || [];
+ const real = ue.filter(e => !/already|cannot be published to itself/i.test(e.message || ''));
+ if (real.length) return { published: false, errors: real };
+ return { published: true, channels: pubs.length };
+}
+
+// ---- auto-membership: "Trending Wallcovering Collection 2026" (custom collection) ----
+// THE NEW-ARRIVALS AUTO-FLOW FIX (DTD 3/3 Option A, 2026-06-19). A CUSTOM collection does
+// NOT auto-populate from tags — membership must be written explicitly with collectionAddProducts.
+// cadence-import.js is the single chokepoint where every product is created+activated+published,
+// so we add membership here, atomic with the publish step. The prior out-of-band mechanism (the
+// Kamatera collections-agent / new-arrivals-rotator) had added ZERO members since Jun 12.
+// Verified read-only: 298339205171 is a CUSTOM collection (ruleSet=null) → collectionAddProducts
+// is the correct mutation (it is invalid against a smart/rule-based collection).
+// GATED behind --collections: even in --commit mode, the live collection write is SKIPPED unless
+// --collections is also passed, so committing this code never auto-writes to the storefront.
+const TRENDING_COLLECTION_ID = 'gid://shopify/Collection/298339205171';
+const M_COLLECTION_ADD = `mutation($id:ID!,$productIds:[ID!]!){
+ collectionAddProducts(id:$id, productIds:$productIds){ userErrors{field message} }
+}`;
+// Add an array of product gids to the Trending collection. "already in collection" userErrors
+// are benign (idempotent re-runs); real errors are surfaced. Batches of 50 (mutation cost).
+async function addToTrendingCollection(productIds) {
+ if (!productIds.length) return { added: 0, errors: [] };
+ const errors = []; let added = 0;
+ for (let i = 0; i < productIds.length; i += 50) {
+ const batch = productIds.slice(i, i + 50);
+ const r = await gqlRetry(M_COLLECTION_ADD, { id: TRENDING_COLLECTION_ID, productIds: batch });
+ const ue = r.json?.data?.collectionAddProducts?.userErrors || [];
+ const real = ue.filter(e => !/already|exists|in the collection/i.test(e.message || ''));
+ if (real.length) { errors.push(...real); }
+ else if (!r.json?.data && r.status !== 200) { errors.push({ message: (r.raw || `status ${r.status}`).slice(0, 120) }); }
+ else { added += batch.length; }
+ }
+ return { added, errors };
+}
+
// ---- 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`);
@@ -336,8 +397,16 @@ async function createProduct(table, row, payload) {
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`] : [] };
+ // PUBLISH STEP (the fix): an ACTIVE product is invisible on the storefront until it's
+ // published to the Online Store channel. Publish here so --activate truly means "live".
+ let published = false;
+ if (activated) {
+ try { const pub = await publishToChannels(pid); published = !!pub.published;
+ if (!published) console.log(` ⚠ ${row.dw_sku} ACTIVE but publish failed: ${JSON.stringify(pub.errors||pub.why).slice(0,120)}`);
+ } catch (e) { console.log(` ⚠ ${row.dw_sku} publish error: ${String(e.message).slice(0,120)}`); }
+ }
+ appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, published, status: activated ? 'ACTIVE' : 'DRAFT' });
+ return { ok:true, pid, activated, published, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
}
// ---- main ----
@@ -359,7 +428,7 @@ 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 activatedSkus=[]; let hitMax=false, hitCap=false;
+ const plan = []; let created=0, failed=0, linked=0; const activatedSkus=[]; const publishedProductIds=[]; 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;
@@ -374,7 +443,7 @@ async function createProduct(table, row, payload) {
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 (res.activated && res.skus) activatedSkus.push(...res.skus); 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 (res.published && res.pid) publishedProductIds.push(res.pid); 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.`); 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; }
@@ -386,6 +455,19 @@ async function createProduct(table, row, payload) {
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); }
}
+ // auto-membership into "Trending Wallcovering Collection 2026" (the New-Arrivals auto-flow fix).
+ // Only NEWLY-PUBLISHED products join (status ACTIVE + published to Online Store). GATED behind
+ // --collections: the live collection write is SKIPPED unless --collections is passed.
+ if (COMMIT && publishedProductIds.length) {
+ if (ADD_TO_COLLECTIONS) {
+ console.log(`\nadding ${publishedProductIds.length} newly-published product(s) to "Trending Wallcovering Collection 2026" (298339205171)…`);
+ try { const c = await addToTrendingCollection(publishedProductIds); console.log(` collection add: ${c.added} added, errors ${c.errors.length}`); if (c.errors.length) console.log(' collection errors:', JSON.stringify(c.errors).slice(0,200)); }
+ catch (e) { console.warn(' collection-add failed (products are published but NOT yet in the Trending collection — re-run with --collections):', e.message); }
+ } else {
+ console.log(`\n⚠ ${publishedProductIds.length} newly-published product(s) were NOT added to the Trending collection (298339205171).`);
+ console.log(` → live collection write is GATED: pass --collections to enable it (see pending-approval / vp-dw-commerce sign-off).`);
+ }
+ }
// advance rotation
saveCursor({ idx: (start + picked.length) % ready.length, lastSlot: SLOT, lastRun: TODAY });
const planFile = path.join(DATADIR, `cadence-plan-${SLOT}-${TODAY}.json`);
@@ -393,7 +475,11 @@ async function createProduct(table, row, payload) {
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).');
+ if (!COMMIT) {
+ console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
+ const wouldPublish = plan.filter(p => p.willActivate).length;
+ if (wouldPublish) console.log(`Of those, ${wouldPublish} would publish → with --commit --activate --collections they would auto-join "Trending Wallcovering Collection 2026" (298339205171). The --collections live write is GATED (pending-approval / vp-dw-commerce).`);
+ }
// refresh the CNCP "Price Coverage" goal panel now that new cost may have landed.
// Read-mostly + isolated to its own tables; never let it break the import.
← 6883b74f cadence: enable --collections so hourly 18/hr new SKUs auto-
·
back to Designer Wallcoverings
·
Add gated Trending-collection backfill apply script (1040 ne ffb5c80a →