import type { Communication } from 'fhir/r4'; import { hasReadReceiptForReference } from './readReceipts'; /** * Each read receipt this viewer has written, by viewer, line and edition, so copies of the list arriving while a * save is out, or stale after it, do not write it again. A newer edition, a new line or another viewer is new work. * A save that did not return the line leaves it `failed`: not stored as far as anyone knows, and not re-sent on its * own, since it may have been. Only an explicit retry sends it again. */ type Outcome = 'pending' | 'confirmed' | 'failed'; export interface ReceiptLedger { readonly entries: Map; /** Clears this viewer's failed receipts (only those for `messages`, when given), for one more try. */ retry: (reader: string, messages?: Communication[]) => void; /** Settles this viewer's failed receipts for `messages` as confirmed: a current read showed the receipt stored. */ confirm: (reader: string, messages: Communication[]) => void; } const MAX_ENTRIES = 1000; const keyOf = (reader: string, message: Communication): string | undefined => message.id ? `${reader}|${message.id}|${message.meta?.versionId ?? ''}` : undefined; export const createReceiptLedger = (): ReceiptLedger => { const entries = new Map(); return { entries, retry: (reader, messages) => { const only = messages ? new Set(messages.map((message) => keyOf(reader, message))) : undefined; for (const [key, outcome] of entries) { if (outcome === 'failed' && key.startsWith(`${reader}|`) && (!only || only.has(key))) entries.delete(key); } }, confirm: (reader, messages) => { for (const message of messages) { const key = keyOf(reader, message); if (key && entries.get(key) === 'failed') entries.set(key, 'confirmed'); } }, }; }; const record = (ledger: ReceiptLedger, key: string, outcome: Outcome) => { ledger.entries.delete(key); ledger.entries.set(key, outcome); // Bounded by forgetting the oldest confirmed receipts only: a pending or failed one is unsettled, and forgetting it // would let a stale list send it again. for (const [old, state] of ledger.entries) { if (ledger.entries.size <= MAX_ENTRIES) break; if (state === 'confirmed') ledger.entries.delete(old); } }; /** The lines on screen whose receipt by `reader`, for this edition, was not confirmed and is still missing. */ export const unconfirmedOf = (ledger: ReceiptLedger, reader: string, messages: Communication[]): Communication[] => messages.filter((message) => { const key = keyOf(reader, message); return Boolean(key) && ledger.entries.get(key as string) === 'failed' && !hasReadReceiptForReference(message, reader); }); /** * Saves `reader`'s receipts in `updated` that are not already pending, confirmed or failed, and settles each from * what the save returned: a line it returned is confirmed, any other is failed. Nothing is sent when nothing is new. * `confirms`, when given, must also accept the returned line as proof of the sent one. */ export async function acknowledgeOnce( ledger: ReceiptLedger, reader: string, updated: Communication[], save: (messages: Communication[]) => Promise, confirms?: (sent: Communication, returned: Communication) => boolean, ): Promise<{ sent: Communication[]; saved: Communication[]; failed: Communication[] }> { const sent = updated.filter((message) => { const key = keyOf(reader, message); return Boolean(key) && !ledger.entries.has(key as string); }); if (sent.length === 0) return { sent, saved: [], failed: [] }; for (const message of sent) record(ledger, keyOf(reader, message) as string, 'pending'); let returned: Communication[] = []; try { returned = await save(sent); } catch (error) { console.error('Read receipts were not confirmed:', error); } const back = new Map(returned.map((message) => [message.id, message])); const proved = (message: Communication) => { const answer = back.get(message.id); return Boolean(answer) && (!confirms || confirms(message, answer as Communication)); }; const saved = sent.filter(proved); const failed = sent.filter((message) => !proved(message)); for (const message of saved) record(ledger, keyOf(reader, message) as string, 'confirmed'); for (const message of failed) record(ledger, keyOf(reader, message) as string, 'failed'); return { sent, saved, failed }; }