import { isRecordChanged, RecordChangedError, RECORD_CHANGED_MESSAGE } from './recordChanged'; import type { SaveOperation } from './saveOperation'; import { editPreconditionOf } from './patientProjection'; type Versioned = { meta?: { versionId?: string; extension?: { url: string; valueString?: string }[] } }; const versionOf = (record: Versioned | undefined): number | undefined => { const version = Number(record?.meta?.versionId); return record?.meta?.versionId && Number.isFinite(version) ? version : undefined; }; /** * The newer of a fresh read and the copy this screen already holds. A read can lag a write by many seconds, so * the answer to this screen's own last save may be newer than what a read returns; either is a whole stored * version, and the conditional write below is what decides. */ export const newerOf = (read: T, known?: T): T => { const readVersion = versionOf(read); const knownVersion = versionOf(known); return known && readVersion !== undefined && knownVersion !== undefined && knownVersion > readVersion ? known : read; }; export interface SaveOverCurrent { /** The save this belongs to: every read and write is admitted by it, and bounded by its deadline. */ operation: SaveOperation; /** The authoritative read. If it fails, nothing is saved. */ read: () => Promise; /** The newest copy this screen already holds, if any. */ known?: () => T | undefined; /** Whether the editor's OPENING baseline still holds on this record: its field or section is unchanged. */ stillHolds: (current: T) => boolean; /** Only the intended change, made on a copy of this record. */ apply: (current: T) => T; /** The conditional write, naming the version the candidate was built on. */ write: (candidate: T, basedOn: string) => Promise; /** Attempts in all; a lost race is re-read and re-checked, never written blind. Default 2. */ attempts?: number; } /** * An edit saved over the record as it is now, never over the one it was opened on, and never over another * writer's change to what it edits. * * Each attempt reads, checks the editor's opening baseline against that record, builds only the intended change * on it, and writes it conditional on its version. A write that loses a race to another writer is read and * checked again, up to `attempts`; a changed baseline, an exhausted race or an out-of-date page is a * RecordChangedError, with nothing written. Nothing is sent, and no answer is used, once `operation` has been * retired or has run out of time. */ export const saveOverCurrent = async (op: SaveOverCurrent): Promise => { const attempts = op.attempts ?? 2; for (let attempt = 1; ; attempt += 1) { const current = newerOf(await op.operation.step(op.read), op.known?.()); // A projected record has no version to show; it saves against the signed baseline it carries instead. const basedOn = current.meta?.versionId ?? editPreconditionOf(current); if (!basedOn) throw new Error('This record could not be read at a version to save against. Nothing was saved.'); if (!op.stillHolds(current)) throw new RecordChangedError(RECORD_CHANGED_MESSAGE, false, current); try { const candidate = op.apply(JSON.parse(JSON.stringify(current)) as T); return await op.operation.step(() => op.write(candidate, basedOn), true); } catch (error) { if (!isRecordChanged(error) || error.outOfDate || attempt >= attempts) throw error; } } };