[object Object]

← back to Designer Wallcoverings

TK-10040: close async image-upload gate bypass in cadence-import — end-of-run sweep verifies activated products' media reached READY, demotes imageless to DRAFT+Needs-Image (root cause of 212 imageless ACTIVE products 2026-07-29 during 500GB cap)

bcebf87b1a7bb57a4f4536e0474180365958d34d · 2026-07-29 16:44:10 -0700 · Steve

Files touched

Diff

commit bcebf87b1a7bb57a4f4536e0474180365958d34d
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 29 16:44:10 2026 -0700

    TK-10040: close async image-upload gate bypass in cadence-import — end-of-run sweep verifies activated products' media reached READY, demotes imageless to DRAFT+Needs-Image (root cause of 212 imageless ACTIVE products 2026-07-29 during 500GB cap)
---
 shopify/scripts/cadence/cadence-import.js | 50 +++++++++++++++++++++++++++++--
 1 file changed, 47 insertions(+), 3 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index ff31f3e1..fd45b5bd 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -932,9 +932,48 @@ async function createProduct(table, row, payload) {
   return { ok:true, pid, activated, published, skus: invSkus };
 }
 
+// TK-10040 IMAGE-GATE (async-upload verification): the create-time gate validates
+// SOURCE image urls, but productSet uploads media ASYNCHRONOUSLY — at the 500GB
+// file cap every upload fails AFTER the mutation returns OK, which shipped 212
+// ACTIVE products with zero images on 2026-07-29. This end-of-run sweep verifies
+// each activated product's media actually reached READY and demotes anything
+// imageless back to DRAFT + Needs-Image (activate-gated.js re-promotes once fixed).
+async function verifyActivatedMedia(pids) {
+  const out = { checked: pids.length, demoted: 0 };
+  if (!pids.length) return out;
+  console.log(`\nverifying image uploads reached READY on ${pids.length} activated product(s)…`);
+  await sleep(30000);                                   // let async media settle
+  const Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{id handle status media(first:10){nodes{... on MediaImage{status}}}}}}`;
+  const DEMOTE = `mutation($input:ProductInput!){productUpdate(input:$input){product{id} userErrors{message}}}`;
+  const TAGS = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{message}}}`;
+  let pending = pids.slice();
+  for (let round = 0; round < 3 && pending.length; round++) {
+    if (round) await sleep(20000);                      // extra settle for still-PROCESSING media
+    const next = [];
+    for (let i = 0; i < pending.length; i += 50) {
+      const r = await gqlRetry(Q, { ids: pending.slice(i, i + 50) });
+      for (const n of (r.json?.data?.nodes || [])) {
+        if (!n || n.status !== 'ACTIVE') continue;
+        const st = (n.media?.nodes || []).map(m => m && m.status).filter(Boolean);
+        if (st.includes('READY')) continue;             // an image landed — gate satisfied
+        if (st.includes('PROCESSING')) { next.push(n.id); continue; }
+        // no READY, nothing PROCESSING (all FAILED or zero media): no image is coming
+        await gqlRetry(DEMOTE, { input: { id: n.id, status: 'DRAFT' } });
+        await gqlRetry(TAGS, { id: n.id, tags: ['Needs-Image'] });
+        out.demoted++;
+        console.log(`  ⚠ ${n.handle}: image upload FAILED/absent → demoted to DRAFT (Needs-Image)`);
+      }
+    }
+    pending = next;
+  }
+  if (pending.length) console.log(`  note: ${pending.length} product(s) still PROCESSING media after ~70s — left ACTIVE; the five-field canary is the backstop.`);
+  if (out.demoted) console.log(`  image-gate: demoted ${out.demoted} imageless product(s) back to DRAFT.`);
+  return out;
+}
+
 // Export internals for unit/inspection harnesses (no behavior change when run as a script;
 // the main IIFE below only runs on direct execution via the require.main guard).
-module.exports = { gate, selectSkus, buildInput, buildSampleOnlyInput, brandFilterClause, scrubBanned, bannedBrandReason };
+module.exports = { gate, selectSkus, buildInput, buildSampleOnlyInput, brandFilterClause, scrubBanned, bannedBrandReason, verifyActivatedMedia };
 
 // ---- main ----
 if (require.main === module) (async () => {
@@ -987,7 +1026,7 @@ if (require.main === module) (async () => {
   for (let i=0;i<picks;i++) picked.push(ready[(start+i)%ready.length]);
   console.log(`\n=== ${COMMIT?'LIVE COMMIT':'DRY-RUN'} — slot ${SLOT}: lead=${ready[start].vendor} · ${picked.length}/${ready.length} READY vendor(s) × up to ${SKUS_PER_VENDOR} SKUs (cursor idx ${cur.idx}→${(start+picked.length)%ready.length}) ===`);
 
-  const plan = []; let created=0, failed=0, linked=0, held=0, activatedCount=0; const activatedSkus=[]; const publishedProductIds=[]; let hitMax=false, hitCap=false;
+  const plan = []; let created=0, failed=0, linked=0, held=0, activatedCount=0; const activatedSkus=[]; const publishedProductIds=[]; const activatedPids=[]; 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;
@@ -1031,7 +1070,7 @@ if (require.main === module) (async () => {
       const res = await createProduct(r.cfg.table, row, payload);
       if (res.held) { held++; /* HELD already logged inside createProduct; counted, never published */ }
       else 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++; vendorCreated++; if (res.activated) activatedCount++; 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 if (res.ok) { created++; vendorCreated++; if (res.activated) activatedCount++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if (res.activated && res.pid) activatedPids.push(res.pid); 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; }
       // dailyCap per-create stop (PJ): once this vendor created its remaining daily allowance THIS
@@ -1046,6 +1085,11 @@ if (require.main === module) (async () => {
     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); }
   }
+  // TK-10040 image-gate sweep: demote any activated product whose async image upload never landed.
+  if (COMMIT && ACTIVATE && activatedPids.length) {
+    try { await verifyActivatedMedia(activatedPids); }
+    catch (e) { console.warn('  image-verify sweep failed (products stay as-is; five-field canary is the backstop):', 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.

← 6cc0f21c auto-save: 2026-07-29T16:40:38 (1 files) — shopify/scripts/a  ·  back to Designer Wallcoverings  ·  auto-save: 2026-07-29T17:10:49 (1 files) — shopify/scripts/c b99dbd61 →