mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge Retry-After on every refused request, stable list pagination and the corrected error contract
This commit is contained in:
commit
6a2282fef8
80 files changed
+2261
-220
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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" } },
|
||||
],
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user