[object Object]

← back to Designer Wallcoverings

activate-gate: push-then-activate width metafield + apply gap tags

85ce69fbd85958d98dc2153a0b03d6b96b83b33c · 2026-06-21 14:02:22 -0700 · Steve

The DRAFT→ACTIVE gate verified the PG width SOURCE but never ensured the
customer-facing custom.width/global.width METAFIELD was pushed, so ~500 ACTIVE
products had width in PG yet no width metafield — storefront + Google Merchant
feed saw no width. Gap tags (Needs-Width/Needs-Image/Needs-Specs) were computed
but never applied.

- PUSH-THEN-ACTIVATE (default, load-bearing): before flipping ACTIVE, if the
  width/length/repeat/material metafield is missing/empty but the PG source row
  has the value, push it via metafieldsSet THEN activate (width to BOTH global
  and custom, matching the cadence importer). Width stays HARD-required — if PG
  has no width either, the product is kept DRAFT.
- APPLY gap tags via tagsAdd on gate-blocked products (reusing gate.tags), and
  still push any recoverable specs on blocked items so the feed isn't missing
  width later.
- Vendor-absent specs remain non-blocking (validator already honors the 'where
  the vendor provides them' clause).
- All new metafieldsSet/tagsAdd calls are gated behind --commit; dry-run only
  logs the push/tag intents.

Deploy stays gated for Steve — this commit is edit + dry-run only, no --commit
run against the live store.

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

Files touched

Diff

commit 85ce69fbd85958d98dc2153a0b03d6b96b83b33c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 21 14:02:22 2026 -0700

    activate-gate: push-then-activate width metafield + apply gap tags
    
    The DRAFT→ACTIVE gate verified the PG width SOURCE but never ensured the
    customer-facing custom.width/global.width METAFIELD was pushed, so ~500 ACTIVE
    products had width in PG yet no width metafield — storefront + Google Merchant
    feed saw no width. Gap tags (Needs-Width/Needs-Image/Needs-Specs) were computed
    but never applied.
    
    - PUSH-THEN-ACTIVATE (default, load-bearing): before flipping ACTIVE, if the
      width/length/repeat/material metafield is missing/empty but the PG source row
      has the value, push it via metafieldsSet THEN activate (width to BOTH global
      and custom, matching the cadence importer). Width stays HARD-required — if PG
      has no width either, the product is kept DRAFT.
    - APPLY gap tags via tagsAdd on gate-blocked products (reusing gate.tags), and
      still push any recoverable specs on blocked items so the feed isn't missing
      width later.
    - Vendor-absent specs remain non-blocking (validator already honors the 'where
      the vendor provides them' clause).
    - All new metafieldsSet/tagsAdd calls are gated behind --commit; dry-run only
      logs the push/tag intents.
    
    Deploy stays gated for Steve — this commit is edit + dry-run only, no --commit
    run against the live store.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/activate-gated.js | 111 ++++++++++++++++++++++++++----
 1 file changed, 97 insertions(+), 14 deletions(-)

diff --git a/shopify/scripts/cadence/activate-gated.js b/shopify/scripts/cadence/activate-gated.js
index 48e049c7..53822388 100644
--- a/shopify/scripts/cadence/activate-gated.js
+++ b/shopify/scripts/cadence/activate-gated.js
@@ -14,6 +14,18 @@
  *   - a width spec present
  *   - a real price > the $4.25 sample fallback
  *
+ * PUSH-THEN-ACTIVATE (Steve 2026-06-20, DEFAULT path — load-bearing, not a fallback):
+ * the storefront + Google Merchant feed read the custom.width / global.width
+ * METAFIELD, not the PG source row, so ~500 ACTIVE products had width in PG but no
+ * width metafield → the feed saw no width. Before flipping ACTIVE, if the width (or
+ * length/repeat/material) metafield is missing/empty but the PG source row HAS the
+ * value, this pass PUSHES it via metafieldsSet, THEN activates. Width stays HARD-
+ * required: if PG has no width either, the product is kept DRAFT (never auto-activated
+ * without width). Gate-blocked products get their gap tags (Needs-Width / Needs-Image
+ * / Needs-Specs) APPLIED via tagsAdd so they're findable for backfill, plus any
+ * recoverable specs pushed. Vendor-absent specs are NOT blocking (validator honors the
+ * "where the vendor provides them" clause). All writes are gated behind --commit.
+ *
  * Only flips products currently in DRAFT. ACTIVE/ARCHIVED are left untouched
  * (so manually-archived items stay archived; already-live items are no-ops).
  *
@@ -110,6 +122,9 @@ const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
 }}}`;
 const mfVal = (mfs, ns, key) => { const m = (mfs||[]).find(x => x.namespace===ns && x.key===key); return m ? m.value : ''; };
 const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{id status} userErrors{field message}}}`;
