feat(05-03): implement pushCoalescer — per-(list,actor) sliding debounce (D-01/D-03)
- module-level Map<string, {count, timer}> 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
This commit is contained in:
@@ -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<void>
|
||||||
|
|
||||||
|
type PendingEntry = {
|
||||||
|
count: number
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level singleton — keyed by `${listId}:${actorId}`.
|
||||||
|
// Entries are self-deleting: deleted when the timer fires.
|
||||||
|
const pending = new Map<string, PendingEntry>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user