From c1758de05e4b4d547c91a9ccc5bc136d5a08f4c5 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 20:58:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(05-03):=20implement=20pushCoalescer=20?= =?UTF-8?q?=E2=80=94=20per-(list,actor)=20sliding=20debounce=20(D-01/D-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-level Map keyed by ${listId}:${actorId} - sliding window: each call within window resets timer and increments count - fires dispatch(listId, actorId, count) once on timer expiry; map entry self-deletes - actorId passed as second arg so caller can apply excludeUserId=actorId (D-03) - default windowMs=45000; injected dispatch keeps module pure and testable --- apps/api/src/lib/pushCoalescer.ts | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/api/src/lib/pushCoalescer.ts 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), + ) + }) +}