/** * What this device has already dismissed, per alert. * * The service keeps an alert "triggered" until the owner disarms it or every * subscriber acknowledges, so `alarmStatus` keeps reporting the same trigger * after this device dismissed it — and the check-in/replay paths keep offering * it too. Raising the takeover from those reports again is the alarm that * cannot be kept silenced, so the client has to remember what it dismissed: * * - a dismissal that named its event suppresses exactly that event; * - a dismissal that could not name one (the native takeover launch carries * only the alert) is recorded as `*`: it suppresses the trigger current at * dismissal time, and is pinned to that concrete event as soon as a status * report reveals it; * - a dismissal is **not** undone by the transport. It used to be: a live * socket frame cleared the memory on the theory that a live frame must be a * new trigger. That is wrong on this service — an alert is triggered once * and then disarmed or delivered, so a live frame for a dismissed alert is * the same alarm arriving down another pipe (measured: after dismissing, the * deferred native takeover replayed a frame and the siren came back). The * only thing that clears the memory is the alert ending (`forget`). * * Pure and in-memory on purpose: the durable part of a dismissal is the ack * queue, and correctness must not depend on storage having finished writing. */ export interface DismissalSource { alertId: string; eventId?: string; } export interface DismissalMemory { mark(alertId: string, eventId?: string | null): void; /** Seed from a persisted ack queue so a replay before the flush cannot ring. */ seed(list: readonly DismissalSource[]): void; /** * Should a trigger raise be suppressed? * * Suppressed when this device dismissed the alert — whether the raise came * from a status report or the socket (`live` only changes where the frame * came from, never whether the user already dismissed it). */ suppress(alertId: string, eventId: string | undefined, live: boolean): boolean; forget(alertId: string): void; /** Test/debug view of what is remembered for an alert. */ remembered(alertId: string): string[]; } export function createDismissals(): DismissalMemory { const seen = new Map>(); const mark = (alertId: string, eventId?: string | null) => { const set = seen.get(alertId) ?? new Set(); set.add(eventId || '*'); seen.set(alertId, set); }; const forget = (alertId: string) => { seen.delete(alertId); }; // `live` is accepted for call-site symmetry and deliberately ignored: the // transport a frame arrived on says nothing about whether a human dismissed // the alarm (see the header comment). const suppress = (alertId: string, eventId: string | undefined, _live: boolean) => { const set = seen.get(alertId); if (!set) return false; if (!eventId) return true; if (set.has(eventId)) return true; if (!set.has('*')) return false; // Pin the anonymous dismissal to the event it actually covered; a later // trigger carries a different id and must ring. if (eventId) { set.delete('*'); set.add(eventId); } return true; }; return { mark, forget, suppress, seed: list => list.forEach(a => mark(a.alertId, a.eventId)), remembered: alertId => [...(seen.get(alertId) ?? [])], }; }