// - which two versions of a resource does this audit row describe? // // The audit event carries `recorded`; each version carries `meta.lastUpdated`. They are close but not // identical - the audit event is stamped when the write is enqueued and processed, which LAGS the // resource write. So pairing them is a judgement, not a lookup. // // "CANNOT DETERMINE" IS A FIRST-CLASS RESULT, deliberately mirroring History-Snapshot Capability A's // `truncated`. Describing the wrong pair would state on a patient's screen that something changed which // did not - worse than describing nothing at all, on a feed whose entire value is being trustworthy // about who did what. The caller renders an honest fallback rather than a guess. export interface VersionedResource { resourceType?: string; meta?: { versionId?: string; lastUpdated?: string }; [key: string]: unknown; } export type VersionPairResult = | { ok: true; previous: VersionedResource; current: VersionedResource } // 'created' — the audit row describes the FIRST version, so there is nothing to diff against and // "changed" would be the wrong word. 'unknown' — no version lines up with the audit timestamp. | { ok: false; reason: 'created' | 'unknown' }; // How far apart a write and its audit stamp may be and still be the same write. The path is // HTTP SQS processor HealthLake, so a few seconds is normal; a minute is generous without being so // wide that two edits in quick succession collapse into one. Bounds both directions: the stamp trails // its write, and the history read can trail the stamp. const AUDIT_LAG_TOLERANCE_MS = 60_000; const millis = (iso: string | undefined): number | undefined => { if (!iso) return undefined; const value = new Date(iso).getTime(); return Number.isFinite(value) ? value : undefined; }; const pairByVersionId = ( versions: VersionedResource[] | undefined, versionId: string, historyComplete: boolean, ): VersionPairResult => { if (!Array.isArray(versions)) return { ok: false, reason: 'unknown' }; const ordered = [...versions].sort( (a, b) => (millis(a.meta?.lastUpdated) ?? 0) - (millis(b.meta?.lastUpdated) ?? 0), ); const index = ordered.findIndex((version) => version.meta?.versionId === versionId); if (index < 0) return { ok: false, reason: 'unknown' }; // The earliest version on hand is the creation only when the history is known to be whole. A capped // history drops the oldest versions, so its first entry can be an edit whose predecessor is missing. if (index === 0) return { ok: false, reason: historyComplete ? 'created' : 'unknown' }; return { ok: true, previous: ordered[index - 1], current: ordered[index] }; }; // Link relations by which the server says a history bundle does not hold every version. const INCOMPLETE_HISTORY_RELATIONS = new Set(['next', 'x-akashic-history-truncated', 'x-akashic-history-rebuilt-by-vread']); export const isHistoryComplete = (bundle: { link?: { relation?: string }[] } | undefined): boolean => Boolean(bundle) && !(bundle?.link ?? []).some((link) => INCOMPLETE_HISTORY_RELATIONS.has(link?.relation ?? '')); export function selectVersionPair( versions: VersionedResource[] | undefined, recorded: string | undefined, versionId?: string, historyComplete = false, ): VersionPairResult { // A row that names the version its write produced is paired exactly. Time is only the fallback for // rows written before the server sent that key, and it cannot tell two quick edits apart. if (versionId) return pairByVersionId(versions, versionId, historyComplete); const recordedAt = millis(recorded); if (!recordedAt || !Array.isArray(versions) || versions.length === 0) { return { ok: false, reason: 'unknown' }; } // Keep only versions we can actually place in time, oldest first. HealthLake returns history // newest-first, so this is a normalisation rather than a preference. const ordered = versions .map((version) => ({ version, at: millis(version.meta?.lastUpdated) })) .filter((entry): entry is { version: VersionedResource; at: number } => entry.at !== undefined) .sort((a, b) => a.at - b.at); if (ordered.length === 0) return { ok: false, reason: 'unknown' }; // The write this audit row describes is the LATEST version at or before the audit timestamp, // allowing for processing lag. Taking the latest (rather than the nearest) matters when several // edits land close together: the audit event always trails its own write, never precedes it. let index = -1; for (let i = 0; i < ordered.length; i += 1) { if (ordered[i].at <= recordedAt + AUDIT_LAG_TOLERANCE_MS) index = i; } if (index < 0) return { ok: false, reason: 'unknown' }; // The window needs a floor as well as a ceiling. `_history` can lag the write this row describes, // and the newest version on hand is then the previous one - which "the latest at or before the // stamp" takes happily, diffs against the one before that, and presents as this row's change. // A version written long before the stamp cannot be what this row describes, so say we cannot // tell. The cost is a pair of edits inside the window collapsing, which the ceiling already accepts. if (recordedAt - ordered[index].at > AUDIT_LAG_TOLERANCE_MS) { return { ok: false, reason: 'unknown' }; } if (index === 0) return { ok: false, reason: 'created' }; return { ok: true, previous: ordered[index - 1].version, current: ordered[index].version }; }