Merge Retry-After on every refused request, stable list pagination and the corrected error contract

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:10:05 +02:00
commit 6a2282fef8
80 files changed
+2261 -220

No files matched your search

+2 -2
View File
@@ -112,7 +112,7 @@ const cycleSymptomDto = z.object({
severity: z.number().int().min(1).max(4).nullable(),
});
const cycleDayLogDto = z
export const cycleDayLogDto = z
.object({
id: z.string(),
date: z.string(),
@@ -145,7 +145,7 @@ const cycleDayLogDto = z
"The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.",
});
const menstrualCycleDto = z
export const menstrualCycleDto = z
.object({
id: z.string(),
startDate: z.string(),
+1 -1
View File
@@ -793,7 +793,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Documents"],
summary: "Suggest filing metadata (drafts only)",
description:
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries the reset instant in `X-RateLimit-Reset` and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
],
+1 -1
View File
@@ -92,7 +92,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"429": {
description:
"More than 60 requests in a minute for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers.",
"More than 60 requests in a minute for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard rate-limit headers: `Retry-After` (whole seconds, at least 1), `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
+85 -5
View File
@@ -21,20 +21,76 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/
* `{ data, error, meta? }`. The OpenAPI surface mirrors that contract
* so iOS / external-ingest clients can decode uniformly.
*/
/**
* One rejected field from a multi-issue 422.
*
* `returnAllZodIssues` has emitted this since v1.4.42 so a client can fix
* three bad fields in one round-trip instead of three, and 166 route files
* send it — but no component schema declared it, so the very consumer it was
* built for could not read it from the contract. `params` never ships: some
* Zod codes embed the offending value in it and that is user content.
*/
const validationIssue = z
.object({
path: z
.string()
.describe(
"Dot-joined path to the rejected field, e.g. `entries.3.measuredAt`. Empty string when the whole body was rejected.",
),
code: z
.string()
.describe(
"Zod issue code, e.g. `invalid_type`, `too_small`, `unrecognized_keys`. Treat an unfamiliar code as a generic rejection of `path`.",
),
message: z
.string()
.describe(
"English sentence describing the rejection. Safe to log; show the user your own wording keyed on `path`.",
),
keys: z
.array(z.string())
.optional()
.describe(
"Only on `unrecognized_keys`: the refused key names, bounded to 10 and 64 characters each. Zod reports every unknown key of one object in a single issue whose `path` is the object, so the names live here and nowhere in `path`.",
),
})
.meta({
id: "ValidationIssue",
description:
"One rejected field in a multi-issue 422. Sanitised: no rejected VALUE is echoed back.",
});
export const errorEnvelope = z
.object({
data: z.null(),
error: z.string(),
details: z
.object({ issues: z.array(validationIssue) })
.optional()
.describe(
"Present on a 422 raised by the multi-issue validator: every field the request got wrong, not only the first. Absent on every other status.",
),
// Loose on purpose. Real refusals put more than the two named keys here —
// `removedIn` / `replacedBy` on a 410, `module` beside `module.disabled`,
// the per-integration context on a failed connection test — and a closed
// object made the body invalid against its own published schema, which any
// strict generated decoder is entitled to reject.
meta: z
.object({
.looseObject({
requestId: z.string().optional(),
errorCode: z.string().optional(),
errorCode: z
.string()
.optional()
.describe(
"Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.",
),
})
.optional(),
})
.meta({
id: "ErrorEnvelope",
description: "Standard error response: data is null, error is human prose.",
description:
"Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip.",
});
export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
@@ -173,7 +229,8 @@ export const invalidBaseTokenResponse = {
export const stdResponses = {
"401": {
description: "Authentication required or invalid credentials.",
description:
"Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the route refused the credential it was handed, not that the response is malformed.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
@@ -181,8 +238,31 @@ export const stdResponses = {
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description: "Rate limit exceeded.",
description:
"Rate limit exceeded. The response carries `Retry-After` (whole seconds, rounded up and never below 1 — wait at least that long before retrying), `X-RateLimit-Limit` (the bucket's cap), `X-RateLimit-Remaining` and `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream provider's own refusal relayed onward — carries none of them, because there is no bucket to describe.",
content: { "application/json": { schema: errorEnvelope } },
// Declared rather than only described: a generated client gets a typed
// accessor for the field the description tells it to back off on.
headers: {
"Retry-After": {
description:
"Whole seconds to wait before retrying. Rounded up, never below 1.",
schema: { type: "integer" as const, minimum: 1 },
},
"X-RateLimit-Limit": {
description: "Requests the bucket allows per window.",
schema: { type: "integer" as const, minimum: 1 },
},
"X-RateLimit-Remaining": {
description: "Requests left in the current window; 0 on a refusal.",
schema: { type: "integer" as const, minimum: 0 },
},
"X-RateLimit-Reset": {
description:
"Instant the current window rolls over, ISO-8601 — not epoch seconds.",
schema: { type: "string" as const, format: "date-time" },
},
},
},
};
+59 -4
View File
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementResource } from "./measurements";
import { moodContextWire } from "./mood";
import { cycleDayLogDto, menstrualCycleDto } from "./cycle";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
@@ -185,6 +186,36 @@ const syncIntakeTombstone = z
"A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncCycleDayTombstone = z
.object({
id: z.string(),
externalId: z
.string()
.nullable()
.describe("The client's own key for the day log, when it set one."),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncCycleDayTombstone",
description:
"A soft-deleted cycle day log, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncCycleTombstone = z
.object({
id: z.string(),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "SyncCycleTombstone",
description:
"A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page.",
});
const syncChangesQuery = z
.object({
cursor: z
@@ -205,7 +236,15 @@ const syncChangesQuery = z
})
.meta({ id: "SyncChangesQuery" });
const syncChangesResponse = z
/**
* Exported so a test can `safeParse` a real `/api/sync/changes` body against
* it. Nothing else checks a response SHAPE against its published schema —
* `openapi:check` proves the YAML matches the registry, and the route-coverage
* guard proves the path is listed, so a schema that disagrees with what
* `apiSuccess()` sends passes both. That is how this endpoint went five
* domains wide against a three-domain contract.
*/
export const syncChangesResponse = z
.object({
serverNow: z.iso.datetime({ offset: true }),
cursor: z
@@ -233,12 +272,28 @@ const syncChangesResponse = z
upserts: z.array(syncIntakeUpsert),
tombstones: z.array(syncIntakeTombstone),
}),
cycleDays: z
.object({
upserts: z.array(cycleDayLogDto),
tombstones: z.array(syncCycleDayTombstone),
})
.describe(
"Cycle day logs. Present on every page, empty arrays when the account has the cycle module off.",
),
cycles: z
.object({
upserts: z.array(menstrualCycleDto),
tombstones: z.array(syncCycleTombstone),
})
.describe(
"Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.",
),
}),
})
.meta({
id: "SyncChangesResponse",
description:
"Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep.",
"Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
});
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -264,9 +319,9 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/sync/changes": {
get: {
tags: ["Sync"],
summary: "Measurements delta feed",
summary: "Multi-domain delta feed",
description:
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init.",
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
requestParams: {
query: syncChangesQuery,
},