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

+44 -60
View File
@@ -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
+21 -16
View File
@@ -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,
);
});
+40 -22
View File
@@ -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) => {
+5 -3
View File
@@ -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
@@ -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);
});
+16 -15
View File
@@ -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,
);
});
+5 -3
View File
@@ -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(
+5 -3
View File
@@ -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
+12 -7
View File
@@ -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,
);
});
/**
+12 -8
View File
@@ -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,
);
});
@@ -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,
});
});
});
@@ -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,
);
},
);
@@ -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
@@ -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 () => {
+9 -6
View File
@@ -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,
);
});
+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,
);
}
+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