import type { Bundle } from 'fhir/r4'; /** * Follow a FHIR searchset's `next` links and return every page's resources. * * WHY THIS EXISTS AS A SHARED THING * * A one-page read that looks complete is this project's most expensive recurring defect, and it has now * bitten in three places: * - **Server-side, 2026-08-13** - `getPractitionerRolesForOrganization` read one page and got 10 of 11 * roles. The eleventh was the role every schedule in the org named, so **every schedule vanished** and * every receptionist surface reported an empty day. Nothing threw. * - **`BillingOverviewPage`** - solved it privately with its own loop. * - **Here** - the appointments calendar, swapping a slot-shaped incompleteness for a page-shaped one. * * **A bigger `_count` is not the fix.** It moves the cliff to a larger customer instead of removing it. * * It reports `truncated` rather than throwing. A caller that hit the bound is showing a subset, and the * useful thing is for it to SAY so - a surface rendering 5,000 rows and admitting there are more is more * useful than one rendering an error. * * `BillingOverviewPage`'s private copy is deliberately left alone: its page-cap warning carries card * 138's sanitisation reasoning tied to its own URLs, and folding it in is a separate change. This is the * home for the NEXT one. */ export interface AllPagesResult { resources: T[]; /** True when the page bound was reached with a `next` link still outstanding. */ truncated: boolean; } /** * @param fetchPage called with the `page` token for each page after the first (undefined for the first). * @param maxPages a backstop, not a tuning knob. Tripping it means "move this aggregation server-side". */ // UNCONSTRAINED, WIDENED FROM `FhirResource` BY - and the reason is worth keeping, because // "add `extends FhirResource` back, it's a FHIR helper" is the obvious wrong edit. // `PractitionerService.fetchPractitioners` returns `Bundle`, and that payload is a // WRAPPER - `{ practitioner, roles, profileCompleteness }` - with no `resourceType` on it at all. It is // not a spec resource and not even resource-SHAPED, yet it is the read the org's entire staff list is // built from. The alternative was a cast at that call site, which hides the hole in the file where it // is hardest to see. This function only ever CARRIES the value - it never reads a field on it - so the // constraint bought nothing and cost the one read that most needed paging. export async function fetchAllBundlePages( fetchPage: (pageParam?: string) => Promise>, maxPages = 50, ): Promise> { const resources: T[] = []; let pageParam: string | undefined; for (let page = 0; page < maxPages; page += 1) { const bundle = await fetchPage(pageParam); for (const entry of bundle.entry ?? []) { if (entry.resource) resources.push(entry.resource as T); } const nextUrl = bundle.link?.find((l) => l.relation === 'next')?.url; // The `next` URL points at the datastore, which the client cannot call - so the PAGE TOKEN is // extracted and re-issued through our own API, which is the convention the invoice list already // uses. Do not "simplify" this to fetching `nextUrl` directly. const match = nextUrl?.match(/page=([^&]*)/); pageParam = match?.[1]; // `page=` with nothing after it is NOT a cursor - it matches, capturing an empty string. The // falsy check below is what handles that, which is why this line does not also coalesce to // `undefined`: a mutation run showed the two spellings are indistinguishable, so the redundant one // was removed rather than left looking load-bearing. if (!pageParam) return { resources, truncated: false }; } return { resources, truncated: true }; }