import type { Config } from '../config.js'; import type { Repos } from '../db.js'; import type { Logger } from '../logger.js'; import { createMatchContext } from './context.js'; import { advanceCheckpoint, newestCreatedAt, pollNewFires } from './ingest.js'; import { matchFire, type MatchContext } from './matcher.js'; export interface MatcherRunner { runOnce(): Promise<{ fires: number; generated: number }>; start(): void; stop(): void; } /** * Fase 1b matcher loop: poll new fires, match each against subscriptions, and * write the resulting notification docs — to `notifications_shadow` (shadow) or * `notifications` (full). Idempotent across restarts: docs are written before * the checkpoint advances, and the 500 m dedupe (against the target collection) * means reprocessing the same fires produces no duplicates. */ export function createMatcherRunner( cfg: Config, repos: Repos, log: Logger, ctx: MatchContext = createMatchContext(cfg, repos), ): MatcherRunner { let timer: NodeJS.Timeout | null = null; let running = false; const target = cfg.matcher.mode === 'shadow' ? repos.notificationsShadow : repos.notifications; async function runOnce(): Promise<{ fires: number; generated: number }> { if (cfg.matcher.mode === 'off' || running) return { fires: 0, generated: 0 }; running = true; try { const fires = await pollNewFires(cfg, repos); if (fires.length === 0) return { fires: 0, generated: 0 }; let generated = 0; for (const fire of fires) { const notifs = await matchFire(fire, ctx); if (notifs.length > 0) { await target.insertMany(notifs as never[]); generated += notifs.length; } } const checkpoint = newestCreatedAt(fires); if (checkpoint) await advanceCheckpoint(repos, checkpoint); log.info({ fires: fires.length, generated, mode: cfg.matcher.mode, target: target.collectionName }, 'matcher: cycle'); return { fires: fires.length, generated }; } finally { running = false; } } function start(): void { if (cfg.matcher.mode === 'off') { log.info('matcher: disabled (MATCHER_MODE=off)'); return; } log.info({ mode: cfg.matcher.mode, intervalMs: cfg.matcher.pollIntervalMs, audience: cfg.matcher.audienceTypes }, 'matcher: starting'); timer = setInterval(() => { void runOnce().catch((err) => log.error({ err: String(err) }, 'matcher: cycle error')); }, cfg.matcher.pollIntervalMs); void runOnce().catch((err) => log.error({ err: String(err) }, 'matcher: initial cycle error')); } function stop(): void { if (timer) { clearInterval(timer); timer = null; } } return { runOnce, start, stop }; }