← back to Marketing Command Center
scripts/publish-calendar-snapshot.mjs
125 lines
#!/usr/bin/env node
// publish-calendar-snapshot.mjs — the DURABLE fix for the /#calendarhub Timeline
// staleness bug (activation layer froze at 2026-08-16 for ~a week).
//
// WHY THIS EXISTS: the Timeline "activation" layer projects from the Postgres
// table bulk_fivefield_worklist, which is Mac2-ONLY (split-ownership — staging
// tables are Mac2-canonical and deliberately NOT on Kamatera). So prod can never
// project it live; it serves the activation layer from the last-deployed snapshot
// file data/calendars-activation.json. Nothing regenerated + redeployed that file,
// so it froze. This script closes that pipeline gap: regenerate on Mac2 (where the
// worklist lives) -> HARD-VALIDATE -> atomically publish the single file to Kamatera
// -> bust prod's 10-min mem cache. It NEVER syncs the worklist table itself
// (that would violate split-ownership) — it publishes the projected snapshot only.
//
// Safe by design: read-only projection + single-file atomic publish. On ANY
// validation failure it ABORTS the publish (prod keeps its last good snapshot),
// posts a CNCP parking-lot card (best-effort), and exits nonzero so the existing
// launchd-job-canary flags it. Intended to run daily via launchd
// (com.steve.marketing-calendar-snapshot).
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SNAPSHOT = path.join(ROOT, 'data', 'calendars-activation.json');
// ── config (abs tool paths — this runs headless under launchd, no shell PATH) ──
const RSYNC = '/opt/homebrew/bin/rsync';
const SSH = '/usr/bin/ssh';
const CURL = '/usr/bin/curl';
const REMOTE_HOST = 'root@45.61.58.125';
const REMOTE_DIR = '/root/DW-Agents/marketing-command-center/data';
const REMOTE_FINAL = `${REMOTE_DIR}/calendars-activation.json`;
const REMOTE_TMP = `${REMOTE_DIR}/.calendars-activation.json.tmp`;
const PUBLIC_URL = 'https://marketing.designerwallcoverings.com/api/calendars/events?fresh=1';
const AUTH = `${process.env.MCC_HTTP_USER || 'admin'}:${process.env.MCC_HTTP_PASS || 'DW2024!'}`;
const MIN_ITEMS = parseInt(process.env.SNAP_MIN_ITEMS, 10) || 15000;
const CNCP_URL = process.env.CNCP_URL || 'http://127.0.0.1:3333/api/parking-lot';
// Readiness self-test: regenerate + run the full validation gate, then STOP before
// the rsync/ssh publish + cache-bust. Lets anyone re-prove the job is healthy without
// touching Kamatera prod. Enabled via `--dry-run` (or `--check`) / DRY_RUN=1. The
// launchd plist passes no args, so the scheduled run is unaffected.
const DRY_RUN = process.argv.includes('--dry-run') || process.argv.includes('--check') || process.env.DRY_RUN === '1';
const log = (m) => console.log(`[publish-calendar-snapshot] ${new Date().toISOString()} ${m}`);
// tomorrow in America/Los_Angeles as YYYY-MM-DD (the activation layer anchors to
// today+1 in server-local time; Mac2 is PT, but pin the TZ so a TZ drift can't
// silently pass validation).
function tomorrowPT() {
const now = new Date();
const pt = new Date(now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
pt.setDate(pt.getDate() + 1);
const y = pt.getFullYear(), m = String(pt.getMonth() + 1).padStart(2, '0'), d = String(pt.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function alertCNCP(title, detail) {
try {
execFileSync(CURL, ['-s', '--max-time', '8', '-X', 'POST', CNCP_URL,
'-H', 'Content-Type: application/json',
'-d', JSON.stringify({ title, detail, source: 'publish-calendar-snapshot', severity: 'warn' })],
{ stdio: 'ignore' });
} catch { /* best-effort — never let the alert channel sink the run */ }
}
function fail(reason) {
log(`ABORT — ${reason}`);
alertCNCP('Calendar snapshot publish FAILED', reason);
// clear failure signature for the launchd-job-canary stderr sweep:
console.error(`publish-calendar-snapshot: FAIL: ${reason}`);
process.exit(1);
}
async function main() {
// 1. regenerate from the LIVE worklist (this writes data/calendars-activation.json)
log('regenerating snapshot from live worklist…');
const lib = require('../modules/calendars/lib');
let data;
try { data = await lib.buildEvents(true); }
catch (e) { fail(`buildEvents threw: ${e.message}`); }
// 2. HARD validation gate — publish ONLY a fresh, live, sane projection
const items = data.items || [];
const first = items[0]?.date;
const expected = tomorrowPT();
if (data.activation_live !== true) fail(`activation_live is ${data.activation_live} (worklist not readable on Mac2 — refusing to publish a snapshot-of-a-snapshot)`);
if (data.activation_error) fail(`activation_error present: ${data.activation_error}`);
if (items.length < MIN_ITEMS) fail(`item count ${items.length} < floor ${MIN_ITEMS} (worklist looks truncated/empty)`);
if (first !== expected) fail(`first activation date ${first} !== tomorrow ${expected} (window not anchored to today+1)`);
log(`validation PASS — activation_live=true, items=${items.length}, window ${first} … ${items[items.length - 1]?.date}`);
if (DRY_RUN) {
log('DRY-RUN — readiness gate PASSED; skipping publish + cache-bust (prod untouched).');
return;
}
// 3. atomic publish (rsync -> remote temp, then ssh mv), retry once on transient failure
const publish = () => {
execFileSync(RSYNC, ['-az', '--timeout=60', SNAPSHOT, `${REMOTE_HOST}:${REMOTE_TMP}`], { stdio: 'inherit' });
execFileSync(SSH, ['-o', 'ConnectTimeout=20', REMOTE_HOST, `mv ${REMOTE_TMP} ${REMOTE_FINAL}`], { stdio: 'inherit' });
};
try { publish(); }
catch (e1) {
log(`publish attempt 1 failed (${e1.message}) — retrying once…`);
try { publish(); }
catch (e2) { fail(`atomic publish failed twice: ${e2.message}`); }
}
log('published to Kamatera (atomic temp->rename).');
// 4. bust prod's in-process mem cache so the calendar jumps to current immediately
try {
execFileSync(CURL, ['-s', '--max-time', '25', '-u', AUTH, '-o', '/dev/null', PUBLIC_URL], { stdio: 'ignore' });
log('prod cache busted via ?fresh=1.');
} catch (e) { log(`WARN cache-bust curl failed (non-fatal, TTL will expire in ~10min): ${e.message}`); }
log('DONE — calendar snapshot current on prod.');
}
main().catch((e) => fail(`unexpected: ${e.stack || e.message}`));