Repository navigation
feat(codegen-ts): add JSON Schema and OpenAPI contract emitters (FR-022) - #517
Merged
Merged
Conversation
Add `contract-schema` (JSON Schema 2020-12, canonical and strict profiles) and `openapi` (OpenAPI 3.1) as ordinary neutral generators in the catalog, over one shared engine in codegen-ts/src/contract. Both carry an owned reference copy, so `meta eject` works for them. The `contract-conformance` corpus holds the golden trees and the error cases. Ajv and the OpenAPI schema validator check every emitted document, and oasdiff runs against the documents in a test. The generated TypeScript API lanes now check real traffic against the document, which found two defects: writes and filters send zoneless timestamps that `date-time` refused, and the report case was missing its `format` parameter. Other ports share the REST contract but are not run against the document. Add docs/features/contract-emitters.md and the recipe that checks a change against the last release with oasdiff. Settle the open questions in the FR-022 design and mark it accepted. Target 1.2.1 on the roadmap; protobuf stays under 1.3 with binary protocols.
Findings from the pre-gate review: - withNull no longer repeats "null" on a schema that already allows it, which wrote a duplicate into `type` and `enum` in strict schemas. - OpenAPI path $refs now queue their shapes, so a generator filter cannot leave a traversal target or a TPH subtype's write shape dangling. - A duplicate operationId is refused, as a component name collision is. - An enum with no values stays an open string, not `enum: []`. - The oasdiff test probes the image rather than the Docker daemon, and the traffic check escapes literal path segments.
Share one comparator, drop a filter branch scalarSchema already covers, fold the TPH and plain path branches together, and compile the traffic check's validators once per pointer. Output is unchanged.
…ms and regenerated agent-context fixtures. Other docs already matched the change. Edits left uncommitted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Changed
server/typescript/packages/codegen-ts:contract-schema(jsonSchemaFile()) writes JSON Schema 2020-12 per object for the read, create and update shapes, plus a strict structured-output subset underprofile: "strict";openapi(openApiFile()) writes one OpenAPI 3.1 document covering the generated REST routes, filter, sort and paging parameters, the error envelope and the wire shapes as components. Both are registered in the generator registry and ejectable.src/contract/modules (field and object schema, strict profile, error types), with the output following the REST contract and thewiresetting.docs/features/contract-emitters.md, thedocs/recipes/check-api-against-last-release.mdrecipe (emit the OpenAPI document at the last tag and atHEAD, then run oasdiff with--fail-on WARN), the CHANGELOG entry, and ADR-0034 Amendment 10. Protobuf is not built.What the document check covers: only the TypeScript generated API lanes are checked against the generated OpenAPI document. The C#, Java, Kotlin and Python ports share the REST contract but are not run against the document. The hosted checks ran the C#, Java and Python lanes and they passed, but those lanes do not use the document.
The no-mistakes gate took 1 run.
Validation
Risk Assessment
✅ Low: The fix round matches each recorded decision: root exports and the baseUri and path options are removed, the TPH hierarchy predicates are shared with the routes, report-export is run-scoped, and docs are corrected. The ejected-copy test and the corpus cases back the changes. I found no defect, and I ran no tests.
Testing
I drove the product through the real CLI in a disposable project.
meta genwrote all four schema files and openapi.json, and the credential field appeared in none of them. A second run left every file unchanged.verify --codegencaught drift after a model edit. Real oasdiff failed a removed filter and passed an added optional field. The ejected copies reproduced the shipped output exactly, and the strict profile refused a recursive type with the documented error. The targeted suites passed, and so did the TypeScript regression run throughscripts/ci-local.sh --only gates --only ts, including the Docker integration tests. I did not run the Java, Python and C# lanes, since this change touches only TypeScript. The disposable project is removed and the worktree is clean.Evidence: Live CLI transcript summary
Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
server/typescript/packages/codegen-ts/src/contract/field-schema.ts:115- Both emitters state a report's decimal as if no wire setting existed.decimalSchemareadswireFormatOf(field), which isfield.root().wireFormat, but a served report reaches the emitters asreportReadModel(...), a detached object with no parent (metadata/src/core/reporting/report-read-model.ts).root()stops at that object,wireFormatis undefined, and the defaultnativeanswers. Concrete input:wire: { decimals: "string" }with reportEntryTotals. The entity field gets{ type: "string", pattern }, whileEntryTotals.totalgetstype: ["string", "number", "null"]with the description 'Set wire.decimals to "string"...' although it is set. That contradicts docs/features/contract-emitters.md 'Set the wire form', hits exactly the projects that follow that advice, and gives a generated client the two-type field the doc warns about. The route itself sends canonical strings (decimalColumnsLinepasses the loaded root for this reason). Same read of the invariant: object-schema.ts:75wireFormatOf(obj)(harmless for a report today, because only a value object usesnullMembers). Nothing catches it:all-fields-wire-string-*holds no report, andreportanddecimalcases set nowirealthough the api-contract corpora pin string/omit, soopenApiCheckvalidates pinned traffic against the loose native golden. Fix: readwireFormatOf(env.root)once inobjectSchemaand pass it intofieldSchema(strict-profile.tsbuildRawfollows throughenv); add a report model underwireto the corpus.server/typescript/packages/codegen-ts/src/generators/openapi-file.ts:56- The document'sformatparameter and its CSV and XML media types depend on which targetopenApiFile()lands in.ctx.config.includeReportExportis per-target (reportExportTargets.has(selfTarget.name), runner.ts:889), because the routes import the export module from their own target. Concrete input:routesFile({ target: "api" }),reportExport({ target: "api" }), andopenApiFile()left on the default target, which is the documented placement (design decision 1) in the documented multi-target layout. The served route answers?format=csv, yet the document omitsformatandtext/csv, with no error. A sibling read has the same defect: reference/openapi.ts:81.routesExposebeside it is correctly run-scoped. Fix: add a run-scoped fact to the gen config (any generator in the run withemitsReportExport), read it in both copies, and add a multi-target behaviour test.server/typescript/packages/codegen-ts/src/contract/object-schema.ts:79- The schemas decide credential and write-denied fields per object, but the routes decide them across the whole TPH hierarchy.routes-file.tspasseshierarchytodecimalColumnsLineandwriteDeniedColumnsLinebecause one column shared by several subtypes is one column (markedFieldNamesin api-surface.ts). Concrete input: subtypesBridgeAuthandCopayAuthboth declaretoken, and onlyBridgeAuth's declaresview.password. The route hidestokenon Copay rows and strips it from Copay writes. YetCopayAuth.schema.jsonliststokenas a required key, so every real Copay response fails the published schema, andCopayAuth.createand.updateadvertise a writable field the server silently drops. The same applies to@formExcludeon one declaration. Sites: object-schema.ts:79 (read filter) and object-schema.ts:103 (isClientWritten, used by create at :113 and update at :126). Thesecret-fieldsandtphcorpus cases do not combine these. Fix: expose the hierarchy-wide predicate from api-surface.ts (an engine module) and use it for a TPH subtype inreadSchemaandisClientWritten, with a corpus case.server/typescript/packages/cli/src/commands/init.ts:142- Every new project's scaffoldedmetaobjects.config.tsstill says 'Need an output nothing in that catalog emits — OpenAPI, JSON Schema, a client, a service layer? Write your own generator'. The change edited the next line of this comment and left this one, yet both are now catalog generators. A new adopter is told to hand-write whatcontract-schemaandopenapiship, which undercuts the point of the feature. It is emitted output, so it is reported and not fixed inline. Fix: drop the two names, as the CLI help and README edits already did.server/typescript/packages/codegen-ts/src/index.ts:432- Unrequired component: the package-root exports of the contract stages (buildOpenApiDocument,OPENAPI_VERSION,objectSchema,shapesOf,fieldSchema,withNull,strictSchemaand the error exports). The intent needs two generators and a recipe, not this public API. Nothing imports them from the root: the references use../contract/...and the tests use src paths. The comment's reason, 'so an owned (ejected) copy imports only this package', is false here, becausemeta ejectcopies those modules intocodegen/templates/(reference/openapi.ts header). Recommended remedy: remove these exports and that comment. The docs import the factories from@metaobjectsdev/codegen-ts/generators, so the two factory exports at the root are unrequired as well.server/typescript/packages/codegen-ts/src/generators/contract-schema-file.ts:28- Unrequired components:jsonSchemaFile({ baseUri })here andopenApiFile({ path })at generators/openapi-file.ts:22, each mirrored in its reference copy. Neither the intent nor the design's settled decisions need them: decision 1 fixes oneopenapi.jsonand atargetoption for placement, andbaseUriappears in no decision. Recommended remedy: remove both options, with their docs rows and thebaseUritest, until an adopter asks for them.server/typescript/packages/codegen-ts/src/contract/build-openapi.ts:411- Second definition of a concept:reportNamesrebuilds 'is this a report' by short name and threads it throughpathsOf(:317, :322), although the read model keepsobject.reportas its subtype.isReport(obj)fromsource-detect.js, already imported here, answers it with no name match. Replace the set withconst report = isReport(obj)inpathsOf. No behaviour change.docs/features/contract-emitters.md:149- Corrected the claim that a polymorphic TPH base stops a strict run.strictFilesskips a base throughhasStrictSchema, and thetphcase has nostrictExclude. Only a reference to a base is refused. The text now says that, and that a base gets no strict file because each subtype has one.docs/superpowers/specs/2026-06-11-fr-022-contract-emitters-design.md:113- Made the same correction in design decision 3: the refusal covers a reference to a polymorphic base, and a TPH base itself gets no strict file.docs/features/contract-emitters.md:150- The inline fix overstates the refusal. 'A reference to a polymorphic TPH base stops the run' holds only when the strict file uses the read shape, meaning the owner has no create shape. The strict profile uses the create shape where one exists, andnestedShapepasses create to the target.createSchemaon a TPH base is a flat object with nooneOf, so nothing throws. Probe throughstrictSchema: a writable entity and anobject.valuewithfield.object @objectRef: Authboth returned a schema inlining Auth's flat create shape, with no error. Anobject.projectionwith the same field threwERR_CONTRACT_STRICT_UNREPRESENTABLEat 'auth'. Correct the sentence: the refusal covers a reference to a polymorphic TPH base reached through a read shape, that is, from an object with no create shape. State that a reference from a create shape inlines the base's own create fields. The 'A TPH base gets no strict file' sentence is correct and stays.docs/superpowers/specs/2026-06-11-fr-022-contract-emitters-design.md:113- Design decision 3 makes the same overstated claim: it lists 'a reference to a polymorphic base' as always refused. A reference from a create shape is not refused.strictSchemainlines the base's flat create shape for a writable entity orobject.valueowner, and only a read-shape owner such asobject.projectionthrowsERR_CONTRACT_STRICT_UNREPRESENTABLE. Apply the same wording asdocs/features/contract-emitters.md, limiting the refusal to a reference reached through a read shape. The 'TPH base gets no strict file' sentence is correct and stays.🔧 Fix applied.
3 issues (1 warning, 2 infos) still open:
server/typescript/packages/codegen-ts/src/index.ts:432- Unrequired component: the package-root exports of the contract stages (buildOpenApiDocument,OPENAPI_VERSION,objectSchema,shapesOf,fieldSchema,withNull,strictSchemaand the error exports). The intent needs two generators and a recipe, not this public API. Nothing imports them from the root: the references use../contract/...and the tests use src paths. The comment's reason, 'so an owned (ejected) copy imports only this package', is false here, becausemeta ejectcopies those modules intocodegen/templates/(reference/openapi.ts header). Recommended remedy: remove these exports and that comment. The docs import the factories from@metaobjectsdev/codegen-ts/generators, so the two factory exports at the root are unrequired as well.docs/features/contract-emitters.md:149- Corrected the claim that a polymorphic TPH base stops a strict run.strictFilesskips a base throughhasStrictSchema, and thetphcase has nostrictExclude. Only a reference to a base is refused. The text now says that, and that a base gets no strict file because each subtype has one.docs/superpowers/specs/2026-06-11-fr-022-contract-emitters-design.md:113- Made the same correction in design decision 3: the refusal covers a reference to a polymorphic base, and a TPH base itself gets no strict file.✅ **Test** - passed
✅ No issues found.
scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchainsLivemeta genin a disposable project withjsonSchemaFile(),jsonSchemaFile({profile:"strict"})andopenApiFile()meta gen --listshows the contract-schema and openapi entriesmeta verify --codegen: clean (rc 0), then stale after a model change (rc 1, names generated/openapi.json)oasdiff v1.35.0 (dockertufin/oasdiff)breaking --fail-on WARN: removing@filterablefails (rc 1), adding an optional field passes (rc 0)meta eject contract-schema openapi, then regenerate with the ejected copies: output identical to the shipped generators (diff -r)Strict profile on a recursiveobject.valuefails with ERR_CONTRACT_STRICT_UNREPRESENTABLE, and afilterentry clears itcd server/typescript/packages/codegen-ts && bun test test/contract test/generator-registry.test.ts test/reference-byte-identical.test.ts test/reference-templates.test.ts test/metaobjects-config.test.ts(319 pass)cd server/typescript/packages/integration-tests && bun test test/contract-oasdiff.test.ts test/api-contract-openapi-check.test.ts(19 pass)scripts/ci-local.sh --only gates --only ts: gates, build and typecheck, TS conformance, unit suites, mutation gate, Postgres round trips and integration-tests (ts), exit 0✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.