/** * EventBus — in-process today, NATS JetStream tomorrow. * * To swap to NATS: replace the EventEmitter body with a NATS JetStream * publish call. Subjects and payload shapes are the durable contract. * * Subjects follow the pattern: .. * signals.tip.served — a tip was returned to the client * signals.tip.feedback — user reacted (done / dismiss / snooze) * signals.task.synced — Todoist task list refreshed for a user */ import { EventEmitter } from 'events'; export type TipServedEvent = { userId: string; tipId: string; policy: string; servedAt: string; }; export type TipFeedbackEvent = { userId: string; tipId: string; action: 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful'; reward: number; // inferred from action + dwellMs (see inferReward in recommender.ts) dwellMs: number | null; createdAt: string; }; export type IntegrationTokenExpiredEvent = { userId: string; provider: string; detectedAt: string; }; export type RewardDeliveryFailedEvent = { userId: string; tipId: string; reward: number; attempts: number; error: string; failedAt: string; }; export type TaskSyncedEvent = { userId: string; count: number; syncedAt: string; }; type EventMap = { 'signals.tip.served': TipServedEvent; 'signals.tip.feedback': TipFeedbackEvent; 'signals.tip.reward_failed': RewardDeliveryFailedEvent; 'signals.task.synced': TaskSyncedEvent; 'signals.integration.token_expired': IntegrationTokenExpiredEvent; }; export type StoredEvent = { id: number; subject: string; payload: unknown; ts: string; }; const RING_SIZE = 500; class Bus extends EventEmitter { private ring: StoredEvent[] = []; private seq = 0; private publishHooks: Array<(subject: string, payload: unknown) => void> = []; /** Register a side-effect hook called on every publish (e.g. NATS bridge) */ onPublish(hook: (subject: string, payload: unknown) => void): void { this.publishHooks.push(hook); } publish(subject: K, payload: EventMap[K]): void { const entry: StoredEvent = { id: ++this.seq, subject, payload, ts: new Date().toISOString(), }; if (this.ring.length >= RING_SIZE) this.ring.shift(); this.ring.push(entry); this.emit(subject, payload); for (const hook of this.publishHooks) hook(subject, payload); } subscribe(subject: K, handler: (payload: EventMap[K]) => void): void { this.on(subject, handler); } /** * Return recent events from the ring buffer. * @param subject optional filter (prefix match, e.g. "signals.tip") * @param userId optional user ID filter * @param limit max events to return (default 100) * @param since only events with id > since */ tail(opts: { subject?: string; userId?: string; limit?: number; since?: number } = {}): StoredEvent[] { const { subject, userId, limit = 100, since = 0 } = opts; let results = this.ring.filter((e) => { if (e.id <= since) return false; if (subject && !e.subject.startsWith(subject)) return false; if (userId) { const p = e.payload as Record; if (p.userId !== userId) return false; } return true; }); if (results.length > limit) results = results.slice(results.length - limit); return results; } } export { Bus }; export const bus = new Bus();