feat(api): say when a list was cut at its ceiling

A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:53:17 +02:00
1 parent 415da581f8
commit 97d1052dd1
28 files changed
+396 -174

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { apiListSuccess, capRows, overfetch } from "../list-cap";
describe("list cap", () => {
it("asks for one row past the limit", () => {
expect(overfetch(100)).toBe(101);
});
it("cuts an over-fetched result back and says it was cut", () => {
expect(capRows([1, 2, 3], 2)).toEqual({
rows: [1, 2],
cap: { truncated: true, limit: 2 },
});
});
it("does not call a list that is exactly full cut", () => {
expect(capRows([1, 2], 2).cap.truncated).toBe(false);
expect(capRows([], 2).cap.truncated).toBe(false);
});
it("keeps data as it is and puts the cap in meta", async () => {
const res = apiListSuccess({ events: [1] }, { truncated: true, limit: 1 });
expect(await res.json()).toEqual({
data: { events: [1] },
error: null,
meta: { truncated: true, limit: 1 },
});
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* One signal for a list the server cut at a ceiling.
*
* A list endpoint that stops at its own `take` and says nothing is the
* quieter of the two completeness lies: the client renders the last row it
* got as the oldest one there is. Several routes had grown their own answer
* (a `truncated` field here, `rows.length === MAX` there, which reports a
* cut on a list that is exactly full) and most had none.
*
* The pattern, in three steps:
*
* 1. query one row past the limit: `take: overfetch(limit)`;
* 2. cut it back: `const { rows, cap } = capRows(found, limit)`;
* 3. answer with `apiListSuccess(data, cap)`.
*
* The signal rides the envelope's `meta` (`meta.truncated`, `meta.limit`), so
* `data` keeps exactly the shape the route always had and a client that never
* reads `meta` decodes it unchanged. `truncated` is exact: true only when a
* row past the limit exists. The OpenAPI side is `listEnvelope` in
* `src/lib/openapi/routes/shared.ts`.
*/
import { apiSuccessWithMeta } from "@/lib/api-response";
export interface ListCap {
/** A row past `limit` exists and was not returned. */
truncated: boolean;
/** The most rows this response could carry. */
limit: number;
}
/** The `take` that lets {@link capRows} tell a full list from a cut one. */
export function overfetch(limit: number): number {
return limit + 1;
}
/** Cut an over-fetched result back to `limit` and say whether it was cut. */
export function capRows<T>(
found: readonly T[],
limit: number,
): { rows: T[]; cap: ListCap } {
const truncated = found.length > limit;
return {
rows: truncated ? found.slice(0, limit) : [...found],
cap: { truncated, limit },
};
}
/** The success envelope for a capped list: `data` as-is, the cap in `meta`. */
export function apiListSuccess<T>(data: T, cap: ListCap, status = 200) {
return apiSuccessWithMeta(
data,
{ truncated: cap.truncated, limit: cap.limit },
status,
);
}