// The search index lags a write by 10 to 20 seconds, so a list re-read by search right after a save shows the old // state. What each successful write of this browser returned is held, keyed by type and id, and overlaid on searched // lists until the search provably has that version or later, or WRITE_THROUGH_HOLD_MS passes. Only writes of this viewer. import { isAsOfIncomplete, SEARCH_TRUNCATED_RELATION } from './searchTruncation'; import { isReturnedResult, withheldText } from './returnedPublication'; type Versioned = { resourceType?: string; id?: string; meta?: { versionId?: string; lastUpdated?: string } }; // settledBy is the start of the search that first proved the entry; it still answers searches that started earlier. // settledFloor is the start of the latest search that showed a newer version; settledBy never drops below it. // decision is a returned result's latest read - held back or readable - with the start of the search that read it and // when; it keeps its own lifetime. An entry holding only that (no write here) is decisionOnly. export type HeldWrite = { resource: T; at: number; deleted?: boolean; deletedAt?: number; settledBy?: number; settledFloor?: number; decision?: { resource: T; started: number; at: number }; decisionOnly?: boolean; // The order of a read decision a newer write superseded: what it read, and when that search started. readOrder?: { resource: T; started: number }; }; export type WriteHold = Map>; export const WRITE_THROUGH_HOLD_MS = 10 * 60 * 1000; const WITHHELD_RECEIPT_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/write-receipt'; /** The server saved it but the read of this viewer withholds it; there is nothing readable to hold or list. */ export const isWithheldReceipt = (body: unknown): boolean => { const outcome = body as { resourceType?: string; issue?: { details?: { coding?: { system?: string; code?: string }[] } }[] } | null; return outcome?.resourceType === 'OperationOutcome' && (outcome.issue ?? []).some((issue) => (issue.details?.coding ?? []) .some((coding) => coding.system === WITHHELD_RECEIPT_SYSTEM && coding.code === 'saved-withheld-from-viewer')); }; /** 1 when `a` is a later copy than `b`, -1 earlier, 0 the same; undefined when neither versionId nor lastUpdated tells. */ export const compareVersions = (a: Versioned, b: Versioned): number | undefined => { const versionA = Number(a.meta?.versionId); const versionB = Number(b.meta?.versionId); if (Number.isFinite(versionA) && Number.isFinite(versionB)) return Math.sign(versionA - versionB); const atA = Date.parse(a.meta?.lastUpdated ?? ''); const atB = Date.parse(b.meta?.lastUpdated ?? ''); if (Number.isFinite(atA) && Number.isFinite(atB)) return Math.sign(atA - atB); return undefined; }; /** * One copy per id from reads that may answer the same resource at different versions: the latest known version * wins, and where versions cannot be told apart the first copy stays. Laid over by the hold only after this, so a * lagging copy can neither undo a newer one nor take the hold's entry for the search catching up. */ export const newestById = (resources: readonly T[]): T[] => { const byId = new Map(); const unnamed: T[] = []; for (const resource of resources) { if (!resource.id) { unnamed.push(resource); continue; } const kept = byId.get(resource.id); if (!kept || compareVersions(resource, kept) === 1) byId.set(resource.id, resource); } return [...byId.values(), ...unnamed]; }; /** True when `held` is known to be a later version than `searched`. */ export const laterThan = (held: Versioned, searched: Versioned): boolean => compareVersions(held, searched) === 1; const isBareAddress = (resource: Versioned): boolean => !resource.meta && Object.keys(resource).length <= 2; // A delete stands over every write that started before it completed, whatever version that write answered with; // only a write started after it can recreate the resource. A write never gives way to one known to be older. // `order` is when a write started, or when a delete completed (startWrite). A delete known only by its address // keeps what was held of the resource, so the lists it belongs to can still be told. export const rememberWrite = (held: WriteHold, resource: T | undefined, now: number, deleted = false, order?: number): void => { if (!resource?.id || isWithheldReceipt(resource)) return; const existing = held.get(resource.id); const decision = existing?.decision; if (existing && !deleted) { if (existing.deleted ? order === undefined || order <= (existing.deletedAt ?? Infinity) : laterThan(existing.resource, resource)) return; } const kept = deleted && existing && isBareAddress(resource) ? existing.resource : resource; // A read decision outlives a write answer no newer than what it read, or one from a write that started before // the search that read it, so a late receipt cannot undo it; a newer write started after it, or a delete, // supersedes it. const startedBefore = order !== undefined && order < (decision?.started ?? -Infinity); const standing = decision && !deleted && (compareVersions(resource, decision.resource) !== 1 || startedBefore) ? decision : undefined; // The superseded decision still orders the reads it overtook, so one of them answering late cannot stand over // the newer write either. const readOrder = deleted ? undefined : decision && !standing ? { resource: decision.resource, started: decision.started } : existing?.readOrder; held.set(resource.id, { resource: kept, at: now, ...(deleted ? { deleted: true, deletedAt: order } : {}), ...(standing ? { decision: standing } : {}), ...(readOrder ? { readOrder } : {}), }); }; // An expired write hold gives way, but a read decision observed since keeps its own lifetime, alone. const retire = (held: WriteHold, id: string, entry: HeldWrite, now: number): boolean => { const decision = entry.decision; if (decision && now - decision.at <= WRITE_THROUGH_HOLD_MS) { if (!entry.decisionOnly) held.set(id, { resource: decision.resource, at: decision.at, decision, decisionOnly: true }); return false; } held.delete(id); return true; }; // The write hold and the read decision each keep their own lifetime: whichever lapses first gives way alone. const lapse = (held: WriteHold, id: string, entry: HeldWrite, now: number): boolean => { if (now - entry.at > WRITE_THROUGH_HOLD_MS) return retire(held, id, entry, now); if (!entry.decision || now - entry.decision.at <= WRITE_THROUGH_HOLD_MS) return false; if (entry.decisionOnly) held.delete(id); else delete entry.decision; return true; }; const heldBack = (resource: Versioned): boolean => isReturnedResult(resource as { id?: string }) && Boolean(withheldText(resource as { id?: string })); const answers = (entry: HeldWrite, searchStarted: number | undefined): boolean => entry.settledBy === undefined || (searchStarted !== undefined && searchStarted < entry.settledBy); // The searched list with the hold laid over it, for a list whose whole query "belongs" describes. A held copy shows // only where it belongs, and settles once the search provably has that version or later, keeping the newest shown. // A delete hides the row whatever version the search returns, and settles only when a complete list it belongs to // omits it. A settled entry still answers a search that started before the one that settled it, so an older answer // cannot bring back what a newer one proved. Everything is released after WRITE_THROUGH_HOLD_MS. export const overlayWrites = ( held: WriteHold, searched: readonly T[], belongs: (resource: T) => boolean, now: number, complete = true, searchStarted?: number, onSettle?: () => void, ): T[] => { held.forEach((entry, id) => { lapse(held, id, entry, now); }); // A search with no known start answers no other search, but the entry stays until it expires, so a late write // is still measured against the newest version any search has shown. const settle = (entry: HeldWrite, newer?: T): void => { const first = entry.settledBy === undefined; entry.settledBy = Math.max(Math.min(entry.settledBy ?? Infinity, searchStarted ?? -Infinity), entry.settledFloor ?? -Infinity); if (newer) entry.resource = newer; if (first || newer) onSettle?.(); }; const searchedIds = new Set(searched.map((resource) => resource.id)); const shown: T[] = []; for (const resource of searched) { let entry = resource.id ? held.get(resource.id) : undefined; // A returned result's latest read decision - held back or readable, the version, and the start of the search // that read it - stands over every answer it supersedes, whoever wrote the result: an older version, or the same // version from a search that started earlier, shows as that decision. Only a newer version, or the same one read // by a later search, replaces it, and either change invalidates what was cached. if (resource.id && isReturnedResult(resource as { id?: string }) && !entry?.deleted) { const decided = entry?.decision; const started = searchStarted ?? -Infinity; const order = decided ? compareVersions(resource, decided.resource) : 1; if (decided && !(order === 1 || (order === 0 && started > decided.started))) { if (belongs(decided.resource)) shown.push(decided.resource); continue; } // An answer the superseded decision provably overtook - no newer, from a search that started no later - // gives way to the newer write that superseded it. Anything not provably older keeps its own standing. const passed = !decided && entry && !entry.decisionOnly && searchStarted !== undefined ? entry.readOrder : undefined; const behind = passed ? compareVersions(resource, passed.resource) : undefined; if (passed && (behind === -1 || behind === 0) && started <= passed.started) { if (belongs(entry!.resource)) shown.push(entry!.resource); continue; } // An answer older than a write made here is measured as any other write's answer is, below. const overtaken = entry && !entry.decisionOnly && compareVersions(resource, entry.resource) === -1; if (!overtaken) { if (!entry) { entry = { resource, at: now, decisionOnly: true }; held.set(resource.id, entry); } // A newer version is ordered by its own read; only the same version keeps the latest start seen. entry.decision = { resource, started: order === 1 ? started : Math.max(started, decided?.started ?? -Infinity), at: now }; if (entry.decisionOnly) entry.resource = resource; if (decided ? order !== 0 || heldBack(resource) !== heldBack(decided.resource) : heldBack(resource)) onSettle?.(); } else if (heldBack(resource)) { // A withholding admitted over a newer write is the current decision; the write's body stays held // apart from it. One whose search has no known start cannot be shown to come before anything. const at = searchStarted ?? Infinity; entry!.decision = { resource, started: order === 1 ? at : Math.max(at, decided?.started ?? -Infinity), at: now }; if (!decided || !heldBack(decided.resource) || order !== 0) onSettle?.(); } else if (decided) { // A later readable answer releases it: the write stands, and that read now orders what it overtook. entry!.readOrder = { resource, started }; delete entry!.decision; onSettle?.(); } if (entry?.decisionOnly || heldBack(resource)) { shown.push(resource); continue; } } if (!entry || !answers(entry, searchStarted)) { // A later search still raises the newest version seen, so an older answer or a late write is measured // against it; a delete keeps its own admission. if (entry && !entry.deleted && compareVersions(resource, entry.resource) === 1) { entry.resource = resource; // Every read that started before this one is measured against what it found, so one still out // when it answered cannot bring back an earlier version. if (searchStarted !== undefined) { entry.settledFloor = Math.max(entry.settledFloor ?? -Infinity, searchStarted); entry.settledBy = Math.max(entry.settledBy ?? -Infinity, entry.settledFloor); } onSettle?.(); } shown.push(resource); continue; } if (entry.deleted) continue; // A returned result the server now holds back stands over any held body; otherwise the newer version wins as // for anything else, so an older complete answer never brings back values a newer one held back. if (heldBack(resource)) { shown.push(resource); continue; } const order = compareVersions(resource, entry.resource); if (order !== undefined && order >= 0) { settle(entry, order > 0 ? resource : undefined); shown.push(resource); continue; } if (belongs(entry.resource)) shown.push(entry.resource); } const added: T[] = []; held.forEach((entry, id) => { if (entry.decisionOnly || searchedIds.has(id) || !belongs(entry.resource) || !answers(entry, searchStarted)) return; if (!entry.deleted) { if (!isReturnedResult(entry.resource as { id?: string })) added.push(entry.resource); } else if (complete) settle(entry); }); return [...added, ...shown]; }; // One hold per resource type for this browser, emptied whenever the signed-in viewer, acting-as subject or // practice changes. Writes are numbered in the order they start; a write that started before the current viewer // began is not admitted. const holds = new Map>(); // Written ids whose resource this viewer could not read back yet: when each was written, the list it belongs to, how // many reads failed, when it may next be read, and the read that owns it while one is in flight. type Awaited = { at: number; attempts: number; dueAt: number; scope?: string; inFlight?: number }; const awaited = new Map>(); export const READ_BACK_ATTEMPTS = 5; export const READ_BACK_DELAY_MS = 3000; // Each delete bars writes that started before it for as long as a hold lasts, even after the search shows it gone. const barriers = new Map>(); // Writes sent whose answer was lost, by the viewer and route they went to, until a read made after them settles each. // Kept across viewer changes: the write may still have landed when its viewer returns. const lostAnswers = new Map(); const lostAnswerListeners = new Set<() => void>(); const lostAnswersChanged = (): void => { lostAnswerListeners.forEach((listener) => listener()); }; const changedAt = new Map(); // Counts every change to what the hold would show for a type, so a cache can tell it is stale without a clock. const generations = new Map(); // When each search bundle started, so its answer is ordered against the writes and searches around it. const searchStarts = new WeakMap(); // The search behind each overlaid bundle, for a reader that reconstructs history and needs every id the index has. const searchedBehind = new WeakMap(); const viewerListeners = new Set<() => void>(); // Told whenever a read back of a type settles, read or not, so every list still shown can schedule or show it. /** What happened to a written id's read back: read (now held and shown), or another failed try. */ export type ReadBackEvent = { read: boolean; scope?: string }; const readBackListeners = new Set<(resourceType: string, event: ReadBackEvent) => void>(); const notifyReadBack = (resourceType: string, event: ReadBackEvent): void => { readBackListeners.forEach((listener) => listener(resourceType, event)); }; let viewerChangedAt = 0; let currentViewer: string | null = null; let sequence = 0; let viewerSince = 0; const bump = (resourceType: string): void => { generations.set(resourceType, (generations.get(resourceType) ?? 0) + 1); }; const holdOf = (resourceType: string): WriteHold => { let hold = holds.get(resourceType); if (!hold) { hold = new Map(); holds.set(resourceType, hold); } return hold; }; // Drops expired entries and records when each expired, so a list cached while it was shown goes stale. const expire = (resourceType: string, now: number): void => { holds.get(resourceType)?.forEach((entry, id, hold) => { const lapsedAt = Math.min(entry.at, entry.decision?.at ?? Infinity) + WRITE_THROUGH_HOLD_MS; if (!lapse(hold, id, entry, now)) return; bump(resourceType); changedAt.set(resourceType, Math.max(changedAt.get(resourceType) ?? 0, lapsedAt)); }); awaited.get(resourceType)?.forEach((entry, id, ids) => { if (now - entry.at > WRITE_THROUGH_HOLD_MS) ids.delete(id); }); barriers.get(resourceType)?.forEach((barrier, id, bars) => { if (now - barrier.at > WRITE_THROUGH_HOLD_MS) bars.delete(id); }); }; export const clearWriteThroughHolds = (): void => { holds.clear(); awaited.clear(); barriers.clear(); changedAt.clear(); viewerChangedAt = 0; lostAnswers.clear(); lostAnswersChanged(); generations.forEach((_, resourceType) => bump(resourceType)); }; /** Numbers a write as it starts; pass the number to the hold when it answers. */ export const startWrite = (): number => ++sequence; /** Whether a write numbered `started` began under the viewer now signed in. */ export const sameViewerSince = (started: number): boolean => started >= viewerSince; /** Changes whenever the viewer, acting-as subject or practice does; a cache keyed on it belongs to one viewer. */ export const viewerEpoch = (): number => viewerSince; /** Who is viewing, as account, acted-as link and practice: the same string for the same viewer across a reload. */ export const writeThroughViewer = (): string | null => currentViewer; /** A write to `route` was sent and its answer lost: it may have landed, so the route waits on a read of what is saved. */ export const noteLostAnswer = (route: string): void => { lostAnswers.set(route, ++sequence); lostAnswersChanged(); }; /** Whether `route` has a lost answer no read has settled yet. */ export const hasLostAnswer = (route: string): boolean => lostAnswers.has(route); /** Numbers a read as it starts, for `settleLostAnswers` once it answers. */ export const startRead = (): number => ++sequence; /** A read numbered `started` answered: every lost answer on these routes noted before it began is settled by it. */ export const settleLostAnswers = (routes: readonly string[], started: number): void => { const settled = routes.filter((route) => (lostAnswers.get(route) ?? Infinity) < started); settled.forEach((route) => lostAnswers.delete(route)); if (settled.length) lostAnswersChanged(); }; export const onLostAnswerChange = (listener: () => void): (() => void) => { lostAnswerListeners.add(listener); return () => { lostAnswerListeners.delete(listener); }; }; /** Calls `listener` on every viewer change, so a service can drop what it shows and caches at once. */ export const onViewerChange = (listener: () => void): (() => void) => { viewerListeners.add(listener); return () => { viewerListeners.delete(listener); }; }; // A list cached before a write of its type is stale even when the write answered with no resource to hold. export const noteWrite = (resourceType: string, now = Date.now()): void => { changedAt.set(resourceType, now); bump(resourceType); }; /** Changes whenever what the hold would show for this type may have: a write, a settle, an expiry or a viewer change. */ export const holdGeneration = (resourceType: string, now = Date.now()): number => { expire(resourceType, now); return generations.get(resourceType) ?? 0; }; /** Numbers a search as it starts; mark its bundle with the number so the overlay can order it. */ export const startSearch = (): number => ++sequence; export const markSearchStarted = (bundle: B, started: number): B => { searchStarts.set(bundle, started); return bundle; }; export const searchStartedOf = (bundle: object | null | undefined): number | undefined => (bundle ? searchStarts.get(bundle) : undefined); /** The bundle as the search returned it, before this viewer's holds were laid over it. */ export const asSearched = (bundle: B): B => (searchedBehind.get(bundle) as B | undefined) ?? bundle; /** `derived` (a bundle built from `from`) still answers `asSearched` with the search `from` was made of. */ export const keepSearched = (derived: B, from: object): B => { searchedBehind.set(derived, asSearched(from)); return derived; }; // Whether a list of this type read at since may differ from what the hold would now show: a write, an expiry or // a change of viewer since then, or a written id due to be read back again. export const writtenSince = (resourceType: string, since: number, now = Date.now()): boolean => { expire(resourceType, now); if ([...(awaited.get(resourceType)?.values() ?? [])].some((entry) => waitingToRead(entry) && entry.dueAt <= now)) return true; return Math.max(changedAt.get(resourceType) ?? 0, viewerChangedAt) > since; }; const givenUp = (entry: Awaited): boolean => entry.attempts >= READ_BACK_ATTEMPTS; const waitingToRead = (entry: Awaited): boolean => entry.inFlight === undefined && !givenUp(entry); /** A write succeeded but its resource could not be read back: the next list of its scope reads it by id. */ export const awaitWritten = (resourceType: string, id: string | undefined, now = Date.now(), startedIn = sequence, scope?: string): void => { if (!id || !sameViewerSince(startedIn)) return; noteWrite(resourceType, now); let ids = awaited.get(resourceType); if (!ids) { ids = new Map(); awaited.set(resourceType, ids); } ids.set(id, { at: now, attempts: 0, dueAt: now, ...(scope === undefined ? {} : { scope }) }); }; /** One read back, owned by the caller that claimed it until it reports how the read went. */ export type ReadBack = { id: string; token: number }; // The written ids of this type and scope due to be read back, dropping any searched already has. Each is owned by // the caller until readBackSettled, so no other reader asks for it meanwhile. One given up on is offered again only // to a deliberate refresh. The token also numbers the read, so its answer is admitted only under the same viewer. export const claimReadBacks = (resourceType: string, searched: readonly Versioned[] = [], scope?: string, deliberate = false, now = Date.now()): ReadBack[] => { expire(resourceType, now); const ids = awaited.get(resourceType); searched.forEach((resource) => { if (resource.id) ids?.delete(resource.id); }); const claimed: ReadBack[] = []; ids?.forEach((entry, id) => { if (entry.inFlight !== undefined || (entry.scope !== undefined && entry.scope !== scope)) return; if (givenUp(entry) ? !deliberate : entry.dueAt > now) return; entry.inFlight = ++sequence; claimed.push({ id, token: entry.inFlight }); }); return claimed; }; /** Calls `listener` whenever a read back settles, so a list whose own read was superseded still learns of it. */ export const onReadBackChange = (listener: (resourceType: string, event: ReadBackEvent) => void): (() => void) => { readBackListeners.add(listener); return () => { readBackListeners.delete(listener); }; }; // The owner of a read back reports it. A failure waits twice as long as the last before the next try; after // READ_BACK_ATTEMPTS no more are made on their own, but the id is kept for a deliberate refresh until it expires. // Every list still shown is told either way, since the read's own list may no longer be the one that publishes. export const readBackSettled = (resourceType: string, readBack: ReadBack, read: boolean, now = Date.now()): void => { const ids = awaited.get(resourceType); const entry = ids?.get(readBack.id); if (!ids || !entry || entry.inFlight !== readBack.token) return; entry.inFlight = undefined; if (read) { ids.delete(readBack.id); } else { entry.attempts += 1; entry.dueAt = givenUp(entry) ? Infinity : now + READ_BACK_DELAY_MS * 2 ** (entry.attempts - 1); bump(resourceType); } notifyReadBack(resourceType, { read, scope: entry.scope }); }; /** The written ids of this type and scope that could not be read back and are no longer asked for on their own. */ export const readBacksGivenUp = (resourceType: string, scope?: string, now = Date.now()): string[] => { expire(resourceType, now); return [...(awaited.get(resourceType)?.entries() ?? [])] .filter(([, entry]) => givenUp(entry) && entry.inFlight === undefined && (entry.scope === undefined || entry.scope === scope)) .map(([id]) => id); }; /** When the next written id of this type and scope is due to be read back on its own, if any is. */ export const nextReadBackDue = (resourceType: string, scope?: string, now = Date.now()): number | undefined => { expire(resourceType, now); const dues = [...(awaited.get(resourceType)?.values() ?? [])] .filter((entry) => waitingToRead(entry) && (entry.scope === undefined || entry.scope === scope)) .map((entry) => entry.dueAt); return dues.length ? Math.min(...dues) : undefined; }; export const setWriteThroughViewer = (viewer: string | null, now = Date.now()): void => { if (viewer === currentViewer) return; currentViewer = viewer; viewerSince = ++sequence; viewerChangedAt = now; holds.clear(); awaited.clear(); barriers.clear(); generations.forEach((_, resourceType) => bump(resourceType)); viewerListeners.forEach((listener) => listener()); lostAnswersChanged(); }; export const holdWrite = (resource: Versioned | undefined, now = Date.now(), startedIn = sequence): void => { if (!resource?.resourceType || isWithheldReceipt(resource) || !sameViewerSince(startedIn)) return; expire(resource.resourceType, now); const barrier = resource.id ? barriers.get(resource.resourceType)?.get(resource.id) : undefined; if (barrier && startedIn <= barrier.order) return; noteWrite(resource.resourceType, now); const recovered = resource.id ? awaited.get(resource.resourceType)?.get(resource.id) : undefined; if (recovered && resource.id) awaited.get(resource.resourceType)?.delete(resource.id); rememberWrite(holdOf(resource.resourceType), resource, now, false, startedIn); // A written id now readable: every list still shown learns of it, not only the one whose read found it. if (recovered) notifyReadBack(resource.resourceType, { read: true, scope: recovered.scope }); }; // Numbered as it completes, so only a write started after the delete answered can stand over it. export const holdDelete = (resource: Versioned | undefined, now = Date.now(), startedIn = sequence): void => { if (!resource?.resourceType || !sameViewerSince(startedIn)) return; noteWrite(resource.resourceType, now); const order = ++sequence; if (resource.id) { let bars = barriers.get(resource.resourceType); if (!bars) { bars = new Map(); barriers.set(resource.resourceType, bars); } bars.set(resource.id, { order, at: now }); } rememberWrite(holdOf(resource.resourceType), resource, now, true, order); }; export const isHeld = (resourceType: string, id: string | null | undefined, now = Date.now()): boolean => { expire(resourceType, now); const entry = id ? holds.get(resourceType)?.get(id) : undefined; return Boolean(entry && entry.settledBy === undefined); }; export const overlayHeld = ( resourceType: string, searched: readonly T[], belongs: (resource: T) => boolean, now = Date.now(), complete = true, searchStarted?: number, ): T[] => { expire(resourceType, now); return overlayWrites(holdOf(resourceType) as WriteHold, searched, belongs, now, complete, searchStarted, () => bump(resourceType)); }; /** False for a search that stopped at a page cap or still names a next page, or one served as of a cutoff and not in * full: none of them can prove a row is gone. */ export const searchIsComplete = (bundle: { link?: { relation?: string }[] } | null | undefined): boolean => !isAsOfIncomplete(bundle) && !(bundle?.link ?? []).some((link) => link.relation === SEARCH_TRUNCATED_RELATION || link.relation === 'next'); // The bundle a fetch hands its caller, overlaid the same way, with a total that counts what is shown. A search that // started under an earlier viewer answers with nothing. Overlay a searched bundle once: a second pass would take the // held copy for the search catching up. export const overlayHeldBundle = ( resourceType: string, bundle: { entry?: { resource?: T }[]; total?: number; link?: { relation?: string }[] }, belongs: (resource: T) => boolean, now = Date.now(), ): typeof bundle => { const started = searchStarts.get(bundle); if (started !== undefined && !sameViewerSince(started)) return { ...bundle, entry: [], ...(bundle.total === undefined ? {} : { total: 0 }) }; // SERVED AS OF A CUTOFF AND NOT IN FULL: this read may show only what the server served. A held body is newer // than it may show, and a row it left out is unknown rather than missing - so nothing is laid over it, added to // it or settled by it. if (isAsOfIncomplete(bundle)) return bundle; const searched = (bundle.entry ?? []).map((entry) => entry.resource).filter((resource): resource is T => resource !== undefined); const shown = overlayHeld(resourceType, searched, belongs, now, searchIsComplete(bundle), started); if (shown.length === searched.length && shown.every((resource, index) => resource === searched[index])) return bundle; const overlaid = { ...bundle, entry: shown.map((resource) => ({ resource })), ...(bundle.total === undefined ? {} : { total: shown.length }) }; searchedBehind.set(overlaid, bundle); return overlaid; }; // One page of a paged or filtered search: a held write updates or hides a row the page lists, but is never added, // since one page cannot say which page or filter a new row belongs to, and never settles a delete. The total keeps // counting every page, less the rows hidden here. export const overlayHeldPage = ( resourceType: string, bundle: { entry?: { resource?: T }[]; total?: number; link?: { relation?: string }[] }, now = Date.now(), ): typeof bundle => { const listed = new Set((bundle.entry ?? []).map((entry) => entry.resource?.id)); const overlaid = overlayHeldBundle(resourceType, bundle, (resource) => listed.has(resource.id), now); const started = searchStarts.get(bundle); if (overlaid === bundle || bundle.total === undefined || (started !== undefined && !sameViewerSince(started))) return overlaid; overlaid.total = Math.max(0, bundle.total - ((bundle.entry?.length ?? 0) - (overlaid.entry?.length ?? 0))); return overlaid; }; /** Whether a resource is about this patient, by the reference fields FHIR uses for it. */ export const refersToPatient = (resource: unknown, patientId: string | undefined): boolean => { if (!patientId) return false; const reference = `Patient/${patientId}`; const fields = resource as { subject?: { reference?: string }; patient?: { reference?: string }; beneficiary?: { reference?: string }; for?: { reference?: string } }; return [fields.subject, fields.patient, fields.beneficiary, fields.for].some((field) => field?.reference === reference); }; const ADDRESS = /[/]([A-Z][A-Za-z]+)[/]([^/]+)$/; // Every successful write through the shared HTTP helpers lands here, with the number it started under. A delete that // answers with the archived resource is held as that version; one that answers with nothing is held as a delete // of `/`. export const holdResponse = (method: string, url: string, body: unknown, startedIn = sequence, now = Date.now()): void => { const resource = body as Versioned | null | undefined; if (resource?.resourceType && resource.id) { holdWrite(resource, now, startedIn); return; } if (method !== 'DELETE') return; const [, resourceType, id] = new URL(url, 'http://local').pathname.match(ADDRESS) ?? []; if (resourceType && id) holdDelete({ resourceType, id }, now, startedIn); };