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

+8 -3
View File
@@ -44,7 +44,12 @@ import {
} from "@/lib/validations/managed-profile-handover";
import { onboardingStateResource } from "./onboarding";
import { aiAccountBlock, moduleAccessMap } from "./profile";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
/**
@@ -877,7 +882,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The activity, and how far back it can see.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
recordActivityResponse,
"RecordActivityEnvelope",
),
@@ -900,7 +905,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Grants given and received.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
grantListResponse,
"AccountGrantListEnvelope",
),
+2 -1
View File
@@ -32,6 +32,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
// `.meta()` CLONES in Zod 4 rather than annotating in place, so the returned
@@ -97,7 +98,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's allergy records.",
content: {
"application/json": {
schema: dataEnvelope(z.array(allergy), "ListAllergiesEnvelope"),
schema: listEnvelope(z.array(allergy), "ListAllergiesEnvelope"),
},
},
},
+2 -1
View File
@@ -48,6 +48,7 @@ import {
errorEnvelope,
loginPasswordSchema,
validationIssue,
listEnvelope,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
import {
@@ -1724,7 +1725,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Recent security events for the caller.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
securityActivityResponse,
"SecurityActivityEnvelope",
),
+2 -1
View File
@@ -21,6 +21,7 @@ import {
measurementTypeEnum,
recordRefusal,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -190,7 +191,7 @@ export const awardsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The record's personal bests, newest first.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(personalRecord),
"ListPersonalRecordsEnvelope",
),
+2 -1
View File
@@ -38,6 +38,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -954,7 +955,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Cycle history.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
cycleHistoryResponse,
"CycleHistoryEnvelope",
),
+2 -1
View File
@@ -28,6 +28,7 @@ import {
idempotentWrite,
recordRefusal,
stdResponses,
listEnvelope,
} from "./shared";
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
@@ -88,7 +89,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's family-history entries.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(familyHistoryEntry),
"ListFamilyHistoryEnvelope",
),
+2 -1
View File
@@ -36,6 +36,7 @@ import {
idempotentWrite,
recordRefusal,
stdResponses,
listEnvelope,
} from "./shared";
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
@@ -298,7 +299,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's illness episodes.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(illnessEpisode),
"ListIllnessEpisodesEnvelope",
),
+3 -2
View File
@@ -12,6 +12,7 @@ import {
idempotentWrite,
recordRefusal,
stdResponses,
listEnvelope,
} from "../shared";
import {
aiCheckFailedResponse,
@@ -892,7 +893,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The ECG recording list (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(ecgListResponse, "EcgListResponseEnvelope"),
schema: listEnvelope(ecgListResponse, "EcgListResponseEnvelope"),
},
},
},
@@ -985,7 +986,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The device-flagged event timeline (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
rhythmEventsResponse,
"RhythmEventsResponseEnvelope",
),
+2 -1
View File
@@ -43,6 +43,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "../shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -596,7 +597,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"The entries, plus a `meta.total` that counts THIS PAGE rather than the whole log — it is the returned array's length, so it can never exceed `limit`.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.object({
items: z.array(medicationSideEffect),
meta: z.object({ total: z.number().int().nonnegative() }),
+2 -1
View File
@@ -25,6 +25,7 @@ import {
idempotentWrite,
recordRefusal,
stdResponses,
listEnvelope,
} from "./shared";
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
@@ -121,7 +122,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Screener history.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
listResponse,
"ListMentalHealthAssessmentsEnvelope",
),
+36
View File
@@ -131,6 +131,42 @@ export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
.meta({ id });
}
/**
* The success envelope of a list the server cuts at a ceiling (see
* `src/lib/api/list-cap.ts`). `data` is the route's own payload, unchanged;
* `meta.truncated` says whether rows past `meta.limit` exist.
*/
export const listCapMeta = z
.object({
requestId: z.string().optional(),
truncated: z
.boolean()
.describe(
"True when more rows exist than this response carries: the list was cut at `limit`, newest or most relevant first as the operation states. False means the list is complete. Show the person that the list is partial rather than letting the last row read as the oldest there is.",
),
limit: z
.number()
.int()
.min(1)
.max(10_000)
.describe("The most rows this response could carry."),
})
.meta({
id: "ListCapMeta",
description:
"How a capped list was cut. Present on every response of an operation that declares it.",
});
export function listEnvelope<T extends z.ZodType>(payload: T, id: string) {
return z
.object({
data: payload,
error: z.null(),
meta: listCapMeta,
})
.meta({ id });
}
// ── Schemas — annotated for spec emission ────────────────────────────
//
// `.meta()` CLONES in Zod 4 rather than annotating in place, so the returned