Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit c1758de05e - Show all commits
+70
View File
@@ -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),
)
})
}