[object Object]

← back to New Import Viewer

consumer.js: fix publish gap (publishablePublish to Online Store) + add required Sample variant

96cd954d2b1ffa9c1d303de87d3bdde75d1adb3b · 2026-06-19 09:18:08 -0700 · steve

REST status:'active' never publishes to a sales channel — products landed on
zero channels (publishedOnPublication=false). Add GraphQL publishablePublish to
the Online Store publication after each active create, plus the mandatory
{DW_SKU}-Sample $4.25 variant. Drafts stay unpublished by design.

Files touched

Diff

commit 96cd954d2b1ffa9c1d303de87d3bdde75d1adb3b
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 09:18:08 2026 -0700

    consumer.js: fix publish gap (publishablePublish to Online Store) + add required Sample variant
    
    REST status:'active' never publishes to a sales channel — products landed on
    zero channels (publishedOnPublication=false). Add GraphQL publishablePublish to
    the Online Store publication after each active create, plus the mandatory
    {DW_SKU}-Sample $4.25 variant. Drafts stay unpublished by design.
---
 consumer.js             | 71 ++++++++++++++++++++++++++++++++++++++++++++++---
 data/launch-queue.jsonl | 10 +++++++
 2 files changed, 78 insertions(+), 3 deletions(-)

diff --git a/consumer.js b/consumer.js
index 0409644..8922d41 100644
--- a/consumer.js
+++ b/consumer.js
@@ -138,7 +138,7 @@ function parseImages(imageUrl, allImages) {
 
 async function shopifyCreate(payload) {
   const url = `https://${SHOP_STORE}/admin/api/${SHOP_API}/products.json`;
-  for (let attempt = 1; attempt <= 3; attempt++) {
+  for (let attempt = 1; attempt <= 5; attempt++) {
     const r = await fetch(url, {
       method: 'POST',
       headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'content-type': 'application/json' },
@@ -155,6 +155,52 @@ async function shopifyCreate(payload) {
   return { ok: false, error: '429 retries exhausted' };
 }
 
+// ── publish gate fix (2026-06-19) ──────────────────────────────────────────
+// REST `status:'active'` does NOT publish a product to a sales channel — it only
+// flips the admin status. `published_at`/`published_scope` are NOT set by the
+// create payload, so every active product this consumer created landed on ZERO
+// channels (publishedOnPublication=false, resourcePublicationsCount=0). The
+// Online Store channel requires an explicit publish call. We resolve the
+// Online Store publication id once, then publishablePublish each active product.
+// Drafts are intentionally NEVER published (image/width gate not yet satisfied).
+const SHOP_GQL = `https://${SHOP_STORE}/admin/api/${SHOP_API}/graphql.json`;
+let ONLINE_STORE_PUB_ID = null;
+async function gql(query, variables) {
+  for (let attempt = 1; attempt <= 5; attempt++) {
+    const r = await fetch(SHOP_GQL, {
+      method: 'POST',
+      headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'content-type': 'application/json' },
+      body: JSON.stringify({ query, variables }),
+    });
+    if (r.status === 429) { await new Promise(res => setTimeout(res, 2000 * attempt)); continue; }
+    const j = await r.json().catch(() => ({}));
+    if (j.errors && /throttled/i.test(JSON.stringify(j.errors))) { await new Promise(res => setTimeout(res, 2000 * attempt)); continue; }
+    return j;
+  }
+  return { errors: [{ message: 'gql 429 retries exhausted' }] };
+}
+async function resolveOnlineStorePub() {
+  if (ONLINE_STORE_PUB_ID) return ONLINE_STORE_PUB_ID;
+  const j = await gql(`{publications(first:25){edges{node{id name}}}}`);
+  const edges = j.data?.publications?.edges || [];
+  const os = edges.find(e => /online store/i.test(e.node.name));
+  ONLINE_STORE_PUB_ID = os ? os.node.id : null;
+  return ONLINE_STORE_PUB_ID;
+}
+// Publish one product to the Online Store channel. Returns {ok} or {ok:false,error}.
+async function shopifyPublishOnlineStore(productId) {
+  const pubId = await resolveOnlineStorePub();
+  if (!pubId) return { ok: false, error: 'Online Store publication not found' };
+  const gid = `gid://shopify/Product/${productId}`;
+  const j = await gql(
+    `mutation($id:ID!,$pub:ID!){publishablePublish(id:$id,input:[{publicationId:$pub}]){userErrors{field message}}}`,
+    { id: gid, pub: pubId });
+  if (j.errors) return { ok: false, error: JSON.stringify(j.errors).slice(0, 200) };
+  const ue = (j.data?.publishablePublish?.userErrors || []).filter(e => !/already/i.test(e.message || ''));
+  if (ue.length) return { ok: false, error: JSON.stringify(ue).slice(0, 200) };
+  return { ok: true };
+}
+
 (async () => {
   console.log(`launch-consumer · mode=${LIVE ? '🔴 LIVE (draft creates)' : '🟢 DRY-RUN'} · limit=${LIMIT} · store=${SHOP_STORE}${SHOP_TOKEN ? '' : ' · NO TOKEN'}`);
   if (flag('--live') && !LIVE) {
@@ -241,7 +287,16 @@ async function shopifyCreate(payload) {
       status: ALLOW_ACTIVE ? 'active' : 'draft',
       tags: [displayVendor, collection].filter(Boolean).join(', '),
       body_html: body,
-      variants: [{ sku: dwSku, ...(price ? { price } : {}), inventory_management: null }],
+      // Main variant + REQUIRED Sample variant (standing rule: every product
+      // ships a {DW_SKU}-Sample at $4.25 with no inventory tracking).
+      // REST requires every variant carry the option value when `options` is set,
+      // so the main variant gets option1:'Default' (Shopify's reserved single-
+      // option title) and the sample gets option1:'Sample'.
+      variants: [
+        { option1: 'Default', sku: dwSku, ...(price ? { price } : {}), inventory_management: null },
+        { option1: 'Sample', sku: `${dwSku}-Sample`, price: '4.25', inventory_management: null },
+      ],
+      options: [{ name: 'Title' }],
       images: images.map(src => ({ src })),
       metafields: [
         { namespace: 'custom', key: 'mfr_sku', value: String(mfr || ''), type: 'single_line_text_field' },
@@ -269,9 +324,19 @@ async function shopifyCreate(payload) {
       ledger({ id: +id, dwSku, status: 'failed', reason: 'created but no id returned' });
     } else if (w.ok) {
       created++; consecFail = 0;
+      // PUBLISH GATE FIX: an active product is invisible to shoppers until it's
+      // published to the Online Store channel. REST create never does this, so
+      // publish explicitly here. Drafts (no --allow-active) are left unpublished
+      // by design. A publish failure does NOT fail the create — the product
+      // exists + is ledgered; we record the publish outcome for re-sweeping.
+      let published = false, publishErr = null;
+      if (ALLOW_ACTIVE && payload.status === 'active') {
+        const pub = await shopifyPublishOnlineStore(w.id);
+        published = pub.ok; if (!pub.ok) publishErr = pub.error;
+      }
       // LEDGER FIRST (before the fallible UPDATE) so a DB hiccup can never leave a
       // created product recorded nowhere → re-created as a duplicate next run.
-      ledger({ id: +id, dwSku, status: 'created', shopifyId: w.id, handle: w.handle });
+      ledger({ id: +id, dwSku, status: 'created', shopifyId: w.id, handle: w.handle, published, publishErr });
       try {
         q(`UPDATE vendor_catalog SET shopify_product_id=${+w.id}, on_shopify=TRUE,
            sync_status='pushed', shopify_synced_at=now() WHERE id=${+id}`);
diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index eb341fb..9d60b75 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -73,3 +73,13 @@
 {"batchId":"launch-5000-315669-mq86br7q","action":"launch","mode":"draft","count":5000,"skus":5000,"manifest":"data/launch-batches/launch-5000-315669-mq86br7q.json","at":"2026-06-10T14:38:26.440Z"}
 {"batchId":"launch-3934-2035693-mq86br9d","action":"launch","mode":"draft","count":3934,"skus":3934,"manifest":"data/launch-batches/launch-3934-2035693-mq86br9d.json","at":"2026-06-10T14:38:26.499Z"}
 {"batchId":"launch-1-908820-mq86zdl3","action":"launch","mode":"active","count":1,"skus":1,"manifest":"data/launch-batches/launch-1-908820-mq86zdl3.json","at":"2026-06-10T14:56:48.519Z"}
+{"batchId":"launch-1000-908820-mqh4yevs","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-908820-mqh4yevs.json","at":"2026-06-16T21:09:59.904Z"}
+{"batchId":"launch-1000-283620-mqh4yfk3","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-283620-mqh4yfk3.json","at":"2026-06-16T21:10:00.773Z"}
+{"batchId":"launch-1000-344347-mqh4yg3m","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-344347-mqh4yg3m.json","at":"2026-06-16T21:10:01.478Z"}
+{"batchId":"launch-1000-38562-mqh4ygnz","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-38562-mqh4ygnz.json","at":"2026-06-16T21:10:02.210Z"}
+{"batchId":"launch-934-253818-mqh4yh6w","action":"launch","mode":"draft","count":934,"skus":934,"manifest":"data/launch-batches/launch-934-253818-mqh4yh6w.json","at":"2026-06-16T21:10:02.890Z"}
+{"batchId":"launch-1000-908820-mqh4yzar","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-908820-mqh4yzar.json","at":"2026-06-16T21:10:26.357Z"}
+{"batchId":"launch-1000-283620-mqh4z00j","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-283620-mqh4z00j.json","at":"2026-06-16T21:10:27.284Z"}
+{"batchId":"launch-1000-344347-mqh4z0kj","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-344347-mqh4z0kj.json","at":"2026-06-16T21:10:28.006Z"}
+{"batchId":"launch-1000-38562-mqh4z135","action":"launch","mode":"draft","count":1000,"skus":1000,"manifest":"data/launch-batches/launch-1000-38562-mqh4z135.json","at":"2026-06-16T21:10:28.675Z"}
+{"batchId":"launch-934-253818-mqh4z1m1","action":"launch","mode":"draft","count":934,"skus":934,"manifest":"data/launch-batches/launch-934-253818-mqh4z1m1.json","at":"2026-06-16T21:10:29.354Z"}

← 5a2715d snapshot: 9790 stats fix + required-indexes note  ·  back to New Import Viewer  ·  cadence: manifest generator (42/batch, oldest-first round-ro fb73edf →