diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4f64d47af..770d0cc8c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -37257,15 +37257,32 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false + ListCapMeta: + type: object + properties: + requestId: + type: string + truncated: + type: boolean + description: "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: + type: integer + minimum: 1 + maximum: 10000 + description: The most rows this response could carry. + required: + - truncated + - limit + additionalProperties: false + description: How a capped list was cut. Present on every response of an operation that declares it. CycleInsightsEnvelope: type: object properties: @@ -45178,14 +45195,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false SecurityActivityResponse: type: object @@ -49458,14 +49472,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false MedicationSideEffect: type: object @@ -64833,14 +64844,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false EcgListResponse: type: object @@ -65103,14 +65111,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false RhythmEventsResponse: type: object @@ -72819,14 +72824,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false IllnessEpisode: type: object @@ -73506,14 +73508,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false ListMentalHealthAssessmentsResponse: type: object @@ -73732,14 +73731,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false Allergy: type: object @@ -73890,14 +73886,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false FamilyHistoryEntry: type: object @@ -81283,14 +81276,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false RecordActivity: type: object @@ -81374,14 +81364,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false AccountGrantList: type: object @@ -83232,14 +83219,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false PersonalRecord: type: object diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts index 26ca3deb2..8e781d144 100644 --- a/src/app/api/account/activity/route.ts +++ b/src/app/api/account/activity/route.ts @@ -35,12 +35,12 @@ * names other delegates, and a grant is not an introduction to the household. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const dynamic = "force-dynamic"; @@ -99,7 +99,7 @@ export const GET = apiHandler(async () => { const retentionDays = getAuditLogRetentionDays(); const windowStart = new Date(Date.now() - retentionDays * 86_400_000); - const rows = await prisma.auditLog.findMany({ + const found = await prisma.auditLog.findMany({ where: { userId: user.id, actorUserId: { not: null }, @@ -113,7 +113,7 @@ export const GET = apiHandler(async () => { createdAt: { gte: windowStart }, }, orderBy: { createdAt: "desc" }, - take: MAX_ROWS, + take: overfetch(MAX_ROWS), select: { id: true, actorUserId: true, @@ -123,6 +123,8 @@ export const GET = apiHandler(async () => { }, }); + const { rows, cap } = capRows(found, MAX_ROWS); + const actorIds = [ ...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)), ]; @@ -140,23 +142,26 @@ export const GET = apiHandler(async () => { createdAt: row.createdAt.toISOString(), })); - // Equality, not `>=`: `take` cannot return more than it was given, and a - // `>` would be a comparison that can never be true. - const truncated = rows.length === MAX_ROWS; + // Exact: one row past the cap was asked for, so a list that is exactly + // full no longer reads as cut. + const truncated = cap.truncated; annotate({ action: { name: "sharing.activity.list" }, meta: { entry_count: entries.length, truncated }, }); - return apiSuccess({ - entries, - // The bound the view states out loud. Resolved here so the UI never has to - // hardcode a number the operator can change. - retentionDays, - // Whether the list is everything inside that window, or its most recent - // hundred rows. The view says which; silence would let the oldest line - // read as the beginning. - truncated, - }); + return apiListSuccess( + { + entries, + // The bound the view states out loud. Resolved here so the UI never has to + // hardcode a number the operator can change. + retentionDays, + // Whether the list is everything inside that window, or its most recent + // hundred rows. The view says which; silence would let the oldest line + // read as the beginning. + truncated, + }, + cap, + ); }); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 0cae8e85d..5d093073b 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; import { GrantError, inviteGrant } from "@/lib/sharing/grants"; import { inviteGrantSchema } from "@/lib/validations/account-sharing"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; /** * Ten invitations an hour, per account. @@ -87,45 +88,62 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "sharing.grants.list" } }); - const [given, received] = await Promise.all([ + const [givenFound, receivedFound] = await Promise.all([ prisma.accountGrant.findMany({ where: { grantorId: user.id }, orderBy: { createdAt: "desc" }, - take: MAX_GRANTS_PER_DIRECTION, + take: overfetch(MAX_GRANTS_PER_DIRECTION), include: { grantee: { select: GRANT_PARTY_SELECT } }, }), prisma.accountGrant.findMany({ where: { granteeId: user.id }, orderBy: { createdAt: "desc" }, - take: MAX_GRANTS_PER_DIRECTION, + take: overfetch(MAX_GRANTS_PER_DIRECTION), include: { grantor: { select: GRANT_PARTY_SELECT } }, }), ]); + const { rows: given, cap: givenCap } = capRows( + givenFound, + MAX_GRANTS_PER_DIRECTION, + ); + const { rows: received, cap: receivedCap } = capRows( + receivedFound, + MAX_GRANTS_PER_DIRECTION, + ); // Revoked and expired rows come back too. They are the consent record — "who // had access, from when to when, and who ended it" is the question the table // exists to answer, and a list that quietly dropped the ended rows would // leave the panel unable to answer it. `state` says which are live. const now = new Date(); - return apiSuccess({ - given: given.map((g) => toGrantView(g, g.grantee, now)), - received: received.map((g) => toGrantView(g, g.grantor, now)), - // v1.37.0 — the audit window, on the payload that carries the rows. - // - // The revoke dialog tells the owner how long "who entered what" stays - // answerable after the access ends, and it can only say that with the - // number the operator configured. It used to read that number off the - // activity feed, a second and independent request, so whether the sentence - // appeared depended on which of two reads finished first — and an owner - // who clicked quickly ended a manage grant having been told less than one - // who clicked slowly, with nothing on screen to say a sentence was - // missing. Riding it here removes the race by construction: a client - // holding a grant to revoke is holding the window it must disclose. - // - // Same resolver as `/api/account/activity` and as the purge job, so the - // two surfaces cannot state different numbers. - retentionDays: getAuditLogRetentionDays(), - }); + // One cap per direction, one signal: either list cut short is a partial + // answer. + const cap = { + truncated: givenCap.truncated || receivedCap.truncated, + limit: MAX_GRANTS_PER_DIRECTION, + }; + return apiListSuccess( + { + given: given.map((g) => toGrantView(g, g.grantee, now)), + received: received.map((g) => toGrantView(g, g.grantor, now)), + // v1.37.0 — the audit window, on the payload that carries the rows. + // + // The revoke dialog tells the owner how long "who entered what" stays + // answerable after the access ends, and it can only say that with the + // number the operator configured. It used to read that number off the + // activity feed, a second and independent request, so whether the sentence + // appeared depended on which of two reads finished first — and an owner + // who clicked quickly ended a manage grant having been told less than one + // who clicked slowly, with nothing on screen to say a sentence was + // missing. Riding it here removes the race by construction: a client + // holding a grant to revoke is holding the window it must disclose. + // + // Same resolver as `/api/account/activity` and as the purge job, so the + // two surfaces cannot state different numbers. + retentionDays: getAuditLogRetentionDays(), + }, + cap, + ); }); export const POST = apiHandler(async (request: NextRequest) => { diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 2f9d4d360..f184c3e3e 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -29,6 +29,7 @@ import { allergyListQuerySchema, } from "@/lib/validations/allergy"; import { toAllergyDTO } from "@/lib/records/dto"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -45,22 +46,23 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? 100; - const rows = await prisma.allergy.findMany({ + const found = await prisma.allergy.findMany({ where: { userId: user.id, deletedAt: null, ...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}), }, orderBy: { createdAt: "desc" }, - take: limit, + take: overfetch(limit), }); + const { rows, cap } = capRows(found, limit); annotate({ action: { name: "allergy.list", entity_type: "allergy" }, meta: { count: rows.length }, }); - return apiSuccess(rows.map(toAllergyDTO)); + return apiListSuccess(rows.map(toAllergyDTO), cap); }); // `withIdempotency` lets an iOS retry / double-tap re-send the same diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts index b7966da2e..b88411fe9 100644 --- a/src/app/api/auth/me/security-activity/route.ts +++ b/src/app/api/auth/me/security-activity/route.ts @@ -10,11 +10,11 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { securityActivityWhere } from "@/lib/auth/security-activity"; import { maskIp } from "@/lib/auth/device-fingerprint"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const dynamic = "force-dynamic"; @@ -40,10 +40,10 @@ export const GET = apiHandler(async (request: NextRequest) => { ? Math.min(Math.floor(limitParam), MAX_LIMIT) : DEFAULT_LIMIT; - const rows = await prisma.auditLog.findMany({ + const found = await prisma.auditLog.findMany({ where: securityActivityWhere(user.id), orderBy: { createdAt: "desc" }, - take: limit, + take: overfetch(limit), select: { action: true, createdAt: true, @@ -53,6 +53,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); + const { rows, cap } = capRows(found, limit); const events: SecurityActivityDTO[] = rows.map((r) => ({ action: r.action, createdAt: r.createdAt.toISOString(), @@ -66,5 +67,5 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { event_count: events.length }, }); - return apiSuccess({ events }); + return apiListSuccess({ events }, cap); }); diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 0e4a90692..73da843d4 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -12,17 +12,14 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiValidationError, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiValidationError, sanitiseZodIssues } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; import { median, mad } from "@/lib/cycle"; import { dayDiff } from "@/lib/cycle/day-math"; import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; const DEFAULT_LIMIT = 24; @@ -54,11 +51,12 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? DEFAULT_LIMIT; - const rows = await prisma.menstrualCycle.findMany({ + const found = await prisma.menstrualCycle.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { startDate: "desc" }, - take: limit, + take: overfetch(limit), }); + const { rows, cap } = capRows(found, limit); // Completed lengths (non-null lengthDays) drive the length stats. const lengths = rows @@ -98,13 +96,16 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { returned: rows.length, regularity }, }); - return apiSuccess({ - cycles: rows.map(toMenstrualCycleDTO), - stats: { - avgLengthDays, - lengthVariabilityDays, - avgPeriodLengthDays, - regularity, + return apiListSuccess( + { + cycles: rows.map(toMenstrualCycleDTO), + stats: { + avgLengthDays, + lengthVariabilityDays, + avgPeriodLengthDays, + regularity, + }, }, - }); + cap, + ); }); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index 661907833..cb1464501 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -28,6 +28,7 @@ import { familyHistoryListQuerySchema, } from "@/lib/validations/family-history"; import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -43,18 +44,19 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? 100; - const rows = await prisma.familyHistoryEntry.findMany({ + const found = await prisma.familyHistoryEntry.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { createdAt: "desc" }, - take: limit, + take: overfetch(limit), }); + const { rows, cap } = capRows(found, limit); annotate({ action: { name: "family-history.list", entity_type: "family_history" }, meta: { count: rows.length }, }); - return apiSuccess(rows.map(toFamilyHistoryEntryDTO)); + return apiListSuccess(rows.map(toFamilyHistoryEntryDTO), cap); }); export const POST = apiHandler( diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 255e0f8c0..57b997184 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -32,6 +32,7 @@ import { illnessEpisodeListQuerySchema, } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "illness"); @@ -58,7 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const { from, to, q, offset, sortDir } = parsed.data; const limit = parsed.data.limit ?? 50; const order = sortDir ?? "desc"; - const rows = await prisma.illnessEpisode.findMany({ + const found = await prisma.illnessEpisode.findMany({ where: { userId: user.id, deletedAt: null, @@ -75,16 +76,17 @@ export const GET = apiHandler(async (request: NextRequest) => { }, // `id` breaks ties so offset paging never repeats or drops a row. orderBy: [{ onsetAt: order }, { id: order }], - take: limit, + take: overfetch(limit), skip: offset ?? 0, }); + const { rows, cap } = capRows(found, limit); annotate({ action: { name: "illness.episode.list", entity_type: "illness_episode" }, meta: { count: rows.length }, }); - return apiSuccess(rows.map(toIllnessEpisodeDTO)); + return apiListSuccess(rows.map(toIllnessEpisodeDTO), cap); }); // `withIdempotency` lets an iOS retry / double-tap re-send the same diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 95d9d306e..b7001d9d2 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -47,6 +47,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { persistEcgRecording } from "@/lib/ecg/persist-recording"; import { isoDateTime } from "@/lib/validations/datetime"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const dynamic = "force-dynamic"; @@ -133,7 +134,7 @@ export const GET = apiHandler(async () => { // sync. const { user } = await requireRecordAuth("manage", "record"); - const rows = await prisma.ecgRecording.findMany({ + const found = await prisma.ecgRecording.findMany({ where: { userId: user.id }, // Everything EXCEPT `waveformEncrypted` — the list never touches the // encrypted blob, so no decrypt happens on this path. @@ -149,8 +150,9 @@ export const GET = apiHandler(async () => { source: true, }, orderBy: { recordedAt: "desc" }, - take: MAX_RECORDINGS, + take: overfetch(MAX_RECORDINGS), }); + const { rows, cap } = capRows(found, MAX_RECORDINGS); const recordings = rows.map((r) => ({ id: r.id, @@ -168,13 +170,16 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "insights.ecg.list" }, - meta: { count: recordings.length }, + meta: { count: recordings.length, truncated: cap.truncated }, }); - return apiSuccess({ - recordings, - hasRecordings: recordings.length > 0, - }); + return apiListSuccess( + { + recordings, + hasRecordings: recordings.length > 0, + }, + cap, + ); }); /** diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index d23a09bb8..6cddeb6ac 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -24,12 +24,12 @@ * (never a query field). No AI gate and no module gate: this is a pure DB * read of device data. */ -import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; import type { MeasurementType } from "@/generated/prisma/client"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const dynamic = "force-dynamic"; @@ -49,7 +49,7 @@ export const GET = apiHandler(async () => { // The `insights` module is the AI analysis opt-out, and nothing here was // written by a model. - const rows = await prisma.measurement.findMany({ + const found = await prisma.measurement.findMany({ where: { userId: user.id, type: { in: EVENT_TYPE_LIST }, @@ -64,8 +64,9 @@ export const GET = apiHandler(async () => { deviceType: true, }, orderBy: { measuredAt: "desc" }, - take: MAX_EVENTS, + take: overfetch(MAX_EVENTS), }); + const { rows, cap } = capRows(found, MAX_EVENTS); const events = rows.map((r) => ({ id: r.id, @@ -78,11 +79,14 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "insights.rhythm-events" }, - meta: { count: events.length }, + meta: { count: events.length, truncated: cap.truncated }, }); - return apiSuccess({ - events, - hasEvents: events.length > 0, - }); + return apiListSuccess( + { + events, + hasEvents: events.length > 0, + }, + cap, + ); }); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index ba27f803e..a82b51f01 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -159,7 +159,8 @@ describe("GET /api/medications/[id]/side-effects", () => { medicationId: "med-1", occurredAt: { gte: new Date(from), lt: new Date(to) }, }, - take: 10, + // One row past the limit, so the response can say it was cut. + take: 11, }); }); }); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 440184ebc..e3a986e89 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -39,6 +39,7 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; import { withIdempotency } from "@/lib/idempotency"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; type RouteParams = { params: Promise<{ id: string }> }; @@ -66,7 +67,7 @@ export const GET = apiHandler( const { from, to, limit } = parsed.data; - const items = await prisma.medicationSideEffect.findMany({ + const found = await prisma.medicationSideEffect.findMany({ where: { userId: user.id, medicationId: id, @@ -80,8 +81,9 @@ export const GET = apiHandler( : {}), }, orderBy: { occurredAt: "desc" }, - take: limit, + take: overfetch(limit), }); + const { rows: items, cap } = capRows(found, limit); annotate({ action: { name: "medication.sideEffect.list" }, @@ -90,10 +92,13 @@ export const GET = apiHandler( // Decrypt each note and strip the raw `notesEncrypted` ciphertext so it // never leaves the server. - return apiSuccess({ - items: items.map(shapeSideEffectNotes), - meta: { total: items.length }, - }); + return apiListSuccess( + { + items: items.map(shapeSideEffectNotes), + meta: { total: items.length }, + }, + cap, + ); }, ); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 5fa11e524..a9a503d6c 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -57,6 +57,7 @@ import type { MeasurementType, MeasurementSource, } from "@/generated/prisma/client"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; function shapeRow(row: { id: string; @@ -118,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } const { instrument, limit, offset } = parsed.data; - const rows = await prisma.mentalHealthAssessment.findMany({ + const pageSize = limit ?? 100; + const found = await prisma.mentalHealthAssessment.findMany({ where: { userId: user.id, deletedAt: null, @@ -129,7 +131,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // Unique tiebreaker — `takenAt` is day-resolution and two instruments // taken in one sitting tie. orderBy: [{ takenAt: "desc" }, { id: "desc" }], - take: limit ?? 100, + take: overfetch(pageSize), skip: offset ?? 0, select: { id: true, @@ -144,11 +146,12 @@ export const GET = apiHandler(async (request: NextRequest) => { createdAt: true, }, }); + const { rows, cap } = capRows(found, pageSize); annotate({ action: { name: "mental-health.list" }, meta: { count: rows.length }, }); - return apiSuccess({ assessments: rows.map(shapeRow) }); + return apiListSuccess({ assessments: rows.map(shapeRow) }, cap); }); // `withIdempotency` lets a double-tap / retry re-send the same diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index df54373ba..a4498ca4a 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -67,7 +67,8 @@ describe("GET /api/personal-records", () => { expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, orderBy: { achievedAt: "desc" }, - take: 100, + // One row past the limit, so the response can say it was cut. + take: 101, }); }); @@ -80,7 +81,8 @@ describe("GET /api/personal-records", () => { expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1", metricType: "VO2_MAX" }, orderBy: { achievedAt: "desc" }, - take: 100, + // One row past the limit, so the response can say it was cut. + take: 101, }); }); @@ -113,7 +115,8 @@ describe("GET /api/personal-records", () => { expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, orderBy: { achievedAt: "desc" }, - take: 25, + // One row past the limit, so the response can say it was cut. + take: 26, }); }); @@ -126,7 +129,8 @@ describe("GET /api/personal-records", () => { expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, orderBy: { achievedAt: "desc" }, - take: 500, + // One row past the limit, so the response can say it was cut. + take: 501, }); }); @@ -189,6 +193,53 @@ describe("GET /api/personal-records", () => { expect(body.data[0].metricType).toBe("VO2_MAX"); }); + it("says when the list was cut at its limit, and not when it is exactly full", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const row = (id: string) => ({ + id, + userId: "user-1", + metricType: "VO2_MAX", + metricSlot: null, + direction: "MAX", + value: 40, + unit: "mL/(kg·min)", + achievedAt: new Date("2026-05-01T07:00:00.000Z"), + sourceMeasurementId: null, + source: "APPLE_HEALTH", + externalId: null, + createdAt: new Date(), + }); + + // Three rows found for ?limit=2: one past the limit exists. + vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([ + row("a"), + row("b"), + row("c"), + ] as never); + const cut = await GET(req("http://localhost/api/personal-records?limit=2")); + const cutBody = (await cut.json()) as { + data: unknown[]; + meta: { truncated: boolean; limit: number }; + }; + expect(cutBody.data).toHaveLength(2); + expect(cutBody.meta).toMatchObject({ truncated: true, limit: 2 }); + + // Exactly two: complete, not cut. + vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([ + row("a"), + row("b"), + ] as never); + const full = await GET( + req("http://localhost/api/personal-records?limit=2"), + ); + const fullBody = (await full.json()) as { + data: unknown[]; + meta: { truncated: boolean }; + }; + expect(fullBody.data).toHaveLength(2); + expect(fullBody.meta.truncated).toBe(false); + }); + // v1.43 (ios #116 item 5) — every stored field stays, and the band for the // age the record was set at rides beside it. it("projects every stored field and adds the benchmark", async () => { diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index ab3ba4623..cc05b352b 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -19,8 +19,8 @@ * returned more rows than the caller asked for and nothing said so. The 500 * ceiling refuses rather than clamps, for the same reason. * - * Response envelope (matches the project-wide `apiSuccess` contract): - * { data: PersonalRecord[], error: null } + * Response envelope (the capped-list contract, `src/lib/api/list-cap.ts`): + * { data: PersonalRecord[], error: null, meta: { truncated, limit } } * * v1.43 (ios #116 item 5) — the route projects each row instead of passing the * Prisma result through. Every stored field stays on the wire, and each row @@ -31,13 +31,14 @@ import { prisma } from "@/lib/db"; import type { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { returnAllZodIssues } from "@/lib/api-response"; import { listPersonalRecordsSchema } from "@/lib/validations/measurement"; import { mayCarryBenchmark, resolveRecordBenchmark, } from "@/lib/personal-records/record-benchmark"; import { toProfileSex } from "@/lib/profile/sex"; +import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; export const dynamic = "force-dynamic"; @@ -62,14 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { metricType, limit } = parsed.data; - const records = await prisma.personalRecord.findMany({ + const found = await prisma.personalRecord.findMany({ where: { userId: user.id, ...(metricType ? { metricType } : {}), }, orderBy: { achievedAt: "desc" }, - take: limit, + take: overfetch(limit), }); + const { rows: records, cap } = capRows(found, limit); const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType)); const profile = wantsBand @@ -94,7 +96,7 @@ export const GET = apiHandler(async (request: NextRequest) => { weightKg: weight?.value ?? null, }; - return apiSuccess( + return apiListSuccess( records.map((r) => ({ id: r.id, userId: r.userId, @@ -110,5 +112,6 @@ export const GET = apiHandler(async (request: NextRequest) => { createdAt: r.createdAt, benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null, })), + cap, ); }); diff --git a/src/lib/api/__tests__/list-cap.test.ts b/src/lib/api/__tests__/list-cap.test.ts new file mode 100644 index 000000000..6ed1de6be --- /dev/null +++ b/src/lib/api/__tests__/list-cap.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/lib/api/list-cap.ts b/src/lib/api/list-cap.ts new file mode 100644 index 000000000..e2bc9ef4f --- /dev/null +++ b/src/lib/api/list-cap.ts @@ -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( + 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(data: T, cap: ListCap, status = 200) { + return apiSuccessWithMeta( + data, + { truncated: cap.truncated, limit: cap.limit }, + status, + ); +} diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 3e175c73f..a943febe5 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -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 = { 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 = { description: "Grants given and received.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( grantListResponse, "AccountGrantListEnvelope", ), diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 63a1f7a40..8df71706c 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -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 = { description: "The caller's allergy records.", content: { "application/json": { - schema: dataEnvelope(z.array(allergy), "ListAllergiesEnvelope"), + schema: listEnvelope(z.array(allergy), "ListAllergiesEnvelope"), }, }, }, diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 6d88bb5b0..e99193678 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { description: "Recent security events for the caller.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( securityActivityResponse, "SecurityActivityEnvelope", ), diff --git a/src/lib/openapi/routes/awards.ts b/src/lib/openapi/routes/awards.ts index 96a6031da..7b7698db9 100644 --- a/src/lib/openapi/routes/awards.ts +++ b/src/lib/openapi/routes/awards.ts @@ -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 = { description: "The record's personal bests, newest first.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( z.array(personalRecord), "ListPersonalRecordsEnvelope", ), diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index c659d49b5..1fdbdb077 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -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 = { description: "Cycle history.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( cycleHistoryResponse, "CycleHistoryEnvelope", ), diff --git a/src/lib/openapi/routes/family-history.ts b/src/lib/openapi/routes/family-history.ts index 83ab75a4d..3941f8b05 100644 --- a/src/lib/openapi/routes/family-history.ts +++ b/src/lib/openapi/routes/family-history.ts @@ -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 = { description: "The caller's family-history entries.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( z.array(familyHistoryEntry), "ListFamilyHistoryEnvelope", ), diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index f9868b872..269cb1f30 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -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 = { description: "The caller's illness episodes.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( z.array(illnessEpisode), "ListIllnessEpisodesEnvelope", ), diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 769d78891..82d0c7e3c 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -12,6 +12,7 @@ import { idempotentWrite, recordRefusal, stdResponses, + listEnvelope, } from "../shared"; import { aiCheckFailedResponse, @@ -892,7 +893,7 @@ export const insightsPaths: NonNullable = { 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 = { description: "The device-flagged event timeline (possibly empty).", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( rhythmEventsResponse, "RhythmEventsResponseEnvelope", ), diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 9edc5ea07..c0c968ed6 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -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 = { "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() }), diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 6687f11c6..b7b81e7d8 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -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 = { description: "Screener history.", content: { "application/json": { - schema: dataEnvelope( + schema: listEnvelope( listResponse, "ListMentalHealthAssessmentsEnvelope", ), diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 31e5c434e..4c20a843b 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -131,6 +131,42 @@ export function dataEnvelope(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(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