← back to Designer Wallcoverings
fix: add weight handling to cadence-import.js create+activate paths (TK-11495)
8722ce1e7de7ad9839cc9905d9daf88bb9dcbe9c · 2026-09-12 04:06:11 -0700 · steve@designerwallcoverings.com
- Require weight-guard.mjs (copied from designerwallcoverings/scripts/lib) at top
- buildInput(): resolveWeightLb() on both roll+sample variants → inventoryItem.measurement.weight
- buildInput(): custom.weight_source=DEFAULTED metafield (TK-11496 provenance)
- buildInput(): willActivate log shows actual lb values + DEFAULTED note
- buildSampleOnlyInput(): SAMPLE_WEIGHT_LB (0.25lb) on sample variant measurement
- buildSampleOnlyInput(): custom.weight_source=DEFAULTED metafield (TK-11496)
Pattern: mirrors stout-onboard/build-payloads.mjs + sanderson-onboard weight-guard usage.
No product created by cadence-import can now reach Shopify at zero weight.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
M shopify/scripts/cadence/cadence-import.jsA shopify/scripts/lib/weight-guard.mjs
Diff
commit 8722ce1e7de7ad9839cc9905d9daf88bb9dcbe9c
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Sat Sep 12 04:06:11 2026 -0700
fix: add weight handling to cadence-import.js create+activate paths (TK-11495)
- Require weight-guard.mjs (copied from designerwallcoverings/scripts/lib) at top
- buildInput(): resolveWeightLb() on both roll+sample variants → inventoryItem.measurement.weight
- buildInput(): custom.weight_source=DEFAULTED metafield (TK-11496 provenance)
- buildInput(): willActivate log shows actual lb values + DEFAULTED note
- buildSampleOnlyInput(): SAMPLE_WEIGHT_LB (0.25lb) on sample variant measurement
- buildSampleOnlyInput(): custom.weight_source=DEFAULTED metafield (TK-11496)
Pattern: mirrors stout-onboard/build-payloads.mjs + sanderson-onboard weight-guard usage.
No product created by cadence-import can now reach Shopify at zero weight.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
shopify/scripts/cadence/cadence-import.js | 20 +++-
shopify/scripts/lib/weight-guard.mjs | 168 ++++++++++++++++++++++++++++++
2 files changed, 185 insertions(+), 3 deletions(-)
diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index d06555e8..6ddd63db 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -32,6 +32,7 @@ const VENDORS = require('./vendors.js');
const { validateBeforeActivate } = require('../lib/validate-before-activate.js'); // single activation gate (Steve 2026-06-20)
const runLog = require('../lib/scraper-run-log.js'); // TK-10374: per-run ground-truth for scraper liveness (writes scraper_run_log)
const { safeStampQuantity } = require('../lib/inventory-stamp-guard.mjs'); // TK-10965: never stock a $0/quote-only sellable variant
+const { resolveWeightLb, isSampleVariant, defaultWeightLb, SAMPLE_WEIGHT_LB } = require('../lib/weight-guard.mjs'); // TK-11495: weight on CREATE+ACTIVATE paths (zero-weight = free-shipping mis-cost)
// Canonical showroom-vendor primitive (list + logic live in fix-live-board/config). Showroom-only
// vendors are addressable-not-discoverable: never add the New Arrivals / Trending discoverability
// tags or auto-membership. Never hardcode a vendor — edit showroom-vendors.json. TK-11186.
@@ -548,6 +549,7 @@ function buildSampleOnlyInput(vendor, cfg, row, activate) {
if (row.pattern_repeat){ m('global','repeat',row.pattern_repeat,SL); }
if (row.collection) m('custom','collection_name',row.collection,SL);
m('global','unit_of_measure','Sample Only',SL); // no priced roll yet — sample-only listing
+ m('custom','weight_source','DEFAULTED',SL); // TK-11496: DEFAULTED provenance (no vendor weight in catalog)
// Activation gate — sample-only has a SINGLE Sample variant (no roll). The gate treats
// unitOfMeasure as not-vendor-provided (sample-only has no roll UOM) so it isn't required.
@@ -582,7 +584,8 @@ function buildSampleOnlyInput(vendor, cfg, row, activate) {
metafields: mf,
productOptions: [{ name:'Title', position:1, values:[{name:'Sample'}] }],
variants: [
- { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:false}, inventoryPolicy:'CONTINUE', taxable:true },
+ // TK-11495: weight added so sample-only products are never created at zero weight. DEFAULTED provenance (TK-11496).
+ { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:false,measurement:{weight:{value:SAMPLE_WEIGHT_LB,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
],
};
// Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
@@ -682,6 +685,9 @@ function buildInput(vendor, cfg, row, retail, activate) {
m('custom','cost',row.cost.toFixed(2),ND); // REAL cost
m('custom','price_updated_at',TODAY,'date'); // 30-day refresh audit
m('global','unit_of_measure',`Priced Per ${soldBy}`,SL);
+ // TK-11495+TK-11496 WEIGHT: stamp provenance so DEFAULTED is distinguishable from vendor-measured.
+ // Cadence catalog rows never carry a vendor weight — always DEFAULTED (per-product-type default).
+ m('custom','weight_source','DEFAULTED',SL); // TK-11496: dw-active-weight-canary/rollup can tell source
// ---- Double-roll MOQ for double-roll lines (gated, inert until DW_DOUBLE_ROLL_MOQ) ----
// theme DesignerWallcoverings-product.liquid defaults prods_quantity_order_min=1; any roll product
// MISSING global.v_prods_quantity_order_min falls back to single-roll-orderable. Stamp 2/2 so the
@@ -712,6 +718,11 @@ function buildInput(vendor, cfg, row, retail, activate) {
});
const ready = gate.ok;
const willActivate = !!(activate && ready);
+ // TK-11495 WEIGHT ACTIVATE CHECK: compute variant weights before building input.
+ // Cadence rows have no vendor-supplied weight → always DEFAULTED (product-type default lb).
+ const _sellWt = resolveWeightLb({ sku: row.dw_sku, option1: variantLabel }, { productType });
+ const _sampleWt = SAMPLE_WEIGHT_LB; // 0.25 lb fixed
+ if (willActivate) console.log(` [weight] ${row.dw_sku}: roll=${_sellWt.toFixed(2)}lb sample=${_sampleWt.toFixed(2)}lb (DEFAULTED — no vendor weight in catalog)`);
const input = {
title, handle, vendor, productType,
status: willActivate ? 'ACTIVE' : 'DRAFT', // DRAFT unless --activate AND the full gate passes
@@ -730,8 +741,11 @@ function buildInput(vendor, cfg, row, retail, activate) {
metafields: mf,
productOptions: [{ name:'Title', position:1, values:[{name:variantLabel},{name:'Sample'}] }],
variants: [
- { optionValues:[{optionName:'Title',name:variantLabel}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2)}, inventoryPolicy:'CONTINUE', taxable:true },
- { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true}, inventoryPolicy:'CONTINUE', taxable:true },
+ // TK-11495: weight added to inventoryItem.measurement so products are never created at zero weight.
+ // resolveWeightLb always returns > 0 (per-type default when vendor has no weight). DEFAULTED provenance
+ // stamped in custom.weight_source metafield above (TK-11496).
+ { optionValues:[{optionName:'Title',name:variantLabel}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2),measurement:{weight:{value:_sellWt,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
+ { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true,measurement:{weight:{value:_sampleWt,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
],
};
// Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
diff --git a/shopify/scripts/lib/weight-guard.mjs b/shopify/scripts/lib/weight-guard.mjs
new file mode 100644
index 00000000..8d8313d0
--- /dev/null
+++ b/shopify/scripts/lib/weight-guard.mjs
@@ -0,0 +1,168 @@
+// weight-guard.mjs — TK-11414 (2026-09-10) prevention primitive.
+// Steve's rule: NO product may go ACTIVE with a missing/zero product WEIGHT
+// (zero-weight collapses orders into the lowest weight tier / free band and
+// mis-costs DW freight). Mirrors the inventory-stamp-guard.mjs pattern: a pure,
+// side-effect-free module onboarders import at two call sites —
+// 1. create payload: weight: resolveWeightLb(variant, product) (unit POUNDS)
+// 2. before activate: const r = await healAndVerifyWeights(gql, gid, product);
+// if (!r.ok) HOLD as draft (never flip ACTIVE at zero weight)
+//
+// TK-11471 (2026-09-11) — the gate and the canary DISAGREED about the invariant:
+// dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant (its last live run split the
+// offenders 195 sample / 190 sellable — i.e. SAMPLES COUNT), but zeroWeightBlockers() filtered
+// samples OUT. A product with a zero-weight SAMPLE therefore passed the gate and then turned the
+// canary red. allZeroWeightVariants() is the gate that matches the canary; zeroWeightBlockers()
+// is kept only for back-compat and is DEPRECATED.
+//
+// Defaults come straight from the approved TK-11414 backfill (samples 0.25 lb,
+// sellable per-product-type). Keep these in sync with that backfill.
+
+export const SAMPLE_WEIGHT_LB = 0.25;
+export const FALLBACK_LB = 2.0;
+
+// product_type -> sellable default weight (POUNDS)
+export const TYPE_DEFAULT_LB = {
+ 'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
+ 'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
+ 'Mural': 4.0,
+ 'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
+ 'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
+ 'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
+ 'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
+};
+
+const norm = t => String(t ?? '').trim().toLowerCase();
+
+/** Sample variant? (importers create `Sample` @ $4.25 + the real unit). */
+export function isSampleVariant(variant = {}) {
+ const label = norm(variant.title ?? variant.option1 ?? '');
+ const sku = norm(variant.sku);
+ if (label.includes('sample') || label.includes('memo')) return true;
+ if (sku.endsWith('-sample') || sku.includes('sample')) return true;
+ const p = Number(variant.price);
+ return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
+}
+
+/** Current weight in lb, or 0 if missing/unparseable. Accepts variant.weight (REST),
+ * variant.grams, or inventoryItem.measurement.weight.value (GraphQL). */
+export function currentWeightLb(variant = {}) {
+ const gql = variant?.inventoryItem?.measurement?.weight;
+ if (gql && gql.value != null) {
+ const v = Number(gql.value);
+ const u = norm(gql.unit); // Shopify WeightUnit enum
+ if (u === 'kilograms' || u.startsWith('kg')) return v * 2.20462;
+ if (u === 'grams' || u === 'g') return v / 453.59237; // TK-11471: was read as POUNDS
+ if (u === 'ounces' || u === 'oz') return v / 16; // TK-11471: was read as POUNDS
+ return v; // POUNDS (and unit-less ⇒ assume lb)
+ }
+ if (variant.grams != null) return Number(variant.grams) / 453.59237;
+ if (variant.weight != null) {
+ const v = Number(variant.weight);
+ const u = norm(variant.weight_unit || 'lb');
+ if (u.startsWith('kg')) return v * 2.20462;
+ if (u === 'g' || u.startsWith('gram')) return v / 453.59237;
+ if (u === 'oz') return v / 16;
+ return v; // lb
+ }
+ return 0;
+}
+
+export function hasZeroWeight(variant = {}) {
+ const w = currentWeightLb(variant);
+ return !Number.isFinite(w) || w <= 0;
+}
+
+/** The default weight (lb) to assign a variant that has none. */
+export function defaultWeightLb(variant = {}, product = {}) {
+ if (isSampleVariant(variant)) return SAMPLE_WEIGHT_LB;
+ return TYPE_DEFAULT_LB[product.productType || product.product_type] ?? FALLBACK_LB;
+}
+
+/** THE CREATE-SIDE GUARD: keep a real positive weight, else fill the default.
+ * Drop-in for the weight field in a create/productSet payload (returns lb). */
+export function resolveWeightLb(variant = {}, product = {}) {
+ const w = currentWeightLb(variant);
+ return (Number.isFinite(w) && w > 0) ? w : defaultWeightLb(variant, product);
+}
+
+/** Normalize a product's variants out of either a GraphQL connection or a plain array. */
+export function variantsOf(product = {}) {
+ return product.variants?.edges?.map(e => e.node) ?? product.variants ?? [];
+}
+
+/** @deprecated TK-11471 — SELLABLE-ONLY view; it filters samples OUT, so it does NOT match the
+ * invariant dw-active-weight-canary enforces (that canary fails on ANY zero-weight ACTIVE
+ * variant, samples included). Kept for back-compat with existing call sites.
+ * Use allZeroWeightVariants() for any new gate. */
+export function zeroWeightBlockers(product = {}) {
+ return variantsOf(product).filter(v => !isSampleVariant(v) && hasZeroWeight(v));
+}
+
+/** THE ACTIVATE-SIDE GUARD (TK-11471): EVERY zero-weight variant, sample included.
+ * Matches dw-active-weight-canary exactly. Non-empty ⇒ DO NOT flip the product ACTIVE. */
+export function allZeroWeightVariants(product = {}) {
+ return variantsOf(product).filter(v => hasZeroWeight(v));
+}
+
+/** The re-query a go-live site must run so the guard MEASURES something. A product query that
+ * omits inventoryItem{measurement{weight}} makes every variant look zero-weight to
+ * currentWeightLb — and a query that omits productType silently defaults every heal to
+ * FALLBACK_LB. Both fields are required. */
+export const WEIGHT_REQUERY = `query($id:ID!){ product(id:$id){ productType variants(first:100){edges{node{ id sku title price inventoryItem{ id measurement{ weight{ value unit } } } }}} } }`;
+
+export const M_WEIGHT_SET = `mutation($id:ID!,$w:Float!){ inventoryItemUpdate(id:$id, input:{measurement:{weight:{value:$w, unit:POUNDS}}}){ userErrors{message} } }`;
+
+/**
+ * SELF-HEAL then VERIFY, the pattern Steve approved in sanderson-onboard/scripts/create_sdg.mjs
+ * (8d09eed) — stranding product is worse than assigning the already-approved default, but a heal
+ * that silently fails must NEVER activate.
+ *
+ * 1. every zero-weight variant (sample included) is written defaultWeightLb() in POUNDS
+ * 2. the product is RE-READ and re-checked — the mutation's own 200 is not evidence
+ * 3. ok === false ⇒ caller must HOLD the product as draft and name `weight>0`
+ *
+ * Idempotent + no-op when all weights are already positive (zero network calls in that case).
+ * Fails CLOSED: an unreadable re-query, a missing inventoryItem id, or a userError all yield
+ * ok:false rather than a silent pass.
+ *
+ * @param {(q:string,v:object)=>Promise<any>} gql the call site's own gql(query, variables)
+ * @param {string} productGid gid://shopify/Product/<id>
+ * @param {object} product the already-fetched product (weights + productType)
+ */
+export async function healAndVerifyWeights(gql, productGid, product = {}, opts = {}) {
+ const requery = opts.requery || WEIGHT_REQUERY;
+ const mutation = opts.mutation || M_WEIGHT_SET;
+ const errs = [], healed = [];
+ let healFailures = 0; // TK-11471: an UNHEALED variant is never a PASS
+ const productType = product.productType || product.product_type;
+
+ const zero = allZeroWeightVariants(product);
+ if (!zero.length) return { ok: true, healed, stillZero: [], errs }; // no-op
+
+ for (const v of zero) {
+ const iid = v?.inventoryItem?.id;
+ const label = v.sku || v.title || v.id || '?';
+ if (!iid) { healFailures++; errs.push(`weight:no-inventory-item:${label}`); continue; }
+ const lb = defaultWeightLb(v, { productType });
+ let r;
+ try { r = await gql(mutation, { id: iid, w: lb }); }
+ catch (e) { healFailures++; errs.push(`weight:${label}:${String(e && e.message || e).slice(0, 80)}`); continue; }
+ const ue = r?.inventoryItemUpdate?.userErrors || [];
+ if (ue.length) healFailures++;
+ ue.forEach(e => errs.push(`weight:${label}:${e.message}`));
+ healed.push({ sku: label, inventoryItemId: iid, lb });
+ }
+
+ // RE-VERIFY against the live record. Never trust the write.
+ let fresh;
+ try { fresh = (await gql(requery, { id: productGid }))?.product; }
+ catch (e) { errs.push(`weight:reverify:${String(e && e.message || e).slice(0, 80)}`); }
+ if (!fresh) { errs.push('weight:reverify-failed'); return { ok: false, healed, stillZero: [], errs }; }
+
+ const stillZero = allZeroWeightVariants(fresh).map(v => v.sku || v.title || v.id || '?');
+ // FAIL CLOSED on an UNHEALED variant even when the re-verify comes back clean. A variant we
+ // could not write (no inventoryItem id, a throw, a userError) is UNMEASURED with respect to our
+ // own action; a clean re-verify that happens to disagree is not licence to activate. Holding is
+ // reversible and the next run is a no-op, so the conservative branch costs nothing.
+ return { ok: stillZero.length === 0 && healFailures === 0, healed, stillZero, errs };
+}
← 1c52930d auto-data-snapshot: 2026-09-12T03:53:49 (3 data files) — sho
·
back to Designer Wallcoverings
·
auto-data-snapshot: 2026-09-12T05:03:08 (3 data files) — sho 98445329 →