[object Object]

← back to Designer Wallcoverings

cadence/activate-gated: publish DRAFT->ACTIVE promotes to Online Store channel

d1277e57a6465fc9679e39e1717ef1e8accdbe82 · 2026-06-26 12:59:39 -0700 · Steve

Root cause of recurring ACTIVE-but-published_at-NULL (PDP 404) bug: this promote
path called productUpdate{status:ACTIVE} then STOPPED. Shopify status:ACTIVE does
NOT auto-publish to the Online Store sales channel, so promoted products stayed
invisible (published_at=null) — e.g. the Schumacher-265 06-11 batch. Mirror the
cadence-import.js publishToChannels() fix: after a successful ACTIVE mutation,
publishablePublish to all channel publications with await + idempotent-retry, log
(not swallow) any publish failure, audit the published flag, and warn in the
summary when promoted != published. Code-only/reversible; deploy is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit d1277e57a6465fc9679e39e1717ef1e8accdbe82
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 12:59:39 2026 -0700

    cadence/activate-gated: publish DRAFT->ACTIVE promotes to Online Store channel
    
    Root cause of recurring ACTIVE-but-published_at-NULL (PDP 404) bug: this promote
    path called productUpdate{status:ACTIVE} then STOPPED. Shopify status:ACTIVE does
    NOT auto-publish to the Online Store sales channel, so promoted products stayed
    invisible (published_at=null) — e.g. the Schumacher-265 06-11 batch. Mirror the
    cadence-import.js publishToChannels() fix: after a successful ACTIVE mutation,
    publishablePublish to all channel publications with await + idempotent-retry, log
    (not swallow) any publish failure, audit the published flag, and warn in the
    summary when promoted != published. Code-only/reversible; deploy is gated.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/activate-gated.js | 51 ++++++++++++++++++++++++++++---
 1 file changed, 47 insertions(+), 4 deletions(-)

diff --git a/shopify/scripts/cadence/activate-gated.js b/shopify/scripts/cadence/activate-gated.js
index 53822388..581c3cba 100644
--- a/shopify/scripts/cadence/activate-gated.js
+++ b/shopify/scripts/cadence/activate-gated.js
@@ -82,6 +82,36 @@ async function gqlRetry(q, v) {
   return { status: 429, raw: 'throttled' };
 }
 
+// ---- publish-to-sales-channels (the missing step — root-caused 2026-06-26) ----
+// Shopify 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. This DRAFT→ACTIVE promote path set status:ACTIVE and
+// STOPPED — so every product it promoted (e.g. the Schumacher-265 06-11 batch) went
+// ACTIVE-but-unpublished. Mirror the cadence-import.js fix (publishToChannels) here
+// so --activate truly means "live on the Online Store".
+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 return.
+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 };
+}
+
 // IMAGE+WIDTH gate from PG (clean source image, has width); the PRICE gate is
 // applied against Shopify's actual per-unit variant price below (the true listed
 // price — robust to per-vendor catalog price-column differences).
@@ -130,7 +160,7 @@ const SL = 'single_line_text_field';
   if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
   fs.mkdirSync(DATADIR, { recursive: true });
   const tables = Object.entries(VENDORS).filter(([v]) => !ONLY || v.toLowerCase() === ONLY.toLowerCase());
-  let promoted = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0;
+  let promoted = 0, publishedCount = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0;
 
   let pushedMf = 0, taggedGaps = 0;
   for (const [vendor, cfg] of tables) {
@@ -231,12 +261,24 @@ const SL = 'single_line_text_field';
           fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:d.dw_sku, product_id:d.gid, action:'metafield-push', metafields:d.mfPush.map(m=>m.namespace+'.'+m.key) }) + '\n');
         }
       }
-      if (!COMMIT) { console.log(`    · would ACTIVATE ${d.dw_sku}`); continue; }
+      if (!COMMIT) { console.log(`    · would ACTIVATE+PUBLISH ${d.dw_sku}`); continue; }
       const r = await gqlRetry(ACTIVATE, { id: d.gid });
       const ue = r.json?.data?.productUpdate?.userErrors;
       if (ue && ue.length) { console.log(`    ✗ ${d.dw_sku}: ${JSON.stringify(ue).slice(0,120)}`); continue; }
       promoted++;
-      fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), vendor, dw_sku: d.dw_sku, product_id: d.gid, from: 'DRAFT', to: 'ACTIVE' }) + '\n');
+      // PUBLISH STEP (the fix, 2026-06-26): status:ACTIVE is invisible on the
+      // storefront until the product is published to the Online Store channel.
+      // Publish here so the promote truly means "live" (await + idempotent-retry
+      // inside publishToChannels). A publish failure is logged, NOT swallowed
+      // silently — it surfaces in stdout and the audit so the canary/rerun catches it.
+      let published = false;
+      try {
+        const pub = await publishToChannels(d.gid);
+        published = !!pub.published;
+        if (!published) console.log(`    ⚠ ${d.dw_sku} ACTIVE but publish failed: ${JSON.stringify(pub.errors||pub.why).slice(0,120)}`);
+      } catch (e) { console.log(`    ⚠ ${d.dw_sku} publish error: ${String(e.message).slice(0,120)}`); }
+      if (published) publishedCount++;
+      fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), vendor, dw_sku: d.dw_sku, product_id: d.gid, from: 'DRAFT', to: 'ACTIVE', published }) + '\n');
       if (promoted % 10 === 0) process.stdout.write(`\r    promoted ${promoted}…`);
     }
 
@@ -258,7 +300,8 @@ const SL = 'single_line_text_field';
       fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:g.dw_sku, product_id:g.gid, action:'tagged', tags:g.tags }) + '\n');
     }
   }
-  console.log(`\n${COMMIT ? `PROMOTED ${promoted} DRAFT→ACTIVE` : `DRY-RUN: ${totalCand} gate-passing rows scanned`}, ${alreadyActive} already active, ${skippedArchived} archived/other skipped.`);
+  console.log(`\n${COMMIT ? `PROMOTED ${promoted} DRAFT→ACTIVE (${publishedCount} published to Online Store)` : `DRY-RUN: ${totalCand} gate-passing rows scanned`}, ${alreadyActive} already active, ${skippedArchived} archived/other skipped.`);
+  if (COMMIT && promoted !== publishedCount) console.log(`  ⚠ ${promoted - publishedCount} product(s) went ACTIVE but did NOT publish to the Online Store — re-run or let the publish canary reconcile.`);
   console.log(COMMIT
     ? `  pushed ${pushedMf} width/spec metafield(s) · tagged ${taggedGaps} gap product(s)`
     : `  (dry-run logged push-then-activate + gap-tag intents above — no writes)`);

← 08e99376 snapshot before git gc  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-26T15:34:21 (6 files) — pending-approval/ d0429e1a →