diff --git a/apps/api/src/lib/pushCoalescer.ts b/apps/api/src/lib/pushCoalescer.ts new file mode 100644 index 0000000..f8a5d5d --- /dev/null +++ b/apps/api/src/lib/pushCoalescer.ts @@ -0,0 +1,70 @@ +/** + * Per-(list, actor) coalescing debounce for list-change push notifications (D-01). + * + * A grocery burst — many rapid saves — collapses into a single push dispatch + * instead of one push per change. The window is sliding: each new call within + * the window resets the timer, so the dispatch fires once the member pauses. + * + * Exports: coalesceListPush + */ + +type DispatchFn = (listId: number, actorId: number, count: number) => Promise + +type PendingEntry = { + count: number + timer: ReturnType +} + +// Module-level singleton — keyed by `${listId}:${actorId}`. +// Entries are self-deleting: deleted when the timer fires. +const pending = new Map() + +/** + * Coalesce list-change push notifications for a single (list, actor) pair. + * + * @param listId - The list that changed. + * @param actorId - The member who made the change (userId). Used as the + * excludeUserId argument to dispatch so the actor is never + * notified of their own changes (D-03). + * @param dispatch - Called once per coalesce window with (listId, actorId, count). + * The caller fans out to all subscribers except actorId. + * @param windowMs - Sliding debounce window in milliseconds (default 45 s). + */ +export function coalesceListPush( + listId: number, + actorId: number, + dispatch: DispatchFn, + windowMs = 45_000, +): void { + const key = `${listId}:${actorId}` + const existing = pending.get(key) + + if (existing) { + // Extend the window on every new call within the burst (sliding debounce). + clearTimeout(existing.timer) + existing.count++ + existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs) + } else { + // First call in a new burst — start a fresh entry. + const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs) + pending.set(key, { count: 1, timer }) + } +} + +function fire( + key: string, + listId: number, + actorId: number, + dispatch: DispatchFn, +): void { + const entry = pending.get(key) + if (!entry) return + const count = entry.count + pending.delete(key) + dispatch(listId, actorId, count).catch((err: unknown) => { + console.error( + `[pushCoalescer] dispatch failed for list ${listId}:`, + err instanceof Error ? err.message : String(err), + ) + }) +}