[object Object]

← back to Marketing Command Center

calendars: decouple launched layer so it stays live where bulk_fivefield_worklist is absent (Kamatera)

87dc441c77de82c4a7d725aa291bddccf77dd78e · 2026-07-16 11:03:08 -0700 · Steve

Give the worklist (RR_SQL) query its own inner try/catch inside computeActivation
so a missing bulk_fivefield_worklist (Mac-only table) no longer sinks the launched
layer, which reads only shopify_products. When the worklist is absent, activation
items come from the last-good snapshot while launched recomputes LIVE. Adds
activation_live/launched_live flags; keeps top-level error null in that normal case
so the 'Live catalog unavailable' banner stops mis-firing.

Files touched

Diff

commit 87dc441c77de82c4a7d725aa291bddccf77dd78e
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 16 11:03:08 2026 -0700

    calendars: decouple launched layer so it stays live where bulk_fivefield_worklist is absent (Kamatera)
    
    Give the worklist (RR_SQL) query its own inner try/catch inside computeActivation
    so a missing bulk_fivefield_worklist (Mac-only table) no longer sinks the launched
    layer, which reads only shopify_products. When the worklist is absent, activation
    items come from the last-good snapshot while launched recomputes LIVE. Adds
    activation_live/launched_live flags; keeps top-level error null in that normal case
    so the 'Live catalog unavailable' banner stops mis-firing.
---
 modules/calendars/lib.js | 127 +++++++++++++++++++++++++++++------------------
 1 file changed, 79 insertions(+), 48 deletions(-)

diff --git a/modules/calendars/lib.js b/modules/calendars/lib.js
index 332b948..a0188a4 100644
--- a/modules/calendars/lib.js
+++ b/modules/calendars/lib.js
@@ -120,57 +120,40 @@ function fieldsFrom(e) {
   };
 }
 
