← 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
M modules/calendars/lib.js
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 →