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