+const METAFIELDS_SET = `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){metafields{namespace key} userErrors{field message}}}`;
+const TAGS_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+const SL = 'single_line_text_field';
 
 (async () => {
   if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
@@ -117,6 +132,7 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
   const tables = Object.entries(VENDORS).filter(([v]) => !ONLY || v.toLowerCase() === ONLY.toLowerCase());
   let promoted = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0;
 
+  let pushedMf = 0, taggedGaps = 0;
   for (const [vendor, cfg] of tables) {
     let cands;
     try { cands = candidates(cfg.table, LIMIT); }
@@ -126,7 +142,8 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
     // batch-check current status (50/req)
     const byGid = new Map(cands.map(c => [`gid://shopify/Product/${String(c.pid).replace(/.*\//,'')}`, c]));
     const ids = [...byGid.keys()];
-    const drafts = [];
+    const drafts = [];           // [{ gid, mfPush:[{namespace,key,value}] }]
+    const gapTagged = [];        // [{ gid, dw_sku, tags:[] }]
     let gateBlocked = 0;
     for (let i=0;i<ids.length;i+=50){
       const r = await gqlRetry(STATUS_Q, { ids: ids.slice(i,i+50) });
@@ -143,14 +160,38 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
         const c = byGid.get(n.id);
         const mfs = (n.metafields?.nodes) || [];
         const liveImgs = (n.images?.nodes || []).map(x => x.url);
+
+        // PUSH-THEN-ACTIVATE (Steve 2026-06-20, default path, not a fallback):
+        // the storefront + Google Merchant feed read the custom.width / global.width
+        // METAFIELD, not the PG source row. ~500 ACTIVE products had width in PG but
+        // no width metafield → the feed saw no width. Here we ensure the metafield is
+        // present: if the live metafield is missing/empty but the PG source row HAS a
+        // value, queue a metafieldsSet to write it, THEN treat the spec as satisfied.
+        // Width is HARD-required: if PG has no width either, the product stays DRAFT.
+        const mfWidth = mfVal(mfs,'global','width') || mfVal(mfs,'custom','width');
+        const specSrc = {
+          width:    { ns:'global', live: mfWidth,                                                 pg: (c && c.width) || '' },
+          length:   { ns:'global', live: mfVal(mfs,'global','length'),                            pg: (c && c.roll_length) || '' },
+          repeat:   { ns:'global', live: mfVal(mfs,'global','repeat'),                            pg: (c && c.pattern_repeat) || '' },
+          material: { ns:'global', live: mfVal(mfs,'global','material') || mfVal(mfs,'custom','material'), pg: (c && c.material) || '' },
+        };
+        const mfPush = [];
+        for (const [key, s] of Object.entries(specSrc)) {
+          if (!s.live && s.pg) {
+            // recoverable from PG → write the customer-facing metafield(s)
+            mfPush.push({ namespace: 'global', key, type: SL, value: String(s.pg) });
+            if (key === 'width') mfPush.push({ namespace: 'custom', key: 'width', type: SL, value: String(s.pg) });
+          }
+        }
+        // resolved spec VALUES post-push: live metafield OR the PG value we're about to push.
+        const resolved = k => specSrc[k].live || specSrc[k].pg || '';
+
         const gate = validateBeforeActivate({
           title: n.title, dwSku: (c && c.dw_sku) || '',
           descriptionHtml: n.descriptionHtml || '',
           specs: {
-            width:  mfVal(mfs,'global','width') || mfVal(mfs,'custom','width') || (c && c.width) || '',
-            length: mfVal(mfs,'global','length') || (c && c.roll_length) || '',
-            repeat: mfVal(mfs,'global','repeat') || (c && c.pattern_repeat) || '',
-            material: mfVal(mfs,'global','material') || mfVal(mfs,'custom','material') || (c && c.material) || '',
+            width: resolved('width'), length: resolved('length'),
+            repeat: resolved('repeat'), material: resolved('material'),
             unitOfMeasure: mfVal(mfs,'global','unit_of_measure') || '',
           },
           vendorSpecs: {
@@ -162,23 +203,65 @@ const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){
           vendorImages: (c && toImageList(c.all_images).length) ? c.all_images : liveImgs,
           variants: (n.variants?.nodes || []).map(v => ({ sku: v.sku })),
         });
-        if (gate.ok) drafts.push(n.id);
-        else { gateBlocked++; fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:(c&&c.dw_sku), product_id:n.id, action:'gate-blocked', reasons:gate.reasons, tags:gate.tags }) + '\n'); }
+        if (gate.ok) {
+          drafts.push({ gid: n.id, mfPush, dw_sku: (c && c.dw_sku) || '' });
+        } else {
+          gateBlocked++;
+          // APPLY the gap tags (Steve 2026-06-20): Needs-Width / Needs-Image /
+          // Needs-Specs were computed but never applied. Tag the blocked product so
+          // it's findable for backfill. Still push any recoverable specs we can —
+          // a width-recovered product that's blocked only on (e.g.) image still gets
+          // its width metafield written so the feed isn't missing it later.
+          if (gate.tags.length) gapTagged.push({ gid: n.id, dw_sku: (c && c.dw_sku) || '', tags: gate.tags, mfPush });
+          fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:(c&&c.dw_sku), product_id:n.id, action:'gate-blocked', reasons:gate.reasons, tags:gate.tags }) + '\n');
+        }
       }
     }
     console.log(`  ${vendor}: ${cands.length} gate-pass · ${drafts.length} DRAFT to promote · ${gateBlocked} gate-blocked · ${alreadyActive} already ACTIVE`);
