sync-daemon.js
1 #!/usr/bin/env node 2 3 /** 4 * Unified Sync Daemon 5 * 6 * Runs all periodic sync operations in one place: 7 * - Email events (opens, clicks, bounces) from Cloudflare Worker 8 * - Unsubscribes from Cloudflare Worker 9 * - Inbound SMS from Twilio API 10 * 11 * This replaces the need for 3 separate cron jobs. 12 */ 13 14 import { syncEmailEvents } from './utils/sync-email-events.js'; 15 import { syncUnsubscribes } from './utils/sync-unsubscribes.js'; 16 import { pollInboundSMS } from './inbound/sms.js'; 17 import Logger from './utils/logger.js'; 18 19 const logger = new Logger('SyncDaemon'); 20 21 // Configuration 22 const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes 23 const INITIAL_DELAY_MS = 5 * 1000; // 5 seconds after startup 24 25 // Track last successful sync times 26 const lastSync = { 27 emailEvents: null, 28 unsubscribes: null, 29 inboundSMS: null, 30 }; 31 32 // Track if we're currently syncing (prevent overlaps) 33 const syncing = { 34 emailEvents: false, 35 unsubscribes: false, 36 inboundSMS: false, 37 }; 38 39 /** 40 * Sync email events from Cloudflare Worker 41 */ 42 async function syncEmailEventsTask() { 43 if (syncing.emailEvents) { 44 logger.warn('Email events sync already in progress, skipping...'); 45 return; 46 } 47 48 syncing.emailEvents = true; 49 try { 50 logger.info('Syncing email events...'); 51 const result = await syncEmailEvents(); 52 lastSync.emailEvents = new Date(); 53 logger.success( 54 `Email events synced: ${result.processed} events (${result.opened} opened, ${result.clicked} clicked)` 55 ); 56 } catch (error) { 57 logger.error('Failed to sync email events:', error); 58 } finally { 59 syncing.emailEvents = false; 60 } 61 } 62 63 /** 64 * Sync unsubscribes from Cloudflare Worker 65 */ 66 async function syncUnsubscribesTask() { 67 if (syncing.unsubscribes) { 68 logger.warn('Unsubscribes sync already in progress, skipping...'); 69 return; 70 } 71 72 syncing.unsubscribes = true; 73 try { 74 logger.info('Syncing unsubscribes...'); 75 const result = await syncUnsubscribes(); 76 lastSync.unsubscribes = new Date(); 77 logger.success(`Unsubscribes synced: ${result.synced} new unsubscribes`); 78 } catch (error) { 79 logger.error('Failed to sync unsubscribes:', error); 80 } finally { 81 syncing.unsubscribes = false; 82 } 83 } 84 85 /** 86 * Poll inbound SMS from Twilio 87 */ 88 async function pollInboundSMSTask() { 89 if (syncing.inboundSMS) { 90 logger.warn('SMS polling already in progress, skipping...'); 91 return; 92 } 93 94 syncing.inboundSMS = true; 95 try { 96 logger.info('Polling inbound SMS...'); 97 const result = await pollInboundSMS(); 98 lastSync.inboundSMS = new Date(); 99 logger.success(`SMS polled: ${result.stored} new messages`); 100 } catch (error) { 101 logger.error('Failed to poll SMS:', error); 102 } finally { 103 syncing.inboundSMS = false; 104 } 105 } 106 107 /** 108 * Run all sync tasks in parallel 109 */ 110 async function runAllSyncs() { 111 logger.info('Starting sync cycle...'); 112 const start = Date.now(); 113 114 // Run all syncs in parallel (they're independent) 115 await Promise.all([syncEmailEventsTask(), syncUnsubscribesTask(), pollInboundSMSTask()]); 116 117 const duration = ((Date.now() - start) / 1000).toFixed(2); 118 logger.success(`Sync cycle completed in ${duration}s`); 119 } 120 121 /** 122 * Display status information 123 */ 124 function displayStatus() { 125 console.log('\n=== Sync Daemon Status ==='); 126 console.log(`Interval: Every ${SYNC_INTERVAL_MS / 1000 / 60} minutes`); 127 console.log(`\nLast Syncs:`); 128 console.log( 129 ` Email Events: ${lastSync.emailEvents ? lastSync.emailEvents.toLocaleString() : 'Never'}` 130 ); 131 console.log( 132 ` Unsubscribes: ${lastSync.unsubscribes ? lastSync.unsubscribes.toLocaleString() : 'Never'}` 133 ); 134 console.log( 135 ` Inbound SMS: ${lastSync.inboundSMS ? lastSync.inboundSMS.toLocaleString() : 'Never'}` 136 ); 137 console.log(`\nNext sync: ${new Date(Date.now() + SYNC_INTERVAL_MS).toLocaleString()}`); 138 console.log('=========================\n'); 139 } 140 141 /** 142 * Main daemon loop 143 */ 144 // eslint-disable-next-line require-await -- Schedules async work with setTimeout 145 async function startDaemon() { 146 logger.success('🚀 Sync Daemon started'); 147 logger.info(`Syncing every ${SYNC_INTERVAL_MS / 1000 / 60} minutes`); 148 logger.info(`First sync in ${INITIAL_DELAY_MS / 1000} seconds...`); 149 150 // Initial sync after delay 151 setTimeout(async () => { 152 await runAllSyncs(); 153 displayStatus(); 154 155 // Set up recurring sync 156 setInterval(async () => { 157 await runAllSyncs(); 158 displayStatus(); 159 }, SYNC_INTERVAL_MS); 160 }, INITIAL_DELAY_MS); 161 162 // Display status every 30 minutes when idle 163 setInterval( 164 () => { 165 if (!syncing.emailEvents && !syncing.unsubscribes && !syncing.inboundSMS) { 166 displayStatus(); 167 } 168 }, 169 30 * 60 * 1000 170 ); 171 } 172 173 /** 174 * Graceful shutdown 175 */ 176 function shutdown(signal) { 177 logger.warn(`\nReceived ${signal}, shutting down gracefully...`); 178 179 // Wait for any in-progress syncs to complete 180 const checkInterval = setInterval(() => { 181 if (!syncing.emailEvents && !syncing.unsubscribes && !syncing.inboundSMS) { 182 clearInterval(checkInterval); 183 logger.success('Shutdown complete'); 184 process.exit(0); 185 } else { 186 logger.info('Waiting for in-progress syncs to complete...'); 187 } 188 }, 1000); 189 190 // Force exit after 30 seconds 191 setTimeout(() => { 192 logger.error('Forced shutdown after 30s timeout'); 193 process.exit(1); 194 }, 30000); 195 } 196 197 // Handle shutdown signals 198 process.on('SIGTERM', () => shutdown('SIGTERM')); 199 process.on('SIGINT', () => shutdown('SIGINT')); 200 201 // Handle uncaught errors 202 process.on('uncaughtException', error => { 203 logger.error('Uncaught exception:', error); 204 process.exit(1); 205 }); 206 207 process.on('unhandledRejection', (reason, promise) => { 208 logger.error('Unhandled rejection at:', promise, 'reason:', reason); 209 process.exit(1); 210 }); 211 212 // Start the daemon 213 startDaemon();