[object Object]

← back to Designer Wallcoverings

Add stock-2026-guard: self-healing enforcement of the active-products-never-OOS hard rule

8664e4edc5380824e73b84b6dc4b7ac28836ae4c · 2026-06-20 11:53:54 -0700 · Steve

Scans the live store for any ACTIVE product whose TRACKED inventory drifted to <=0 and
re-sets every tracked variant to 2026 on_hand at the Ventura Blvd location (the exact
inventorySetQuantities mechanism cadence-import.js uses at activation). Skips untracked
POD/sample items (their 0 is intentional). Dry-run by default; --commit applies. Scheduled
every 6h via com.steve.dw-stock-2026-guard. First live run: 2540 candidates scanned, 0 tracked
products needed fixing (catalog already compliant). Backs the import-time rule as a drift net.

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

Files touched

Diff

commit 8664e4edc5380824e73b84b6dc4b7ac28836ae4c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 20 11:53:54 2026 -0700

    Add stock-2026-guard: self-healing enforcement of the active-products-never-OOS hard rule
    
    Scans the live store for any ACTIVE product whose TRACKED inventory drifted to <=0 and
    re-sets every tracked variant to 2026 on_hand at the Ventura Blvd location (the exact
    inventorySetQuantities mechanism cadence-import.js uses at activation). Skips untracked
    POD/sample items (their 0 is intentional). Dry-run by default; --commit applies. Scheduled
    every 6h via com.steve.dw-stock-2026-guard. First live run: 2540 candidates scanned, 0 tracked
    products needed fixing (catalog already compliant). Backs the import-time rule as a drift net.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore                                         |   1 +
 .../cadence/com.steve.dw-stock-2026-guard.plist    |  13 +++
 shopify/scripts/cadence/stock-2026-guard.js        | 111 +++++++++++++++++++++
 3 files changed, 125 insertions(+)

diff --git a/.gitignore b/.gitignore
index d6dd3bfe..f34e79d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,4 @@ DW-Websites/LosAngelesFabrics/node_modules/
 DW-Programming/room-setting-app/data/gallery/renders.json
 DW-Programming/data/color-clean-ledger.jsonl
 shopify/scripts/gmc-race-clean/out/
