← 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
M consumer.jsM data/launch-queue.jsonl
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 →