Skip to content

feat(codegen-ts): add JSON Schema and OpenAPI contract emitters (FR-022) - #517

Merged
dmealing merged 6 commits into
mainfrom
fm/mo-fr022-emitters
Oct 31, 2026
Merged

dmealing merged 6 commits into
mainfrom
fm/mo-fr022-emitters

Conversation

@dmealing

@dmealing dmealing commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • Adds two reference generators in 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 under profile: "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.
  • Shape logic lives in new src/contract/ modules (field and object schema, strict profile, error types), with the output following the REST contract and the wire setting.
  • Adds docs/features/contract-emitters.md, the docs/recipes/check-api-against-last-release.md recipe (emit the OpenAPI document at the last tag and at HEAD, 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 gen wrote all four schema files and openapi.json, and the credential field appeared in none of them. A second run left every file unchanged. verify --codegen caught 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 through scripts/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.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Run meta gen with jsonSchemaFile and openApiFile: schemas and an OpenAPI 3.1 document are written, credentials omitted, rerun is unchanged ✅ pass live live-transcript.md; live-output/generated
Edit the model, then run meta verify --codegen: it fails and names the stale openapi.json ✅ pass live live-transcript.md
oasdiff breaking between two emitted documents: removed filterable fails with --fail-on WARN, added optional field passes ✅ pass live live-transcript.md; live-output/openapi.json, new.json, add.json
meta eject contract-schema openapi, then run the owned copies: output identical to the shipped generators ✅ pass live live-transcript.md
Adversarial: strict profile on a recursive value object is refused with ERR_CONTRACT_STRICT_UNREPRESENTABLE, and a filter clears it ✅ pass live live-transcript.md
Complete TypeScript regression: gates, TS conformance, unit suites and Docker integration-tests ✅ pass live scripts/ci-local.sh --only gates --only ts exit 0
Evidence: Live CLI transcript summary
Live run, disposable project (Customer entity, credential field pw), `meta gen` via worktree CLI:
- gen: 5 written (Customer .schema/.create/.update/.strict + openapi.json 3.1.0, paths /api/customers, /api/customers/{id}); credential `pw` absent from every shape
- second gen: 5 unchanged; verify --codegen rc=0
- remove @filterable from model: verify --codegen rc=1 "~ generated/openapi.json (committed content differs from a fresh regen)"
- oasdiff v1.35.0 (docker) breaking v1 vs removed-filterable --fail-on WARN: rc=1 (filter[...] params deleted)
- oasdiff add optional field: rc=0 "No breaking changes"
- meta eject contract-schema openapi -> codegen/generators/*.ts; run ejected copy -> output byte-identical (diff -r)
- recursive object.value + strict profile: "[ERR_CONTRACT_STRICT_UNREPRESENTABLE] 'acme::shop::Node' ... recursive"; with filter -> ok
  • Evidence: Emitted schemas and openapi.json, plus oasdiff inputs, kept locally with the run.

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 issues (1 warning, 2 infos)
  • ⚠️ server/typescript/packages/codegen-ts/src/contract/field-schema.ts:115 - Both emitters state a report's decimal as if no wire setting existed. decimalSchema reads wireFormatOf(field), which is field.root().wireFormat, but a served report reaches the emitters as reportReadModel(...), a detached object with no parent (metadata/src/core/reporting/report-read-model.ts). root() stops at that object, wireFormat is undefined, and the default native answers. Concrete input: wire: { decimals: "string" } with report EntryTotals. The entity field gets { type: "string", pattern }, while EntryTotals.total gets type: ["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 (decimalColumnsLine passes the loaded root for this reason). Same read of the invariant: object-schema.ts:75 wireFormatOf(obj) (harmless for a report today, because only a value object uses nullMembers). Nothing catches it: all-fields-wire-string-* holds no report, and report and decimal cases set no wire although the api-contract corpora pin string/omit, so openApiCheck validates pinned traffic against the loose native golden. Fix: read wireFormatOf(env.root) once in objectSchema and pass it into fieldSchema (strict-profile.ts buildRaw follows through env); add a report model under wire to the corpus.
  • ⚠️ server/typescript/packages/codegen-ts/src/generators/openapi-file.ts:56 - The document's format parameter and its CSV and XML media types depend on which target openApiFile() lands in. ctx.config.includeReportExport is 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" }), and openApiFile() 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 omits format and text/csv, with no error. A sibling read has the same defect: reference/openapi.ts:81. routesExpose beside it is correctly run-scoped. Fix: add a run-scoped fact to the gen config (any generator in the run with emitsReportExport), 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.ts passes hierarchy to decimalColumnsLine and writeDeniedColumnsLine because one column shared by several subtypes is one column (markedFieldNames in api-surface.ts). Concrete input: subtypes BridgeAuth and CopayAuth both declare token, and only BridgeAuth's declares view.password. The route hides token on Copay rows and strips it from Copay writes. Yet CopayAuth.schema.json lists token as a required key, so every real Copay response fails the published schema, and CopayAuth.create and .update advertise a writable field the server silently drops. The same applies to @formExclude on one declaration. Sites: object-schema.ts:79 (read filter) and object-schema.ts:103 (isClientWritten, used by create at :113 and update at :126). The secret-fields and tph corpus 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 in readSchema and isClientWritten, with a corpus case.
  • ⚠️ server/typescript/packages/cli/src/commands/init.ts:142 - Every new project's scaffolded metaobjects.config.ts still 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 what contract-schema and openapi ship, 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, strictSchema and 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, because meta eject copies those modules into codegen/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 and openApiFile({ 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 one openapi.json and a target option for placement, and baseUri appears in no decision. Recommended remedy: remove both options, with their docs rows and the baseUri test, until an adopter asks for them.
  • ℹ️ server/typescript/packages/codegen-ts/src/contract/build-openapi.ts:411 - Second definition of a concept: reportNames rebuilds 'is this a report' by short name and threads it through pathsOf (:317, :322), although the read model keeps object.report as its subtype. isReport(obj) from source-detect.js, already imported here, answers it with no name match. Replace the set with const report = isReport(obj) in pathsOf. No behaviour change.
  • ℹ️ docs/features/contract-emitters.md:149 - Corrected the claim that a polymorphic TPH base stops a strict run. strictFiles skips a base through hasStrictSchema, and the tph case has no strictExclude. 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, and nestedShape passes create to the target. createSchema on a TPH base is a flat object with no oneOf, so nothing throws. Probe through strictSchema: a writable entity and an object.value with field.object @objectRef: Auth both returned a schema inlining Auth's flat create shape, with no error. An object.projection with the same field threw ERR_CONTRACT_STRICT_UNREPRESENTABLE at '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. strictSchema inlines the base's flat create shape for a writable entity or object.value owner, and only a read-shape owner such as object.projection throws ERR_CONTRACT_STRICT_UNREPRESENTABLE. Apply the same wording as docs/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, strictSchema and 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, because meta eject copies those modules into codegen/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. strictFiles skips a base through hasStrictSchema, and the tph case has no strictExclude. 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.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Run meta gen with jsonSchemaFile and openApiFile: schemas and an OpenAPI 3.1 document are written, credentials omitted, rerun is unchanged ✅ pass live live-transcript.md; live-output/generated
Edit the model, then run meta verify --codegen: it fails and names the stale openapi.json ✅ pass live live-transcript.md
oasdiff breaking between two emitted documents: removed filterable fails with --fail-on WARN, added optional field passes ✅ pass live live-transcript.md; live-output/openapi.json, new.json, add.json
meta eject contract-schema openapi, then run the owned copies: output identical to the shipped generators ✅ pass live live-transcript.md
Adversarial: strict profile on a recursive value object is refused with ERR_CONTRACT_STRICT_UNREPRESENTABLE, and a filter clears it ✅ pass live live-transcript.md
Complete TypeScript regression: gates, TS conformance, unit suites and Docker integration-tests ✅ pass live scripts/ci-local.sh --only gates --only ts exit 0
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • Live meta gen in a disposable project with jsonSchemaFile(), jsonSchemaFile({profile:"strict"}) and openApiFile()
  • meta gen --list shows the contract-schema and openapi entries
  • meta verify --codegen: clean (rc 0), then stale after a model change (rc 1, names generated/openapi.json)
  • oasdiff v1.35.0 (docker tufin/oasdiff) breaking --fail-on WARN: removing @filterable fails (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 recursive object.value fails with ERR_CONTRACT_STRICT_UNREPRESENTABLE, and a filter entry clears it
  • cd 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.

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.
@dmealing
dmealing merged commit f2064c4 into main Oct 31, 2026
9 checks passed
@dmealing
dmealing deleted the fm/mo-fr022-emitters branch October 31, 2026 08:35
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