// The workspace half of an original view. It opens one view per workspace instance, carries that view on each chart // read through per-call options, keeps only receipts that match the exact bytes served, and before Start submits the // rendered versions it can cite. Whatever it cannot prove is simply not cited, and a view that never opened sends // nothing at all, so care goes on unchanged either way. import type { ReadOptions, ServedAnswer } from './readOptions'; export const VIEW_HEADER = 'X-Anchor-View'; export const RECEIPT_HEADER = 'X-Anchor-Receipt'; /** The server's bounds: receipt numbers below 64, at most 500 cited versions. */ export const MAX_RECEIPT_BATCHES = 64; export const MAX_VIEW_TUPLES = 500; const VIEW_ID = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const RECEIPT = /^v1; view=([0-9a-f]{40}); seq=(\d{1,2}); batch=([0-9a-f]{64})$/; export interface ViewEntry { seq: number; type: string; id: string; versionId: string } /** How the view reaches its routes; the hook supplies the one host the chart reads use. */ export interface ViewTransport { open: (body: { clientInstanceId: string; encounterId?: string; operationId?: string }) => Promise; submit: (viewId: string, body: { operationId: string; entries: ViewEntry[] }, signal?: AbortSignal) => Promise; } export type ViewScope = { encounterId: string } | { operationId: string }; /** What a Start can name: the frozen selection, and how many rendered versions it could not cite. */ export interface SubmittedView { viewId: string; manifestDigest: string; cited: number; uncited: number } const keyOf = (type: string, id: string, versionId: string): string => `${type}/${id}/${versionId}`; const sha256Hex = async (text: string): Promise => { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); }; const PATIENT_FIELDS = ['subject', 'patient', 'beneficiary', 'for'] as const; /** The server's own rule, kept narrower: a local reference to this patient, or the patient itself. */ const belongsTo = (resource: Record, patientId: string): boolean => { const local = `Patient/${patientId}`; if (resource.resourceType === 'Patient') return resource.id === patientId; if (PATIENT_FIELDS.some((field) => resource[field]?.reference === local)) return true; return resource.resourceType === 'AuditEvent' && (resource.entity ?? []).some((entity: Record) => entity?.what?.reference === local); }; const servedVersionsOf = (body: unknown, patientId: string): Array<{ type: string; id: string; versionId: string }> => { const resources = (body as { resourceType?: string })?.resourceType === 'Bundle' ? ((body as { entry?: Array<{ resource?: unknown }> }).entry ?? []).map((entry) => entry?.resource) : [body]; return (resources as Array | undefined>) .filter((resource) => resource?.resourceType && resource.id && typeof resource.meta?.versionId === 'string' && belongsTo(resource, patientId)) .map((resource) => ({ type: resource!.resourceType, id: resource!.id, versionId: resource!.meta.versionId })); }; export interface AnchorView { /** Opens the view once; a failure leaves the view closed and every read unchanged. */ open: (scope: ViewScope) => Promise; viewId: () => string | undefined; /** The visit or create operation the open view is bound to. */ scope: () => ViewScope | undefined; /** Per-call options for one chart read, or none when no view is collecting. */ readOptions: () => ReadOptions | undefined; /** Records the versions this workspace actually showed. */ rendered: (resources: Array<{ resourceType?: string; id?: string; meta?: { versionId?: string } } | undefined>) => void; /** Freezes the selection for this Start; undefined when there is no view or the server refused it. */ submit: (operationId: string, signal?: AbortSignal) => Promise; } export function createAnchorView(transport: ViewTransport, patientId: string, clientInstanceId: string): AnchorView { let viewId: string | undefined; let boundScope: ViewScope | undefined; let opening: Promise | undefined; let frozen = false; const receipted = new Map(); const shown = new Set(); const bind = async (answer: ServedAnswer): Promise => { const match = RECEIPT.exec(answer.header(RECEIPT_HEADER)?.trim() ?? ''); if (!match || match[1] !== viewId || frozen) return; const seq = Number(match[2]); if (!Number.isInteger(seq) || seq >= MAX_RECEIPT_BATCHES) return; if (await sha256Hex(answer.text) !== match[3]) return; let body: unknown; try { body = JSON.parse(answer.text); } catch { return; } for (const version of servedVersionsOf(body, patientId)) { const key = keyOf(version.type, version.id, version.versionId); if (!receipted.has(key)) receipted.set(key, seq); } }; return { open: (scope) => { opening ??= transport.open({ clientInstanceId, ...scope }) .then((answer) => { const id = (answer as { viewId?: unknown } | undefined)?.viewId; viewId = typeof id === 'string' && VIEW_ID.test(id) ? id : undefined; boundScope = viewId ? scope : undefined; return viewId; }) .catch(() => undefined); return opening; }, viewId: () => viewId, scope: () => boundScope, readOptions: () => (viewId && !frozen ? { headers: { [VIEW_HEADER]: viewId }, onResponse: bind } : undefined), rendered: (resources) => { for (const resource of resources) { const versionId = resource?.meta?.versionId; if (resource?.resourceType && resource.id && typeof versionId === 'string') shown.add(keyOf(resource.resourceType, resource.id, versionId)); } }, submit: async (operationId, signal) => { if (!viewId) return undefined; const entries: ViewEntry[] = []; let uncited = 0; for (const key of shown) { const seq = receipted.get(key); if (seq === undefined) { uncited += 1; continue; } const [type, id, versionId] = key.split('/'); entries.push({ seq, type, id, versionId }); } // A selection the server would cut short is not the view as shown; it is left unproved. if (entries.length > MAX_VIEW_TUPLES) return undefined; frozen = true; try { const body = { operationId, entries }; const answer = await (signal ? transport.submit(viewId, body, signal) : transport.submit(viewId, body)) as { manifestDigest?: unknown } | undefined; const digest = answer?.manifestDigest; return typeof digest === 'string' && DIGEST.test(digest) ? { viewId, manifestDigest: digest, cited: entries.length, uncited } : undefined; } catch { return undefined; } }, }; } /** * What a Start of this visit sends: its own new operation and the view frozen under it. Nothing when no view of * this visit opened or the selection could not be frozen, so the Start is written exactly as before. */ /** The manifest's share of the Start's save deadline; past it the visit starts unproved, as if no view had opened. */ export const MANIFEST_ALLOWANCE_MS = 3_000; export async function startInView( view: AnchorView | undefined, encounterId: string, newOperationId: () => string = () => crypto.randomUUID(), signal?: AbortSignal, allowanceMs = MANIFEST_ALLOWANCE_MS, ): Promise<{ operationId?: string; viewReference?: Record }> { const scope = view?.scope(); if (!view || !scope || !('encounterId' in scope) || scope.encounterId !== encounterId || signal?.aborted) return {}; const operationId = newOperationId(); const deadline = Date.now() + allowanceMs; const controller = new AbortController(); const stop = () => controller.abort(); signal?.addEventListener('abort', stop, { once: true }); const closed = new Promise((resolve) => controller.signal.addEventListener('abort', () => resolve(undefined), { once: true })); const timer = setTimeout(stop, allowanceMs); try { const work = view.submit(operationId, controller.signal); work.catch(() => undefined); const submitted = await Promise.race([work, closed]); // A selection frozen after its bound is never named: the Start has already gone on without it. if (!submitted || controller.signal.aborted || Date.now() >= deadline) return {}; return { operationId, viewReference: startHeaderOf(submitted) }; } catch { return {}; } finally { clearTimeout(timer); signal?.removeEventListener('abort', stop); controller.abort(); } } /** The Start's reference to its frozen selection. */ export const startHeaderOf = (submitted: SubmittedView): Record => ({ [VIEW_HEADER]: `${submitted.viewId}; manifest=${submitted.manifestDigest}` });