import type { Bundle } from 'fhir/r4'; import { fetchAllBundlePages } from './fetchAllBundlePages'; import { AS_OF_INCOMPLETE_RELATION, isAsOfIncomplete, SEARCH_TRUNCATED_RELATION } from './searchTruncation'; import { markSearchStarted, startSearch } from './writeThroughHold'; import { RECENT_WRITES_UNRESOLVED_RELATION, mayMissRecentWrite } from './recentWritesUnresolved'; /** * Follow a searchset's `next` links and return ONE merged `Bundle` - the same contract a caller already * has, with the truncation carried on it. * * WHY THIS EXISTS ALONGSIDE `fetchAllBundlePages` * * The paginator returns `{ resources, truncated }`, which is the right shape for a NEW caller. But the * ~50 service reads this sweep has to fix all return `Promise>` today, and their consumers * unpack the bundle, cache the bundle, and hand the bundle to other hooks. Changing all of them to a * tuple at once is a large, mechanical, error-prone edit whose blast radius is every clinical screen - * and the point of the sweep is to REMOVE a silent wrong answer, not to trade it for a loud one. * * SO THE SHAPE IS COPIED FROM THE SERVER, WHICH SOLVED THIS EXACT PROBLEM ALREADY. `getMergedHistory` * (api `utils/history-paging.ts`) merges every `_history` page into one Bundle, strips the paging links - * they point at HealthLake and the client cannot follow them - and appends a namespaced link relation * when it hit its cap. This is that, for searches. A caller that does nothing keeps working; a caller * that cares asks `isSearchTruncated`. * * **`truncated` is NOT dropped, and that is the whole reason this returns a Bundle rather than an * array.**: *"the paginator returns `truncated`; a caller that drops it re-creates a silent * short list with extra steps."* An array has nowhere to put it, so it would have been dropped by * construction at every one of these call sites. * * **A bigger `_count` is not a fix and this is not one either** - it moves nothing. This FOLLOWS the * pages. The page size stays whatever the caller asked for, which is what keeps probe honest. */ export { SEARCH_TRUNCATED_RELATION }; /** Did this merged searchset stop before the end? */ export const isSearchTruncated = (bundle: Pick | null | undefined): boolean => (bundle?.link ?? []).some((link) => link.relation === SEARCH_TRUNCATED_RELATION); /** Paging links are stripped: they point at the datastore, which the browser cannot call. */ const PAGING_RELATIONS = new Set(['self', 'next', 'previous', 'prev', 'first', 'last']); /** * @param fetchPage called with the `page` token for every page after the first (undefined for the first). * The convention is `if (pageParam) params.set('page', pageParam)`. * @param maxPages a backstop, NOT a tuning knob. Tripping it means "move this aggregation server-side". */ /** * UNCONSTRAINED, WIDENED FROM `FhirResource` BY. See `fetchAllBundlePages` for the full * reasoning: `fetchPractitioners` returns `Bundle`, whose payload is a wrapper * object with no `resourceType` at all, and it is the read the org's whole staff list is built from. * Do not "restore" the constraint - it only ever rejected the caller that most needed this. */ export async function fetchAllPagesAsBundle( fetchPage: (pageParam?: string) => Promise>, maxPages?: number, ): Promise> { let firstPage: Bundle | undefined; // Any page served as of a cutoff may say it is incomplete; the merged answer says so whichever page did. let asOfIncomplete = false; // Any page saying it may be missing a recent change makes the whole list say so, not only the first. let unresolved = false; const started = startSearch(); const { resources, truncated } = await fetchAllBundlePages(async (pageParam) => { const page = await fetchPage(pageParam); // The FIRST page is kept whole, not just its entries: a searchset carries `id`, `meta` and // `type`, and callers that hand the bundle onward expect a real Bundle rather than a shape with // only `entry` on it. if (firstPage === undefined) firstPage = page; if (isAsOfIncomplete(page)) asOfIncomplete = true; if (mayMissRecentWrite(page)) unresolved = true; return page; }, maxPages); const links = (firstPage?.link ?? []).filter((link) => !PAGING_RELATIONS.has(link.relation ?? '')); if (truncated) links.push({ relation: SEARCH_TRUNCATED_RELATION, url: '' }); if (asOfIncomplete && !links.some((link) => link.relation === AS_OF_INCOMPLETE_RELATION)) { links.push({ relation: AS_OF_INCOMPLETE_RELATION, url: '' }); } if (unresolved && !mayMissRecentWrite({ link: links })) links.push({ relation: RECENT_WRITES_UNRESOLVED_RELATION, url: '' }); return markSearchStarted({ ...(firstPage ?? { resourceType: 'Bundle', type: 'searchset' } as Bundle), // `total` is REWRITTEN to what we actually hold. HealthLake's `total` is the match count for the // whole search, so leaving it would have a merged bundle claim a size its `entry` does not have - // and a caller rendering "N results" beside N-minus-something rows is the same lie in a new place. total: resources.length, entry: resources.map((resource) => ({ resource })), link: links.length > 0 ? links : undefined, }, started); }