-    for (const gid of drafts) {
-      const c = byGid.get(gid);
-      if (!COMMIT) { console.log(`    · would ACTIVATE ${c.dw_sku}`); continue; }
-      const r = await gqlRetry(ACTIVATE, { id: gid });
+
+    // PROMOTE: push any recoverable width/specs metafield FIRST, then activate.
+    for (const d of drafts) {
+      if (d.mfPush.length) {
+        if (!COMMIT) console.log(`    · would push ${d.mfPush.map(m=>m.namespace+'.'+m.key).join(',')} metafield(s) for ${d.dw_sku}`);
+        else {
+          const mr = await gqlRetry(METAFIELDS_SET, { mfs: d.mfPush.map(m => ({ ownerId: d.gid, ...m })) });
+          const mue = mr.json?.data?.metafieldsSet?.userErrors;
+          if (mue && mue.length) { console.log(`    ✗ ${d.dw_sku} metafield push: ${JSON.stringify(mue).slice(0,120)}`); continue; }
+          pushedMf += d.mfPush.length;
+          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; }
+      const r = await gqlRetry(ACTIVATE, { id: d.gid });
       const ue = r.json?.data?.productUpdate?.userErrors;
-      if (ue && ue.length) { console.log(`    ✗ ${c.dw_sku}: ${JSON.stringify(ue).slice(0,120)}`); continue; }
+      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: c.dw_sku, product_id: gid, from: 'DRAFT', to: 'ACTIVE' }) + '\n');
+      fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), vendor, dw_sku: d.dw_sku, product_id: d.gid, from: 'DRAFT', to: 'ACTIVE' }) + '\n');
       if (promoted % 10 === 0) process.stdout.write(`\r    promoted ${promoted}…`);
     }
+
+    // TAG the gate-blocked products (stays DRAFT) — and push any recoverable specs.
+    for (const g of gapTagged) {
+      if (g.mfPush && g.mfPush.length) {
+        if (!COMMIT) console.log(`    · would push ${g.mfPush.map(m=>m.namespace+'.'+m.key).join(',')} metafield(s) for ${g.dw_sku} (still DRAFT)`);
+        else {
+          const mr = await gqlRetry(METAFIELDS_SET, { mfs: g.mfPush.map(m => ({ ownerId: g.gid, ...m })) });
+          const mue = mr.json?.data?.metafieldsSet?.userErrors;
+          if (!(mue && mue.length)) pushedMf += g.mfPush.length;
+        }
+      }
+      if (!COMMIT) { console.log(`    · would tag ${g.dw_sku} → ${g.tags.join(', ')}`); continue; }
+      const tr = await gqlRetry(TAGS_ADD, { id: g.gid, tags: g.tags });
+      const tue = tr.json?.data?.tagsAdd?.userErrors;
+      if (tue && tue.length) { console.log(`    ✗ ${g.dw_sku} tag: ${JSON.stringify(tue).slice(0,120)}`); continue; }
+      taggedGaps++;
+      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.`);
-  if (COMMIT && promoted) console.log(`audit → ${AUDIT}`);
+  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)`);
+  if (COMMIT && (promoted || pushedMf || taggedGaps)) console.log(`audit → ${AUDIT}`);
   if (!COMMIT) console.log('Re-run with --commit to promote.');
 })();

← daa2dccb Zoffany batch title normalization (2026-06-21): backtick->ap  ·  back to Designer Wallcoverings  ·  Zoffany colorway verification (DTD 2026-06-21): authoritativ fcce8e0e →