Skip to content

Register the schemas that annotated themselves into nothing - #824

Merged
MBombeck merged 5 commits into
mainfrom
fix/openapi-meta
Aug 22, 2026
Merged

Register the schemas that annotated themselves into nothing#824
MBombeck merged 5 commits into
mainfrom
fix/openapi-meta

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

Sixty-four annotations in the OpenAPI registry named a schema and registered nothing.

In Zod 4 .meta() clones the schema and registers the clone, so a bare statement that discards the return value is a no-op:

loginPasswordSchema.meta({ id: "LoginPasswordRequest", ... });        // registers nothing
const LoginPasswordRequest = loginPasswordSchema.meta({ ... });       // registers

The failure is invisible from the registry side, because the annotation looks correct and the document still builds. It shows only in the emitted file: MeasurementType, MeasurementSource, LoginPasswordRequest, CreateMeasurementRequest, ListMeasurementsQuery and CreateBatchWorkoutRequest appeared zero times under components.schemas, while the one schema that was force-registered by hand appeared once.

What it cost: the schemas inlined anonymously at every use site, and the descriptions written for them were never published at all. A client generated from this contract got a nameless duplicate type per operation instead of one named model, which is the opposite of why the contract is published.

What changed

Seventy-seven calls converted to the assigning form, use sites repointed. Where a schema comes from src/lib/validations/* the import is untouched and the annotated clone takes a new name, so the runtime validator is never mutated.

The seven in shared.ts had no use site in shared.ts at all: their consumers are eight other modules, which now import the annotated clones. Without that step none of them could ever have registered.

Sixty-three new named components in the emitted document, none removed. MoodTagLayout needed the same forced registration as Medication and CoachPrefs — its annotation was already correct, but both consumers .extend() the base, and .extend() builds a new object rather than a reference.

Verified rather than eyeballed

The document diff is three thousand lines, so both halves were checked by fully dereferencing every $ref in the baseline and in the regenerated file and deep-comparing the resolved paths trees.

The result across every affected path: 116 added description strings and nothing else. No property, type, enum, required, format or array-length difference anywhere, no path added or removed, no pre-existing component removed. Those descriptions are exactly the text the bare calls had been throwing away.

Findings

Two published descriptions were factually wrong. HealthRecordExportRequest and CreateShareLinkRequest both described a sections field that neither schema has — the export route refuses it explicitly, and the share link freezes scope through selection plus documentIds / documentOnly. Wiring them as written would have published a lie, so both are corrected to what the routes take. Both were also annotated in a module that imported the schemas for no other purpose and never referenced them; they now live beside the operations that send them.

Six apparent hits were not defects. The record-settings.ts calls sit inside a z.union([...]) argument list, where the returned clone is the union member, so they always worked. Verified against the committed spec: all six ids were already published.

Three annotations were dead twice. Two named ids for a query the route does not publish (it merges both modes into one hand-written query), and one named a query whose parameters are hand-written because it carries a cross-field .refine() the expansion cannot express. No use site to wire, and keeping them as unused exports turned knip red. Every clause of their text is already published on the operations, so nothing left the contract.

Twelve ids legitimately never reach components.schemas and are left in the assigning form for consistency: they annotate requestParams.query objects, which the emitter expands into inline parameters. Every pre-existing query id behaves the same way.

Gates: typecheck, lint, format:check, knip, openapi:check, 21,551 unit tests and the production build, all green.

Zod 4's `.meta()` clones the schema and registers the clone, so a bare
`someSchema.meta({ id })` statement registers nothing at all. Thirty-nine of
those across eleven route modules published neither the name nor the
description written for it: the schema inlined anonymously at every use site,
and a client generated from the contract got a duplicated nameless type where
one named model belongs. That last part is the cost that matters, because the
native client is the reason the contract is published.

Bind each annotated clone to a const and point the route table at that const.
Twenty-nine ids now emit under `components.schemas`, referenced from their
thirty use sites. Seven stay absent by design: they annotate
`requestParams.query` objects, which the emitter expands into inline
`parameters` rather than a component. `MoodTagLayout` stays absent too, for
the reason `Medication` and `CoachPrefs` already carry a forced registration
in the components block: its only consumer is an `.extend()`, and extending
builds a new object rather than referencing the base.

Two annotations in the illness module named ids that no route ever referenced,
`GetIllnessDayLogQuery` and `ListIllnessDayLogsQuery` — the day-log path
publishes the merged `GetIllnessDayLogsQuery` instead. They were dead twice
over, so they go rather than get wired to nothing.

`docs/api/openapi.yaml` grows the twenty-nine components and their refs. The
only other movement in it is thirty descriptions appearing where the text had
never reached the document. No request or response shape changes.
The annotation was already in the assigning form and still never reached
the document, because both consumers extend the base rather than
reference it and .extend() builds a new object. Same shape as Medication
and CoachPrefs, same remedy: name it in the forced-registration block.
`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.

Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.

Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.

Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.

Three findings the wiring turned up:

`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.

`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
The generated document is the whole visible effect of both halves, so it
conflicted line for line. Regenerated from the merged registry rather
than resolved by hand.
@MBombeck
MBombeck merged commit 1efac85 into main Aug 22, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant