feat(lists): read and show the list cap on every capped list

Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:04:11 +02:00
1 parent be79603d53
commit dc5120b59a
49 files changed
+574 -204

No files matched your search

+30
View File
@@ -56,6 +56,7 @@ import {
validateResponseContext,
withRecordFenceHeaders,
} from "./record-fence";
import { readListCap, type ListCap } from "@/lib/api/list-cap-shape";
/** HTTP-level failure from an `/api/...` route — non-OK status. */
export class ApiError extends Error {
@@ -214,6 +215,35 @@ function withJsonBody(
return merged;
}
/**
* GET a capped list — resolves with `data` and the envelope's list cap
* (`meta.truncated` / `meta.limit`), null when the route sent none. The one
* client reader of the cap; pair it with `ListCapNotice`.
*/
export async function apiGetList<T = unknown>(
path: string,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(path, {
...init,
method: "GET",
});
return { data, cap: readListCap(meta) };
}
/** POST variant of {@link apiGetList}, for a list read with a body. */
export async function apiPostList<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<{ data: T; cap: ListCap | null }> {
const { data, meta } = await apiFetchEnvelope<T>(
path,
withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
);
return { data, cap: readListCap(meta) };
}
/** GET — resolves with the unwrapped `data` payload. */
export function apiGet<T = unknown>(
path: string,
+22
View File
@@ -0,0 +1,22 @@
/**
* The wire shape of a list cap, safe for the client bundle.
*
* A capped list answers with `meta: { truncated, limit }` on the envelope
* (`apiListSuccess` in `./list-cap.ts`). That is the one place the signal
* rides; `readListCap` is the one place a client reads it.
*/
export interface ListCap {
/** A row past `limit` exists and was not returned. */
truncated: boolean;
/** The most rows this response could carry. */
limit: number;
}
/** The cap an envelope's `meta` carries, or null when it carries none. */
export function readListCap(meta: unknown): ListCap | null {
if (!meta || typeof meta !== "object") return null;
const { truncated, limit } = meta as Record<string, unknown>;
if (typeof truncated !== "boolean") return null;
if (typeof limit !== "number" || !Number.isFinite(limit)) return null;
return { truncated, limit };
}
+7 -7
View File
@@ -17,16 +17,16 @@
* `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`.
* `src/lib/openapi/routes/shared.ts`. A client reads it through `apiGetList`
* / `apiPostList` (`src/lib/api/api-fetch.ts`) and shows it with
* `ListCapNotice`; `src/__tests__/list-cap-reader-guard.test.ts` binds every
* capped route to such a reader.
*/
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;
}
import type { ListCap } from "./list-cap-shape";
export type { ListCap } from "./list-cap-shape";
/** The `take` that lets {@link capRows} tell a full list from a cut one. */
export function overfetch(limit: number): number {