From 4fbbdb12c20e2a1ed88cd5ed332ac6b04df659b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 00:27:02 +0200 Subject: [PATCH] fix(fhir): page _offset as far as a series reaches The 100000 ceiling refused offsets a correctly paging client reaches on its own: an Observation series with a heart-rate history runs well past a hundred thousand rows, and the next link the server handed out led straight into a 400. The 500 the ceiling was added for came from an offset such as 1e20 that no longer floors to an exact integer and that the database cannot read as an OFFSET. The refusal now sits at the largest exact integer, and a next link never carries an offset the server would refuse. --- docs/api/openapi.yaml | 24 ++++++------- src/app/api/fhir/__tests__/route.test.ts | 43 ++++++++++++++++++++---- src/lib/fhir/rest.ts | 19 ++++++----- src/lib/openapi/routes/health-record.ts | 12 +++---- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0adfaa593..62c9b564c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,9 +1274,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1312,9 +1312,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1349,9 +1349,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1378,9 +1378,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1408,9 +1408,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1605,9 +1605,9 @@ paths: - in: query name: _offset schema: - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number - description: At most 100000; a larger offset is a 400 OperationOutcome. + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index eaf538a41..5d2fd4499 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -39,7 +39,12 @@ vi.mock("next/headers", () => ({ import { GET as observationGet } from "../Observation/route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; -import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest"; +import { + loadFhirContext, + MAX_COUNT, + MAX_OFFSET, + searchsetResponse, +} from "@/lib/fhir/rest"; import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; import { observationsFromReportData } from "@/lib/fhir/resources"; @@ -147,9 +152,9 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); - it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + it("refuses only an offset a number cannot carry exactly", async () => { vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); - for (const offset of ["100001", "1e20", "Infinity"]) { + for (const offset of ["9007199254740992", "1e20", "Infinity"]) { const res = await observationGet(req(`?_offset=${offset}`)); expect(res.status).toBe(400); const outcome = (await res.json()) as { @@ -159,9 +164,35 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(outcome.resourceType).toBe("OperationOutcome"); expect(outcome.issue[0].code).toBe("invalid"); } - // The ceiling itself still pages (an empty page past the end). - const atCeiling = await observationGet(req("?_offset=100000")); - expect(atCeiling.status).toBe(200); + // A long series pages past a hundred thousand rows, and the ceiling + // itself still pages (an empty page past the end). + for (const offset of ["100001", "250000", String(MAX_OFFSET)]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(200); + } + }); + + it("keeps emitting next links deep into a long series, never past the ceiling", () => { + const url = new URL("http://localhost/api/fhir/Observation"); + const deep = searchsetResponse(url, [], 300_000, 50, 150_000); + const nextOf = async (res: Response) => + ( + (await res.json()) as { link: Array<{ relation: string; url: string }> } + ).link.find((l) => l.relation === "next"); + return Promise.all([ + nextOf(deep).then((next) => { + expect(next?.url).toContain("_offset=150050"); + }), + nextOf( + searchsetResponse( + url, + [], + Number.POSITIVE_INFINITY, + 50, + MAX_OFFSET - 10, + ), + ).then((next) => expect(next).toBeUndefined()), + ]); }); it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index ebd1c54e3..053ffd736 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -92,19 +92,21 @@ export const DEFAULT_COUNT = 50; export const MAX_COUNT = 200; /** - * Ceiling on `_offset`. No record pages that far (the largest search, the - * Observation time series, is bounded by the readings in a date range), and - * an unbounded offset reached the database as a skip it cannot represent and - * answered 500. + * Ceiling on `_offset`: the largest integer a JavaScript number holds + * exactly. It is no paging limit (the Observation time series of a heart-rate + * history pages well past a hundred thousand rows, and every page's `next` + * link must keep working); it refuses only an offset that cannot be passed + * on faithfully. Above it `Math.floor` returns a float such as `1e20`, which + * the database cannot read as a `bigint` `OFFSET` and which answered 500. */ -export const MAX_OFFSET = 100_000; +export const MAX_OFFSET = Number.MAX_SAFE_INTEGER; /** * Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`; * `offset` floors at 0. A non-numeric / negative input collapses to the * default rather than erroring — FHIR search is forgiving on paging params. - * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers - * it with a 400 `invalid` OperationOutcome. + * An offset above {@link MAX_OFFSET} (including `Infinity`) is the one + * refusal: the caller answers it with a 400 `invalid` OperationOutcome. */ export function parsePaging( searchParams: URLSearchParams, @@ -127,6 +129,7 @@ export function parsePaging( * Build the absolute self link, and a next link when more rows remain. The * URL is rebuilt from the request URL with the paging params normalised, so * the echoed link always carries the clamped `_count` and concrete `_offset`. + * A next link never carries an offset {@link parsePaging} would refuse. */ function pagingLinks( requestUrl: URL, @@ -140,7 +143,7 @@ function pagingLinks( const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }]; const nextOffset = offset + count; - if (nextOffset < total) { + if (nextOffset < total && nextOffset <= MAX_OFFSET) { const next = new URL(requestUrl.toString()); next.searchParams.set("_count", String(count)); next.searchParams.set("_offset", String(nextOffset)); diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 20e0e4042..303055b89 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -380,7 +380,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -410,7 +410,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), date: z.string().optional(), code: z.string().optional(), @@ -442,7 +442,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -472,7 +472,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -502,7 +502,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), }, @@ -682,7 +682,7 @@ export const healthRecordPaths: NonNullable = { .number() .optional() .describe( - "At most 100000; a larger offset is a 400 OperationOutcome.", + "Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.", ), }), },