-// ── build the enriched activation layer from live dw_unified ─────────────────
+// ── build the enriched activation layer (+ live launched layer) ──────────────
+// The two layers have DECOUPLED failure domains. The launched layer reads only
+// shopify_products (present on every host), so it stays LIVE even where the
+// drain worklist bulk_fivefield_worklist is absent (e.g. Kamatera, where the
+// worklist is Mac-local). In that case the activation layer is served from the
+// last good snapshot while launched is recomputed live — a missing worklist no
+// longer sinks the launched layer (its own inner try/catch contains it).
 async function computeActivation() {
   const c = await pool().connect();
-  let rows, enrich = {}, launchedRows = [];
+  let rows = null, enrich = {}, launchedRows = [];
+  let activationLive = false, activationErr = null;
   try {
-    rows = (await c.query(RR_SQL)).rows;
-    // enrich in chunks — one round-trip per 5k shopify_ids
-    const ids = [...new Set(rows.map(r => r.shopify_id).filter(Boolean))];
-    for (let i = 0; i < ids.length; i += 5000) {
-      const chunk = ids.slice(i, i + 5000);
-      const q = await c.query(
-        `SELECT shopify_id, vendor, product_type, retail_price, min_variant_price, price,
-                tags, metafields, pattern_name, title, image_url, handle
-           FROM shopify_products WHERE shopify_id = ANY($1::text[])`, [chunk]);
-      for (const r of q.rows) enrich[r.shopify_id] = r;
+    try {
+      rows = (await c.query(RR_SQL)).rows;
+      // enrich in chunks — one round-trip per 5k shopify_ids
+      const ids = [...new Set(rows.map(r => r.shopify_id).filter(Boolean))];
+      for (let i = 0; i < ids.length; i += 5000) {
+        const chunk = ids.slice(i, i + 5000);
+        const q = await c.query(
+          `SELECT shopify_id, vendor, product_type, retail_price, min_variant_price, price,
+                  tags, metafields, pattern_name, title, image_url, handle
+             FROM shopify_products WHERE shopify_id = ANY($1::text[])`, [chunk]);
+        for (const r of q.rows) enrich[r.shopify_id] = r;
+      }
+      activationLive = true;
+    } catch (e) {
+      activationErr = e.message;   // worklist absent on this host → keep snapshot activation
+      rows = null;
     }
-    // already-launched products (last 30 days) — the past-side layer
+    // launched layer is INDEPENDENT of the worklist — always recompute live
     launchedRows = (await c.query(LAUNCHED_SQL, [String(LAUNCHED_DAYS)])).rows;
   } finally { c.release(); }
 
-  const overrides = store.readJson(OVERRIDES_FILE, {}) || {};
-  const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() + 1);
-  const total = rows.length;
-  const items = new Array(total);
-  for (let seq = 0; seq < total; seq++) {
-    const r = rows[seq];
-    const day = Math.floor(seq / PER_DAY), pos = seq % PER_DAY, hour = slotHourForPos(pos);
-    let dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day, hour, SLOT_MIN, 0, 0);
-    let slot = hour;
-    // manual drag override wins (keyed by shopify_id or dw_sku)
-    const ov = overrides[r.shopify_id] || (r.dw_sku && overrides[r.dw_sku]);
-    if (ov && ov.go_live_date) {
-      if (ov.slot_hour != null) slot = ov.slot_hour;
-      const [Y, M, D] = ov.go_live_date.split('-').map(Number);
-      dt = new Date(Y, M - 1, D, slot, SLOT_MIN, 0, 0);
-    }
-    const e = enrich[r.shopify_id] || {};
-    items[seq] = {
-      key: r.shopify_id || r.dw_sku,
-      layer: 'activation',
-      date: `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`,
-      go_live_at: dt.toISOString(),
-      slot_hour: slot,
-      vendor: e.vendor || r.vendor || '',            // prefer sanitized storefront vendor
-      dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '', fix_type: r.fix_type,
-      ...fieldsFrom(e),
-      shopify_id: r.shopify_id || '',
-    };
-  }
-
-  // launched layer — one item per already-live product, on its launch date
+  // launched layer — one item per already-live product, on its launch date (LIVE)
   const launched = launchedRows.map(r => {
     const d = new Date(r.created_at_shopify);
     return {
@@ -182,14 +165,59 @@ async function computeActivation() {
       shopify_id: r.shopify_id,
     };
   });
+
+  // activation layer — project LIVE if the worklist was readable, else reuse the
+  // last good snapshot's activation items (the intended source where it's absent)
+  let items, total, start_date, end_date;
+  if (activationLive && rows) {
+    const overrides = store.readJson(OVERRIDES_FILE, {}) || {};
+    const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() + 1);
+    total = rows.length;
+    items = new Array(total);
+    for (let seq = 0; seq < total; seq++) {
+      const r = rows[seq];
+      const day = Math.floor(seq / PER_DAY), pos = seq % PER_DAY, hour = slotHourForPos(pos);
+      let dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day, hour, SLOT_MIN, 0, 0);
+      let slot = hour;
+      // manual drag override wins (keyed by shopify_id or dw_sku)
+      const ov = overrides[r.shopify_id] || (r.dw_sku && overrides[r.dw_sku]);
+      if (ov && ov.go_live_date) {
+        if (ov.slot_hour != null) slot = ov.slot_hour;
+        const [Y, M, D] = ov.go_live_date.split('-').map(Number);
+        dt = new Date(Y, M - 1, D, slot, SLOT_MIN, 0, 0);
+      }
+      const e = enrich[r.shopify_id] || {};
+      items[seq] = {
+        key: r.shopify_id || r.dw_sku,
+        layer: 'activation',
+        date: `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`,
+        go_live_at: dt.toISOString(),
+        slot_hour: slot,
+        vendor: e.vendor || r.vendor || '',          // prefer sanitized storefront vendor
+        dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '', fix_type: r.fix_type,
+        ...fieldsFrom(e),
+        shopify_id: r.shopify_id || '',
+      };
+    }
+    start_date = items[0]?.date || null;
+    end_date = items[total - 1]?.date || null;
+  } else {
+    const disk = store.readJson(CACHE_FILE, null) || {};
+    items = disk.items || [];
+    total = disk.total || items.length;
+    start_date = disk.start_date || null;
+    end_date = disk.end_date || null;
+  }
+
   const out = {
     generated_at: new Date().toISOString(),
-    total, per_day: PER_DAY,
-    start_date: items[0]?.date || null,
-    end_date: items[total - 1]?.date || null,
+    total, per_day: PER_DAY, start_date, end_date,
     items,
-    launched,                          // already-live products (last 30 days)
+    launched,                          // already-live products (last 30 days) — LIVE
     launched_count: launched.length,
+    activation_live: activationLive,   // false where worklist absent (activation from snapshot)
+    launched_live: true,               // launched always recomputes live from shopify_products
+    ...(activationErr ? { activation_error: activationErr } : {}),
   };
   store.writeJson(CACHE_FILE, out);
   return out;
@@ -260,6 +288,9 @@ async function buildEvents(force) {
   return {
     generated_at: act.generated_at,
     stale: act.stale || false, mock: act.mock || false, error: act.error || null,
+    activation_live: act.activation_live !== false,   // false only where the worklist is absent (activation from snapshot)
+    launched_live: act.launched_live || false,        // launched recomputed live from shopify_products
+    activation_error: act.activation_error || null,   // diagnostic only — NOT surfaced as the top-level error banner
     activation: { total: act.total || 0, start: act.start_date, end: act.end_date, per_day: act.per_day || PER_DAY },
     window: { start: winStart, end: winEnd, today: todayISO },
     counts, items,

← c1ee3c8 clients panel: per-group CSV export (respects filter; includ  ·  back to Marketing Command Center  ·  refresh-clients.sh: read CSVs from project data/sources (lau ad37a60 →