← 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
M shopify/scripts/cadence/cadence-import.js
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 →