+shopify/scripts/cadence/stock-2026-guard.latest.json
diff --git a/shopify/scripts/cadence/com.steve.dw-stock-2026-guard.plist b/shopify/scripts/cadence/com.steve.dw-stock-2026-guard.plist
new file mode 100644
index 00000000..c3751661
--- /dev/null
+++ b/shopify/scripts/cadence/com.steve.dw-stock-2026-guard.plist
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.dw-stock-2026-guard</string>
+  <key>ProgramArguments</key><array>
+    <string>/bin/bash</string><string>-lc</string>
+    <string>/usr/local/bin/node "$HOME/Projects/Designer-Wallcoverings/shopify/scripts/cadence/stock-2026-guard.js" --commit &gt;&gt; /tmp/dw-stock-2026-guard.log 2&gt;&amp;1</string>
+  </array>
+  <key>StartInterval</key><integer>21600</integer>
+  <key>RunAtLoad</key><false/>
+  <key>StandardOutPath</key><string>/tmp/dw-stock-2026-guard.launchd.out.log</string>
+  <key>StandardErrorPath</key><string>/tmp/dw-stock-2026-guard.launchd.err.log</string>
+</dict></plist>
diff --git a/shopify/scripts/cadence/stock-2026-guard.js b/shopify/scripts/cadence/stock-2026-guard.js
new file mode 100644
index 00000000..c6baf24c
--- /dev/null
+++ b/shopify/scripts/cadence/stock-2026-guard.js
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * stock-2026-guard.js — HARD RULE "active products are never out of stock" (Steve 2026-06-20).
+ *
+ * Scans the LIVE store for any ACTIVE product whose TRACKED inventory has drifted to <= 0
+ * (out of stock / oversold) and re-sets EVERY tracked variant of that product to 2026 on_hand
+ * at the Ventura Blvd location — the exact mechanism cadence-import.js uses at activation.
+ * This is the self-healing net behind the import-time rule: if a tracked active product ever
+ * reads 0/negative (oversell, manual edit, activation outside the cadence path), it gets restored.
+ *
+ * Deliberately SKIPS untracked variants (print-on-demand / sample / made-to-order items) —
+ * their "0" is intentional and forcing 2026 on them would be wrong.
+ *
+ * SAFE BY DEFAULT: dry-run unless --commit is passed. The scheduled launchd job passes --commit.
+ *
+ * Run:  node stock-2026-guard.js            # dry-run, report only
+ *       node stock-2026-guard.js --commit   # apply the fix
+ */
+const fs = require('fs');
+const path = require('path');
+
+// ---- portable secret load (Mac2 secrets master, co-located .env, or Kamatera /root/.env) ----
+const ENV_PATHS = [
+  process.env.GUARD_ENV_FILE,
+  path.join(__dirname, '.env'),
+  '/Users/stevestudio2/Projects/secrets-manager/.env',
+  process.env.HOME ? path.join(process.env.HOME, '.env') : null,
+  '/root/.env',
+].filter(Boolean);
+let ENVTXT = '';
+for (const p of ENV_PATHS) { try { ENVTXT = fs.readFileSync(p, 'utf8'); break; } catch (e) {} }
+const env = k => process.env[k] || (ENVTXT.match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1];
+
+const STORE = (env('SHOPIFY_STORE') || 'designer-laboratory-sandbox.myshopify.com').replace(/^https?:\/\//, '');
+const TOKEN = env('SHOPIFY_ADMIN_TOKEN');
+const EP = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const INV_LOCATION = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd. (matches cadence-import.js)
+const QTY = 2026;
+const COMMIT = process.argv.includes('--commit');
+const OUT = path.join(__dirname, 'stock-2026-guard.latest.json');
+
+if (!TOKEN) { console.error('[stock-2026-guard] no SHOPIFY_ADMIN_TOKEN — abort'); process.exit(2); }
+
+async function gql(query, variables) {
+  for (let attempt = 0; attempt < 5; attempt++) {
+    const r = await fetch(EP, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+    if (r.status === 429 || r.status >= 500) { await new Promise(s => setTimeout(s, 1000 * (attempt + 1))); continue; }
+    const j = await r.json();
+    if (j.errors && /throttl/i.test(JSON.stringify(j.errors))) { await new Promise(s => setTimeout(s, 1500 * (attempt + 1))); continue; }
+    return j;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+const SCAN = `query($cursor:String){ products(first:100, after:$cursor, query:"status:active inventory_total:<1"){
+  pageInfo{ hasNextPage endCursor }
+  nodes{ id title vendor variants(first:50){ nodes{ sku inventoryQuantity inventoryItem{ id tracked } } } }
+} }`;
+const INV_SET = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{ field message code } } }`;
+
+(async () => {
+  const started = new Date().toISOString();
+  const targets = []; // { title, vendor, items:[inventoryItemId], skus:[] }
+  let cursor = null, pages = 0, scanned = 0;
+  do {
+    const j = await gql(SCAN, { cursor });
+    const conn = j.data && j.data.products;
+    if (!conn) { console.error('[stock-2026-guard] scan error:', JSON.stringify(j.errors || j)); break; }
+    for (const p of conn.nodes) {
+      scanned++;
+      const trackedVars = p.variants.nodes.filter(v => v.inventoryItem && v.inventoryItem.tracked && v.inventoryItem.id);
+      // only act when a TRACKED variant is actually <= 0 (skip untracked POD/sample items)
+      const hasZero = trackedVars.some(v => (v.inventoryQuantity ?? 0) <= 0);
+      if (hasZero && trackedVars.length) {
+        targets.push({ title: p.title, vendor: p.vendor, items: trackedVars.map(v => v.inventoryItem.id), skus: trackedVars.map(v => v.sku) });
+      }
+    }
+    cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
+    pages++;
+  } while (cursor && pages < 200);
+
+  const pairs = targets.flatMap(t => t.items.map(id => ({ inventoryItemId: id, locationId: INV_LOCATION, quantity: QTY })));
+  console.log(`[stock-2026-guard] scanned ${scanned} active-≤0 candidates · ${targets.length} products with a TRACKED variant ≤0 · ${pairs.length} variant-sets needed`);
+  targets.slice(0, 15).forEach(t => console.log('  ·', String(t.title).slice(0, 50), '|', t.skus.join(', ')));
+
+  let applied = 0; const errors = [];
+  if (COMMIT && pairs.length) {
+    for (let i = 0; i < pairs.length; i += 250) {
+      const r = await gql(INV_SET, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: pairs.slice(i, i + 250) } });
+      const ue = (r.data && r.data.inventorySetQuantities && r.data.inventorySetQuantities.userErrors) || [];
+      if (ue.length) errors.push(...ue); else applied += pairs.slice(i, i + 250).length;
+    }
+    console.log(`[stock-2026-guard] COMMIT: set ${applied}/${pairs.length} variants to ${QTY}` + (errors.length ? ` · ${errors.length} userErrors` : ''));
+  } else if (pairs.length) {
+    console.log(`[stock-2026-guard] DRY-RUN: would set ${pairs.length} variants to ${QTY}. Pass --commit to apply.`);
+  } else {
+    console.log('[stock-2026-guard] nothing to fix — every active tracked product is in stock. ✅');
+  }
+
+  const report = { ts: started, store: STORE, committed: COMMIT, scanned, productsFixed: targets.length, variantSets: pairs.length, applied, errors: errors.slice(0, 20) };
+  try { fs.writeFileSync(OUT, JSON.stringify(report, null, 2)); } catch (e) {}
+
+  // best-effort CNCP card when it actually fixed something (matches the DW canary fleet pattern)
+  if (COMMIT && applied) {
+    try {
+      await fetch('http://127.0.0.1:3333/api/wins', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(2500),
+        body: JSON.stringify({ project: 'dw-shopify', title: `stock-2026-guard restored ${targets.length} active product(s) to 2026`, summary: `Active products had drifted to ≤0 tracked inventory; reset ${applied} variants to 2026 at Ventura Blvd.` }) });
+    } catch (e) {}
+  }
+  process.exit(errors.length ? 1 : 0);
+})().catch(e => { console.error('[stock-2026-guard] fatal:', e.message); process.exit(2); });

← 84e9e2e1 cadence: backfill+normalize widths to DW canonical 'N Inches  ·  back to Designer Wallcoverings  ·  fix(activation-gate): stem-compare images + non-blocking all 09a7f4be →