From 3fcac747d9f8ce6b01bfe486a2cf89d1f2132b28 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 00:54:36 -0400 Subject: [PATCH 1/9] feat(typescript): hand the template modules over on eject, and make requirements and agent-docs ejectable `meta eject ` used to copy a thin generator that decided what to emit and called the package to write it. It now also copies the transitive closure of the template modules the generator calls into `codegen/templates/`, the way it already copies the HTTP adapter, and the owned generator imports them from there. The engine (generator base, model walks, naming and type-mapping tables, projection and report lowering) stays in the package; `ENGINE_MODULES` lists it and the shared eject manifest's engine surface carries the same list. `requirements` and `agent-docs` get reference templates, are listed in `docs.generators`, and `meta docs` runs an owned copy in place of the packaged one. `meta gen` skips the packaged factory of either, as it does for `docsFile()`. Every one of the 24 ejectable generators is ejected alone into an empty project by a test derived from the catalog: nothing it holds imports a packaged emit module, it typechecks as an adopter's project does, and it writes what the packaged generator writes over six models. The TypeScript entries are gone from the manifest's pending list. The repository's own owned copies (the test-generators package and the examples) carry their template modules, written by scripts/sync-owned-template-copies.ts and held by a test. The release checklist's assess block now fails the deploy. --- CHANGELOG.md | 54 +- .../references/owning-generators.md | 2 +- .../references/typescript.md | 28 +- docs/RELEASING-docs-checklist.md | 7 +- docs/features/own-your-codegen.md | 149 ++- docs/ports/typescript.md | 16 +- .../codegen/generators/entity.ts | 41 +- .../codegen/generators/queries.ts | 32 +- .../codegen/generators/routes.ts | 20 +- .../codegen/templates/date-time-regex.ts | 118 +++ .../codegen/templates/drizzle-schema.ts | 737 ++++++++++++++ .../codegen/templates/entity-constants.ts | 196 ++++ .../codegen/templates/entity-file.ts | 237 +++++ .../codegen/templates/enum-int-codec.ts | 67 ++ .../codegen/templates/enums-file.ts | 61 ++ .../codegen/templates/field-meta.ts | 284 ++++++ .../codegen/templates/filter-allowlist.ts | 195 ++++ .../codegen/templates/filter-shared.ts | 115 +++ .../codegen/templates/filter-type.ts | 124 +++ .../codegen/templates/inferred-types.ts | 420 ++++++++ .../codegen/templates/jsdoc.ts | 88 ++ .../codegen/templates/net-regex.ts | 24 + .../codegen/templates/projection-decl.ts | 240 +++++ .../codegen/templates/queries-file.ts | 451 ++++++++ .../codegen/templates/queries.ts | 312 ++++++ .../codegen/templates/relations-block.ts | 90 ++ .../codegen/templates/routes-index.ts | 82 ++ .../codegen/templates/tph-discriminator.ts | 239 +++++ .../codegen/templates/value-object-file.ts | 112 ++ .../codegen/templates/view-decl.ts | 247 +++++ .../codegen/templates/zod-validators.ts | 962 ++++++++++++++++++ .../showcase/codegen/generators/entity.ts | 41 +- .../showcase/codegen/generators/queries.ts | 32 +- .../showcase/codegen/generators/routes.ts | 20 +- .../codegen/templates/date-time-regex.ts | 118 +++ .../codegen/templates/drizzle-schema.ts | 737 ++++++++++++++ .../codegen/templates/entity-constants.ts | 196 ++++ .../showcase/codegen/templates/entity-file.ts | 237 +++++ .../codegen/templates/enum-int-codec.ts | 67 ++ .../showcase/codegen/templates/enums-file.ts | 61 ++ .../showcase/codegen/templates/field-meta.ts | 284 ++++++ .../codegen/templates/filter-allowlist.ts | 195 ++++ .../codegen/templates/filter-shared.ts | 115 +++ .../showcase/codegen/templates/filter-type.ts | 124 +++ .../codegen/templates/inferred-types.ts | 420 ++++++++ examples/showcase/codegen/templates/jsdoc.ts | 88 ++ .../showcase/codegen/templates/net-regex.ts | 24 + .../codegen/templates/projection-decl.ts | 240 +++++ .../codegen/templates/queries-file.ts | 451 ++++++++ .../showcase/codegen/templates/queries.ts | 312 ++++++ .../codegen/templates/relations-block.ts | 90 ++ .../codegen/templates/routes-index.ts | 82 ++ .../codegen/templates/tph-discriminator.ts | 239 +++++ .../codegen/templates/value-object-file.ts | 112 ++ .../showcase/codegen/templates/view-decl.ts | 247 +++++ .../codegen/templates/zod-validators.ts | 962 ++++++++++++++++++ .../references/owning-generators.md | 2 +- .../references/owning-generators.md | 2 +- .../references/owning-generators.md | 2 +- .../references/owning-generators.md | 2 +- .../references/typescript.md | 28 +- .../references/owning-generators.md | 2 +- .../references/typescript.md | 28 +- .../registry.json | 154 +-- scripts/sync-owned-template-copies.ts | 77 +- .../packages/cli/src/commands/docs.ts | 44 +- .../packages/cli/src/commands/eject.ts | 127 ++- .../packages/cli/src/lib/catalog-listing.ts | 46 + .../packages/cli/src/lib/install-set.ts | 21 +- .../packages/cli/src/lib/template-eject.ts | 519 ++++++++++ .../catalog-declarations-resolved.test.ts | 5 + .../cli/test/catalog-factory-exports.test.ts | 5 +- .../packages/cli/test/catalog-listing.test.ts | 42 + .../packages/cli/test/eject-multi.test.ts | 19 +- .../cli/test/eject-template-handover.test.ts | 149 +++ .../cli/test/eject-templates-contract.test.ts | 436 ++++++++ .../packages/cli/test/eject.test.ts | 7 +- .../test/gen-split-tree-single-import.test.ts | 25 +- .../integration/eject-shared-model.test.ts | 8 +- .../test/integration/gen-multi-target.test.ts | 5 +- .../cli/test/owned-copies-current.test.ts | 104 ++ .../packages/cli/test/support/owned-tiers.ts | 67 ++ .../packages/cli/test/support/write-owned.ts | 15 + .../packages/cli/test/template-eject.test.ts | 470 +++++++++ .../codegen-ts-react/src/reference/form.ts | 6 +- .../src/templates/form-file.ts | 5 +- .../test/reference-templates.test.ts | 12 +- .../src/reference/grid-hook.ts | 17 +- .../codegen-ts-tanstack/src/reference/grid.ts | 13 +- .../src/reference/hooks.ts | 15 +- .../src/templates/columns-file.ts | 12 +- .../src/templates/grid-hook-file.ts | 16 +- .../src/templates/hooks-file.ts | 26 +- .../test/reference-templates.test.ts | 13 +- .../packages/codegen-ts/package.json | 25 + .../packages/codegen-ts/src/docs-door.ts | 13 +- .../packages/codegen-ts/src/engine-surface.ts | 64 ++ .../codegen-ts/src/generator-registry.ts | 3 +- .../packages/codegen-ts/src/generator.ts | 16 +- .../src/generators/agent-docs-file.ts | 13 +- .../codegen-ts/src/generators/index.ts | 11 +- .../packages/codegen-ts/src/index.ts | 68 +- .../src/packaged-docs-generators.ts | 8 + .../codegen-ts/src/reference-templates.ts | 34 +- .../codegen-ts/src/reference/agent-docs.ts | 334 ++++++ .../codegen-ts/src/reference/api-docs.ts | 21 +- .../codegen-ts/src/reference/callable.ts | 14 +- .../codegen-ts/src/reference/cube-model.ts | 35 +- .../packages/codegen-ts/src/reference/docs.ts | 22 +- .../codegen-ts/src/reference/entity.ts | 41 +- .../codegen-ts/src/reference/extractor.ts | 13 +- .../codegen-ts/src/reference/names.ts | 2 +- .../codegen-ts/src/reference/output-parser.ts | 15 +- .../codegen-ts/src/reference/output-prompt.ts | 13 +- .../codegen-ts/src/reference/prompt-render.ts | 15 +- .../codegen-ts/src/reference/queries.ts | 32 +- .../codegen-ts/src/reference/render-helper.ts | 11 +- .../src/reference/requirement-tests.ts | 7 +- .../codegen-ts/src/reference/requirements.ts | 89 ++ .../codegen-ts/src/reference/routes-hono.ts | 10 +- .../codegen-ts/src/reference/routes.ts | 20 +- .../codegen-ts/src/reference/shared-model.ts | 15 +- .../codegen-ts/src/reference/trace-helper.ts | 16 +- .../codegen-ts/src/templates/names-decl.ts | 6 +- .../src/templates/zod-validators.ts | 20 +- .../test/agent-ui-page-parity.test.ts | 39 +- .../test/generator-registry.test.ts | 13 +- .../test/reference-byte-identical.test.ts | 149 +++ .../test/reference-templates.test.ts | 2 +- .../packages/test-generators/README.md | 12 +- .../src/{ => generators}/barrel.ts | 0 .../src/{ => generators}/entity.ts | 41 +- .../src/{ => generators}/queries.ts | 32 +- .../src/{ => generators}/routes.ts | 20 +- .../packages/test-generators/src/index.ts | 8 +- .../src/templates/date-time-regex.ts | 118 +++ .../src/templates/drizzle-schema.ts | 737 ++++++++++++++ .../src/templates/entity-constants.ts | 196 ++++ .../src/templates/entity-file.ts | 237 +++++ .../src/templates/enum-int-codec.ts | 67 ++ .../src/templates/enums-file.ts | 61 ++ .../src/templates/field-meta.ts | 284 ++++++ .../src/templates/filter-allowlist.ts | 195 ++++ .../src/templates/filter-shared.ts | 115 +++ .../src/templates/filter-type.ts | 124 +++ .../src/templates/inferred-types.ts | 420 ++++++++ .../test-generators/src/templates/jsdoc.ts | 88 ++ .../src/templates/net-regex.ts | 24 + .../src/templates/projection-decl.ts | 240 +++++ .../src/templates/queries-file.ts | 451 ++++++++ .../test-generators/src/templates/queries.ts | 312 ++++++ .../src/templates/relations-block.ts | 90 ++ .../src/templates/routes-index.ts | 82 ++ .../src/templates/tph-discriminator.ts | 239 +++++ .../src/templates/value-object-file.ts | 112 ++ .../src/templates/view-decl.ts | 247 +++++ .../src/templates/zod-validators.ts | 962 ++++++++++++++++++ .../test/owned-copies-current.test.ts | 95 -- 158 files changed, 19782 insertions(+), 826 deletions(-) create mode 100644 examples/advanced-modeling/codegen/templates/date-time-regex.ts create mode 100644 examples/advanced-modeling/codegen/templates/drizzle-schema.ts create mode 100644 examples/advanced-modeling/codegen/templates/entity-constants.ts create mode 100644 examples/advanced-modeling/codegen/templates/entity-file.ts create mode 100644 examples/advanced-modeling/codegen/templates/enum-int-codec.ts create mode 100644 examples/advanced-modeling/codegen/templates/enums-file.ts create mode 100644 examples/advanced-modeling/codegen/templates/field-meta.ts create mode 100644 examples/advanced-modeling/codegen/templates/filter-allowlist.ts create mode 100644 examples/advanced-modeling/codegen/templates/filter-shared.ts create mode 100644 examples/advanced-modeling/codegen/templates/filter-type.ts create mode 100644 examples/advanced-modeling/codegen/templates/inferred-types.ts create mode 100644 examples/advanced-modeling/codegen/templates/jsdoc.ts create mode 100644 examples/advanced-modeling/codegen/templates/net-regex.ts create mode 100644 examples/advanced-modeling/codegen/templates/projection-decl.ts create mode 100644 examples/advanced-modeling/codegen/templates/queries-file.ts create mode 100644 examples/advanced-modeling/codegen/templates/queries.ts create mode 100644 examples/advanced-modeling/codegen/templates/relations-block.ts create mode 100644 examples/advanced-modeling/codegen/templates/routes-index.ts create mode 100644 examples/advanced-modeling/codegen/templates/tph-discriminator.ts create mode 100644 examples/advanced-modeling/codegen/templates/value-object-file.ts create mode 100644 examples/advanced-modeling/codegen/templates/view-decl.ts create mode 100644 examples/advanced-modeling/codegen/templates/zod-validators.ts create mode 100644 examples/showcase/codegen/templates/date-time-regex.ts create mode 100644 examples/showcase/codegen/templates/drizzle-schema.ts create mode 100644 examples/showcase/codegen/templates/entity-constants.ts create mode 100644 examples/showcase/codegen/templates/entity-file.ts create mode 100644 examples/showcase/codegen/templates/enum-int-codec.ts create mode 100644 examples/showcase/codegen/templates/enums-file.ts create mode 100644 examples/showcase/codegen/templates/field-meta.ts create mode 100644 examples/showcase/codegen/templates/filter-allowlist.ts create mode 100644 examples/showcase/codegen/templates/filter-shared.ts create mode 100644 examples/showcase/codegen/templates/filter-type.ts create mode 100644 examples/showcase/codegen/templates/inferred-types.ts create mode 100644 examples/showcase/codegen/templates/jsdoc.ts create mode 100644 examples/showcase/codegen/templates/net-regex.ts create mode 100644 examples/showcase/codegen/templates/projection-decl.ts create mode 100644 examples/showcase/codegen/templates/queries-file.ts create mode 100644 examples/showcase/codegen/templates/queries.ts create mode 100644 examples/showcase/codegen/templates/relations-block.ts create mode 100644 examples/showcase/codegen/templates/routes-index.ts create mode 100644 examples/showcase/codegen/templates/tph-discriminator.ts create mode 100644 examples/showcase/codegen/templates/value-object-file.ts create mode 100644 examples/showcase/codegen/templates/view-decl.ts create mode 100644 examples/showcase/codegen/templates/zod-validators.ts create mode 100644 server/typescript/packages/cli/src/lib/template-eject.ts create mode 100644 server/typescript/packages/cli/test/eject-template-handover.test.ts create mode 100644 server/typescript/packages/cli/test/eject-templates-contract.test.ts create mode 100644 server/typescript/packages/cli/test/owned-copies-current.test.ts create mode 100644 server/typescript/packages/cli/test/support/owned-tiers.ts create mode 100644 server/typescript/packages/cli/test/support/write-owned.ts create mode 100644 server/typescript/packages/cli/test/template-eject.test.ts create mode 100644 server/typescript/packages/codegen-ts/src/engine-surface.ts create mode 100644 server/typescript/packages/codegen-ts/src/reference/agent-docs.ts create mode 100644 server/typescript/packages/codegen-ts/src/reference/requirements.ts rename server/typescript/packages/test-generators/src/{ => generators}/barrel.ts (100%) rename server/typescript/packages/test-generators/src/{ => generators}/entity.ts (89%) rename server/typescript/packages/test-generators/src/{ => generators}/queries.ts (94%) rename server/typescript/packages/test-generators/src/{ => generators}/routes.ts (98%) create mode 100644 server/typescript/packages/test-generators/src/templates/date-time-regex.ts create mode 100644 server/typescript/packages/test-generators/src/templates/drizzle-schema.ts create mode 100644 server/typescript/packages/test-generators/src/templates/entity-constants.ts create mode 100644 server/typescript/packages/test-generators/src/templates/entity-file.ts create mode 100644 server/typescript/packages/test-generators/src/templates/enum-int-codec.ts create mode 100644 server/typescript/packages/test-generators/src/templates/enums-file.ts create mode 100644 server/typescript/packages/test-generators/src/templates/field-meta.ts create mode 100644 server/typescript/packages/test-generators/src/templates/filter-allowlist.ts create mode 100644 server/typescript/packages/test-generators/src/templates/filter-shared.ts create mode 100644 server/typescript/packages/test-generators/src/templates/filter-type.ts create mode 100644 server/typescript/packages/test-generators/src/templates/inferred-types.ts create mode 100644 server/typescript/packages/test-generators/src/templates/jsdoc.ts create mode 100644 server/typescript/packages/test-generators/src/templates/net-regex.ts create mode 100644 server/typescript/packages/test-generators/src/templates/projection-decl.ts create mode 100644 server/typescript/packages/test-generators/src/templates/queries-file.ts create mode 100644 server/typescript/packages/test-generators/src/templates/queries.ts create mode 100644 server/typescript/packages/test-generators/src/templates/relations-block.ts create mode 100644 server/typescript/packages/test-generators/src/templates/routes-index.ts create mode 100644 server/typescript/packages/test-generators/src/templates/tph-discriminator.ts create mode 100644 server/typescript/packages/test-generators/src/templates/value-object-file.ts create mode 100644 server/typescript/packages/test-generators/src/templates/view-decl.ts create mode 100644 server/typescript/packages/test-generators/src/templates/zod-validators.ts delete mode 100644 server/typescript/packages/test-generators/test/owned-copies-current.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10089c882..55122868b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,7 +126,8 @@ generator catalog and the Atlas evaluation are 1.2 work. neutral and both still run by `meta docs`; a `generators` entry naming either string is refused the way `"docs"` is. Java: `plantuml`, `html-docs`, `ai-docs`, `json-schema` and the deprecated `mustache`, each by the class name a pom already uses. `mustache` will not ship - eject source. The four Java generators that can ship it do: see **Java and Kotlin** below. + eject source. The two TypeScript generators are ejectable (see the TypeScript entry below), + and the four Java generators that can ship it do: see **Java and Kotlin** below. - **Java and Kotlin: eject hands over the emitters a generator writes through, and `api-docs`, `plantuml`, `html-docs`, `ai-docs` and `json-schema` are ejectable.** An ejected @@ -176,6 +177,57 @@ generator catalog and the Atlas evaluation are 1.2 work. so a copy owns emit code only; the old static methods and constants on the two generators stay as deprecated forwarders. +- **A TypeScript generator is ejected whole: the code that writes its output comes with it.** + `meta eject ` used to copy a thin generator that decided what to emit and called the + package to write it: an owned `entity` was 223 lines over about 3,500 packaged ones. It now + also copies the transitive closure of the template modules the generator calls into + `codegen/templates/`, the way it already copies the HTTP adapter into `codegen/runtime/`, + and the owned generator imports them from there. This covers every stock generator except + `template`, the Mustache primitive, which has no emit logic of its own (the ruling every + port shares). Ruling and reasoning: ADR-0034 Amendment 8; how it works: + [own-your-codegen](docs/features/own-your-codegen.md#what-the-copy-holds). + - **What stays in the package is the engine.** The generator base, the model walks, the + naming and type-mapping tables (`names`, `naming`, `column-mapper`) and the projection and + report lowering `meta migrate` also runs. The list is `ENGINE_MODULES`, exported from + `@metaobjectsdev/codegen-ts`, and every name an owned file takes from the engine is + exported from that entry too (66 names were added to it; none was removed or + changed). A module a generator reaches that is not on the list is copied. + - **A copy differs from the package in its import lines only.** A relative import of an + engine module becomes an import of `@metaobjectsdev/codegen-ts`, a relative import of + another template module becomes the path to your copy, and `ts-poet` becomes the engine's + own, so a copy cannot split the `Code` class identity. `meta eject --list` compares every + file under `codegen/templates/` as it compares the generators, and prints the `diff` + command for each that differs. `--format json` gains `templates[]` on each ejected row. + The install line names what the copies import, with the range the package declares + (`@toon-format/toon` for `requirements`). + - **Eject alone, from an empty project, and it works.** A test derived from the catalog + ejects each of the 24 generators by itself, then checks that nothing it holds imports a + packaged module that writes output, that every file typechecks the way an adopter's + `tsconfig.codegen.json` checks it, and that it writes what the packaged generator writes, + byte for byte, over six models that make every generator emit. + - **`requirements` and `agent-docs` are in the catalog and ejectable.** They are the + generators behind `meta docs --requirements` and `meta docs --agent`. They had no registry + row, so no listing, no eject and no gate saw them; they are registered, and now they eject + with their template modules like the rest. List an owned copy in + `docs: { generators: [...] }` and `meta docs` runs it in place of the packaged one, as it + does for `docs` and `api-docs`; `docsSurface` now takes `"requirements"` and `"agent"`. + `meta gen` also skips a packaged `requirementsFile()` or `agentDocsFile()` wired in + `generators`, with the warning it gives for `docsFile()`: `meta docs` is their one door, + and a wired one wrote the same pages into the generated-code directory as well. Remove it + from `generators`. + `meta docs` hands the agent surface the physical schema it computes on + `ctx.docsDoor.agent`; the packaged `agentDocsFile()` reads it there when its options give + none, so its output is unchanged. + - **Copies made before 1.1.0 keep working and gain nothing.** They import their renderers + from the package entry, which still exports them. `meta eject ` over one leaves it + alone and copies no template module it would never call; `meta eject --force` + takes the new shape, so merge your own edits into the new file first. + - **The reference templates changed their imports, not their output.** Each imports the + template modules it calls by path (`../templates/…`) and the engine from the package + entry. Five template modules gained a package subpath so another codegen package can reach + them by path: `@metaobjectsdev/codegen-ts/templates/{entity-meta-file,field-meta,filter-shared,queries,tph-discriminator}`. + This repository's own owned copies (the `test-generators` package and the two examples) + carry their template modules too; `scripts/sync-owned-template-copies.ts` writes them. - **The Java `entity` and `extractor` generators can be ejected.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with the rest of its emit, into your `codegen/` module. They were the two Java generators that diff --git a/agent-context/skills/metaobjects-codegen/references/owning-generators.md b/agent-context/skills/metaobjects-codegen/references/owning-generators.md index 3de6cf6b9..2e5c27e4f 100644 --- a/agent-context/skills/metaobjects-codegen/references/owning-generators.md +++ b/agent-context/skills/metaobjects-codegen/references/owning-generators.md @@ -17,7 +17,7 @@ it there, in the same change, and keep going: | Port | Where the fix goes | |---|---| -| **TypeScript** | Your ejected copy under `codegen/generators/` — and, for routes, the HTTP adapter it calls: `meta eject routes` / `routes-hono` / `entity` also copy that adapter's source (mount helpers, filter parser, error envelopes, pagination) into `codegen/runtime/`, and the ejected output imports it from there, so a bug in a route's behaviour is fixed in that copy. If you are running a generator you never ejected (imported from its package), `meta eject ` it first and swap the import for the local one eject prints. Every stock generator can be ejected except the `template` primitive, which has no emit logic of its own to own. An ejected `docs` or `api-docs` goes in `docs: { generators: [...] }`, not in `generators`: `meta docs` runs it. A generator `meta gen --list` marks `package-only` has no reference template: replace it in `generators` with your own `Generator`, starting from its source in the installed package. | +| **TypeScript** | Your ejected copy under `codegen/generators/` — and, for routes, the HTTP adapter it calls: `meta eject routes` / `routes-hono` / `entity` also copy that adapter's source (mount helpers, filter parser, error envelopes, pagination) into `codegen/runtime/`, and the ejected output imports it from there, so a bug in a route's behaviour is fixed in that copy. If you are running a generator you never ejected (imported from its package), `meta eject ` it first and swap the import for the local one eject prints. Every stock generator can be ejected except the `template` primitive, which has no emit logic of its own to own, and an ejected generator comes with the template modules that write its output (`codegen/templates/`): the text of what it emits is yours to change too. An ejected `docs` or `api-docs` goes in `docs: { generators: [...] }`, not in `generators`: `meta docs` runs it. A generator `meta gen --list` marks `package-only` has no reference template: replace it in `generators` with your own `Generator`, starting from its source in the installed package. | | **Java / Kotlin** | Your own generator class. Subclass the reference generator and override the `protected` method that emits the wrong piece; when that piece is `private`, copy the reference generator's source (Apache-2.0) into your own package and edit it. Put the class in a codegen module that the module running `metaobjects:generate` depends on — the plugin loads `` from that module's compile classpath, and `provided` scope keeps it out of your packaged app — then point `` at it. Full steps: the Java and Kotlin references. | | **C# / Python** | Your owned copy (`dotnet meta eject ` / `metaobjects eject `, then edit it), or a generator of your own that replaces the artifact — an `IGenerator` in `codegen/Program.cs` on C#, a `module:symbol` entry on Python — or a template spec (`--template-spec`). | diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 3bf129111..721acc502 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -126,16 +126,17 @@ name. Import by default and eject to customize: an owned copy you never change o behind the reference. (`entityFile` / `queriesFile` / `routesFile` / `barrel` were removed from that subpath at 1.0 and restored in 1.1, so on a 1.0.x install an owned copy is the only path for those four.) The engine primitives come from the package main entry, -`@metaobjectsdev/codegen-ts`. Three entries cannot be ejected and are `package-only` in -`meta gen --list`: the `template` primitive, which has no emit logic of its own, and -`requirements` and `agent-docs`, the pages `meta docs` writes, which do not ship a -reference template yet. +`@metaobjectsdev/codegen-ts`. One entry cannot be ejected and is `package-only` in +`meta gen --list`: the `template` primitive, which has no emit logic of its own. An ejected +generator comes with the template modules that write its output (`codegen/templates/`); the +engine stays in the package. -Three ejectable entries behave differently from the rest: +Some ejectable entries behave differently from the rest: -- **`docs` and `api-docs` are run by `meta docs`, not `meta gen`.** `meta docs` writes - both with nothing wired; never put a docs generator, packaged or owned, in `generators`. - To change the pages, `meta eject docs` (or `api-docs`) and list the copy in the docs +- **`docs`, `api-docs`, `requirements` and `agent-docs` are run by `meta docs`, not `meta gen`.** + `meta docs` writes all four with nothing wired; never put a docs generator in `generators` + (`meta gen` skips `docs` and `api-docs`, and an owned copy of any of the four). To change the pages, `meta eject docs` (or `api-docs`, + `requirements`, `agent-docs`) and list the copy in the docs block, `docs: { generators: [docsFile()] }`: it carries a `docsSurface` marker, and `meta docs` and `meta verify --docs` run it in place of the packaged one. To change only a page's wording, put your own `templates/docs/entity-page.md.mustache` in the project @@ -143,10 +144,11 @@ Three ejectable entries behave differently from the rest: - **The ER diagram comes from `meta docs`** (overview page, and each entity's page). The standalone `mermaidErDiagram()` / `"mermaid-er"` generator was removed in 1.1; a config that still wires it fails with a message saying so. Remove it and run `meta docs`. -- **`shared-model` ejects as a thin generator.** The copy owns which source files may - contribute and where the published version comes from. The artifact, its hash and the - manifest a consumer's `meta deps sync` verifies stay in the package - (`buildSharedModel`, `renderSharedModelFiles`); keep calling both. +- **`shared-model` ejects with its artifact builder.** The copy decides which source files + may contribute and where the published version comes from. The artifact, its hash and the + manifest a consumer's `meta deps sync` verifies are written by `buildSharedModel` and + `renderSharedModelFiles`, copied beside it: change what they do and you stop agreeing with + every consumer. In an owned generator, read the project's own files through `ctx.inputRoot`. Under `meta verify --codegen` the run writes into a temporary tree and `ctx.projectRoot` is that @@ -160,7 +162,7 @@ skipped stub. Options: `filter` (a predicate over `subType`, `level`, `status`, (`"concern"`, the default, or `"member"`; anything else is refused), `warnUncovered`, `renderers` / `resolveRenderer`. `meta eject requirement-tests` copies the generator and its default stub renderer into `codegen/generators/requirement-tests.ts`; the requirement -walk, each test's identity and the claim digest stay in the package. The requirement +walk, each test's identity and the claim digest are engine and stay in the package. The requirement checks in `meta verify` are core and are not ejectable. The table below is a per-emission reference, NOT the selection surface. Select with diff --git a/docs/RELEASING-docs-checklist.md b/docs/RELEASING-docs-checklist.md index cd70c8eb9..554b37b5e 100644 --- a/docs/RELEASING-docs-checklist.md +++ b/docs/RELEASING-docs-checklist.md @@ -81,7 +81,8 @@ not an edit. Confirm rather than change: if node scripts/build-assess-prompt.mjs --out /tmp/assess.md && [ -s /tmp/assess.md ]; then cp /tmp/assess.md "$GITHUB_WORKSPACE/www/assess.md" else - echo "::warning::metaobjects $TAG could not build assess.md — /assess.md will 404." + echo "::error::metaobjects $TAG could not build assess.md" >&2 + exit 1 fi else # older tags: the skill is one file, so the old copy is complete @@ -95,7 +96,9 @@ not an edit. Confirm rather than change: ``` A script that exists but fails must NOT fall back to the `sed` copy: on these tags that - copy is missing the capability sheet and the JSON contract. + copy is missing the capability sheet and the JSON contract. It fails the deploy + instead (`exit 1`), so a release never publishes a site whose `/assess.md` is missing + or incomplete; only the older-tag branch, which has no script to fail, still warns. - [ ] `www/index.html` and every other page — the four registry coordinates plus `metamodel` are injected into any element carrying `data-registry="…"`, and the derived counts (`fixtures`, `corpora`, `baseTypes`) into any element carrying diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index b1e08fdaf..1cb3ccebe 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -50,7 +50,7 @@ Every stock generator can be used two ways: | | Import it | Own a copy | |---|---|---| | How | `import { entityFile } from "@metaobjectsdev/codegen-ts/generators";` | `meta eject entity`, then `import { entityFile } from "./codegen/generators/entity.js";` | -| In your repo | the import, nothing else | `codegen/generators/entity.ts`, and for `entity`, `routes` and `routes-hono` the HTTP adapter source under `codegen/runtime/` | +| In your repo | the import, nothing else | `codegen/generators/entity.ts`, the template modules it calls under `codegen/templates/`, and for `entity`, `routes` and `routes-hono` the HTTP adapter source under `codegen/runtime/` | | An upstream fix | arrives when you upgrade the package | arrives when you merge it into your copy | | Changing what it emits | only through its options (`filter`, `target`, `expose`, …) | edit the file | | After a package upgrade | output can change; `meta verify --codegen` reports it until you run `meta gen` | output changes only when your copy does | @@ -82,6 +82,58 @@ keeps its name in the copy, so the change to your config is one import line, and `meta eject` prints it. Replace the package import; a second import beside it leaves `generators` bound to the packaged generator and your edits do nothing. +### What the copy holds + +An owned generator owns **everything that writes its output**, not the decisions alone. +The generator file chooses which nodes get a file, where it lands and in what order. The +text of each file is written by template modules, and `meta eject` copies those too: the +transitive closure of the template modules the generator calls, into `codegen/templates/`, +the way it copies the HTTP adapter into `codegen/runtime/`. + +```text +codegen/ + generators/entity.ts # the generator: what to emit, where, in what order + templates/entity-file.ts # the code that writes each file (and what it calls): + templates/zod-validators.ts # drizzle-schema, zod-validators, filter-allowlist, … + templates/cube/… # a module from another directory of the package keeps it + runtime/drizzle-fastify/… # the HTTP adapter the emitted routes import +``` + +What stays in the package is the **engine**, which decides what there is to emit rather than +writing it: the generator base and contexts, the model walks (which objects are projections, +which fields are sortable, which route an entity is served on, which requirements a model +holds), the naming tables and the type-mapping tables (`names.ts`, `naming.ts`, +`column-mapper.ts`), and the projection and report lowering that `meta migrate` and +`meta verify` run. An owned file imports the engine from the package's public entry, +`@metaobjectsdev/codegen-ts`. The list is `ENGINE_MODULES`, exported from that entry; a module +that is not on it and that a generator reaches is copied. An owned generator therefore +imports no packaged module that returns emitted text, and a test ejects each generator alone +into an empty project to prove it: it compiles as your `tsconfig.codegen.json` checks it and +writes what the packaged generator writes, byte for byte. + +A copy differs from the packaged module in its **import lines only**. A relative import of +an engine module becomes an import of `@metaobjectsdev/codegen-ts`, a relative import of +another template module becomes the path to your copy of it, and `ts-poet` becomes the +engine's own (a copy must build `Code` with the same `ts-poet` the engine uses). Nothing else +is touched, so "what did upstream change?" is a diff of two files whose body is the same. + +- **Two generators that call one module share one copy.** `entity` and `queries` both call + `zod-validators`; ejecting the second finds the first's file and leaves it. +- **Eject never overwrites a file you changed.** `--force` replaces the generator and its + template modules, and the report says what it discarded. +- **`meta eject --list` compares each copy** against what the installed packages would write + today: `identical`, `same content, your formatting`, `DIFFERS: N line(s) only in the + package, M only in yours`, or `not in the package`, with the `diff -u` command for each + that differs. Take an upgrade by merging the package's change into your copy; the two + files differ by their import lines alone. +- **A copy from before 1.1 gets no template modules it would never call.** It imports the + renderers from the package, so copying them beside it would be dead code. `meta eject + --force` takes the new shape; to keep your changes, merge them into the new file. +- **The install line follows.** The copies import `@metaobjectsdev/codegen-ts`, + `@metaobjectsdev/metadata` and, for the prompt and docs tiers, `@metaobjectsdev/render`; + `requirements` also imports `@toon-format/toon`. `meta eject` prints the line, with the + range the package declares. + An imported generator is still a reference helper, not a guarantee. Its output is not covered by the [compatibility policy](../compatibility-policy.md) and can change in a MINOR, which is what receiving fixes on upgrade means. If you need generated output to @@ -109,7 +161,8 @@ falls behind the reference. To drop one: (`git log -p -- codegen/generators/.ts` shows no edit after the eject) or from the diff against the reference (`diff -u node_modules/@metaobjectsdev/codegen-ts/src/reference/.ts codegen/generators/.ts` - shows only upstream changes). + shows only upstream changes, and the import lines, which differ by design: a copy + imports your project's template modules where the reference imports the package's). - **A copy with lines of your own is a customization.** Keep it. For `entity`, `routes` and `routes-hono`, check the adapter too. Ejecting one also @@ -126,6 +179,13 @@ falls behind the reference. To drop one: generator, because it is what points the generated code at your adapter (its `runtimeImport` option), and the packaged generator always imports `@metaobjectsdev/runtime-ts`. Stop here for that generator. + + Check the template modules the same way. The text listing marks every file under + `codegen/templates/` against what the installed packages write, and a fix to what a + generator emits is as often made there as in the generator file. If any file the + generator calls is not `identical`, the copy carries a change of yours: keep it. + `meta gen --list --format json` carries the same comparison as `source.templateCopy[]` on + the generator's row, with a `verdict` per file. 2. In `metaobjects.config.ts`, replace the local import with the package one. The factory name does not change: @@ -134,7 +194,8 @@ falls behind the reference. To drop one: +import { queriesFile } from "@metaobjectsdev/codegen-ts/generators"; ``` -3. Delete `codegen/generators/.ts`. +3. Delete `codegen/generators/.ts`, and the `codegen/templates/` files that no other + owned generator calls (`meta eject --list` shows each one). 4. Run `meta gen`, review the diff, then `meta verify --codegen`. What the diff in step 4 holds: @@ -167,26 +228,30 @@ and `agent-docs`, the pages `meta docs` and `meta docs --agent` write, were regi the eject census and do not ship a reference template yet (ADR-0034 Amendment 8). `meta gen --list` marks all three `package-only`. -Three of the ejectable ones differ from the rest in what runs them or in what the copy may +Five of the ejectable ones differ from the rest in what runs them or in what the copy may change. -### `docs` and `api-docs` are run by `meta docs` +### `docs`, `api-docs`, `requirements` and `agent-docs` are run by `meta docs` -`meta docs` writes the model pages and the API reference with nothing wired. Eject one of -the two generators behind it when you want to change which nodes get a page, where a page -lands, how the overview page is laid out, or which forms of the API reference are written: +`meta docs` writes the model pages, the API reference, the requirement ledger and the pages +an agent reads, with nothing wired. Eject one of the four generators behind it when you want +to change which nodes get a page, where a page lands, how the overview page is laid out, +which forms of the API reference are written, which files the ledger becomes, or which pages +an agent is given: ```bash -meta eject docs api-docs +meta eject docs api-docs requirements agent-docs ``` ```ts import { docsFile } from "./codegen/generators/docs.js"; import { apiDocsFile } from "./codegen/generators/api-docs.js"; +import { requirementsFile } from "./codegen/generators/requirements.js"; +import { agentDocsFile } from "./codegen/generators/agent-docs.js"; export default defineConfig({ // … - docs: { generators: [docsFile(), apiDocsFile()] }, + docs: { generators: [docsFile(), apiDocsFile(), requirementsFile(), agentDocsFile()] }, }); ``` @@ -194,8 +259,8 @@ The copies are listed in the `docs` block, which is `meta docs`'s own config, an the top-level `generators`, which is what `meta gen` runs: - **`meta docs` runs a listed copy in place of the packaged generator.** Each copy carries - a `docsSurface` marker (`"model"` for `docs`, `"api"` for `api-docs`) saying which surface - it takes over. The summary line of `meta docs` names the generator, so you can see that + a `docsSurface` marker (`"model"` for `docs`, `"api"` for `api-docs`, `"requirements"` for + `requirements`, `"agent"` for `agent-docs`) saying which surface it takes over. The summary line of `meta docs` names the generator, so you can see that your copy ran. - **`meta verify --docs` checks against it.** That gate calls `meta docs`, so an edit to the copy shows up as drift in the committed pages until you run `meta docs` again. @@ -211,15 +276,22 @@ the top-level `generators`, which is what `meta gen` runs: - **`meta gen --list` shows it.** A copy listed in the docs block marks its row ``WIRED in `docs.generators` `` (`project.wiredInDocsDoor` in `--format json`). -You can eject one and not the other. The two page sets link to each other, so `meta docs` -tells both generators the same facts on `ctx.docsDoor`: the api surfaces from your `docs:` -config, the directory of the api surface being written, and whether the model pages are -written. A copy reads them there and takes no options for them. +You can eject any of them and not the others. The model and api page sets link to each +other, so `meta docs` tells both generators the same facts on `ctx.docsDoor`: the api +surfaces from your `docs:` config, the directory of the api surface being written, and +whether the model pages are written. The agent surface needs the physical schema, which +`meta docs` computes from your dialect and column naming strategy (the generator package owns +none of it) and hands over on `ctx.docsDoor.agent`; pass `schema` and `columnNamingStrategy` +as options to state them yourself. A copy reads these and takes no options for them. -| | The copy owns | Stays in the package | +| | The generator file decides | Copied beside it, so yours too | |---|---|---| -| `docs` | which objects and templates get a page, where a page lands, which page template renders, the whole overview page (`renderIndexPage`) | the page payloads (`buildEntityDocData`, `buildTemplateDocData`), the ER diagram (`renderMermaidErBlock`), the page templates | +| `docs` | which objects and templates get a page, where a page lands, which page template renders, the whole overview page (`renderIndexPage`) | the page payloads (`buildEntityDocData`, `buildTemplateDocData`) and the ER diagram (`renderMermaidErBlock`) | | `api-docs` | which units get a page, where a page lands, whether the index and the agent form (`AGENT-API.md`) are written | the model of the generated API (`buildApiModelForRun`, which also reads the run: layout, `apiPrefix`, whether Hono routes are wired) and its renderers (`renderEntityApiPage`, `renderApiIndex`, `renderAgentApi`) | +| `requirements` | which files the ledger becomes and where they land (`requirements.md`, `requirements.toon`); a project with no `requirement.*` node gets none | the two writers (`requirements-markdown`, `requirements-toon`) and the projection they read (`requirements-view`) | +| `agent-docs` | which of the three pages are written and where; the schema page's inputs (lineage, cardinality, enums) | the three page renderers (`agent-schema-page`, `agent-ui-page`, `agent-requirements-page`) | + +The page templates themselves (the Mustache files `meta docs` renders) stay in the package. Two things need no eject. To change the wording of a model page, put a file of the same name in your project (`templates/docs/entity-page.md.mustache` or @@ -227,8 +299,10 @@ name in your project (`templates/docs/entity-page.md.mustache` or and `apiDocsFile()` are never listed anywhere: `meta docs` runs them when the `docs` block names no generator for their surface. -The `requirements` and `agent` pages of `meta docs` are not generators in the catalog and -cannot be ejected. +`meta gen` skips a copy of any of the four with a warning that names `docs.generators`, and +`meta docs` keeps running the packaged generator until you move it. The packaged +`requirementsFile()` and `agentDocsFile()` are `meta docs` surfaces too, so they are listed in +`meta gen --list` as run by `meta docs`; they carry the same `docsSurface` as an owned copy. ### The ER diagram comes from `meta docs` @@ -243,14 +317,14 @@ saying so. To upgrade, delete the import and the `mermaidErDiagram()` entry (or `"mermaid-er"` name) from `generators`, delete the `docs/model.md` it wrote, and run `meta docs`. -### `shared-model`: you own the inputs, the package owns the artifact +### `shared-model`: you own the inputs and the artifact builder `sharedModelFile()` publishes part of your model as an artifact and a manifest that another project's `meta deps sync` copies and verifies ([metadata dependencies](metadata-dependencies.md)). Its output is a contract with that -other project, so the copy is split the way `cube-model`'s is: +other project, so the copy comes with a warning on the parts a consumer checks: -| The copy owns | Stays in the package | +| The generator file decides | Copied beside it, so yours too | |---|---| | which of your source files may contribute (`files`, default the run's own) | `buildSharedModel`: the load, the `include` / `exclude` selection, the closure check, the serialized document, the re-load with core vocabulary | | where the published version comes from (`nearestPackageVersion` reads the nearest `package.json`; replace it with your own source of truth) | `renderSharedModelFiles`: the two file names, the artifact's hash, the manifest | @@ -265,10 +339,11 @@ import { sharedModelFile } from "./codegen/generators/shared-model.js"; generators: [sharedModelFile({ name: "acme-common", include: ["acme::common::**"] })], ``` -A copy that calls those two functions publishes exactly what the packaged generator -publishes, and a consumer's sync accepts it. Nothing a consumer checks is in the copy, so -you cannot change it there by accident; to change it you would have to replace the calls, -and then you have stopped agreeing with every consumer of the artifact. Read the project's +An unchanged copy publishes exactly what the packaged generator publishes, and a consumer's +sync accepts it. The two functions are in your project now, so they are yours to change, and +a consumer is the one that checks them: the selection, the closure, the bytes, the hash and +the manifest. Change what they do and you have stopped agreeing with every consumer of the +artifact. Read the project's own files through `ctx.inputRoot`, as the copy does for the version: under `meta verify --codegen` the run writes into a temporary tree and `ctx.projectRoot` is that tree. An owned `docs` or `api-docs` copy reads through it too: under `meta docs` it is the @@ -305,6 +380,10 @@ the reply parser (`extractObject`), and the metadata-driven `ObjectManager`. The `./fastify` entry — the `ObjectManager`-backed mount — is helper-tier but no generator calls it, so eject does not copy it. +The same rule applies to the code that writes the output: ejecting a generator also copies +the template modules it calls, into `codegen/templates/`, and what stays in the package is +the engine ([what the copy holds](#what-the-copy-holds)). + Each ejected generator takes `runtimeImport` to move the copy (relative to the output root, like `dbImport`, or a path alias) or, with `runtimeImport: "@metaobjectsdev/runtime-ts"`, to go back to the package. `meta verify --codegen` never reports the copy as drift: it @@ -330,6 +409,15 @@ commit first and check `git diff`. A file you did edit is merged the same way as generator: `git merge-file --diff3 `, where the version you copied from is the one in your git history. +The template modules under `codegen/templates/` are compared the same way, against what the +installed packages would write there today, and differ from the package by their import +lines alone: + +```bash +diff -u node_modules/@metaobjectsdev/codegen-ts/src/templates/zod-validators.ts \ + codegen/templates/zod-validators.ts +``` + Whatever you own, hand-edits inside a generated file survive regeneration — but *how* they survive depends on what the toolchain can see, and it is worth knowing which case you are in: @@ -543,7 +631,7 @@ generator code. The 20-line programmatic shape for each port is in | Port | Invocation | Programmatic — write a `Generator` | Declarative — template + scope | |---|---|---|---| -| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To use a stock generator as shipped, import its factory from its package (`meta gen --list` prints the line) and wire it. To change one: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to put in `metaobjects.config.ts`. Edit them freely. The prompt tier (`prompt-render`, `output-parser`, `extractor`, `output-prompt`, `render-helper`) ejects like the rest: you own which templates get a module and where it lands, while the render and extract engines those modules call stay in the package. `requirement-tests` ejects as one file holding the generator and its default stub renderer, so the stub text is yours to change; the requirement walk, each test's identity and the claim digest stay in the package. `cube-model` (the Cube data-model exporter, [cube-export.md](cube-export.md)) ejects as one file as well: which entities get a cube, where the files land and the YAML call are yours, while the mapping (`buildCubeModel`, which raises every refusal) and the YAML writer (`renderCubeYaml`) stay in the package. `callable` and `trace-helper` eject the same way as the prompt tier: the copy decides which objects get a module and where it lands, and the module body comes from `renderCallableFile` / `renderTraceHelper` in the package. `shared-model` (FR-023's publisher generator) ejects as a thin generator: the copy owns which source files contribute and where the version comes from, and the artifact a consumer verifies comes from `buildSharedModel` / `renderSharedModelFiles` in the package. `docs` and `api-docs` eject too, and `meta docs` runs the copy from `docs: { generators: [...] }`, never from the top-level `generators` ([details](#the-docs-generators-and-shared-model-typescript)). Every stock generator can be ejected except the `template` primitive, which has no emit logic of its own to own (the same ruling in every port): `meta gen --list` marks it **package-only** and `meta eject` names it as such. | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | +| **TypeScript** | `meta init` → `meta gen --list --probe` → `meta eject ` → `meta gen` (Bun/Node CLI) | **Yes.** `meta generator new ` writes a working generator of your own into `codegen/generators/` and wires it — the first move for an output nothing ships. To use a stock generator as shipped, import its factory from its package (`meta gen --list` prints the line) and wire it. To change one: `meta init` scaffolds the LAYOUT and an empty selection (ADR-0034 Amendment 2); `meta eject ...` copies each generator you choose into `codegen/generators/*.ts` and prints the import to put in `metaobjects.config.ts`. Edit them freely. Every stock generator can be ejected except the `template` primitive, which has no emit logic of its own to own (the same ruling in every port): `meta gen --list` marks it **package-only** and `meta eject` names it as such. An ejected generator comes with the code that writes its output: the template modules it calls are copied into `codegen/templates/` ([what the copy holds](#what-the-copy-holds)), so the prompt tier, `callable`, `trace-helper`, `cube-model`, `shared-model`, the UI tier and the docs tier are as ownable as `entity` and `routes`. What stays in the package is the engine: the generator base, the model walks (including the requirement walk, each test's identity and the claim digest), the naming and type-mapping tables, and the render and extract engines of `@metaobjectsdev/render`. `requirement-tests` is one file holding the generator and its default stub renderer. `docs`, `api-docs`, `requirements` and `agent-docs` are run by `meta docs`, which runs a copy listed in `docs: { generators: [...] }` ([details](#the-docs-generators-and-shared-model-typescript)). | **Yes** — `templateGenerator({ template, scope, outputPattern })` in the config's `generators: [...]`. No CLI flag: the config already takes generator values. | | **Java / Kotlin** | `mvn metaobjects:generate` / `mvn metaobjects:verify` (`metaobjects-maven-plugin`) | **Yes.** Extend `FileEmittingGenerator` and read the model through `ModelWalk` (both in `metaobjects-codegen-base`) for one of your own. Every generator — built-in or your own — is named in `` and loaded from the project classpath: one seam, not two. There is no default suite, so `` is the complete list. Kotlin runs through the same goal. | **Yes** — `TemplateScopeGenerator` wired as an ordinary ``. No CLI flag: `` is already the seam. | | **C#** | `dotnet meta gen` / `dotnet meta verify` (.NET tool) | **Yes.** Implement `IGenerator` in the owned console project `codegen/` and list it in `codegen/Program.cs`; `dotnet meta gen` / `verify --codegen` hand off to that project whenever `codegen/Codegen.csproj` exists. `dotnet meta eject ` scaffolds the project, or write its two files by hand. An owned generator the `--generators` selection does not name still runs. | **Yes** — `dotnet meta gen --template-spec --template-root `. | | **Python** | `metaobjects gen` / `metaobjects verify` (console-script) | **Yes.** Name your generator as `module:symbol` in `--generators` or a target's `generators` in `metaobjects.config.yaml`; the symbol is an instance or a function returning one. Read the model through `metaobjects.codegen.model_walk`. (`--provider module:symbol` registers **metamodel vocabulary**, not a generator.) | **Yes** — `metaobjects gen --template-spec --templates `. | @@ -560,8 +648,9 @@ defect in the generator that emitted it. Once you run that generator in your bui yours. Fix it in your build, in the same change, and keep going: - **TypeScript:** edit your ejected copy (`meta eject ` first if you never ejected - it) — or, when the defect is in how a route behaves at request time (a filter, an error - body, pagination), the adapter copy eject placed beside it in `codegen/runtime/`. + it) — in the generator file, or in the template module that writes the text (the copy eject + placed beside it in `codegen/templates/`), or, when the defect is in how a route behaves at + request time (a filter, an error body, pagination), the adapter copy in `codegen/runtime/`. - **Java / Kotlin:** subclass the reference generator, or copy its source (Apache-2.0, in the `-sources` jar) into a codegen module the generating module depends on. Point `` at your class. The plugin loads it from that module's compile classpath. diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index 9a8ad4dbb..4da9d9734 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -60,7 +60,12 @@ and `configKeys` on each row, and the text listing prints neither. Import the on want from their package: nothing is copied, and an upgrade is how their fixes arrive. To change what one emits, `meta eject ` copies its reference template into `codegen/generators/` (ADR-0034 scaffold-and-own — yours to edit) and prints the local -import to swap in; `meta gen` then runs that copy, not the package's. +import to swap in; `meta gen` then runs that copy, not the package's. The copy comes with +the code that writes the output: the template modules the generator calls land in +`codegen/templates/`, and what stays in the package is the engine (the generator base, the +model walks, the naming and type-mapping tables). Every stock generator ejects this way +except the `template` primitive, which has no emit logic of its own +([what the copy holds](../features/own-your-codegen.md#what-the-copy-holds)). ```ts // metaobjects.config.ts @@ -335,9 +340,9 @@ source, or an abstract one, generates nothing. The contract is in `model/cubes/.yml` for each concrete, table-backed entity that declares dimensions, measures or segments (plus the join-target and alias cubes its `@via` dimensions need), with a rollup for each served report. It is a reference helper and opt-in: `meta eject cube-model` -copies it into `codegen/generators/cube-model.ts`, and `meta verify --codegen` drift-checks the -files. The mapping (`buildCubeModel`) and the YAML writer (`renderCubeYaml`) stay in the -package. Only TypeScript has an exporter; the files are language-neutral YAML. Point the +copies it into `codegen/generators/cube-model.ts`, with the mapping (`buildCubeModel`) and the +YAML writer (`renderCubeYaml`) beside it in `codegen/templates/cube/`, and `meta verify --codegen` +drift-checks the files. Only TypeScript has an exporter; the files are language-neutral YAML. Point the generator at the Cube project with a target, and pass `dialect` when the config's is not `postgres` or `mysql`: @@ -734,7 +739,8 @@ if not, `npm i zod`. `retired` one a skipped stub. It is a reference helper and a recommended approach, not a contract. Run it from the package, or own it: `meta eject requirement-tests` copies the generator and its default stub renderer into `codegen/generators/requirement-tests.ts`. The -requirement checks in `meta verify` are core and are not ejectable. +requirement walk, each test's identity and the claim digest are engine, shared with the +requirement checks in `meta verify`, which are core and are not ejectable. ```ts // metaobjects.config.ts diff --git a/examples/advanced-modeling/codegen/generators/entity.ts b/examples/advanced-modeling/codegen/generators/entity.ts index fe80e9f41..c9e135aac 100644 --- a/examples/advanced-modeling/codegen/generators/entity.ts +++ b/examples/advanced-modeling/codegen/generators/entity.ts @@ -33,7 +33,8 @@ // composes-with: queries.ts, routes.ts, barrel.ts (they import the files this emits). // // The composition (`renderEntity`) is the relocated body of the built-in entity composer — -// byte-identical to start, now YOURS to change. It imports only public engine primitives. +// byte-identical to start, now YOURS to change. It imports the engine from the package and +// the renderers from `../templates/`, which `meta eject` copies beside it. // ts-poet combinators come from the engine package, NOT a bare "ts-poet" import: the // Code sections composed here must share ONE ts-poet instance with the render* @@ -49,30 +50,10 @@ import { type Generator, type GeneratorFactory, type RenderContext, - // sub-renderers (engine primitives) — the LEGO blocks this composition assembles: - renderDrizzleSchema, - renderInferredTypes, - renderEnumTypeAliases, - renderZodValidators, - renderEntityConstants, - renderFilterAllowlist, - renderSortAllowlist, - renderFilterType, - renderTphDiscriminatorUnion, - isTphDiscriminatorBase, - renderProjectionDecl, - renderValueObjectFile, - renderSharedEnumsFile, - SHARED_ENUMS_BASENAME, - // predicates + helpers: isProjection, isWriteThrough, isAbstract, hasWritableRdbSource, - isTphSubtype, - // engine composer — used for the delegated write-through variant: - renderEntityFile, - // engine plumbing: formatTs, ownedRuntimeImport, entityOutputPath, @@ -82,6 +63,20 @@ import { GENERATED_EDIT_NOTE, sidecarLine, } from "@metaobjectsdev/codegen-ts"; +import { renderDrizzleSchema } from "../templates/drizzle-schema.js"; +import { renderEntityConstants } from "../templates/entity-constants.js"; +import { renderEntityFile } from "../templates/entity-file.js"; +import { renderSharedEnumsFile, SHARED_ENUMS_BASENAME } from "../templates/enums-file.js"; +import { renderFilterAllowlist, renderSortAllowlist } from "../templates/filter-allowlist.js"; +import { renderFilterType } from "../templates/filter-type.js"; +import { renderInferredTypes, renderEnumTypeAliases } from "../templates/inferred-types.js"; +import { renderProjectionDecl } from "../templates/projection-decl.js"; +import { + renderTphDiscriminatorUnion, + isTphDiscriminatorBase, +} from "../templates/tph-discriminator.js"; +import { renderValueObjectFile } from "../templates/value-object-file.js"; +import { renderZodValidators, isTphSubtype } from "../templates/zod-validators.js"; export interface RenderEntityOpts { readonly allowlists?: boolean; @@ -126,8 +121,8 @@ function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntit } // #214 — a write-through entity read-view (writable table + a read-only replica view + // derived origin.* fields) needs the hybrid file (table + `.existing()` view decl + a - // z.infer read type carrying the derived fields). Delegate to the engine composer rather - // than duplicate that branch here (mirrors the projection delegation above). + // z.infer read type carrying the derived fields). Delegate to `renderEntityFile` (copied + // beside this file, `templates/entity-file.ts`) rather than duplicate that branch here (mirrors the projection delegation above). if (isWriteThrough(entity)) { return renderEntityFile(entity, ctx, { allowlists }); } diff --git a/examples/advanced-modeling/codegen/generators/queries.ts b/examples/advanced-modeling/codegen/generators/queries.ts index f450200dc..836e47f03 100644 --- a/examples/advanced-modeling/codegen/generators/queries.ts +++ b/examples/advanced-modeling/codegen/generators/queries.ts @@ -21,9 +21,10 @@ // each block; call your own instead to change a verb's body. // composes-with: entity.ts (imports the table + InsertSchema it emits). // -// NOTE: the advanced TPH-base + projection variants delegate to the engine's composer -// (`renderQueriesFile`) — they're rarely customized. To own those too, copy their branches -// out of the package source. The vanilla path here is byte-identical to the built-in. +// NOTE: the advanced TPH-base + projection variants delegate to `renderQueriesFile`, which +// `meta eject` copies beside this file (`codegen/templates/queries-file.ts`) — they are rarely +// customized, and are yours to change there. The vanilla path here is byte-identical to the +// built-in. // ts-poet combinators come from the engine package, NOT a bare "ts-poet" import: the // Code sections composed here must share ONE ts-poet instance with the render* @@ -39,27 +40,28 @@ import { type RenderContext, dbTypeBlock, entityModuleSpecifier, - renderFindByIdFn, - renderListFn, - renderCreateFn, - renderInsertPreservingFn, - renderUpdateFn, - renderDeleteByIdFn, - renderReverseFinderFns, - reverseFksFor, - isTphDiscriminatorBase, isProjection, isWriteThrough, - isTphSubtype, servesReadApi, - hasAutoSetFields, - renderQueriesFile, // engine composer — used for the delegated variants formatTs, entityOutputPath, GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine, } from "@metaobjectsdev/codegen-ts"; +import { renderQueriesFile } from "../templates/queries-file.js"; +import { + renderFindByIdFn, + renderListFn, + renderCreateFn, + renderInsertPreservingFn, + renderUpdateFn, + renderDeleteByIdFn, + renderReverseFinderFns, + reverseFksFor, +} from "../templates/queries.js"; +import { isTphDiscriminatorBase } from "../templates/tph-discriminator.js"; +import { isTphSubtype, hasAutoSetFields } from "../templates/zod-validators.js"; // --- composition (OWNED for the common case) --- function renderQueries(obj: MetaObject, ctx: RenderContext): string { diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 2c6886786..e90763626 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -42,8 +42,8 @@ // composes-with: entity.ts (imports the table/schemas/allowlists), queries.ts. // // The composition (`renderRoutes`) is the relocated body of the engine's `renderRoutesFile` -// — byte-identical output to start, now YOURS to change. It imports only public engine -// primitives. +// — byte-identical output to start, now YOURS to change. It imports the engine from the +// package and the renderers from `../templates/`, which `meta eject` copies beside it. // ts-poet combinators come from the engine package, NOT a bare "ts-poet" import: the Code // fragments composed here must share ONE ts-poet instance with the engine's, or (with a @@ -64,21 +64,13 @@ import { exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS, - isTphSubtype, - isTphDiscriminatorBase, - tphPlan, - tphDiscriminatorPin, - tphStorageObject, isProjection, isWriteThrough, isReport, itemRouteField, reportDecimalColumns, - DEFAULT_ID_FIELD, servesReadApi, formatTs, - renderRoutesIndex, - routesIndexFileName, entityOutputPath, effectivePackage, crossEntitySpecifier, @@ -93,6 +85,14 @@ import { GENERATED_EDIT_NOTE, sidecarLine, } from "@metaobjectsdev/codegen-ts"; +import { DEFAULT_ID_FIELD } from "../templates/queries.js"; +import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js"; +import { isTphDiscriminatorBase, tphPlan } from "../templates/tph-discriminator.js"; +import { + isTphSubtype, + tphDiscriminatorPin, + tphStorageObject, +} from "../templates/zod-validators.js"; // --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. --- // Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a diff --git a/examples/advanced-modeling/codegen/templates/date-time-regex.ts b/examples/advanced-modeling/codegen/templates/date-time-regex.ts new file mode 100644 index 000000000..4bfd72164 --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/date-time-regex.ts @@ -0,0 +1,118 @@ +// Canonical Zod emission for the calendar/clock subtypes on the WRITE side +// (insert / update / preserving schemas, and a value object's jsonb members). +// +// 1.0.8 emitted a bare `z.string()` for `field.date`, `field.time` and a string-mode +// `field.timestamp`, so a request body of `{"dueDate": "next tuesday"}` validated and +// the text reached the column (SQLite stores it verbatim; Postgres rejects it with a +// 500). These are anchored regexes rather than Zod's own `z.string().date()` / +// `.datetime()` / `z.iso.*`, because those differ between Zod 3 and Zod 4 (`z.iso` is +// Zod-4-only, and `.datetime()` rejects the space-separated spelling the databases +// emit), and the codegen peer range is `zod >=3.23.0 <5`. `.regex()` behaves +// identically on both — the same reasoning as `net-regex.ts`. +// +// The date part is CALENDAR-exact: month lengths and the Gregorian leap rule (every 4th +// year, not every 100th, but every 400th) are spelled out in the regex, so `2020-02-30`, +// `2026-04-31` and `2023-02-29` are refused while `2024-02-29` and `2000-02-29` pass. It +// stays a regex rather than a `.refine()` so the emitted expression remains a plain +// `ZodString` on Zod 3 as well as Zod 4 (a Zod 3 refine is a `ZodEffects`, which the +// chains appended after it — `.max()`, `.nullable()` — do not all accept). Before 1.0.9 +// this was a shape check only, and `POST {"birthDate": "2020-02-30"}` was a 201. +// +// Every spelling the runtime ITSELF produces must pass, or the check breaks round-trips: +// - `new Date().toISOString()` — the @autoSet stamp, and JSON.stringify(Date); +// - `YYYY-MM-DD HH:MM:SS` — SQLite CURRENT_TIMESTAMP / CURRENT_TIME text; +// - `YYYY-MM-DD HH:MM:SS.ffffff+00` — Postgres string-mode timestamptz output; +// - `YYYY-MM-DDTHH:MM` — an `` value, which +// the generated React form submits; +// - `HH:MM[:SS[.f]]` (+ an offset) — Postgres time / timetz, ``. + +const TIME_OF_DAY = "(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?"; +const UTC_OFFSET = "(?:[Zz]|[+-](?:[01]\\d|2[0-3])(?::?[0-5]\\d)?)?"; +// 31-day months, 30-day months, February 1–28 in any year, and February 29 in a leap +// year: `YY` + a multiple of 4 other than `00`, or a multiple-of-4 century + `00`. +const DAY_IN_MONTH = + "(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))"; +const LEAP_YEAR = "(?:\\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)"; +const CALENDAR_DATE = `(?:\\d{4}-${DAY_IN_MONTH}|${LEAP_YEAR}-02-29)`; + +// Each check carries its own short message. Without one, Zod 4 answers +// "Invalid string: must match pattern /…/" — the whole calendar regex, ~300 characters, +// in every API 400 for a bad date. A string second argument to `.regex()` is the issue +// message on Zod 3 and Zod 4 alike. +export const DATE_FORMAT_MESSAGE = "must be an ISO date (YYYY-MM-DD)"; +export const TIME_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]], optional offset)"; +export const TIMESTAMP_FORMAT_MESSAGE = + "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])"; + +/** `field.date` — an ISO calendar date, `YYYY-MM-DD`, and nothing else. */ +export const ZOD_DATE_EXPR = + `z.string().regex(/^${CALENDAR_DATE}$/, ${JSON.stringify(DATE_FORMAT_MESSAGE)})`; + +/** `field.time` — a time of day, `HH:MM[:SS[.fraction]]`, optionally zoned. */ +export const ZOD_TIME_EXPR = + `z.string().regex(/^${TIME_OF_DAY}${UTC_OFFSET}$/, ${JSON.stringify(TIME_FORMAT_MESSAGE)})`; + +/** `field.time` on a MySQL column — a time of day with NO offset. MySQL's `TIME` has no + * zone and refuses one (`'12:30:00+05:00'` is ERROR 1292, Incorrect time value), so the + * portable check above would pass a body the database then rejects with a 500. */ +export const TIME_NO_OFFSET_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]]), without an offset"; +export const ZOD_TIME_NO_OFFSET_EXPR = + `z.string().regex(/^${TIME_OF_DAY}$/, ${JSON.stringify(TIME_NO_OFFSET_FORMAT_MESSAGE)})`; + +/** `field.timestamp` (string mode) — an ISO 8601 / SQL timestamp: a calendar date, + * optionally followed by `T` or a space, a time of day, and a UTC offset. */ +export const ZOD_TIMESTAMP_EXPR = + `z.string().regex(/^${CALENDAR_DATE}(?:[Tt ]${TIME_OF_DAY}${UTC_OFFSET})?$/, ${JSON.stringify(TIMESTAMP_FORMAT_MESSAGE)})`; + +// An instant timestamp is rewritten to UTC on WRITE. +// +// SQLite and D1 keep a string-mode timestamp as TEXT and compare it as text, so a value +// stored as sent — `2026-09-21T01:00:00+05:00`, which is 20:00Z — sorted after +// `2026-09-20T21:00:00Z` and `filter[tastedAt][gt]=…21:00:00Z` returned it. The rewritten +// form is the canonical wire spelling of an instant (fixtures/persistence-conformance/ +// normalization.md): `YYYY-MM-DDTHH:MM:SS[.fff]Z`, millisecond resolution, no trailing +// zeros, no fraction at all when it is zero. Its prefix through the seconds is fixed-width, +// so text order equals instant order down to the second. The runtime filter parser +// rewrites a bound the same way (`utcIsoInstant` in runtime-ts — the two must agree). +// Postgres timestamptz stores the instant either way, so it is unaffected. +// +// A value ALREADY spelled `YYYY-MM-DDTHH:MM:SS[.f…]Z` is kept byte-for-byte: it is in UTC and +// orders correctly as it is, and rc.4 turning `…12:10:00Z` into `…12:10:00.000Z` broke an +// adopter's round-trip of a timestamp inside a jsonb value object. +// +// A value with NO zone is read as UTC, and a date-only value as midnight UTC. A default +// `field.timestamp` is an instant whose wire form is "always UTC" +// (docs/features/api-contract.md, "Type encodings"; ADR-0036 Wave 2); a wall clock is +// what `@localTime` declares, and those fields never reach this helper. Kept as sent, +// `2026-10-06` sorted before `2026-10-06T00:00:00Z` as text, so +// `filter[dueAt][gte]=2026-10-06T00:00:00Z` omitted the row. The string handed to +// `Date.parse` is the ECMAScript date-time format it is REQUIRED to read (seconds and a +// three-digit fraction padded in, the offset as `±HH:MM`), so the result does not depend +// on the engine's tolerance for Postgres' `+00`, a six-digit fraction, or a zoneless string +// (which ECMAScript would read as LOCAL time). It runs after the format check, so it only +// ever sees a well-formed value. +// +// Emitted ONCE per module as a local function (generated model code imports nothing from +// MetaObjects at runtime), and referenced by name from each schema that needs it. + +/** Name of the module-local normalizer a timestamp WRITE shape transforms through. */ +export const UTC_TIMESTAMP_HELPER = "utcIsoTimestamp"; + +/** The normalizer's declaration, emitted at the top of a module that uses it. */ +export const UTC_TIMESTAMP_HELPER_DECL = [ + "/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept", + " * as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC.", + " * SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */", + `function ${UTC_TIMESTAMP_HELPER}(v: string): string {`, + " if (/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$/.test(v)) return v;", + " const m = /^(\\d{4}-\\d{2}-\\d{2})(?:[Tt ](\\d{2}:\\d{2})(?::(\\d{2})(?:\\.(\\d+))?)?(?:([Zz])|([+-]\\d{2}):?(\\d{2})?)?)?$/.exec(v);", + " if (m === null) return v;", + ' const t = Date.parse(`${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}`}`);', + ' return Number.isNaN(t) ? v : new Date(t).toISOString().replace(/\\.?0+Z$/, "Z");', + "}", +].join("\n"); + +/** `field.timestamp` (string mode, instant — not `@localTime`) on a WRITE shape: the format + * check, then the to-UTC rewrite above. A module emitting it must also emit + * {@link UTC_TIMESTAMP_HELPER_DECL}. */ +export const ZOD_TIMESTAMP_UTC_EXPR = `${ZOD_TIMESTAMP_EXPR}.transform(${UTC_TIMESTAMP_HELPER})`; diff --git a/examples/advanced-modeling/codegen/templates/drizzle-schema.ts b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts new file mode 100644 index 000000000..da6208c9f --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts @@ -0,0 +1,737 @@ +// Drizzle schema template — emits a single sqliteTable() or pgTable() definition, +// with FK .references() on FK columns derived from relationship children, +// plus the relations() block auto-emitted at the end. + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { + IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, + IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, + GENERATION_INCREMENT, GENERATION_UUID, + FIELD_ATTR_AUTO_SET, + FIELD_ATTR_OBJECT_REF, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_DATE, +} from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { + mapColumnType, PG_IDENTITY_CAPABLE_FNS, + type ColumnSpec, type EnumIntCustomType, +} from "@metaobjectsdev/codegen-ts"; +import { tableNameFromEntity } from "@metaobjectsdev/codegen-ts"; +import { dialectModule } from "@metaobjectsdev/codegen-ts"; +import { + namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr, +} from "@metaobjectsdev/codegen-ts"; +import { resolveTableSchema } from "@metaobjectsdev/metadata"; +import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderRelationsBlock } from "./relations-block.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js"; +import { tphStorageObject } from "./zod-validators.js"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render the Drizzle table definition for one entity, including: + * - FK .references() on FK columns derived from relationship children + * - relations() block at the end of the section + * + * Returns a Code object so ts-poet can deduplicate imports when this composes + * with the rest of the entity file. Biome formatting runs after composition. + */ +export function renderDrizzleSchema( + obj: MetaObject, + ctx: RenderContext, + /** + * OUT param: the int-backed-enum codec const names this table DECLARES. A caller that + * emits another artifact referencing the same codecs into the SAME module — entity-file's + * write-through replica view — passes a set here and hands it to that emitter, which then + * references the consts instead of re-declaring them. A duplicate module-scope `const` is + * a JS parse error, so the module would not even load. + */ + declaredEnumIntCodecs?: Set, +): Code { + const dialect = ctx.dialect; + const { tableFn, core: importModule } = dialectModule(dialect); + const tableFnSym = imp(`${tableFn}@${importModule}`); + + const tableName = obj.dbTable ?? tableNameFromEntity(obj.name, ctx.columnNamingStrategy); + const varName = ctx.collectionName(obj.name); + + // §A6/§B2 — reference `Names` instead of embedding the physical names a SECOND + // time. `namesRef` is undefined whenever the names generator is not active in this run + // (opt-in under ADR-0034 scaffold-and-own — an unconditional import would break every + // project that has not enabled it) or the object has no primary source — both PRESENCE + // guards. No per-site equality check: whenever the constant exists, it IS the name; + // `primaryRdbSource` (@metaobjectsdev/metadata) already refuses (throws) any object + // whose `@role: primary` sources disagree on a name, and BOTH branches reach it — the + // constant through `resolveObjectNames`, and `obj.dbTable` above on its own — so this + // file never has to re-check, and the literal arm is refused too rather than silently + // binding the inherited parent's table when the names generator is out of the run. + const names = namesRef(obj, ctx); + const tableNameExpr: Code = physicalNameExpr(names, tableName, obj); + + // @schema — the table binding must land in the SAME schema the migration creates the table + // in. migrate-ts qualifies every table, view, index and FK it emits (`CREATE TABLE + // "sales"."widgets"`), and under ADR-0015 it owns where a table lives; codegen dropping the + // schema meant generated queries hit `public.widgets` while the migration built + // `sales.widgets`. Whether that surfaced depended entirely on the adopter's `search_path`, + // which is the worst kind of bug — it works on the machine that has it configured. + // + // Drizzle expresses this with a DIFFERENT call shape rather than a third argument: + // `pgSchema("sales").table(name, …)`. It is built inline rather than hoisted to a `const` + // so two tables in one file sharing a schema cannot collide on a generated identifier; a + // schema object is just a descriptor, so a second call costs nothing. + // + // sqlite/d1 are excluded because SQLite has no schema concept at all — migrate already + // REFUSES a non-default @schema there (expected-schema.ts), so there is nothing to honour + // and no divergence to close. + const tableSchema = dialect === "sqlite" ? undefined : resolveTableSchema(obj); + const schemaExpr: Code | undefined = + tableSchema === undefined ? undefined + : sourceSchemaExpr(names) ?? code`${JSON.stringify(tableSchema)}`; + const tableCall: Code = schemaExpr === undefined + ? code`${tableFnSym}` + : code`${imp(`${dialect === "mysql" ? "mysqlSchema" : "pgSchema"}@${importModule}`)}(${schemaExpr}).table`; + // A column's constant, on the same terms — but resolved against the entity that DECLARES + // the field, which is not always `obj`. Under TPH the fold below emits a subtype's own + // columns into this base's table, and those columns live in the SUBTYPE's names artifact; + // looking them up in the base's finds nothing. + // + // This used to pass `names` for every column and call the resulting miss "normal". It was + // not: the base's artifact genuinely does not carry a subtype's column, so every TPH + // subtype column fell through to the literal while `Names.fields..column` sat + // right there, already imported by the subtype's own artifact. C# never had the bug + // because its emitter passes the subtype entity to `ColumnRef`; this is that shape. + // + // `fromPackage` stays `obj.package` so the import path is computed relative to the file + // being emitted (this base's), not to the subtype's own package. + // THIS entity's artifact first. It already carries every INHERITED field — a concrete + // entity's artifact spreads its abstract parent's `fields` — so an `extends` chain needs + // no redirect, and taking one would be a regression: an abstract base has no source, so + // it has no artifact of its own to redirect TO, and the column would fall back to a + // literal that `obj`'s artifact was carrying all along. + // + // A miss here means the field belongs to a different entity ENTIRELY, which under TPH is + // exactly the subtype whose columns this base's table absorbs. Only then resolve against + // the declaring entity, found through the field's own parent link. + // + // `isMetaObject`, never `instanceof`: two physical copies of @metaobjectsdev/metadata in + // one process give the class object and the instance different identities, so the check + // would return false for a real node — silently. + // `obj` never changes across this function, so its effective package is computed once + // and reused everywhere below (the `fromPackage` passed to `namesRef` here, and the + // per-field `renderColumn` calls in both the own-field and TPH-subtype-fold loops). + const pkg = effectivePackage(obj); + const columnNameExpr = (field: MetaField, dbName: string): Code => { + if (names === undefined || names.resolved.fields[field.name] !== undefined) { + return columnExpr(names, field.name, dbName); + } + const owner = field.parent; + // `fromPackage` is obj's: the import path is relative to the file being emitted (this + // base's), not to the subtype's own package. + const ownerNames = + owner !== undefined && isMetaObject(owner) && owner !== obj + ? namesRef(owner, ctx, pkg) + : undefined; + return columnExpr(ownerNames, field.name, dbName); + }; + + const primary = obj.primaryIdentity(); + const rawPkFields = primary?.attr(IDENTITY_ATTR_FIELDS); + const pkFieldsList: string[] = Array.isArray(rawPkFields) + ? rawPkFields as string[] + : typeof rawPkFields === "string" + ? rawPkFields.split(",").map((f) => f.trim()).filter(Boolean) + : []; + const pkFieldNames = new Set(pkFieldsList); + const pkGeneration = primary?.attr(IDENTITY_ATTR_GENERATION) as string | undefined; + + const fkMap = buildFkMapForEntity(obj, ctx); + + const isComposite = pkFieldNames.size > 1; + + // Collect secondary identities and the field names that need .unique() on + // their column. identity.secondary is ALWAYS unique (no @unique attr) — only + // An `identity.secondary` reaches the table ONLY as the `uniqueIndex(...)` callback + // emitted further down — never additionally as `.unique()` on its column. + // + // It used to do both for a single-column identity, under a comment describing an + // either/or ("single-column identities propagate .unique() to the column; multi-column + // ones emit a callback uniqueIndex() entry instead") that the code did not implement: + // the callback loop has no column-count condition, so a one-column secondary got a + // column-level unique AND a table-level unique index. `migrate-ts` emits only the index + // for that shape — it mirrors a `__unique` constraint for `@unique` fields + // and nothing extra for a secondary — so the generated schema declared a constraint the + // database does not have, and `drizzle-kit push` run against it proposes creating one. + // + // `.unique()` still comes from the FIELD's own `@unique`, via the column mapper, which + // is exactly the arm migrate mirrors. + const secondaryIdentities = obj.secondaryIdentities(); + + const columnLines: Code[] = []; + // Collect CHECK constraints for enum columns; emitted as table-level check() callbacks. + const checkConstraints: Array<{ name: Code; expr: Code }> = []; + /** + * One CHECK entry, composed against `Names` when the artifact is in the run. + * + * #293 — migrate's `
__chk` is the shared convention and it is the AUTHORITY: + * its suffix form is systematic across five constraint kinds and those names are already + * in live databases, so flipping migrate instead would emit DROP/ADD CONSTRAINT churn + * against production for a cosmetic difference. Referencing the constants therefore + * composes the IDENTICAL string at runtime rather than changing the convention — the + * template literal below evaluates to exactly what the literal arm spells. + * + * The expression's column goes through `sql.raw`, not `sql.identifier`: the latter QUOTES + * the name, which changes the constraint text migrate compares against. + */ + const checkEntry = (field: MetaField, spec: ColumnSpec): { name: Code; expr: Code } => { + const colExpr = columnNameExpr(field, spec.dbName); + const values = spec.checkConstraintValues; + const sqlSym = imp("sql@drizzle-orm"); + // No artifact in this run (the documented ADR-0034 opt-out) — spell both halves, which + // is what this file did unconditionally before. + if (names === undefined || values === undefined) { + return { + name: code`${JSON.stringify(`${tableName}_${spec.dbName}_chk`)}`, + expr: code`${spec.checkConstraint ?? ""}`, + }; + } + // `\${` emits a literal `${` into the generated source: the name becomes a template + // literal and the expression an interpolation, both evaluated at run time in the + // consumer's code, where they produce the identical strings the literal arm spells. + return { + name: code`\`\${${tableNameExpr}}_\${${colExpr}}_chk\``, + expr: code`\${${sqlSym}.raw(${colExpr})} IN (${values})`, + }; + }; + // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by + // const name so a shared enum used by two fields of the SAME entity emits once. + const enumIntTypes = new Map(); + for (const child of obj.fields()) { + // #213 — a derived (origin-bearing) field is read-only, materialized on the + // read (view) side, NOT a column on the entity's write table (FR-024 §7). + // Emitting it here would declare a Drizzle column for a table column migrate + // no longer creates. + if (child.isDerived()) continue; + const isPk = pkFieldNames.has(child.name); + const fkInfo = fkMap.get(child.name); + // Compute the column spec once per field and reuse it for both the column + // line and the CHECK collection. + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // FR-017 Tier 2 — TPH single-table inheritance. When this entity is a + // discriminator base, fold every concrete subtype's own columns into this + // one table. Subtype-only columns are ALWAYS nullable (a row of any other + // subtype stores NULL there) and never carry a DB default (a default would + // stamp onto other-subtype inserts), regardless of the field's @required. + // Subtype entities emit no table of their own (the value-object path). + for (const child of collectTphSubtypeFields(obj, ctx.loadedRoot)) { + // #213 — a TPH subtype's derived field is read-only too; never a table column. + if (child.isDerived()) continue; + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn( + spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true, + ); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + // Enum CHECK constraints stay valid under TPH: `NULL IN (...)` is NULL + // (not false), so other-subtype rows with NULL pass the check. + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // Build all table callback entries + const callbackEntries: Code[] = []; + + if (isComposite && primary !== undefined) { + callbackEntries.push(buildCompositeKeyCallback(pkFieldNames, importModule)); + } + + /** + * One index callback entry, for BOTH `identity.secondary` (unique) and `index.lookup` + * (non-unique). The two shapes differ only in that boolean, and migrate treats their + * physical attributes identically — `@expr`/`@using`/`@orders`/`@where` are registered on + * both by the same provider — so they are emitted by ONE function here. They used to be + * two near-identical loops, which is how the physical attributes came to be honoured by + * neither: a rule written twice is a rule that can be forgotten twice. + * + * What was missing is a SHAPE divergence, not a naming one (the names were reconciled + * separately). An `@expr`-only index emitted NOTHING at all here, because both loops + * required a non-empty `@fields` — while migrate emits it, keying off `@expr`. And + * `@where` never reached this file, so a PARTIAL unique index read as a FULLY unique one + * in the generated schema: `drizzle-kit push` would propose replacing the database's + * partial index with a total one, and any diff written from generated source disagreed + * with the database over a difference that was not real. Same class as the sqlite + * emitter dropping these in 0.15.21. + */ + const indexEntry = ( + // `type`/`subType` as well as `name`: the index-name resolver and the artifact's + // `identities` / `indexes` collections both key off which KIND of node this is, and + // ADR-0040 put that distinction in the type rather than in an attribute. + node: { name: string; type: string; subType: string; attr: (n: string) => unknown }, + fieldNames: string[], + unique: boolean, + ): Code | undefined => { + const str = (n: string): string | undefined => { + const raw = node.attr(n); + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + }; + const expr = str(IDENTITY_ATTR_EXPR); + // An expression index keys off @expr; a plain one needs @fields. Mirrors migrate's + // `if (fieldNames.length === 0 && expr === undefined) continue`. + if (fieldNames.length === 0 && expr === undefined) return undefined; + + const sqlSym = imp("sql@drizzle-orm"); + const indexSym = imp(`${unique ? "uniqueIndex" : "index"}@${importModule}`); + + // @orders is positional against @fields; migrate only serializes it when at least one + // entry is `desc`, because an all-ascending array must be indistinguishable from no + // orders at all for diff stability. Match that exactly or the two disagree on a + // no-op. + const ordersRaw = node.attr(IDENTITY_ATTR_ORDERS); + const orders = Array.isArray(ordersRaw) + ? fieldNames.map((_, i) => (ordersRaw[i] === "desc" ? "desc" : "asc")) + : []; + const anyDesc = orders.some((o) => o === "desc"); + const colRefs = fieldNames.map((f, i) => + anyDesc ? `table.${f}.${orders[i] === "desc" ? "desc" : "asc"}()` : `table.${f}`); + + // sqlite's `.on()` accepts only columns — no SQL — so an expression index is not + // expressible there. It is skipped rather than emitted wrong; migrate still creates it, + // and a Drizzle declaration that silently indexed a COLUMN instead of the expression + // would be a worse answer than an absent one. + if (expr !== undefined && dialect === "sqlite") return undefined; + + // @using selects the access method and takes the columns itself, so it REPLACES .on(). + // Postgres-only: SQLite has no USING clause, and MySQL's (btree/hash) is an index + // option rather than an access method taking the columns. migrate omits a `btree` + // value as the default, and so does this — emitting it would be a spurious difference. + const using = dialect === "postgres" ? str(IDENTITY_ATTR_USING) : undefined; + const target: Code = + expr !== undefined ? code`${sqlSym}\`${expr}\`` + : code`${colRefs.join(", ")}`; + // The CONSTANT when the artifact is in the run, the literal otherwise — and the + // literal arm goes through `resolveIndexName` too, so the two paths answer with one + // function. Both halves matter: migrate's expected-schema resolves the same name and + // the two spelled it independently until now, which is how codegen came to declare + // `idx_
_` against an index the database actually held under + // `identity.name` (fdb4118f1); and an empty `index.lookup` name — which the loader + // accepts, unlike an identity — is refused here rather than emitting `index("")`. + const indexName = indexNameExpr(names, node); + const head: Code = + using !== undefined && using !== "btree" + ? code`${indexSym}(${indexName}).using(${JSON.stringify(using)}, ${target})` + : code`${indexSym}(${indexName}).on(${target})`; + + // @where — the partial-index predicate, on Postgres and SQLite. MySQL has no partial + // indexes, so there the index is declared over the whole table; the adopter's DDL + // cannot say otherwise either. + const where = dialect === "mysql" ? undefined : str(IDENTITY_ATTR_WHERE); + return where === undefined ? head : code`${head}.where(${sqlSym}\`${where}\`)`; + }; + + // identity.secondary — always unique (uniqueness is in the type, no boolean). + for (const sec of secondaryIdentities) { + const fields = sec.attr(IDENTITY_ATTR_FIELDS) as string[] | undefined; + // The identity's OWN name, which is what migrate puts in the database + // (`expected-schema.ts`, secondary-identity pass) and what `index.lookup` three + // blocks below already emits. This used to compose `idx_
_...` by running + // the naming strategy over the FIELD names — two defects in one expression: the name + // disagreed with migrate's (so a DROP INDEX written from generated source failed, and + // any schema diff reported a difference that was not real), and running the strategy + // over a field NAME cannot see `@column`, so a declared physical column was invisible + // to it. Gated by `secondary-index-name-parity.test.ts`, whose fixture is de-blinded + // against exactly that second defect. + // Column refs use the callback param `table` (not the outer varName) so TS does not see + // the table referencing itself inside its own initializer (TS7022/TS7024). + const entry = indexEntry(sec, Array.isArray(fields) ? fields : [], /* unique */ true); + if (entry !== undefined) callbackEntries.push(entry); + } + + // index.lookup — always non-unique. Use MetaIndex.fields() per ADR-0039. + for (const lookup of obj.lookupIndexes()) { + const entry = indexEntry(lookup, lookup.fields(), /* unique */ false); + if (entry !== undefined) callbackEntries.push(entry); + } + + // Emit table-level CHECK constraints for enum fields. Both halves are composed by + // `checkEntry` above, which owns the constant-vs-literal decision for the pair. + for (const { name, expr } of checkConstraints) { + const checkSym = imp(`check@${importModule}`); + const sqlSym = imp("sql@drizzle-orm"); + callbackEntries.push(code`${checkSym}(${name}, ${sqlSym}\`${expr}\`)`); + } + + let tableBlock: Code; + if (callbackEntries.length > 0) { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}, (table) => [ + ${joinCode(callbackEntries, { on: ",\n ", trim: false })} +]); +`; + } else { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}); +`; + } + + // Emit the relations() block (returns null if no relations). + const relationsBlock = renderRelationsBlock(obj, ctx); + + // Int-backed enum codecs are declared BEFORE the table that references them. + // Sorted by const name so output is deterministic regardless of field order. + const enumIntBlocks = [...enumIntTypes.values()] + .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName)) + .map((t) => renderEnumIntCustomType(t, importModule)); + if (declaredEnumIntCodecs !== undefined) { + for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name); + } + + const blocks: Code[] = [...enumIntBlocks, tableBlock]; + if (relationsBlock !== null) blocks.push(relationsBlock); + return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" }); +} + +interface FkInfo { + targetVarName: string; // e.g., "users" + targetEntityName: string; // e.g., "User" — used for the import path + targetPkField: string; // e.g., "id" +} + +/** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */ +function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { + const result = new Map(); + // FR-017 TPH: a discriminator base's table carries every concrete subtype's folded + // columns (collectTphSubtypeFields), so it carries their FKs too — otherwise a reference + // declared on a subtype, or on an abstract level between it and the base, lands as a + // plain column. Same effective, not-already-on-the-base rule as the column fold. + // A copy: referenceIdentities() is the node's cached array, and loaded metadata is read-only. + const refs = [...obj.referenceIdentities()]; + const baseRefNames = new Set(refs.map((r) => r.name)); + for (const sub of tphConcreteSubtypes(obj, ctx.loadedRoot)) { + for (const ref of sub.referenceIdentities()) { + if (!baseRefNames.has(ref.name)) refs.push(ref); + } + } + for (const ref of refs) { + // @enforce: false → logical-only reference. Skip the .references() emission; + // the column stays plain. Drizzle's relations() block (driven by + // relation-resolver) still includes the relationship for query navigation. + if (!ref.enforce) continue; + const fkFieldNames = ref.fields; + if (fkFieldNames.length === 0) continue; + const fkField = fkFieldNames[0]!; + // Two subtypes declaring the same reference fold to one column with one FK. + if (result.has(fkField)) continue; + const targetName = ref.targetEntity; + if (!targetName) continue; + // @references may be authored bare OR package-qualified, and the loader can + // resolve it to an FQN (e.g. the YAML front-end qualifies it). Strip the + // package so the lookup matches the object's bare name — mirrors the + // relation-resolver, which already does this. + const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName)); + if (!targetObj) continue; + // A TPH subtype has no table const of its own; its rows live in the base's table. + const storage = tphStorageObject(targetObj); + const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id"; + result.set(fkField, { + targetVarName: ctx.collectionName(storage.name), + targetEntityName: storage.name, + targetPkField, + }); + } + return result; +} + +/** + * Build the `primaryKey({ columns: [table.f1, table.f2] })` Code expression + * for composite primary keys. Uses imp() so ts-poet tracks the import. + */ +function buildCompositeKeyCallback( + pkFieldNames: Set, + importModule: string, +): Code { + const primaryKeySym = imp(`primaryKey@${importModule}`); + const columnRefs = Array.from(pkFieldNames) + .map((f) => `table.${f}`) + .join(", "); + return code`${primaryKeySym}({ columns: [${columnRefs}] })`; +} + +/** Build a JS-style object literal string (not JSON.stringify which uses quoted keys). + * Array values get `as const` appended so Drizzle's text(...,{ enum: [...] }) + * narrows the inferred column type to a literal union instead of bare `string`. */ +function inlineObjectLiteral(obj: Record): string { + const entries = Object.entries(obj).map(([k, v]) => { + const lit = JSON.stringify(v); + if (Array.isArray(v)) { + return `${k}: ${lit} as const`; + } + return `${k}: ${lit}`; + }); + return `{ ${entries.join(", ")} }`; +} + +/** Render one column line (field name + Drizzle column expression). */ +/** SQLite "now" for a timestamp column, spelled like Date.toISOString(). Must stay + * byte-identical to migrate-ts `SQLITE_ISO_NOW`, which owns the DDL (ADR-0015). */ +const SQLITE_ISO_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"; + +function renderColumn( + spec: ColumnSpec, + // §A6 — the column's physical name as an EXPRESSION: either the `Names` constant + // or the same literal as before. Passed in rather than derived here so one call site owns + // the decision (and the equality check that makes it byte-safe). + dbNameExpr: Code, + field: MetaField, + ctx: RenderContext, + isPk: boolean, + pkGeneration: string | undefined, + fkInfo: FkInfo | undefined, + isComposite: boolean, + entityPackage: string | undefined = undefined, + // Name of the entity this column belongs to — used to detect a self-referential + // FK (target entity === this entity), which Drizzle emits without a self-import. + currentEntityName: string = "", + // FR-017 Tier 2 — TPH subtype-only column: force nullable (drop .notNull()) + // and suppress any DB default (other-subtype rows must stay NULL here). + forceNullable: boolean = false, +): Code { + // An int-backed field.enum's column function is a LOCAL generated const (the + // customType helper emitted into this same file), so it must not be imported + // from drizzle-orm/*-core like a built-in column type would be. + const fnSym = + spec.enumIntCustomType !== undefined + ? spec.enumIntCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); + + let baseCall: Code; + if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) { + baseCall = code`${fnSym}(${dbNameExpr}, ${inlineObjectLiteral(spec.fnOptions)})`; + } else { + baseCall = code`${fnSym}(${dbNameExpr})`; + } + + let pkSuffix = ""; + if (isPk) { + if (pkGeneration === GENERATION_INCREMENT) { + if (ctx.dialect === "sqlite") { + // Composite PKs don't use .primaryKey() per-column; table callback owns it. + pkSuffix = isComposite ? "" : ".primaryKey({ autoIncrement: true })"; + } else if (ctx.dialect === "mysql") { + // MySQL: an AUTO_INCREMENT column. The generated writes read the new key back + // with Drizzle's `$returningId()` (MySQL has no RETURNING). + pkSuffix = isComposite ? ".autoincrement()" : ".primaryKey().autoincrement()"; + } else { + // Postgres: the column KEEPS the type the column-mapper derived and + // GAINS the identity clause. It does not get a second spelling. + // + // This used to substitute `serial`/`bigserial` for the mapped + // `integer`/`bigint`, and that was the 1.0 defect wearing another hat: + // migrate emits `… GENERATED BY DEFAULT AS IDENTITY` (emit/postgres.ts + // renderColumn) for the same field, and `serial` is NOT that column. + // `serial` is historical sugar for integer + an owned sequence + a + // genuine DEFAULT nextval(...); an identity column carries neither a + // default nor a droppable sequence. So the generated Drizzle schema + // misrepresented the live PK of every integer-keyed table migrate had + // created — one question, two doors, two answers, and nothing compared + // them. migrate owns the DDL (ADR-0015), so codegen is what changes — + // the same call #293 made when the two named a CHECK constraint + // differently. + // + // The clause is gated on the MAPPED column function rather than on the + // field subtype, so the two cannot drift apart again: only Drizzle's + // int family (integer/bigint/smallint, via PgIntColumnBaseBuilder) + // carries .generatedByDefaultAsIdentity(). A subtype outside that + // family with @generation:"increment" is a combination Postgres cannot + // express at all — migrate emits `TEXT GENERATED BY DEFAULT AS + // IDENTITY` and fails at apply — and the honest thing here is to leave + // the declared type alone and let that failure be loud, rather than + // silently substituting an integer column for the declared one. + pkSuffix = isComposite ? "" : ".primaryKey()"; + if ( + spec.enumIntCustomType === undefined && + PG_IDENTITY_CAPABLE_FNS.has(spec.fnName) + ) { + pkSuffix += ".generatedByDefaultAsIdentity()"; + } + } + } else if (pkGeneration === GENERATION_UUID) { + pkSuffix = isComposite + ? "" + : ctx.dialect === "postgres" + ? ".primaryKey().defaultRandom()" + // SQLite and MySQL have no uuid default: the id is minted client-side. + : ".primaryKey().$defaultFn(() => crypto.randomUUID())"; + } else { + // No generation: natural PK. Composite hands off to table callback. + pkSuffix = isComposite ? "" : ".primaryKey()"; + } + } + + let modifiersStr = pkSuffix; + // No `isUnique` parameter here any more, and its absence is the fix rather than a tidy-up. + // A single-column `identity.secondary` used to append `.unique()` on top of the constraint + // migrate already emits under the identity's own name, so codegen declared one constraint + // the database does not have. When that producer was removed both call sites started + // passing a literal `false`, leaving a parameter whose every branch was unreachable — a + // dead switch that reads like a live one. `spec.modifiers` carries whatever uniqueness the + // column genuinely has, and the loop below is the only thing that applies it. + for (const m of spec.modifiers) { + // Single-column PKs imply notNull/unique; avoid emitting them twice. + // Composite-PK columns are NOT declared with .primaryKey(), so they DO need .notNull(). + if (isPk && !isComposite && (m === ".notNull()" || m === ".unique()")) continue; + // TPH subtype-only column: never .notNull() / .unique() — rows of other + // subtypes store NULL, so neither constraint can hold across the table. + if (forceNullable && (m === ".notNull()" || m === ".unique()")) continue; + modifiersStr += m; + } + + // sqlDefaultSegment must be a Code segment (not a raw string) so ts-poet tracks + // the `sql` import via imp(); a raw `.default(sql`...`)` would leave `sql` + // unresolved in the generated file. + let sqlDefaultSegment: Code | null = null; + if (spec.defaultExpr !== undefined && !isPk && !forceNullable) { + if (spec.defaultExpr.kind === "now") { + if (ctx.dialect === "sqlite") { + const sqlSym = imp("sql@drizzle-orm"); + // A timestamp column defaults to the ISO UTC form generated code writes, the same + // expression `meta migrate` puts in the DDL (migrate-ts SQLITE_ISO_NOW). + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(${SQLITE_ISO_NOW})\`)` + : code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`; + } else if (ctx.dialect === "mysql") { + // MySQL's datetime builder has no defaultNow(); spell the expression default, + // at the column's millisecond precision. + const sqlSym = imp("sql@drizzle-orm"); + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(CURRENT_TIMESTAMP(3))\`)` + : field.subType === FIELD_SUBTYPE_DATE + ? code`.default(${sqlSym}\`(CURRENT_DATE)\`)` + : code`.default(${sqlSym}\`(CURRENT_TIME)\`)`; + } else { + modifiersStr += `.defaultNow()`; + } + } else if (spec.defaultExpr.kind === "arrayLiteral") { + // isArray field: Drizzle's .array().default(x) (postgres) and + // .$type().default(x) (sqlite json) want a JS array literal, not the + // raw @default string. Elements are pre-rendered TS source literals. + // MySQL cannot give a JSON column a literal DEFAULT, so there the array is filled in + // client-side on insert. + modifiersStr += ctx.dialect === "mysql" + ? `.$defaultFn(() => [${spec.defaultExpr.elements.join(", ")}])` + : `.default([${spec.defaultExpr.elements.join(", ")}])`; + } else if (spec.defaultExpr.kind === "sqlExpr") { + const sqlSym = imp("sql@drizzle-orm"); + // Raw SQL keyword/expression — emit as sql`` for both dialects. This + // is also the always-typechecks fallback for an array @default whose shape + // parseArrayDefault couldn't safely model. + sqlDefaultSegment = code`.default(${sqlSym}\`${spec.defaultExpr.raw}\`)`; + } else { + // literal + modifiersStr += `.default(${JSON.stringify(spec.defaultExpr.value)})`; + } + } + + // FK .references() uses imp() so ts-poet tracks the cross-entity import. + let fkRefSegment: Code | null = null; + if (fkInfo !== undefined && !isPk) { + // Always annotate the .references() callback with the dialect's Any*Column return + // type. Drizzle needs this to break circular type inference — not only for + // self-referential FKs but also for cross-module circular references (table A → B + // while B → A), which otherwise surface as TS7022 ("implicitly has type 'any' + // because it does not have a type annotation and is referenced … in its own + // initializer") under `strict`. The annotation is a harmless explicit supertype + // for acyclic FKs, so emitting it unconditionally is safe. + const anyColType = dialectModule(ctx.dialect).anyColumn; + // Used only as a return-type annotation → type-only import (t:) so it emits + // `import type` and doesn't fail tsc under `verbatimModuleSyntax` (TS1484). (#165) + const anyColSym = imp(`t:${anyColType}@${spec.importModule}`); + if (fkInfo.targetEntityName === currentEntityName) { + // Self-referential FK (e.g. createdBy → this same table): reference the local + // table const directly — NOT a self-import. + fkRefSegment = code`.references((): ${anyColSym} => ${fkInfo.targetVarName}.${fkInfo.targetPkField})`; + } else { + const targetSpec = crossEntitySpecifier( + ctx.outputLayout, + entityPackage, + ctx.packageOf.get(fkInfo.targetEntityName), + fkInfo.targetEntityName, + ctx.extStyle, + ); + const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`); + fkRefSegment = code`.references((): ${anyColSym} => ${targetVarSym}.${fkInfo.targetPkField})`; + } + } + + // @autoSet fields: emit a $defaultFn so Drizzle inserts stamp the server-side timestamp + // automatically. This means callers don't need to supply createdAt / updatedAt in INSERT + // calls — Drizzle fills them in. The stamp's shape must match ctx.timestampMode — the base + // column type already does (mapColumnType, above) — or a "date" column ends up with a + // $defaultFn that hands Drizzle a string, which fails to typecheck (reported against an + // adopting project: TS2322 "Type 'string' is not assignable to type 'Date | SQL'", + // cascading into every generated insert/update query touching the field). + const autoSet = field.attr(FIELD_ATTR_AUTO_SET); + const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate") + ? ctx.timestampMode === "date" + ? `.$defaultFn(() => new Date())` + : `.$defaultFn(() => new Date().toISOString())` + : ""; + + // $type() chain — emitted as Code (not a string modifier) so ts-poet can + // hoist the cross-module type import for objectRef variants. Positioned + // immediately after the baseCall so the chain reads `.text(...).$type<...>().notNull()...` + // which Drizzle accepts in any order but is conventional for "type narrowing + // first." + // Resolve a VO name → an imported type symbol (shared layout/package/extStyle-aware + // helper, so the .$type import matches the field's TS type + Zod schema). + // ADR-0044/#228 — resolve the field's @objectRef to the value object's EMITTED + // name (bare when unique in the run, package-qualified on a cross-package + // short-name collision), resolved package-locally from the FIELD's declaring + // package. `name` (the bare dollarTypeRef name) is the byte-identical fallback + // when the ref doesn't resolve to an emitted value object. + const voSym = (name: string) => { + const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); + const emitted = typeof refRaw === "string" + ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + : name; + return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); + }; + + let dollarTypeSegment: Code | string = ""; + if (spec.dollarTypeRef !== undefined) { + const ref = spec.dollarTypeRef; + if (ref.kind === "scalar") { + dollarTypeSegment = `.$type<${ref.tsType}${ref.array ? "[]" : ""}>()`; + } else if (ref.kind === "objectRef") { + const refSym = voSym(ref.name); + dollarTypeSegment = ref.array ? code`.$type<${refSym}[]>()` : code`.$type<${refSym}>()`; + } else { + // field.map → Record; V is a scalar or a hoisted value-object. + dollarTypeSegment = "scalar" in ref.value + ? `.$type>()` + : code`.$type>()`; + } + } + + const columnLine = code` ${field.name}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`; + return spec.leadingComment !== undefined + ? code` // ${spec.leadingComment}\n${columnLine}` + : columnLine; +} diff --git a/examples/advanced-modeling/codegen/templates/entity-constants.ts b/examples/advanced-modeling/codegen/templates/entity-constants.ts new file mode 100644 index 000000000..4a79303a4 --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/entity-constants.ts @@ -0,0 +1,196 @@ +// Entity-constants template — emits an `` const with all the +// metadata-derived strings consumers should use INSTEAD of magic strings. +// +// Shape (each non-$-prefixed key is a per-field object): +// +// export const Subscriber = { +// $entity: "Subscriber", +// $table: "subscribers", +// $path: "/subscribers", +// +// email: { +// name: "email", +// label: "Email Address", // @title on the surface's view, else @title on the field, else humanized field name +// view: "text", // MetaView subtype +// htmlType: "email", // optional; only when the view maps to a real HTML input type +// rules: { // optional; derived from validator children +// required: "Email is required", +// maxLength: { value: 255, message: "Must be 255 characters or fewer" }, +// pattern: { value: /.../, message: "Invalid email" }, +// }, +// }, +// // ... +// } as const; +// +// Consumers spread `form.input.email` from useEntityForm and never touch +// per-attribute access (placeholder, rules, etc.) by hand — the helper +// picks them up from this object automatically. + +import { code, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject } from "@metaobjectsdev/metadata"; +import { resolveTableName } from "@metaobjectsdev/metadata"; +import { physicalNameExpr } from "@metaobjectsdev/codegen-ts"; +import { + buildEntityUiDescriptor, + resourcePath, + type UiFieldDescriptor, + type UiRule, +} from "@metaobjectsdev/codegen-ts"; + +// `resourcePath` moved to entity-ui-descriptor.ts with the rest of the form derivation. +// Re-exported here because it is part of this module's published surface (the package +// barrel re-exports it from this path, and api-model.ts imports it from here). +export { resourcePath }; + +/** + * Render one rule entry as the RHF-shaped code fragment the descriptor emits. + * + * Order is the descriptor's, which is declaration order — see the ordering note in + * entity-ui-descriptor.ts for why that is not incidental. + */ +function renderRule(r: UiRule): string { + switch (r.kind) { + case "required": + return `required: ${JSON.stringify(r.message)}`; + case "minLength": + return `minLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "maxLength": + return `maxLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "pattern": { + // Emit as a RegExp literal /.../ — `as const` preserves the value-ref. The + // descriptor carries the RAW pattern; forward-slash and backslash are escaped + // HERE, because closing a TypeScript regex literal is an emission concern. + const safe = r.pattern.replace(/\\/g, "\\\\").replace(/\//g, "\\/"); + return `pattern: { value: /${safe}/, message: ${JSON.stringify(r.message)} }`; + } + } +} + +/** Build one nested field-object entry like `email: { name, label, ... },`. */ +function renderFieldEntry(f: UiFieldDescriptor): string { + const entries: string[] = [ + `name: ${JSON.stringify(f.name)}`, + `label: ${JSON.stringify(f.label)}`, + `view: ${JSON.stringify(f.view)}`, + ]; + if (f.htmlType !== undefined) entries.push(`htmlType: ${JSON.stringify(f.htmlType)}`); + if (f.rules.length > 0) entries.push(`rules: { ${f.rules.map(renderRule).join(", ")} }`); + + // A `field.enum`'s member symbols. Emitted whenever the field has them, because the + // descriptor reports `view: "dropdown"` for an enum and a dropdown without options is + // a promise the descriptor cannot keep. + if (f.options !== undefined && f.options.length > 0) { + entries.push(`options: [${f.options.map((v) => JSON.stringify(v)).join(", ")}] as const`); + } + + // Currency-specific keys: only emitted for currency-subtype fields. + if (f.currency !== undefined) { + entries.push(`currency: ${JSON.stringify(f.currency.currency)}`); + entries.push(`locale: ${JSON.stringify(f.currency.locale)}`); + } + + return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`; +} + +/** + * The field-rules constant of an `object.value`: `export const = { $entity, : { name, + * label, view, rules, ... } } as const`. The same per-field shape an entity's constant carries, + * minus `$table` and `$path` — a value object has no source and no route — so a caller reads a + * limit as `..rules.maxLength.value` instead of reaching through the Zod schema's + * getters. Emitted for the value subtype only (see `renderValueObjectFile`). + */ +export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code { + const descriptor = buildEntityUiDescriptor(obj); + // The emitted name (collision-qualified, ADR-0044) matches the value object's interface. + const name = emittedName ?? descriptor.entity; + const body = joinCode( + [code` $entity: ${JSON.stringify(descriptor.entity)}`, ...descriptor.fields.map((f) => code`${renderFieldEntry(f)}`)], + { on: ",\n" }, + ); + return code` +/** + * Metadata constants for the value object ${name}: each field's name, label, view and the + * validation rules derived from its validator children (e.g. \`${name}..rules.maxLength.value\`). + */ +export const ${name} = { +${body}, +} as const; +`; +} + +export function renderEntityConstants( + obj: MetaObject, + /** + * @deprecated Accepted and IGNORED. The API base URL left the descriptor: generated + * client artifacts emit entity-relative paths and the base is supplied once at the + * client provider (`baseUrl`), because where the browser sends a request is a + * deployment fact rather than something to freeze at `meta gen` time. + * + * It must STAY in the signature: `src/reference/entity.ts` is copied verbatim into + * adopter repos (ADR-0034) and calls this positionally, so removing the parameter + * would fail to compile in every ejected copy. Removal is a separate later break. + */ + _apiPrefix = "", + // §A6. OPTIONAL, and it must stay optional: `src/reference/entity.ts` is copied + // verbatim into adopter repos by ADR-0034 scaffold-and-own and calls this with two + // arguments. A required parameter would fail to compile in every ejected copy. + names?: { readonly name: string; readonly symbol: Code } | undefined, +): Code { + // ONE derivation of the form surface, shared with the `agent/ui.md` docs page — see + // entity-ui-descriptor.ts. The descriptor already walks the RESOLVING fields(), so + // inherited fields (from extends:/super:) appear here as they always have. + const descriptor = buildEntityUiDescriptor(obj); + const entityName = descriptor.entity; + const tableName = resolveTableName(obj); + const path = descriptor.path; + + const fieldEntries: string[] = descriptor.fields.map(renderFieldEntry); + + // A6/B2 — reference the constant whenever the artifact is in the run. No equality + // guard: primaryRdbSource (@metaobjectsdev/metadata) refuses any object whose + // @role: primary sources disagree on a physical name, and both the constant and the + // literal below resolve through it, so a reference here is the single spelling. `physicalNameExpr` accepts + // any `{ symbol }`-shaped value, so it works with this function's `{ name, symbol }` + // parameter as well as `namesRef`'s own `{ resolved, symbol }`. + const tableLine: Code = code` $table: ${physicalNameExpr(names, tableName, obj)}`; + + const body = joinCode( + [ + code` $entity: ${JSON.stringify(entityName)}`, + tableLine, + code` $path: ${JSON.stringify(path)}`, + ...fieldEntries.map((e) => code`${e}`), + ], + { on: ",\n" }, + ); + + return code` +/** + * Metadata constants for ${entityName}. + * + * Use these instead of magic strings so TS catches typos and refactors stay + * coherent. Each non-dollar-prefixed key is a per-field object carrying + * name, label, view, an htmlType where the field's view maps to one, and the + * RHF-shaped validation rules derived from the field's validator children. + * + * placeholder and helpText are NOT emitted — no provider registers an attribute + * for them, so nothing here could derive one. (htmlType is emitted, but derived + * from the view subtype; the @htmlType override was removed for the same reason.) + * useEntityForm still honours placeholder if you add it: this file is generated, + * hand edits inside it survive regeneration through the three-way merge, and the + * consumers read this const rather than the metadata. That is the intended way + * to set it. + * + * Typical usage with the metaobjects React form helper: + * + * import { useEntityForm } from '@metaobjectsdev/react'; + * const form = useEntityForm(${entityName}, ${entityName}InsertSchema); + * + */ +export const ${entityName} = { +${body}, +} as const; +`; +} diff --git a/examples/advanced-modeling/codegen/templates/entity-file.ts b/examples/advanced-modeling/codegen/templates/entity-file.ts new file mode 100644 index 000000000..932048d7e --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/entity-file.ts @@ -0,0 +1,237 @@ +// Entity file composer — combines drizzle-schema, inferred-types, and zod-validators +// into one file with the @generated header. ts-poet deduplicates imports. +// +// Dispatch: +// isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections) +// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema) +// vanilla / write-through entity → Drizzle table path + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { renderDrizzleSchema } from "./drizzle-schema.js"; +import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; +import { renderZodValidators, isTphSubtype, primaryIdentityFieldNames } from "./zod-validators.js"; +import { renderEntityConstants } from "./entity-constants.js"; +import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; +import { renderFilterType } from "./filter-type.js"; +import { renderTphDiscriminatorUnion, isTphDiscriminatorBase } from "./tph-discriminator.js"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "@metaobjectsdev/codegen-ts"; +import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts"; +import { renderProjectionDecl } from "./projection-decl.js"; +import { + projectionViewName, projectionViewSchema, projectionViewRole, +} from "@metaobjectsdev/codegen-ts"; +import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { namesRef, namesConstArg, physicalNameExpr, sourceSchemaExpr } from "@metaobjectsdev/codegen-ts"; +import { hasWritableRdbSource } from "@metaobjectsdev/codegen-ts"; +import { renderValueObjectFile } from "./value-object-file.js"; +import { isAbstract } from "@metaobjectsdev/codegen-ts"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render-time options for the entity-file composer. + * + * `allowlists` (default `true`) controls whether the Fastify-flavored + * `FilterAllowlist` + `SortAllowlist` blocks (plus their + * `runtime-ts/drizzle-fastify` type-only imports) are emitted. Workers/Lambda + * consumers that don't mount Fastify-style server routes can pass `false` and + * drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side + * `Filter` type is always emitted — consumers still want it for typed + * client calls regardless of how the server is wired. + * + * Whether the file emits ANY server runtime binding at all (Drizzle table/view, + * the allowlists) is governed by the TARGET, not this option: `ctx.selfTarget.runtime`. + * A contract-only target (`runtime: false`) renders every object as its plain + * shape (interface + Zod) and every projection as its read schema — no + * `drizzle-orm`, no `runtime-ts`. `allowlists` is a finer Fastify-vs-Hono opt-out + * that only matters within a runtime target. + */ +export interface RenderEntityFileOpts { + readonly allowlists?: boolean; +} + +export function renderEntityFile( + entity: MetaObject, + ctx: RenderContext, + opts?: RenderEntityFileOpts, +): string { + // Contract-only target ⇒ no server runtime: no Drizzle pgView/pgTable, no + // runtime-ts allowlists. The read schema + inferred types still emit. + const runtime = ctx.selfTarget.runtime; + const allowlists = runtime ? (opts?.allowlists ?? true) : false; + + // --- Abstract path (shape only) --- + // An abstract entity contributes shape via inheritance only — it must NEVER + // produce a Drizzle table / migration footprint / filter allowlist, even when + // it carries a source.rdb child. This is the cross-port invariant (abstract → + // no instance/write artifacts, including CREATE TABLE). It still emits its + // value-object shape (interface + Zod) so subclasses/consumers can reference + // it. The entity-file generator suppresses this entirely when + // emitAbstractShapes is off; here we only guarantee "shape, never table". + if (isAbstract(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Projection path (read-only: view-backed entity with no table source) --- + // Projections intentionally get the z.enum() validator but NOT a named enum + // type alias — emitting aliases here is a deliberate v1 scope decision. + if (isProjection(entity)) { + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts builds, so the projection's view + // name + per-field dbCol reference the exact constant the names artifact exports + // (undefined when the artifact is not in this run — see names.ts). `namesRef`'s + // `{ resolved, symbol }` return is exactly the shape `ProjectionDeclOpts.names` wants. + return renderProjectionDecl(entity, ctx.loadedRoot, { + columnNamingStrategy: ctx.columnNamingStrategy, + dialect: ctx.dialect, + apiPrefix: ctx.apiPrefix, + timestampMode: ctx.timestampMode, + allowlists, + ctx, + // Contract target drops the Drizzle .existing() view decl + drizzle-orm import. + includeViewDecl: runtime, + names: namesRef(entity, ctx), + }); + } + + // --- Value-only / contract path (no Drizzle table) --- + // Reached when the entity has no writable source.rdb (in-memory / transit + // shape) OR the target is contract-only (a UI/wire package gets the read shape, + // not a DB table). Either way: interface + Zod, no migration footprint, no + // drizzle-orm. Consumers validate via the Zod schema and type via the interface. + // + // A TPH subtype (FR-017) also routes here: it inherits the base's writable + // source.rdb via extends (so hasWritableRdbSource is now true under the ADR-0039 + // resolving read), but the base owns the single shared table — the subtype must + // emit its per-subtype read schema (Schema), never its own pgTable. + if (!runtime || !hasWritableRdbSource(entity) || isTphSubtype(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Vanilla / write-through entity path --- + const enumAliases = renderEnumTypeAliases(entity, ctx); + // FR-017 Tier 1: when this entity carries @discriminator AND has concrete + // subtypes, append the discriminated-union type alias, type guards, and + // the parse(row) dispatcher. Returns null otherwise (no subtypes, or + // not a discriminator-bearing entity); the section is suppressed cleanly. + const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot); + // FR-017: when a discriminator base also has a union block, the union owns the + // bare `` type — so the inferred Drizzle row type is emitted as + // `Row` to avoid a duplicate `export type `. + const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot); + + // #214 — a write-through entity read-view (FR-024 §7): reads route to the replica + // VIEW, so the entity file additionally declares the `.existing()` view (carrying the + // derived fields the write table omits, #213) and a read schema `Schema` + // whose `z.infer` IS the read type (dialect-agnostic — a Drizzle view is not + // a Table, so InferSelectModel/`$inferSelect` don't uniformly apply; the Zod schema's + // nullability mirrors the view columns exactly like a projection). The write table + + // Insert/Update stay derived-free. `.existing()` is a runtime-target Drizzle binding, + // and this path only runs in a runtime target (contract-only/non-writable returned above). + // A TPH discriminator base owns `export type ` via its discriminated-union block, + // so it must NOT also emit the view-schema read type (that would be a duplicate-identifier + // compile error). A base+write-through combo keeps the TPH polymorphic read path (reads + // the base table); routing its reads through a replica view is a documented non-goal. + const writeThrough = isWriteThrough(entity) && !tphBase; + // Rendered BEFORE the replica view below, for two reasons that both matter: the table + // DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate, + // and a duplicate module-scope const is a parse error), and those consts must precede the + // view's use of them in the emitted file or the view hits a TDZ ReferenceError at import. + const declaredEnumIntCodecs = new Set(); + const schemaBlock = renderDrizzleSchema(entity, ctx, declaredEnumIntCodecs); + const viewSections: Code[] = []; + if (writeThrough) { + const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1); + const fields = entity.fields(); + const entityPkg = effectivePackage(entity); + // ADR-0044/#228 — resolve a view column's `@objectRef` to the value object's + // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports + // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) + // under a cross-package short-name collision. + const voRef = (field: MetaField): { name: string; module: string } => { + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); + return { name, module }; + }; + // ONE selection of the replica source: `projectionViewName` names it, and this is the + // role its entry has in Names.sources. Hardcoding "replica" here would be a + // second derivation of the very thing extract-view-spec.ts exists to decide once. + const viewRole = projectionViewRole(entity) ?? SOURCE_ROLE_REPLICA; + const entityNames = namesRef(entity, ctx); + const viewOpts = { + dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voRef, + // PK fields type non-null in the replica-view decl + read schema even + // without @required (a PK is never NULL; see ViewDeclOpts.pkFieldNames). + pkFieldNames: new Set(primaryIdentityFieldNames(entity)) as ReadonlySet, + // §A6 — the entity's names artifact supplies this view's COLUMN constants. A + // replica view exposes the entity's own fields, so `Names.fields..column` + // is the same physical name the table side binds; a derived field the artifact + // does not carry falls back to the literal via `columnExpr`. The view's own NAME + // is passed separately below and stays a literal — the artifact holds the primary + // (table) source's name, not this one. + names: entityNames, + // The table above already declared these; the view references them by name. + declaredEnumIntCodecs, + // The replica view's OWN @schema, from the same source node projectionViewName picks + // — never the entity's, which is the WRITE TABLE's and would qualify this view with a + // schema that belongs to something else. A view and the table it replicates need not + // live in the same schema, and a name paired with someone else's schema is worse than + // no schema at all. + // + // Both this and the view's NAME are now CONSTANTS. They were literals for a real + // reason that has stopped being true: Names carried the primary source only, + // so a write-through entity's two physical names had one slot between them and the + // second was emitted in full — the same escape C#'s AppDbContext had, in a different + // language, which is what showed it was the artifact's shape and not the binding. + schema: sourceSchemaExpr(entityNames, viewRole) ?? projectionViewSchema(entity), + }; + const z = imp("z@zod"); + const docs = renderDocsFor(entity); + const docsPrefix = docs ? `${docs}\n` : ""; + viewSections.push( + renderExistingViewDecl( + fields, + physicalNameExpr(entityNames, projectionViewName(entity, ctx.columnNamingStrategy), entity, viewRole), + `${camel}View`, + viewOpts, + ), + code` +export const ${entity.name}Schema = ${renderViewReadZodObject(fields, viewOpts)}; +`, + code` +${docsPrefix}export type ${entity.name} = ${z}.infer; +`, + ); + } + + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts (and this file's own projection + // branch above) build, so the descriptor's $table references the exact constant the + // names artifact exports (undefined when the artifact is not in this run). + const constantsNames = namesRef(entity, ctx); + + const sections: Code[] = [ + schemaBlock, + ...viewSections, + renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */), + ...(enumAliases !== null ? [enumAliases] : []), + renderZodValidators(entity, ctx), + renderEntityConstants(entity, ctx.apiPrefix, namesConstArg(constantsNames)), + ...(allowlists ? [renderFilterAllowlist(entity, undefined, ctx), renderSortAllowlist(entity, undefined, ctx)] : []), + renderFilterType(entity), + ...(tphBlock !== null ? [tphBlock] : []), + ]; + + // Render ts-poet body first (ts-poet hoists imp()-tracked imports to the top), + // then prepend the @generated header so it lands at line 1 — convention for + // generated files and what most tooling (overwrite-policy, IDEs) expects. + const body = joinCode(sections, { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Source metadata: ${entity.name} (${entity.fqn()})\n` + + sidecarLine(`${entity.name}.extra.ts`); + return header + body; +} diff --git a/examples/advanced-modeling/codegen/templates/enum-int-codec.ts b/examples/advanced-modeling/codegen/templates/enum-int-codec.ts new file mode 100644 index 000000000..cbef388d9 --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/enum-int-codec.ts @@ -0,0 +1,67 @@ +/** + * The int-backed `field.enum` codec emitter, shared by the two artifacts that can + * reference one: the entity's Drizzle table (`drizzle-schema.ts`) and any view-backed + * read model (`view-decl.ts` — a projection or an entity read-view). + * + * It lives in its own module because the codec is a MODULE-LOCAL const in whatever file + * declares the column, so every emitter that can produce such a column must be able to + * declare it too. While only the table template owned it, the view template emitted the + * call site and let the const name fall through to `imp(fnName@drizzle-orm/*-core)` — + * importing a member that package does not export, which `meta gen` reported as success + * and only `tsc` caught. + * + * The invariant this module exists to protect: the codec is declared ONCE PER MODULE, and + * it must precede every use. A write-through entity puts its table and its replica view in + * one file and both reference the same consts, so `entity-file.ts` renders the table first, + * collects the names it declared, and hands them to the view — which then references them + * instead of re-declaring. Two module-scope `const`s of one name is a JS parse error, and a + * declaration emitted after its use is a TDZ ReferenceError at import: both are worse than + * the missing-codec bug, because neither lets the module load at all. + */ +import { code, imp, type Code } from "@metaobjectsdev/codegen-ts"; +import type { EnumIntCustomType } from "@metaobjectsdev/codegen-ts"; + +/** + * Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two + * lookup maps. + * + * The codec lives HERE, in the column definition, so nothing downstream needs to + * know about it: `db.insert().values()` encodes on bind, a selected row decodes on + * read, and a filter comparison encodes because Drizzle binds through the column + * type. That is why this shape was chosen over a Zod write-transform plus a + * generated read-decode — TS's generated queries return raw Drizzle rows and have + * no decode seam, so the query-layer approach meant inventing one and wrapping + * every generated read. It is also the direct analogue of what the other four + * ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed + * `customEnumeration`, Python `ObjectManager` coercion). + * + * `fromDriver` throws on an unmapped integer rather than returning undefined: a + * value outside the map means the DB holds data the model says is impossible + * (a hand-written INSERT, or a member removed without a migration), and silently + * yielding `undefined` for a non-nullable field would surface far from the cause. + */ +export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code { + const customTypeSym = imp(`customType@${importModule}`); + const union = t.members.map((m) => JSON.stringify(m)).join(" | "); + const toEntries = t.members + .map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`) + .join(", "); + const fromEntries = t.members + .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`) + .join(", "); + return code` +const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; +const ${t.fromIntConstName}: Record = { ${fromEntries} }; +const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => ${t.toIntConstName}[value], + fromDriver: (value) => { + const member = ${t.fromIntConstName}[value]; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`); + } + return member; + }, +}); +`; +} diff --git a/examples/advanced-modeling/codegen/templates/enums-file.ts b/examples/advanced-modeling/codegen/templates/enums-file.ts new file mode 100644 index 000000000..0bfac3256 --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/enums-file.ts @@ -0,0 +1,61 @@ +// FR-019 — shared enums module. +// +// Emits, ONCE per run, the materialized (non-@provided) shared enum types that +// at least one concrete entity field references via `extends` of a root-level +// abstract `field.enum`. Each enum yields: +// • `export type = "A" | "B";` — the cross-port type identity +// • `export const Enum = z.enum(["A","B"]);` — the shared Zod validator +// +// Consuming entity files import these instead of redeclaring the union inline. +// @provided enums are NEVER materialized here (they live in hand-written code, +// imported from the configured providedEnumModule). + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts"; +import { materializedSharedEnums, type SharedEnum } from "@metaobjectsdev/codegen-ts"; +import { enumUnionString } from "./inferred-types.js"; + +/** Basename (no extension) of the shared-enums module emitted at the entity-module target root. */ +export const SHARED_ENUMS_BASENAME = "enums"; + +/** The exported Zod-constant name for a shared enum (`Enum`). */ +export function sharedEnumZodConstName(enumName: string): string { + return `${enumName}Enum`; +} + +/** One enum's two declarations (type alias + shared z.enum const). */ +function renderOneSharedEnum(e: SharedEnum): Code { + const z = imp("z@zod"); + const members = e.values.map((v) => JSON.stringify(v)).join(", "); + return code` +export type ${e.name} = ${enumUnionString(e.values)}; +export const ${sharedEnumZodConstName(e.name)} = ${z}.enum([${members}]); +`; +} + +/** + * The full shared-enums module body, or null when the model has no materialized + * shared enums (so the generator emits no file at all). + * + * `opts.select` (FR-023 §11.1 item 2) — the model-wide scope predicate over a + * CONSUMING entity's `resolutionKey()`: an enum extended only by entities + * `select` excludes is not materialized. This module renders once per run from + * the WHOLE loaded root (never per-generator-filtered — see #266), so it is the + * one shared-enums call site that must be threaded the scope predicate + * separately from `ctx.matches`. Omitted ⇒ every entity counts, byte-identical + * to a caller that never passes `opts` at all. + */ +export function renderSharedEnumsFile( + root: MetaRoot, + opts?: { select?: (fqn: string) => boolean }, +): string | null { + const enums = materializedSharedEnums(root, opts?.select); + if (enums.length === 0) return null; + + const body = joinCode(enums.map(renderOneSharedEnum), { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Shared enum types (FR-019): one declaration per reused package-level enum.\n`; + return header + body; +} diff --git a/examples/advanced-modeling/codegen/templates/field-meta.ts b/examples/advanced-modeling/codegen/templates/field-meta.ts new file mode 100644 index 000000000..f0e666fbb --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/field-meta.ts @@ -0,0 +1,284 @@ +// Shared field-level metadata helpers — consumed by entity-constants.ts, +// projection-decl.ts, and any future generator that needs per-field inference. +// +// All helpers take a MetaField node. + +import { MetaField } from "@metaobjectsdev/metadata"; +import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata"; +import { stripPackage } from "@metaobjectsdev/metadata"; +import { + FIELD_SUBTYPE_STRING, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_TIME, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_ENUM, + FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_URI, + FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_OBJECT, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STRING_FORMAT, + FIELD_ATTR_LENIENT, + STRING_FORMAT_EMAIL, + STRING_FORMAT_HOSTNAME, + VIEW_SUBTYPE_TEXT, + VIEW_SUBTYPE_DATE, + VIEW_SUBTYPE_NUMBER, + VIEW_SUBTYPE_CHECKBOX, + VIEW_SUBTYPE_CURRENCY, + VIEW_SUBTYPE_DROPDOWN, + FIELD_ATTR_CURRENCY, + FIELD_ATTR_CURRENCY_DEFAULT, + VIEW_CURRENCY_ATTR_LOCALE, + VIEW_CURRENCY_ATTR_LOCALE_DEFAULT, +} from "@metaobjectsdev/metadata"; +import { enumValues, zodEnumExpr } from "@metaobjectsdev/codegen-ts"; +import { viewForContext } from "@metaobjectsdev/codegen-ts"; +import { ZOD_INET_EXPR } from "./net-regex.js"; + +// --------------------------------------------------------------------------- +// inferViewKind +// --------------------------------------------------------------------------- + +/** + * Resolve the cell-renderer key (view kind) for a field, for the surface named + * by `context`. + * + * An explicit view declared for THAT surface wins; field subType determines the + * default. #356: `context` is required rather than defaulting, because a caller + * that omits it is exactly the caller that renders somebody else's view. + */ +export function inferViewKind(field: MetaField, context: string): string { + // Explicit view for this surface (own or inherited via extends) has highest priority. + const viewChild = viewForContext(field, context); + if (viewChild) return viewChild.subType; + // Field subtype → default view. + return defaultViewForSubType(field.subType); +} + +function defaultViewForSubType(subType: string): string { + switch (subType) { + case FIELD_SUBTYPE_BOOLEAN: + return VIEW_SUBTYPE_CHECKBOX; + case FIELD_SUBTYPE_INT: + case FIELD_SUBTYPE_LONG: + case FIELD_SUBTYPE_DOUBLE: + case FIELD_SUBTYPE_FLOAT: + case FIELD_SUBTYPE_DECIMAL: + return VIEW_SUBTYPE_NUMBER; + case FIELD_SUBTYPE_DATE: + case FIELD_SUBTYPE_TIME: + case FIELD_SUBTYPE_TIMESTAMP: + return VIEW_SUBTYPE_DATE; + case FIELD_SUBTYPE_CURRENCY: + return VIEW_SUBTYPE_CURRENCY; + case FIELD_SUBTYPE_ENUM: + // A closed member set is a CHOICE, and the generated form has always rendered it as + // one: `codegen-ts-react`'s `viewKindFor` returns `dropdown` for an enum declaring no + // view, and emits a `` value, which +// the generated React form submits; +// - `HH:MM[:SS[.f]]` (+ an offset) — Postgres time / timetz, ``. + +const TIME_OF_DAY = "(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?"; +const UTC_OFFSET = "(?:[Zz]|[+-](?:[01]\\d|2[0-3])(?::?[0-5]\\d)?)?"; +// 31-day months, 30-day months, February 1–28 in any year, and February 29 in a leap +// year: `YY` + a multiple of 4 other than `00`, or a multiple-of-4 century + `00`. +const DAY_IN_MONTH = + "(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))"; +const LEAP_YEAR = "(?:\\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)"; +const CALENDAR_DATE = `(?:\\d{4}-${DAY_IN_MONTH}|${LEAP_YEAR}-02-29)`; + +// Each check carries its own short message. Without one, Zod 4 answers +// "Invalid string: must match pattern /…/" — the whole calendar regex, ~300 characters, +// in every API 400 for a bad date. A string second argument to `.regex()` is the issue +// message on Zod 3 and Zod 4 alike. +export const DATE_FORMAT_MESSAGE = "must be an ISO date (YYYY-MM-DD)"; +export const TIME_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]], optional offset)"; +export const TIMESTAMP_FORMAT_MESSAGE = + "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])"; + +/** `field.date` — an ISO calendar date, `YYYY-MM-DD`, and nothing else. */ +export const ZOD_DATE_EXPR = + `z.string().regex(/^${CALENDAR_DATE}$/, ${JSON.stringify(DATE_FORMAT_MESSAGE)})`; + +/** `field.time` — a time of day, `HH:MM[:SS[.fraction]]`, optionally zoned. */ +export const ZOD_TIME_EXPR = + `z.string().regex(/^${TIME_OF_DAY}${UTC_OFFSET}$/, ${JSON.stringify(TIME_FORMAT_MESSAGE)})`; + +/** `field.time` on a MySQL column — a time of day with NO offset. MySQL's `TIME` has no + * zone and refuses one (`'12:30:00+05:00'` is ERROR 1292, Incorrect time value), so the + * portable check above would pass a body the database then rejects with a 500. */ +export const TIME_NO_OFFSET_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]]), without an offset"; +export const ZOD_TIME_NO_OFFSET_EXPR = + `z.string().regex(/^${TIME_OF_DAY}$/, ${JSON.stringify(TIME_NO_OFFSET_FORMAT_MESSAGE)})`; + +/** `field.timestamp` (string mode) — an ISO 8601 / SQL timestamp: a calendar date, + * optionally followed by `T` or a space, a time of day, and a UTC offset. */ +export const ZOD_TIMESTAMP_EXPR = + `z.string().regex(/^${CALENDAR_DATE}(?:[Tt ]${TIME_OF_DAY}${UTC_OFFSET})?$/, ${JSON.stringify(TIMESTAMP_FORMAT_MESSAGE)})`; + +// An instant timestamp is rewritten to UTC on WRITE. +// +// SQLite and D1 keep a string-mode timestamp as TEXT and compare it as text, so a value +// stored as sent — `2026-09-21T01:00:00+05:00`, which is 20:00Z — sorted after +// `2026-09-20T21:00:00Z` and `filter[tastedAt][gt]=…21:00:00Z` returned it. The rewritten +// form is the canonical wire spelling of an instant (fixtures/persistence-conformance/ +// normalization.md): `YYYY-MM-DDTHH:MM:SS[.fff]Z`, millisecond resolution, no trailing +// zeros, no fraction at all when it is zero. Its prefix through the seconds is fixed-width, +// so text order equals instant order down to the second. The runtime filter parser +// rewrites a bound the same way (`utcIsoInstant` in runtime-ts — the two must agree). +// Postgres timestamptz stores the instant either way, so it is unaffected. +// +// A value ALREADY spelled `YYYY-MM-DDTHH:MM:SS[.f…]Z` is kept byte-for-byte: it is in UTC and +// orders correctly as it is, and rc.4 turning `…12:10:00Z` into `…12:10:00.000Z` broke an +// adopter's round-trip of a timestamp inside a jsonb value object. +// +// A value with NO zone is read as UTC, and a date-only value as midnight UTC. A default +// `field.timestamp` is an instant whose wire form is "always UTC" +// (docs/features/api-contract.md, "Type encodings"; ADR-0036 Wave 2); a wall clock is +// what `@localTime` declares, and those fields never reach this helper. Kept as sent, +// `2026-10-06` sorted before `2026-10-06T00:00:00Z` as text, so +// `filter[dueAt][gte]=2026-10-06T00:00:00Z` omitted the row. The string handed to +// `Date.parse` is the ECMAScript date-time format it is REQUIRED to read (seconds and a +// three-digit fraction padded in, the offset as `±HH:MM`), so the result does not depend +// on the engine's tolerance for Postgres' `+00`, a six-digit fraction, or a zoneless string +// (which ECMAScript would read as LOCAL time). It runs after the format check, so it only +// ever sees a well-formed value. +// +// Emitted ONCE per module as a local function (generated model code imports nothing from +// MetaObjects at runtime), and referenced by name from each schema that needs it. + +/** Name of the module-local normalizer a timestamp WRITE shape transforms through. */ +export const UTC_TIMESTAMP_HELPER = "utcIsoTimestamp"; + +/** The normalizer's declaration, emitted at the top of a module that uses it. */ +export const UTC_TIMESTAMP_HELPER_DECL = [ + "/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept", + " * as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC.", + " * SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */", + `function ${UTC_TIMESTAMP_HELPER}(v: string): string {`, + " if (/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$/.test(v)) return v;", + " const m = /^(\\d{4}-\\d{2}-\\d{2})(?:[Tt ](\\d{2}:\\d{2})(?::(\\d{2})(?:\\.(\\d+))?)?(?:([Zz])|([+-]\\d{2}):?(\\d{2})?)?)?$/.exec(v);", + " if (m === null) return v;", + ' const t = Date.parse(`${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}`}`);', + ' return Number.isNaN(t) ? v : new Date(t).toISOString().replace(/\\.?0+Z$/, "Z");', + "}", +].join("\n"); + +/** `field.timestamp` (string mode, instant — not `@localTime`) on a WRITE shape: the format + * check, then the to-UTC rewrite above. A module emitting it must also emit + * {@link UTC_TIMESTAMP_HELPER_DECL}. */ +export const ZOD_TIMESTAMP_UTC_EXPR = `${ZOD_TIMESTAMP_EXPR}.transform(${UTC_TIMESTAMP_HELPER})`; diff --git a/examples/showcase/codegen/templates/drizzle-schema.ts b/examples/showcase/codegen/templates/drizzle-schema.ts new file mode 100644 index 000000000..da6208c9f --- /dev/null +++ b/examples/showcase/codegen/templates/drizzle-schema.ts @@ -0,0 +1,737 @@ +// Drizzle schema template — emits a single sqliteTable() or pgTable() definition, +// with FK .references() on FK columns derived from relationship children, +// plus the relations() block auto-emitted at the end. + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { + IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, + IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, + GENERATION_INCREMENT, GENERATION_UUID, + FIELD_ATTR_AUTO_SET, + FIELD_ATTR_OBJECT_REF, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_DATE, +} from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { + mapColumnType, PG_IDENTITY_CAPABLE_FNS, + type ColumnSpec, type EnumIntCustomType, +} from "@metaobjectsdev/codegen-ts"; +import { tableNameFromEntity } from "@metaobjectsdev/codegen-ts"; +import { dialectModule } from "@metaobjectsdev/codegen-ts"; +import { + namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr, +} from "@metaobjectsdev/codegen-ts"; +import { resolveTableSchema } from "@metaobjectsdev/metadata"; +import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderRelationsBlock } from "./relations-block.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js"; +import { tphStorageObject } from "./zod-validators.js"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render the Drizzle table definition for one entity, including: + * - FK .references() on FK columns derived from relationship children + * - relations() block at the end of the section + * + * Returns a Code object so ts-poet can deduplicate imports when this composes + * with the rest of the entity file. Biome formatting runs after composition. + */ +export function renderDrizzleSchema( + obj: MetaObject, + ctx: RenderContext, + /** + * OUT param: the int-backed-enum codec const names this table DECLARES. A caller that + * emits another artifact referencing the same codecs into the SAME module — entity-file's + * write-through replica view — passes a set here and hands it to that emitter, which then + * references the consts instead of re-declaring them. A duplicate module-scope `const` is + * a JS parse error, so the module would not even load. + */ + declaredEnumIntCodecs?: Set, +): Code { + const dialect = ctx.dialect; + const { tableFn, core: importModule } = dialectModule(dialect); + const tableFnSym = imp(`${tableFn}@${importModule}`); + + const tableName = obj.dbTable ?? tableNameFromEntity(obj.name, ctx.columnNamingStrategy); + const varName = ctx.collectionName(obj.name); + + // §A6/§B2 — reference `Names` instead of embedding the physical names a SECOND + // time. `namesRef` is undefined whenever the names generator is not active in this run + // (opt-in under ADR-0034 scaffold-and-own — an unconditional import would break every + // project that has not enabled it) or the object has no primary source — both PRESENCE + // guards. No per-site equality check: whenever the constant exists, it IS the name; + // `primaryRdbSource` (@metaobjectsdev/metadata) already refuses (throws) any object + // whose `@role: primary` sources disagree on a name, and BOTH branches reach it — the + // constant through `resolveObjectNames`, and `obj.dbTable` above on its own — so this + // file never has to re-check, and the literal arm is refused too rather than silently + // binding the inherited parent's table when the names generator is out of the run. + const names = namesRef(obj, ctx); + const tableNameExpr: Code = physicalNameExpr(names, tableName, obj); + + // @schema — the table binding must land in the SAME schema the migration creates the table + // in. migrate-ts qualifies every table, view, index and FK it emits (`CREATE TABLE + // "sales"."widgets"`), and under ADR-0015 it owns where a table lives; codegen dropping the + // schema meant generated queries hit `public.widgets` while the migration built + // `sales.widgets`. Whether that surfaced depended entirely on the adopter's `search_path`, + // which is the worst kind of bug — it works on the machine that has it configured. + // + // Drizzle expresses this with a DIFFERENT call shape rather than a third argument: + // `pgSchema("sales").table(name, …)`. It is built inline rather than hoisted to a `const` + // so two tables in one file sharing a schema cannot collide on a generated identifier; a + // schema object is just a descriptor, so a second call costs nothing. + // + // sqlite/d1 are excluded because SQLite has no schema concept at all — migrate already + // REFUSES a non-default @schema there (expected-schema.ts), so there is nothing to honour + // and no divergence to close. + const tableSchema = dialect === "sqlite" ? undefined : resolveTableSchema(obj); + const schemaExpr: Code | undefined = + tableSchema === undefined ? undefined + : sourceSchemaExpr(names) ?? code`${JSON.stringify(tableSchema)}`; + const tableCall: Code = schemaExpr === undefined + ? code`${tableFnSym}` + : code`${imp(`${dialect === "mysql" ? "mysqlSchema" : "pgSchema"}@${importModule}`)}(${schemaExpr}).table`; + // A column's constant, on the same terms — but resolved against the entity that DECLARES + // the field, which is not always `obj`. Under TPH the fold below emits a subtype's own + // columns into this base's table, and those columns live in the SUBTYPE's names artifact; + // looking them up in the base's finds nothing. + // + // This used to pass `names` for every column and call the resulting miss "normal". It was + // not: the base's artifact genuinely does not carry a subtype's column, so every TPH + // subtype column fell through to the literal while `Names.fields..column` sat + // right there, already imported by the subtype's own artifact. C# never had the bug + // because its emitter passes the subtype entity to `ColumnRef`; this is that shape. + // + // `fromPackage` stays `obj.package` so the import path is computed relative to the file + // being emitted (this base's), not to the subtype's own package. + // THIS entity's artifact first. It already carries every INHERITED field — a concrete + // entity's artifact spreads its abstract parent's `fields` — so an `extends` chain needs + // no redirect, and taking one would be a regression: an abstract base has no source, so + // it has no artifact of its own to redirect TO, and the column would fall back to a + // literal that `obj`'s artifact was carrying all along. + // + // A miss here means the field belongs to a different entity ENTIRELY, which under TPH is + // exactly the subtype whose columns this base's table absorbs. Only then resolve against + // the declaring entity, found through the field's own parent link. + // + // `isMetaObject`, never `instanceof`: two physical copies of @metaobjectsdev/metadata in + // one process give the class object and the instance different identities, so the check + // would return false for a real node — silently. + // `obj` never changes across this function, so its effective package is computed once + // and reused everywhere below (the `fromPackage` passed to `namesRef` here, and the + // per-field `renderColumn` calls in both the own-field and TPH-subtype-fold loops). + const pkg = effectivePackage(obj); + const columnNameExpr = (field: MetaField, dbName: string): Code => { + if (names === undefined || names.resolved.fields[field.name] !== undefined) { + return columnExpr(names, field.name, dbName); + } + const owner = field.parent; + // `fromPackage` is obj's: the import path is relative to the file being emitted (this + // base's), not to the subtype's own package. + const ownerNames = + owner !== undefined && isMetaObject(owner) && owner !== obj + ? namesRef(owner, ctx, pkg) + : undefined; + return columnExpr(ownerNames, field.name, dbName); + }; + + const primary = obj.primaryIdentity(); + const rawPkFields = primary?.attr(IDENTITY_ATTR_FIELDS); + const pkFieldsList: string[] = Array.isArray(rawPkFields) + ? rawPkFields as string[] + : typeof rawPkFields === "string" + ? rawPkFields.split(",").map((f) => f.trim()).filter(Boolean) + : []; + const pkFieldNames = new Set(pkFieldsList); + const pkGeneration = primary?.attr(IDENTITY_ATTR_GENERATION) as string | undefined; + + const fkMap = buildFkMapForEntity(obj, ctx); + + const isComposite = pkFieldNames.size > 1; + + // Collect secondary identities and the field names that need .unique() on + // their column. identity.secondary is ALWAYS unique (no @unique attr) — only + // An `identity.secondary` reaches the table ONLY as the `uniqueIndex(...)` callback + // emitted further down — never additionally as `.unique()` on its column. + // + // It used to do both for a single-column identity, under a comment describing an + // either/or ("single-column identities propagate .unique() to the column; multi-column + // ones emit a callback uniqueIndex() entry instead") that the code did not implement: + // the callback loop has no column-count condition, so a one-column secondary got a + // column-level unique AND a table-level unique index. `migrate-ts` emits only the index + // for that shape — it mirrors a `
__unique` constraint for `@unique` fields + // and nothing extra for a secondary — so the generated schema declared a constraint the + // database does not have, and `drizzle-kit push` run against it proposes creating one. + // + // `.unique()` still comes from the FIELD's own `@unique`, via the column mapper, which + // is exactly the arm migrate mirrors. + const secondaryIdentities = obj.secondaryIdentities(); + + const columnLines: Code[] = []; + // Collect CHECK constraints for enum columns; emitted as table-level check() callbacks. + const checkConstraints: Array<{ name: Code; expr: Code }> = []; + /** + * One CHECK entry, composed against `Names` when the artifact is in the run. + * + * #293 — migrate's `
__chk` is the shared convention and it is the AUTHORITY: + * its suffix form is systematic across five constraint kinds and those names are already + * in live databases, so flipping migrate instead would emit DROP/ADD CONSTRAINT churn + * against production for a cosmetic difference. Referencing the constants therefore + * composes the IDENTICAL string at runtime rather than changing the convention — the + * template literal below evaluates to exactly what the literal arm spells. + * + * The expression's column goes through `sql.raw`, not `sql.identifier`: the latter QUOTES + * the name, which changes the constraint text migrate compares against. + */ + const checkEntry = (field: MetaField, spec: ColumnSpec): { name: Code; expr: Code } => { + const colExpr = columnNameExpr(field, spec.dbName); + const values = spec.checkConstraintValues; + const sqlSym = imp("sql@drizzle-orm"); + // No artifact in this run (the documented ADR-0034 opt-out) — spell both halves, which + // is what this file did unconditionally before. + if (names === undefined || values === undefined) { + return { + name: code`${JSON.stringify(`${tableName}_${spec.dbName}_chk`)}`, + expr: code`${spec.checkConstraint ?? ""}`, + }; + } + // `\${` emits a literal `${` into the generated source: the name becomes a template + // literal and the expression an interpolation, both evaluated at run time in the + // consumer's code, where they produce the identical strings the literal arm spells. + return { + name: code`\`\${${tableNameExpr}}_\${${colExpr}}_chk\``, + expr: code`\${${sqlSym}.raw(${colExpr})} IN (${values})`, + }; + }; + // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by + // const name so a shared enum used by two fields of the SAME entity emits once. + const enumIntTypes = new Map(); + for (const child of obj.fields()) { + // #213 — a derived (origin-bearing) field is read-only, materialized on the + // read (view) side, NOT a column on the entity's write table (FR-024 §7). + // Emitting it here would declare a Drizzle column for a table column migrate + // no longer creates. + if (child.isDerived()) continue; + const isPk = pkFieldNames.has(child.name); + const fkInfo = fkMap.get(child.name); + // Compute the column spec once per field and reuse it for both the column + // line and the CHECK collection. + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // FR-017 Tier 2 — TPH single-table inheritance. When this entity is a + // discriminator base, fold every concrete subtype's own columns into this + // one table. Subtype-only columns are ALWAYS nullable (a row of any other + // subtype stores NULL there) and never carry a DB default (a default would + // stamp onto other-subtype inserts), regardless of the field's @required. + // Subtype entities emit no table of their own (the value-object path). + for (const child of collectTphSubtypeFields(obj, ctx.loadedRoot)) { + // #213 — a TPH subtype's derived field is read-only too; never a table column. + if (child.isDerived()) continue; + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn( + spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true, + ); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + // Enum CHECK constraints stay valid under TPH: `NULL IN (...)` is NULL + // (not false), so other-subtype rows with NULL pass the check. + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // Build all table callback entries + const callbackEntries: Code[] = []; + + if (isComposite && primary !== undefined) { + callbackEntries.push(buildCompositeKeyCallback(pkFieldNames, importModule)); + } + + /** + * One index callback entry, for BOTH `identity.secondary` (unique) and `index.lookup` + * (non-unique). The two shapes differ only in that boolean, and migrate treats their + * physical attributes identically — `@expr`/`@using`/`@orders`/`@where` are registered on + * both by the same provider — so they are emitted by ONE function here. They used to be + * two near-identical loops, which is how the physical attributes came to be honoured by + * neither: a rule written twice is a rule that can be forgotten twice. + * + * What was missing is a SHAPE divergence, not a naming one (the names were reconciled + * separately). An `@expr`-only index emitted NOTHING at all here, because both loops + * required a non-empty `@fields` — while migrate emits it, keying off `@expr`. And + * `@where` never reached this file, so a PARTIAL unique index read as a FULLY unique one + * in the generated schema: `drizzle-kit push` would propose replacing the database's + * partial index with a total one, and any diff written from generated source disagreed + * with the database over a difference that was not real. Same class as the sqlite + * emitter dropping these in 0.15.21. + */ + const indexEntry = ( + // `type`/`subType` as well as `name`: the index-name resolver and the artifact's + // `identities` / `indexes` collections both key off which KIND of node this is, and + // ADR-0040 put that distinction in the type rather than in an attribute. + node: { name: string; type: string; subType: string; attr: (n: string) => unknown }, + fieldNames: string[], + unique: boolean, + ): Code | undefined => { + const str = (n: string): string | undefined => { + const raw = node.attr(n); + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + }; + const expr = str(IDENTITY_ATTR_EXPR); + // An expression index keys off @expr; a plain one needs @fields. Mirrors migrate's + // `if (fieldNames.length === 0 && expr === undefined) continue`. + if (fieldNames.length === 0 && expr === undefined) return undefined; + + const sqlSym = imp("sql@drizzle-orm"); + const indexSym = imp(`${unique ? "uniqueIndex" : "index"}@${importModule}`); + + // @orders is positional against @fields; migrate only serializes it when at least one + // entry is `desc`, because an all-ascending array must be indistinguishable from no + // orders at all for diff stability. Match that exactly or the two disagree on a + // no-op. + const ordersRaw = node.attr(IDENTITY_ATTR_ORDERS); + const orders = Array.isArray(ordersRaw) + ? fieldNames.map((_, i) => (ordersRaw[i] === "desc" ? "desc" : "asc")) + : []; + const anyDesc = orders.some((o) => o === "desc"); + const colRefs = fieldNames.map((f, i) => + anyDesc ? `table.${f}.${orders[i] === "desc" ? "desc" : "asc"}()` : `table.${f}`); + + // sqlite's `.on()` accepts only columns — no SQL — so an expression index is not + // expressible there. It is skipped rather than emitted wrong; migrate still creates it, + // and a Drizzle declaration that silently indexed a COLUMN instead of the expression + // would be a worse answer than an absent one. + if (expr !== undefined && dialect === "sqlite") return undefined; + + // @using selects the access method and takes the columns itself, so it REPLACES .on(). + // Postgres-only: SQLite has no USING clause, and MySQL's (btree/hash) is an index + // option rather than an access method taking the columns. migrate omits a `btree` + // value as the default, and so does this — emitting it would be a spurious difference. + const using = dialect === "postgres" ? str(IDENTITY_ATTR_USING) : undefined; + const target: Code = + expr !== undefined ? code`${sqlSym}\`${expr}\`` + : code`${colRefs.join(", ")}`; + // The CONSTANT when the artifact is in the run, the literal otherwise — and the + // literal arm goes through `resolveIndexName` too, so the two paths answer with one + // function. Both halves matter: migrate's expected-schema resolves the same name and + // the two spelled it independently until now, which is how codegen came to declare + // `idx_
_` against an index the database actually held under + // `identity.name` (fdb4118f1); and an empty `index.lookup` name — which the loader + // accepts, unlike an identity — is refused here rather than emitting `index("")`. + const indexName = indexNameExpr(names, node); + const head: Code = + using !== undefined && using !== "btree" + ? code`${indexSym}(${indexName}).using(${JSON.stringify(using)}, ${target})` + : code`${indexSym}(${indexName}).on(${target})`; + + // @where — the partial-index predicate, on Postgres and SQLite. MySQL has no partial + // indexes, so there the index is declared over the whole table; the adopter's DDL + // cannot say otherwise either. + const where = dialect === "mysql" ? undefined : str(IDENTITY_ATTR_WHERE); + return where === undefined ? head : code`${head}.where(${sqlSym}\`${where}\`)`; + }; + + // identity.secondary — always unique (uniqueness is in the type, no boolean). + for (const sec of secondaryIdentities) { + const fields = sec.attr(IDENTITY_ATTR_FIELDS) as string[] | undefined; + // The identity's OWN name, which is what migrate puts in the database + // (`expected-schema.ts`, secondary-identity pass) and what `index.lookup` three + // blocks below already emits. This used to compose `idx_
_...` by running + // the naming strategy over the FIELD names — two defects in one expression: the name + // disagreed with migrate's (so a DROP INDEX written from generated source failed, and + // any schema diff reported a difference that was not real), and running the strategy + // over a field NAME cannot see `@column`, so a declared physical column was invisible + // to it. Gated by `secondary-index-name-parity.test.ts`, whose fixture is de-blinded + // against exactly that second defect. + // Column refs use the callback param `table` (not the outer varName) so TS does not see + // the table referencing itself inside its own initializer (TS7022/TS7024). + const entry = indexEntry(sec, Array.isArray(fields) ? fields : [], /* unique */ true); + if (entry !== undefined) callbackEntries.push(entry); + } + + // index.lookup — always non-unique. Use MetaIndex.fields() per ADR-0039. + for (const lookup of obj.lookupIndexes()) { + const entry = indexEntry(lookup, lookup.fields(), /* unique */ false); + if (entry !== undefined) callbackEntries.push(entry); + } + + // Emit table-level CHECK constraints for enum fields. Both halves are composed by + // `checkEntry` above, which owns the constant-vs-literal decision for the pair. + for (const { name, expr } of checkConstraints) { + const checkSym = imp(`check@${importModule}`); + const sqlSym = imp("sql@drizzle-orm"); + callbackEntries.push(code`${checkSym}(${name}, ${sqlSym}\`${expr}\`)`); + } + + let tableBlock: Code; + if (callbackEntries.length > 0) { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}, (table) => [ + ${joinCode(callbackEntries, { on: ",\n ", trim: false })} +]); +`; + } else { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}); +`; + } + + // Emit the relations() block (returns null if no relations). + const relationsBlock = renderRelationsBlock(obj, ctx); + + // Int-backed enum codecs are declared BEFORE the table that references them. + // Sorted by const name so output is deterministic regardless of field order. + const enumIntBlocks = [...enumIntTypes.values()] + .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName)) + .map((t) => renderEnumIntCustomType(t, importModule)); + if (declaredEnumIntCodecs !== undefined) { + for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name); + } + + const blocks: Code[] = [...enumIntBlocks, tableBlock]; + if (relationsBlock !== null) blocks.push(relationsBlock); + return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" }); +} + +interface FkInfo { + targetVarName: string; // e.g., "users" + targetEntityName: string; // e.g., "User" — used for the import path + targetPkField: string; // e.g., "id" +} + +/** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */ +function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { + const result = new Map(); + // FR-017 TPH: a discriminator base's table carries every concrete subtype's folded + // columns (collectTphSubtypeFields), so it carries their FKs too — otherwise a reference + // declared on a subtype, or on an abstract level between it and the base, lands as a + // plain column. Same effective, not-already-on-the-base rule as the column fold. + // A copy: referenceIdentities() is the node's cached array, and loaded metadata is read-only. + const refs = [...obj.referenceIdentities()]; + const baseRefNames = new Set(refs.map((r) => r.name)); + for (const sub of tphConcreteSubtypes(obj, ctx.loadedRoot)) { + for (const ref of sub.referenceIdentities()) { + if (!baseRefNames.has(ref.name)) refs.push(ref); + } + } + for (const ref of refs) { + // @enforce: false → logical-only reference. Skip the .references() emission; + // the column stays plain. Drizzle's relations() block (driven by + // relation-resolver) still includes the relationship for query navigation. + if (!ref.enforce) continue; + const fkFieldNames = ref.fields; + if (fkFieldNames.length === 0) continue; + const fkField = fkFieldNames[0]!; + // Two subtypes declaring the same reference fold to one column with one FK. + if (result.has(fkField)) continue; + const targetName = ref.targetEntity; + if (!targetName) continue; + // @references may be authored bare OR package-qualified, and the loader can + // resolve it to an FQN (e.g. the YAML front-end qualifies it). Strip the + // package so the lookup matches the object's bare name — mirrors the + // relation-resolver, which already does this. + const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName)); + if (!targetObj) continue; + // A TPH subtype has no table const of its own; its rows live in the base's table. + const storage = tphStorageObject(targetObj); + const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id"; + result.set(fkField, { + targetVarName: ctx.collectionName(storage.name), + targetEntityName: storage.name, + targetPkField, + }); + } + return result; +} + +/** + * Build the `primaryKey({ columns: [table.f1, table.f2] })` Code expression + * for composite primary keys. Uses imp() so ts-poet tracks the import. + */ +function buildCompositeKeyCallback( + pkFieldNames: Set, + importModule: string, +): Code { + const primaryKeySym = imp(`primaryKey@${importModule}`); + const columnRefs = Array.from(pkFieldNames) + .map((f) => `table.${f}`) + .join(", "); + return code`${primaryKeySym}({ columns: [${columnRefs}] })`; +} + +/** Build a JS-style object literal string (not JSON.stringify which uses quoted keys). + * Array values get `as const` appended so Drizzle's text(...,{ enum: [...] }) + * narrows the inferred column type to a literal union instead of bare `string`. */ +function inlineObjectLiteral(obj: Record): string { + const entries = Object.entries(obj).map(([k, v]) => { + const lit = JSON.stringify(v); + if (Array.isArray(v)) { + return `${k}: ${lit} as const`; + } + return `${k}: ${lit}`; + }); + return `{ ${entries.join(", ")} }`; +} + +/** Render one column line (field name + Drizzle column expression). */ +/** SQLite "now" for a timestamp column, spelled like Date.toISOString(). Must stay + * byte-identical to migrate-ts `SQLITE_ISO_NOW`, which owns the DDL (ADR-0015). */ +const SQLITE_ISO_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"; + +function renderColumn( + spec: ColumnSpec, + // §A6 — the column's physical name as an EXPRESSION: either the `Names` constant + // or the same literal as before. Passed in rather than derived here so one call site owns + // the decision (and the equality check that makes it byte-safe). + dbNameExpr: Code, + field: MetaField, + ctx: RenderContext, + isPk: boolean, + pkGeneration: string | undefined, + fkInfo: FkInfo | undefined, + isComposite: boolean, + entityPackage: string | undefined = undefined, + // Name of the entity this column belongs to — used to detect a self-referential + // FK (target entity === this entity), which Drizzle emits without a self-import. + currentEntityName: string = "", + // FR-017 Tier 2 — TPH subtype-only column: force nullable (drop .notNull()) + // and suppress any DB default (other-subtype rows must stay NULL here). + forceNullable: boolean = false, +): Code { + // An int-backed field.enum's column function is a LOCAL generated const (the + // customType helper emitted into this same file), so it must not be imported + // from drizzle-orm/*-core like a built-in column type would be. + const fnSym = + spec.enumIntCustomType !== undefined + ? spec.enumIntCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); + + let baseCall: Code; + if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) { + baseCall = code`${fnSym}(${dbNameExpr}, ${inlineObjectLiteral(spec.fnOptions)})`; + } else { + baseCall = code`${fnSym}(${dbNameExpr})`; + } + + let pkSuffix = ""; + if (isPk) { + if (pkGeneration === GENERATION_INCREMENT) { + if (ctx.dialect === "sqlite") { + // Composite PKs don't use .primaryKey() per-column; table callback owns it. + pkSuffix = isComposite ? "" : ".primaryKey({ autoIncrement: true })"; + } else if (ctx.dialect === "mysql") { + // MySQL: an AUTO_INCREMENT column. The generated writes read the new key back + // with Drizzle's `$returningId()` (MySQL has no RETURNING). + pkSuffix = isComposite ? ".autoincrement()" : ".primaryKey().autoincrement()"; + } else { + // Postgres: the column KEEPS the type the column-mapper derived and + // GAINS the identity clause. It does not get a second spelling. + // + // This used to substitute `serial`/`bigserial` for the mapped + // `integer`/`bigint`, and that was the 1.0 defect wearing another hat: + // migrate emits `… GENERATED BY DEFAULT AS IDENTITY` (emit/postgres.ts + // renderColumn) for the same field, and `serial` is NOT that column. + // `serial` is historical sugar for integer + an owned sequence + a + // genuine DEFAULT nextval(...); an identity column carries neither a + // default nor a droppable sequence. So the generated Drizzle schema + // misrepresented the live PK of every integer-keyed table migrate had + // created — one question, two doors, two answers, and nothing compared + // them. migrate owns the DDL (ADR-0015), so codegen is what changes — + // the same call #293 made when the two named a CHECK constraint + // differently. + // + // The clause is gated on the MAPPED column function rather than on the + // field subtype, so the two cannot drift apart again: only Drizzle's + // int family (integer/bigint/smallint, via PgIntColumnBaseBuilder) + // carries .generatedByDefaultAsIdentity(). A subtype outside that + // family with @generation:"increment" is a combination Postgres cannot + // express at all — migrate emits `TEXT GENERATED BY DEFAULT AS + // IDENTITY` and fails at apply — and the honest thing here is to leave + // the declared type alone and let that failure be loud, rather than + // silently substituting an integer column for the declared one. + pkSuffix = isComposite ? "" : ".primaryKey()"; + if ( + spec.enumIntCustomType === undefined && + PG_IDENTITY_CAPABLE_FNS.has(spec.fnName) + ) { + pkSuffix += ".generatedByDefaultAsIdentity()"; + } + } + } else if (pkGeneration === GENERATION_UUID) { + pkSuffix = isComposite + ? "" + : ctx.dialect === "postgres" + ? ".primaryKey().defaultRandom()" + // SQLite and MySQL have no uuid default: the id is minted client-side. + : ".primaryKey().$defaultFn(() => crypto.randomUUID())"; + } else { + // No generation: natural PK. Composite hands off to table callback. + pkSuffix = isComposite ? "" : ".primaryKey()"; + } + } + + let modifiersStr = pkSuffix; + // No `isUnique` parameter here any more, and its absence is the fix rather than a tidy-up. + // A single-column `identity.secondary` used to append `.unique()` on top of the constraint + // migrate already emits under the identity's own name, so codegen declared one constraint + // the database does not have. When that producer was removed both call sites started + // passing a literal `false`, leaving a parameter whose every branch was unreachable — a + // dead switch that reads like a live one. `spec.modifiers` carries whatever uniqueness the + // column genuinely has, and the loop below is the only thing that applies it. + for (const m of spec.modifiers) { + // Single-column PKs imply notNull/unique; avoid emitting them twice. + // Composite-PK columns are NOT declared with .primaryKey(), so they DO need .notNull(). + if (isPk && !isComposite && (m === ".notNull()" || m === ".unique()")) continue; + // TPH subtype-only column: never .notNull() / .unique() — rows of other + // subtypes store NULL, so neither constraint can hold across the table. + if (forceNullable && (m === ".notNull()" || m === ".unique()")) continue; + modifiersStr += m; + } + + // sqlDefaultSegment must be a Code segment (not a raw string) so ts-poet tracks + // the `sql` import via imp(); a raw `.default(sql`...`)` would leave `sql` + // unresolved in the generated file. + let sqlDefaultSegment: Code | null = null; + if (spec.defaultExpr !== undefined && !isPk && !forceNullable) { + if (spec.defaultExpr.kind === "now") { + if (ctx.dialect === "sqlite") { + const sqlSym = imp("sql@drizzle-orm"); + // A timestamp column defaults to the ISO UTC form generated code writes, the same + // expression `meta migrate` puts in the DDL (migrate-ts SQLITE_ISO_NOW). + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(${SQLITE_ISO_NOW})\`)` + : code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`; + } else if (ctx.dialect === "mysql") { + // MySQL's datetime builder has no defaultNow(); spell the expression default, + // at the column's millisecond precision. + const sqlSym = imp("sql@drizzle-orm"); + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(CURRENT_TIMESTAMP(3))\`)` + : field.subType === FIELD_SUBTYPE_DATE + ? code`.default(${sqlSym}\`(CURRENT_DATE)\`)` + : code`.default(${sqlSym}\`(CURRENT_TIME)\`)`; + } else { + modifiersStr += `.defaultNow()`; + } + } else if (spec.defaultExpr.kind === "arrayLiteral") { + // isArray field: Drizzle's .array().default(x) (postgres) and + // .$type().default(x) (sqlite json) want a JS array literal, not the + // raw @default string. Elements are pre-rendered TS source literals. + // MySQL cannot give a JSON column a literal DEFAULT, so there the array is filled in + // client-side on insert. + modifiersStr += ctx.dialect === "mysql" + ? `.$defaultFn(() => [${spec.defaultExpr.elements.join(", ")}])` + : `.default([${spec.defaultExpr.elements.join(", ")}])`; + } else if (spec.defaultExpr.kind === "sqlExpr") { + const sqlSym = imp("sql@drizzle-orm"); + // Raw SQL keyword/expression — emit as sql`` for both dialects. This + // is also the always-typechecks fallback for an array @default whose shape + // parseArrayDefault couldn't safely model. + sqlDefaultSegment = code`.default(${sqlSym}\`${spec.defaultExpr.raw}\`)`; + } else { + // literal + modifiersStr += `.default(${JSON.stringify(spec.defaultExpr.value)})`; + } + } + + // FK .references() uses imp() so ts-poet tracks the cross-entity import. + let fkRefSegment: Code | null = null; + if (fkInfo !== undefined && !isPk) { + // Always annotate the .references() callback with the dialect's Any*Column return + // type. Drizzle needs this to break circular type inference — not only for + // self-referential FKs but also for cross-module circular references (table A → B + // while B → A), which otherwise surface as TS7022 ("implicitly has type 'any' + // because it does not have a type annotation and is referenced … in its own + // initializer") under `strict`. The annotation is a harmless explicit supertype + // for acyclic FKs, so emitting it unconditionally is safe. + const anyColType = dialectModule(ctx.dialect).anyColumn; + // Used only as a return-type annotation → type-only import (t:) so it emits + // `import type` and doesn't fail tsc under `verbatimModuleSyntax` (TS1484). (#165) + const anyColSym = imp(`t:${anyColType}@${spec.importModule}`); + if (fkInfo.targetEntityName === currentEntityName) { + // Self-referential FK (e.g. createdBy → this same table): reference the local + // table const directly — NOT a self-import. + fkRefSegment = code`.references((): ${anyColSym} => ${fkInfo.targetVarName}.${fkInfo.targetPkField})`; + } else { + const targetSpec = crossEntitySpecifier( + ctx.outputLayout, + entityPackage, + ctx.packageOf.get(fkInfo.targetEntityName), + fkInfo.targetEntityName, + ctx.extStyle, + ); + const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`); + fkRefSegment = code`.references((): ${anyColSym} => ${targetVarSym}.${fkInfo.targetPkField})`; + } + } + + // @autoSet fields: emit a $defaultFn so Drizzle inserts stamp the server-side timestamp + // automatically. This means callers don't need to supply createdAt / updatedAt in INSERT + // calls — Drizzle fills them in. The stamp's shape must match ctx.timestampMode — the base + // column type already does (mapColumnType, above) — or a "date" column ends up with a + // $defaultFn that hands Drizzle a string, which fails to typecheck (reported against an + // adopting project: TS2322 "Type 'string' is not assignable to type 'Date | SQL'", + // cascading into every generated insert/update query touching the field). + const autoSet = field.attr(FIELD_ATTR_AUTO_SET); + const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate") + ? ctx.timestampMode === "date" + ? `.$defaultFn(() => new Date())` + : `.$defaultFn(() => new Date().toISOString())` + : ""; + + // $type() chain — emitted as Code (not a string modifier) so ts-poet can + // hoist the cross-module type import for objectRef variants. Positioned + // immediately after the baseCall so the chain reads `.text(...).$type<...>().notNull()...` + // which Drizzle accepts in any order but is conventional for "type narrowing + // first." + // Resolve a VO name → an imported type symbol (shared layout/package/extStyle-aware + // helper, so the .$type import matches the field's TS type + Zod schema). + // ADR-0044/#228 — resolve the field's @objectRef to the value object's EMITTED + // name (bare when unique in the run, package-qualified on a cross-package + // short-name collision), resolved package-locally from the FIELD's declaring + // package. `name` (the bare dollarTypeRef name) is the byte-identical fallback + // when the ref doesn't resolve to an emitted value object. + const voSym = (name: string) => { + const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); + const emitted = typeof refRaw === "string" + ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + : name; + return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); + }; + + let dollarTypeSegment: Code | string = ""; + if (spec.dollarTypeRef !== undefined) { + const ref = spec.dollarTypeRef; + if (ref.kind === "scalar") { + dollarTypeSegment = `.$type<${ref.tsType}${ref.array ? "[]" : ""}>()`; + } else if (ref.kind === "objectRef") { + const refSym = voSym(ref.name); + dollarTypeSegment = ref.array ? code`.$type<${refSym}[]>()` : code`.$type<${refSym}>()`; + } else { + // field.map → Record; V is a scalar or a hoisted value-object. + dollarTypeSegment = "scalar" in ref.value + ? `.$type>()` + : code`.$type>()`; + } + } + + const columnLine = code` ${field.name}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`; + return spec.leadingComment !== undefined + ? code` // ${spec.leadingComment}\n${columnLine}` + : columnLine; +} diff --git a/examples/showcase/codegen/templates/entity-constants.ts b/examples/showcase/codegen/templates/entity-constants.ts new file mode 100644 index 000000000..4a79303a4 --- /dev/null +++ b/examples/showcase/codegen/templates/entity-constants.ts @@ -0,0 +1,196 @@ +// Entity-constants template — emits an `` const with all the +// metadata-derived strings consumers should use INSTEAD of magic strings. +// +// Shape (each non-$-prefixed key is a per-field object): +// +// export const Subscriber = { +// $entity: "Subscriber", +// $table: "subscribers", +// $path: "/subscribers", +// +// email: { +// name: "email", +// label: "Email Address", // @title on the surface's view, else @title on the field, else humanized field name +// view: "text", // MetaView subtype +// htmlType: "email", // optional; only when the view maps to a real HTML input type +// rules: { // optional; derived from validator children +// required: "Email is required", +// maxLength: { value: 255, message: "Must be 255 characters or fewer" }, +// pattern: { value: /.../, message: "Invalid email" }, +// }, +// }, +// // ... +// } as const; +// +// Consumers spread `form.input.email` from useEntityForm and never touch +// per-attribute access (placeholder, rules, etc.) by hand — the helper +// picks them up from this object automatically. + +import { code, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject } from "@metaobjectsdev/metadata"; +import { resolveTableName } from "@metaobjectsdev/metadata"; +import { physicalNameExpr } from "@metaobjectsdev/codegen-ts"; +import { + buildEntityUiDescriptor, + resourcePath, + type UiFieldDescriptor, + type UiRule, +} from "@metaobjectsdev/codegen-ts"; + +// `resourcePath` moved to entity-ui-descriptor.ts with the rest of the form derivation. +// Re-exported here because it is part of this module's published surface (the package +// barrel re-exports it from this path, and api-model.ts imports it from here). +export { resourcePath }; + +/** + * Render one rule entry as the RHF-shaped code fragment the descriptor emits. + * + * Order is the descriptor's, which is declaration order — see the ordering note in + * entity-ui-descriptor.ts for why that is not incidental. + */ +function renderRule(r: UiRule): string { + switch (r.kind) { + case "required": + return `required: ${JSON.stringify(r.message)}`; + case "minLength": + return `minLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "maxLength": + return `maxLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "pattern": { + // Emit as a RegExp literal /.../ — `as const` preserves the value-ref. The + // descriptor carries the RAW pattern; forward-slash and backslash are escaped + // HERE, because closing a TypeScript regex literal is an emission concern. + const safe = r.pattern.replace(/\\/g, "\\\\").replace(/\//g, "\\/"); + return `pattern: { value: /${safe}/, message: ${JSON.stringify(r.message)} }`; + } + } +} + +/** Build one nested field-object entry like `email: { name, label, ... },`. */ +function renderFieldEntry(f: UiFieldDescriptor): string { + const entries: string[] = [ + `name: ${JSON.stringify(f.name)}`, + `label: ${JSON.stringify(f.label)}`, + `view: ${JSON.stringify(f.view)}`, + ]; + if (f.htmlType !== undefined) entries.push(`htmlType: ${JSON.stringify(f.htmlType)}`); + if (f.rules.length > 0) entries.push(`rules: { ${f.rules.map(renderRule).join(", ")} }`); + + // A `field.enum`'s member symbols. Emitted whenever the field has them, because the + // descriptor reports `view: "dropdown"` for an enum and a dropdown without options is + // a promise the descriptor cannot keep. + if (f.options !== undefined && f.options.length > 0) { + entries.push(`options: [${f.options.map((v) => JSON.stringify(v)).join(", ")}] as const`); + } + + // Currency-specific keys: only emitted for currency-subtype fields. + if (f.currency !== undefined) { + entries.push(`currency: ${JSON.stringify(f.currency.currency)}`); + entries.push(`locale: ${JSON.stringify(f.currency.locale)}`); + } + + return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`; +} + +/** + * The field-rules constant of an `object.value`: `export const = { $entity, : { name, + * label, view, rules, ... } } as const`. The same per-field shape an entity's constant carries, + * minus `$table` and `$path` — a value object has no source and no route — so a caller reads a + * limit as `..rules.maxLength.value` instead of reaching through the Zod schema's + * getters. Emitted for the value subtype only (see `renderValueObjectFile`). + */ +export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code { + const descriptor = buildEntityUiDescriptor(obj); + // The emitted name (collision-qualified, ADR-0044) matches the value object's interface. + const name = emittedName ?? descriptor.entity; + const body = joinCode( + [code` $entity: ${JSON.stringify(descriptor.entity)}`, ...descriptor.fields.map((f) => code`${renderFieldEntry(f)}`)], + { on: ",\n" }, + ); + return code` +/** + * Metadata constants for the value object ${name}: each field's name, label, view and the + * validation rules derived from its validator children (e.g. \`${name}..rules.maxLength.value\`). + */ +export const ${name} = { +${body}, +} as const; +`; +} + +export function renderEntityConstants( + obj: MetaObject, + /** + * @deprecated Accepted and IGNORED. The API base URL left the descriptor: generated + * client artifacts emit entity-relative paths and the base is supplied once at the + * client provider (`baseUrl`), because where the browser sends a request is a + * deployment fact rather than something to freeze at `meta gen` time. + * + * It must STAY in the signature: `src/reference/entity.ts` is copied verbatim into + * adopter repos (ADR-0034) and calls this positionally, so removing the parameter + * would fail to compile in every ejected copy. Removal is a separate later break. + */ + _apiPrefix = "", + // §A6. OPTIONAL, and it must stay optional: `src/reference/entity.ts` is copied + // verbatim into adopter repos by ADR-0034 scaffold-and-own and calls this with two + // arguments. A required parameter would fail to compile in every ejected copy. + names?: { readonly name: string; readonly symbol: Code } | undefined, +): Code { + // ONE derivation of the form surface, shared with the `agent/ui.md` docs page — see + // entity-ui-descriptor.ts. The descriptor already walks the RESOLVING fields(), so + // inherited fields (from extends:/super:) appear here as they always have. + const descriptor = buildEntityUiDescriptor(obj); + const entityName = descriptor.entity; + const tableName = resolveTableName(obj); + const path = descriptor.path; + + const fieldEntries: string[] = descriptor.fields.map(renderFieldEntry); + + // A6/B2 — reference the constant whenever the artifact is in the run. No equality + // guard: primaryRdbSource (@metaobjectsdev/metadata) refuses any object whose + // @role: primary sources disagree on a physical name, and both the constant and the + // literal below resolve through it, so a reference here is the single spelling. `physicalNameExpr` accepts + // any `{ symbol }`-shaped value, so it works with this function's `{ name, symbol }` + // parameter as well as `namesRef`'s own `{ resolved, symbol }`. + const tableLine: Code = code` $table: ${physicalNameExpr(names, tableName, obj)}`; + + const body = joinCode( + [ + code` $entity: ${JSON.stringify(entityName)}`, + tableLine, + code` $path: ${JSON.stringify(path)}`, + ...fieldEntries.map((e) => code`${e}`), + ], + { on: ",\n" }, + ); + + return code` +/** + * Metadata constants for ${entityName}. + * + * Use these instead of magic strings so TS catches typos and refactors stay + * coherent. Each non-dollar-prefixed key is a per-field object carrying + * name, label, view, an htmlType where the field's view maps to one, and the + * RHF-shaped validation rules derived from the field's validator children. + * + * placeholder and helpText are NOT emitted — no provider registers an attribute + * for them, so nothing here could derive one. (htmlType is emitted, but derived + * from the view subtype; the @htmlType override was removed for the same reason.) + * useEntityForm still honours placeholder if you add it: this file is generated, + * hand edits inside it survive regeneration through the three-way merge, and the + * consumers read this const rather than the metadata. That is the intended way + * to set it. + * + * Typical usage with the metaobjects React form helper: + * + * import { useEntityForm } from '@metaobjectsdev/react'; + * const form = useEntityForm(${entityName}, ${entityName}InsertSchema); + * + */ +export const ${entityName} = { +${body}, +} as const; +`; +} diff --git a/examples/showcase/codegen/templates/entity-file.ts b/examples/showcase/codegen/templates/entity-file.ts new file mode 100644 index 000000000..932048d7e --- /dev/null +++ b/examples/showcase/codegen/templates/entity-file.ts @@ -0,0 +1,237 @@ +// Entity file composer — combines drizzle-schema, inferred-types, and zod-validators +// into one file with the @generated header. ts-poet deduplicates imports. +// +// Dispatch: +// isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections) +// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema) +// vanilla / write-through entity → Drizzle table path + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { renderDrizzleSchema } from "./drizzle-schema.js"; +import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; +import { renderZodValidators, isTphSubtype, primaryIdentityFieldNames } from "./zod-validators.js"; +import { renderEntityConstants } from "./entity-constants.js"; +import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; +import { renderFilterType } from "./filter-type.js"; +import { renderTphDiscriminatorUnion, isTphDiscriminatorBase } from "./tph-discriminator.js"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "@metaobjectsdev/codegen-ts"; +import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts"; +import { renderProjectionDecl } from "./projection-decl.js"; +import { + projectionViewName, projectionViewSchema, projectionViewRole, +} from "@metaobjectsdev/codegen-ts"; +import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { namesRef, namesConstArg, physicalNameExpr, sourceSchemaExpr } from "@metaobjectsdev/codegen-ts"; +import { hasWritableRdbSource } from "@metaobjectsdev/codegen-ts"; +import { renderValueObjectFile } from "./value-object-file.js"; +import { isAbstract } from "@metaobjectsdev/codegen-ts"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render-time options for the entity-file composer. + * + * `allowlists` (default `true`) controls whether the Fastify-flavored + * `FilterAllowlist` + `SortAllowlist` blocks (plus their + * `runtime-ts/drizzle-fastify` type-only imports) are emitted. Workers/Lambda + * consumers that don't mount Fastify-style server routes can pass `false` and + * drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side + * `Filter` type is always emitted — consumers still want it for typed + * client calls regardless of how the server is wired. + * + * Whether the file emits ANY server runtime binding at all (Drizzle table/view, + * the allowlists) is governed by the TARGET, not this option: `ctx.selfTarget.runtime`. + * A contract-only target (`runtime: false`) renders every object as its plain + * shape (interface + Zod) and every projection as its read schema — no + * `drizzle-orm`, no `runtime-ts`. `allowlists` is a finer Fastify-vs-Hono opt-out + * that only matters within a runtime target. + */ +export interface RenderEntityFileOpts { + readonly allowlists?: boolean; +} + +export function renderEntityFile( + entity: MetaObject, + ctx: RenderContext, + opts?: RenderEntityFileOpts, +): string { + // Contract-only target ⇒ no server runtime: no Drizzle pgView/pgTable, no + // runtime-ts allowlists. The read schema + inferred types still emit. + const runtime = ctx.selfTarget.runtime; + const allowlists = runtime ? (opts?.allowlists ?? true) : false; + + // --- Abstract path (shape only) --- + // An abstract entity contributes shape via inheritance only — it must NEVER + // produce a Drizzle table / migration footprint / filter allowlist, even when + // it carries a source.rdb child. This is the cross-port invariant (abstract → + // no instance/write artifacts, including CREATE TABLE). It still emits its + // value-object shape (interface + Zod) so subclasses/consumers can reference + // it. The entity-file generator suppresses this entirely when + // emitAbstractShapes is off; here we only guarantee "shape, never table". + if (isAbstract(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Projection path (read-only: view-backed entity with no table source) --- + // Projections intentionally get the z.enum() validator but NOT a named enum + // type alias — emitting aliases here is a deliberate v1 scope decision. + if (isProjection(entity)) { + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts builds, so the projection's view + // name + per-field dbCol reference the exact constant the names artifact exports + // (undefined when the artifact is not in this run — see names.ts). `namesRef`'s + // `{ resolved, symbol }` return is exactly the shape `ProjectionDeclOpts.names` wants. + return renderProjectionDecl(entity, ctx.loadedRoot, { + columnNamingStrategy: ctx.columnNamingStrategy, + dialect: ctx.dialect, + apiPrefix: ctx.apiPrefix, + timestampMode: ctx.timestampMode, + allowlists, + ctx, + // Contract target drops the Drizzle .existing() view decl + drizzle-orm import. + includeViewDecl: runtime, + names: namesRef(entity, ctx), + }); + } + + // --- Value-only / contract path (no Drizzle table) --- + // Reached when the entity has no writable source.rdb (in-memory / transit + // shape) OR the target is contract-only (a UI/wire package gets the read shape, + // not a DB table). Either way: interface + Zod, no migration footprint, no + // drizzle-orm. Consumers validate via the Zod schema and type via the interface. + // + // A TPH subtype (FR-017) also routes here: it inherits the base's writable + // source.rdb via extends (so hasWritableRdbSource is now true under the ADR-0039 + // resolving read), but the base owns the single shared table — the subtype must + // emit its per-subtype read schema (Schema), never its own pgTable. + if (!runtime || !hasWritableRdbSource(entity) || isTphSubtype(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Vanilla / write-through entity path --- + const enumAliases = renderEnumTypeAliases(entity, ctx); + // FR-017 Tier 1: when this entity carries @discriminator AND has concrete + // subtypes, append the discriminated-union type alias, type guards, and + // the parse(row) dispatcher. Returns null otherwise (no subtypes, or + // not a discriminator-bearing entity); the section is suppressed cleanly. + const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot); + // FR-017: when a discriminator base also has a union block, the union owns the + // bare `` type — so the inferred Drizzle row type is emitted as + // `Row` to avoid a duplicate `export type `. + const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot); + + // #214 — a write-through entity read-view (FR-024 §7): reads route to the replica + // VIEW, so the entity file additionally declares the `.existing()` view (carrying the + // derived fields the write table omits, #213) and a read schema `Schema` + // whose `z.infer` IS the read type (dialect-agnostic — a Drizzle view is not + // a Table, so InferSelectModel/`$inferSelect` don't uniformly apply; the Zod schema's + // nullability mirrors the view columns exactly like a projection). The write table + + // Insert/Update stay derived-free. `.existing()` is a runtime-target Drizzle binding, + // and this path only runs in a runtime target (contract-only/non-writable returned above). + // A TPH discriminator base owns `export type ` via its discriminated-union block, + // so it must NOT also emit the view-schema read type (that would be a duplicate-identifier + // compile error). A base+write-through combo keeps the TPH polymorphic read path (reads + // the base table); routing its reads through a replica view is a documented non-goal. + const writeThrough = isWriteThrough(entity) && !tphBase; + // Rendered BEFORE the replica view below, for two reasons that both matter: the table + // DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate, + // and a duplicate module-scope const is a parse error), and those consts must precede the + // view's use of them in the emitted file or the view hits a TDZ ReferenceError at import. + const declaredEnumIntCodecs = new Set(); + const schemaBlock = renderDrizzleSchema(entity, ctx, declaredEnumIntCodecs); + const viewSections: Code[] = []; + if (writeThrough) { + const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1); + const fields = entity.fields(); + const entityPkg = effectivePackage(entity); + // ADR-0044/#228 — resolve a view column's `@objectRef` to the value object's + // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports + // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) + // under a cross-package short-name collision. + const voRef = (field: MetaField): { name: string; module: string } => { + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); + return { name, module }; + }; + // ONE selection of the replica source: `projectionViewName` names it, and this is the + // role its entry has in Names.sources. Hardcoding "replica" here would be a + // second derivation of the very thing extract-view-spec.ts exists to decide once. + const viewRole = projectionViewRole(entity) ?? SOURCE_ROLE_REPLICA; + const entityNames = namesRef(entity, ctx); + const viewOpts = { + dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voRef, + // PK fields type non-null in the replica-view decl + read schema even + // without @required (a PK is never NULL; see ViewDeclOpts.pkFieldNames). + pkFieldNames: new Set(primaryIdentityFieldNames(entity)) as ReadonlySet, + // §A6 — the entity's names artifact supplies this view's COLUMN constants. A + // replica view exposes the entity's own fields, so `Names.fields..column` + // is the same physical name the table side binds; a derived field the artifact + // does not carry falls back to the literal via `columnExpr`. The view's own NAME + // is passed separately below and stays a literal — the artifact holds the primary + // (table) source's name, not this one. + names: entityNames, + // The table above already declared these; the view references them by name. + declaredEnumIntCodecs, + // The replica view's OWN @schema, from the same source node projectionViewName picks + // — never the entity's, which is the WRITE TABLE's and would qualify this view with a + // schema that belongs to something else. A view and the table it replicates need not + // live in the same schema, and a name paired with someone else's schema is worse than + // no schema at all. + // + // Both this and the view's NAME are now CONSTANTS. They were literals for a real + // reason that has stopped being true: Names carried the primary source only, + // so a write-through entity's two physical names had one slot between them and the + // second was emitted in full — the same escape C#'s AppDbContext had, in a different + // language, which is what showed it was the artifact's shape and not the binding. + schema: sourceSchemaExpr(entityNames, viewRole) ?? projectionViewSchema(entity), + }; + const z = imp("z@zod"); + const docs = renderDocsFor(entity); + const docsPrefix = docs ? `${docs}\n` : ""; + viewSections.push( + renderExistingViewDecl( + fields, + physicalNameExpr(entityNames, projectionViewName(entity, ctx.columnNamingStrategy), entity, viewRole), + `${camel}View`, + viewOpts, + ), + code` +export const ${entity.name}Schema = ${renderViewReadZodObject(fields, viewOpts)}; +`, + code` +${docsPrefix}export type ${entity.name} = ${z}.infer; +`, + ); + } + + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts (and this file's own projection + // branch above) build, so the descriptor's $table references the exact constant the + // names artifact exports (undefined when the artifact is not in this run). + const constantsNames = namesRef(entity, ctx); + + const sections: Code[] = [ + schemaBlock, + ...viewSections, + renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */), + ...(enumAliases !== null ? [enumAliases] : []), + renderZodValidators(entity, ctx), + renderEntityConstants(entity, ctx.apiPrefix, namesConstArg(constantsNames)), + ...(allowlists ? [renderFilterAllowlist(entity, undefined, ctx), renderSortAllowlist(entity, undefined, ctx)] : []), + renderFilterType(entity), + ...(tphBlock !== null ? [tphBlock] : []), + ]; + + // Render ts-poet body first (ts-poet hoists imp()-tracked imports to the top), + // then prepend the @generated header so it lands at line 1 — convention for + // generated files and what most tooling (overwrite-policy, IDEs) expects. + const body = joinCode(sections, { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Source metadata: ${entity.name} (${entity.fqn()})\n` + + sidecarLine(`${entity.name}.extra.ts`); + return header + body; +} diff --git a/examples/showcase/codegen/templates/enum-int-codec.ts b/examples/showcase/codegen/templates/enum-int-codec.ts new file mode 100644 index 000000000..cbef388d9 --- /dev/null +++ b/examples/showcase/codegen/templates/enum-int-codec.ts @@ -0,0 +1,67 @@ +/** + * The int-backed `field.enum` codec emitter, shared by the two artifacts that can + * reference one: the entity's Drizzle table (`drizzle-schema.ts`) and any view-backed + * read model (`view-decl.ts` — a projection or an entity read-view). + * + * It lives in its own module because the codec is a MODULE-LOCAL const in whatever file + * declares the column, so every emitter that can produce such a column must be able to + * declare it too. While only the table template owned it, the view template emitted the + * call site and let the const name fall through to `imp(fnName@drizzle-orm/*-core)` — + * importing a member that package does not export, which `meta gen` reported as success + * and only `tsc` caught. + * + * The invariant this module exists to protect: the codec is declared ONCE PER MODULE, and + * it must precede every use. A write-through entity puts its table and its replica view in + * one file and both reference the same consts, so `entity-file.ts` renders the table first, + * collects the names it declared, and hands them to the view — which then references them + * instead of re-declaring. Two module-scope `const`s of one name is a JS parse error, and a + * declaration emitted after its use is a TDZ ReferenceError at import: both are worse than + * the missing-codec bug, because neither lets the module load at all. + */ +import { code, imp, type Code } from "@metaobjectsdev/codegen-ts"; +import type { EnumIntCustomType } from "@metaobjectsdev/codegen-ts"; + +/** + * Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two + * lookup maps. + * + * The codec lives HERE, in the column definition, so nothing downstream needs to + * know about it: `db.insert().values()` encodes on bind, a selected row decodes on + * read, and a filter comparison encodes because Drizzle binds through the column + * type. That is why this shape was chosen over a Zod write-transform plus a + * generated read-decode — TS's generated queries return raw Drizzle rows and have + * no decode seam, so the query-layer approach meant inventing one and wrapping + * every generated read. It is also the direct analogue of what the other four + * ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed + * `customEnumeration`, Python `ObjectManager` coercion). + * + * `fromDriver` throws on an unmapped integer rather than returning undefined: a + * value outside the map means the DB holds data the model says is impossible + * (a hand-written INSERT, or a member removed without a migration), and silently + * yielding `undefined` for a non-nullable field would surface far from the cause. + */ +export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code { + const customTypeSym = imp(`customType@${importModule}`); + const union = t.members.map((m) => JSON.stringify(m)).join(" | "); + const toEntries = t.members + .map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`) + .join(", "); + const fromEntries = t.members + .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`) + .join(", "); + return code` +const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; +const ${t.fromIntConstName}: Record = { ${fromEntries} }; +const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => ${t.toIntConstName}[value], + fromDriver: (value) => { + const member = ${t.fromIntConstName}[value]; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`); + } + return member; + }, +}); +`; +} diff --git a/examples/showcase/codegen/templates/enums-file.ts b/examples/showcase/codegen/templates/enums-file.ts new file mode 100644 index 000000000..0bfac3256 --- /dev/null +++ b/examples/showcase/codegen/templates/enums-file.ts @@ -0,0 +1,61 @@ +// FR-019 — shared enums module. +// +// Emits, ONCE per run, the materialized (non-@provided) shared enum types that +// at least one concrete entity field references via `extends` of a root-level +// abstract `field.enum`. Each enum yields: +// • `export type = "A" | "B";` — the cross-port type identity +// • `export const Enum = z.enum(["A","B"]);` — the shared Zod validator +// +// Consuming entity files import these instead of redeclaring the union inline. +// @provided enums are NEVER materialized here (they live in hand-written code, +// imported from the configured providedEnumModule). + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts"; +import { materializedSharedEnums, type SharedEnum } from "@metaobjectsdev/codegen-ts"; +import { enumUnionString } from "./inferred-types.js"; + +/** Basename (no extension) of the shared-enums module emitted at the entity-module target root. */ +export const SHARED_ENUMS_BASENAME = "enums"; + +/** The exported Zod-constant name for a shared enum (`Enum`). */ +export function sharedEnumZodConstName(enumName: string): string { + return `${enumName}Enum`; +} + +/** One enum's two declarations (type alias + shared z.enum const). */ +function renderOneSharedEnum(e: SharedEnum): Code { + const z = imp("z@zod"); + const members = e.values.map((v) => JSON.stringify(v)).join(", "); + return code` +export type ${e.name} = ${enumUnionString(e.values)}; +export const ${sharedEnumZodConstName(e.name)} = ${z}.enum([${members}]); +`; +} + +/** + * The full shared-enums module body, or null when the model has no materialized + * shared enums (so the generator emits no file at all). + * + * `opts.select` (FR-023 §11.1 item 2) — the model-wide scope predicate over a + * CONSUMING entity's `resolutionKey()`: an enum extended only by entities + * `select` excludes is not materialized. This module renders once per run from + * the WHOLE loaded root (never per-generator-filtered — see #266), so it is the + * one shared-enums call site that must be threaded the scope predicate + * separately from `ctx.matches`. Omitted ⇒ every entity counts, byte-identical + * to a caller that never passes `opts` at all. + */ +export function renderSharedEnumsFile( + root: MetaRoot, + opts?: { select?: (fqn: string) => boolean }, +): string | null { + const enums = materializedSharedEnums(root, opts?.select); + if (enums.length === 0) return null; + + const body = joinCode(enums.map(renderOneSharedEnum), { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Shared enum types (FR-019): one declaration per reused package-level enum.\n`; + return header + body; +} diff --git a/examples/showcase/codegen/templates/field-meta.ts b/examples/showcase/codegen/templates/field-meta.ts new file mode 100644 index 000000000..f0e666fbb --- /dev/null +++ b/examples/showcase/codegen/templates/field-meta.ts @@ -0,0 +1,284 @@ +// Shared field-level metadata helpers — consumed by entity-constants.ts, +// projection-decl.ts, and any future generator that needs per-field inference. +// +// All helpers take a MetaField node. + +import { MetaField } from "@metaobjectsdev/metadata"; +import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata"; +import { stripPackage } from "@metaobjectsdev/metadata"; +import { + FIELD_SUBTYPE_STRING, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_TIME, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_ENUM, + FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_URI, + FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_OBJECT, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STRING_FORMAT, + FIELD_ATTR_LENIENT, + STRING_FORMAT_EMAIL, + STRING_FORMAT_HOSTNAME, + VIEW_SUBTYPE_TEXT, + VIEW_SUBTYPE_DATE, + VIEW_SUBTYPE_NUMBER, + VIEW_SUBTYPE_CHECKBOX, + VIEW_SUBTYPE_CURRENCY, + VIEW_SUBTYPE_DROPDOWN, + FIELD_ATTR_CURRENCY, + FIELD_ATTR_CURRENCY_DEFAULT, + VIEW_CURRENCY_ATTR_LOCALE, + VIEW_CURRENCY_ATTR_LOCALE_DEFAULT, +} from "@metaobjectsdev/metadata"; +import { enumValues, zodEnumExpr } from "@metaobjectsdev/codegen-ts"; +import { viewForContext } from "@metaobjectsdev/codegen-ts"; +import { ZOD_INET_EXPR } from "./net-regex.js"; + +// --------------------------------------------------------------------------- +// inferViewKind +// --------------------------------------------------------------------------- + +/** + * Resolve the cell-renderer key (view kind) for a field, for the surface named + * by `context`. + * + * An explicit view declared for THAT surface wins; field subType determines the + * default. #356: `context` is required rather than defaulting, because a caller + * that omits it is exactly the caller that renders somebody else's view. + */ +export function inferViewKind(field: MetaField, context: string): string { + // Explicit view for this surface (own or inherited via extends) has highest priority. + const viewChild = viewForContext(field, context); + if (viewChild) return viewChild.subType; + // Field subtype → default view. + return defaultViewForSubType(field.subType); +} + +function defaultViewForSubType(subType: string): string { + switch (subType) { + case FIELD_SUBTYPE_BOOLEAN: + return VIEW_SUBTYPE_CHECKBOX; + case FIELD_SUBTYPE_INT: + case FIELD_SUBTYPE_LONG: + case FIELD_SUBTYPE_DOUBLE: + case FIELD_SUBTYPE_FLOAT: + case FIELD_SUBTYPE_DECIMAL: + return VIEW_SUBTYPE_NUMBER; + case FIELD_SUBTYPE_DATE: + case FIELD_SUBTYPE_TIME: + case FIELD_SUBTYPE_TIMESTAMP: + return VIEW_SUBTYPE_DATE; + case FIELD_SUBTYPE_CURRENCY: + return VIEW_SUBTYPE_CURRENCY; + case FIELD_SUBTYPE_ENUM: + // A closed member set is a CHOICE, and the generated form has always rendered it as + // one: `codegen-ts-react`'s `viewKindFor` returns `dropdown` for an enum declaring no + // view, and emits a `` value, which +// the generated React form submits; +// - `HH:MM[:SS[.f]]` (+ an offset) — Postgres time / timetz, ``. + +const TIME_OF_DAY = "(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?"; +const UTC_OFFSET = "(?:[Zz]|[+-](?:[01]\\d|2[0-3])(?::?[0-5]\\d)?)?"; +// 31-day months, 30-day months, February 1–28 in any year, and February 29 in a leap +// year: `YY` + a multiple of 4 other than `00`, or a multiple-of-4 century + `00`. +const DAY_IN_MONTH = + "(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))"; +const LEAP_YEAR = "(?:\\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)"; +const CALENDAR_DATE = `(?:\\d{4}-${DAY_IN_MONTH}|${LEAP_YEAR}-02-29)`; + +// Each check carries its own short message. Without one, Zod 4 answers +// "Invalid string: must match pattern /…/" — the whole calendar regex, ~300 characters, +// in every API 400 for a bad date. A string second argument to `.regex()` is the issue +// message on Zod 3 and Zod 4 alike. +export const DATE_FORMAT_MESSAGE = "must be an ISO date (YYYY-MM-DD)"; +export const TIME_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]], optional offset)"; +export const TIMESTAMP_FORMAT_MESSAGE = + "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])"; + +/** `field.date` — an ISO calendar date, `YYYY-MM-DD`, and nothing else. */ +export const ZOD_DATE_EXPR = + `z.string().regex(/^${CALENDAR_DATE}$/, ${JSON.stringify(DATE_FORMAT_MESSAGE)})`; + +/** `field.time` — a time of day, `HH:MM[:SS[.fraction]]`, optionally zoned. */ +export const ZOD_TIME_EXPR = + `z.string().regex(/^${TIME_OF_DAY}${UTC_OFFSET}$/, ${JSON.stringify(TIME_FORMAT_MESSAGE)})`; + +/** `field.time` on a MySQL column — a time of day with NO offset. MySQL's `TIME` has no + * zone and refuses one (`'12:30:00+05:00'` is ERROR 1292, Incorrect time value), so the + * portable check above would pass a body the database then rejects with a 500. */ +export const TIME_NO_OFFSET_FORMAT_MESSAGE = "must be a time of day (HH:MM[:SS[.fff]]), without an offset"; +export const ZOD_TIME_NO_OFFSET_EXPR = + `z.string().regex(/^${TIME_OF_DAY}$/, ${JSON.stringify(TIME_NO_OFFSET_FORMAT_MESSAGE)})`; + +/** `field.timestamp` (string mode) — an ISO 8601 / SQL timestamp: a calendar date, + * optionally followed by `T` or a space, a time of day, and a UTC offset. */ +export const ZOD_TIMESTAMP_EXPR = + `z.string().regex(/^${CALENDAR_DATE}(?:[Tt ]${TIME_OF_DAY}${UTC_OFFSET})?$/, ${JSON.stringify(TIMESTAMP_FORMAT_MESSAGE)})`; + +// An instant timestamp is rewritten to UTC on WRITE. +// +// SQLite and D1 keep a string-mode timestamp as TEXT and compare it as text, so a value +// stored as sent — `2026-09-21T01:00:00+05:00`, which is 20:00Z — sorted after +// `2026-09-20T21:00:00Z` and `filter[tastedAt][gt]=…21:00:00Z` returned it. The rewritten +// form is the canonical wire spelling of an instant (fixtures/persistence-conformance/ +// normalization.md): `YYYY-MM-DDTHH:MM:SS[.fff]Z`, millisecond resolution, no trailing +// zeros, no fraction at all when it is zero. Its prefix through the seconds is fixed-width, +// so text order equals instant order down to the second. The runtime filter parser +// rewrites a bound the same way (`utcIsoInstant` in runtime-ts — the two must agree). +// Postgres timestamptz stores the instant either way, so it is unaffected. +// +// A value ALREADY spelled `YYYY-MM-DDTHH:MM:SS[.f…]Z` is kept byte-for-byte: it is in UTC and +// orders correctly as it is, and rc.4 turning `…12:10:00Z` into `…12:10:00.000Z` broke an +// adopter's round-trip of a timestamp inside a jsonb value object. +// +// A value with NO zone is read as UTC, and a date-only value as midnight UTC. A default +// `field.timestamp` is an instant whose wire form is "always UTC" +// (docs/features/api-contract.md, "Type encodings"; ADR-0036 Wave 2); a wall clock is +// what `@localTime` declares, and those fields never reach this helper. Kept as sent, +// `2026-10-06` sorted before `2026-10-06T00:00:00Z` as text, so +// `filter[dueAt][gte]=2026-10-06T00:00:00Z` omitted the row. The string handed to +// `Date.parse` is the ECMAScript date-time format it is REQUIRED to read (seconds and a +// three-digit fraction padded in, the offset as `±HH:MM`), so the result does not depend +// on the engine's tolerance for Postgres' `+00`, a six-digit fraction, or a zoneless string +// (which ECMAScript would read as LOCAL time). It runs after the format check, so it only +// ever sees a well-formed value. +// +// Emitted ONCE per module as a local function (generated model code imports nothing from +// MetaObjects at runtime), and referenced by name from each schema that needs it. + +/** Name of the module-local normalizer a timestamp WRITE shape transforms through. */ +export const UTC_TIMESTAMP_HELPER = "utcIsoTimestamp"; + +/** The normalizer's declaration, emitted at the top of a module that uses it. */ +export const UTC_TIMESTAMP_HELPER_DECL = [ + "/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept", + " * as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC.", + " * SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */", + `function ${UTC_TIMESTAMP_HELPER}(v: string): string {`, + " if (/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$/.test(v)) return v;", + " const m = /^(\\d{4}-\\d{2}-\\d{2})(?:[Tt ](\\d{2}:\\d{2})(?::(\\d{2})(?:\\.(\\d+))?)?(?:([Zz])|([+-]\\d{2}):?(\\d{2})?)?)?$/.exec(v);", + " if (m === null) return v;", + ' const t = Date.parse(`${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}`}`);', + ' return Number.isNaN(t) ? v : new Date(t).toISOString().replace(/\\.?0+Z$/, "Z");', + "}", +].join("\n"); + +/** `field.timestamp` (string mode, instant — not `@localTime`) on a WRITE shape: the format + * check, then the to-UTC rewrite above. A module emitting it must also emit + * {@link UTC_TIMESTAMP_HELPER_DECL}. */ +export const ZOD_TIMESTAMP_UTC_EXPR = `${ZOD_TIMESTAMP_EXPR}.transform(${UTC_TIMESTAMP_HELPER})`; diff --git a/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts b/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts new file mode 100644 index 000000000..da6208c9f --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts @@ -0,0 +1,737 @@ +// Drizzle schema template — emits a single sqliteTable() or pgTable() definition, +// with FK .references() on FK columns derived from relationship children, +// plus the relations() block auto-emitted at the end. + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { + IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, + IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, + GENERATION_INCREMENT, GENERATION_UUID, + FIELD_ATTR_AUTO_SET, + FIELD_ATTR_OBJECT_REF, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_DATE, +} from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { + mapColumnType, PG_IDENTITY_CAPABLE_FNS, + type ColumnSpec, type EnumIntCustomType, +} from "@metaobjectsdev/codegen-ts"; +import { tableNameFromEntity } from "@metaobjectsdev/codegen-ts"; +import { dialectModule } from "@metaobjectsdev/codegen-ts"; +import { + namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr, +} from "@metaobjectsdev/codegen-ts"; +import { resolveTableSchema } from "@metaobjectsdev/metadata"; +import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderRelationsBlock } from "./relations-block.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js"; +import { tphStorageObject } from "./zod-validators.js"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render the Drizzle table definition for one entity, including: + * - FK .references() on FK columns derived from relationship children + * - relations() block at the end of the section + * + * Returns a Code object so ts-poet can deduplicate imports when this composes + * with the rest of the entity file. Biome formatting runs after composition. + */ +export function renderDrizzleSchema( + obj: MetaObject, + ctx: RenderContext, + /** + * OUT param: the int-backed-enum codec const names this table DECLARES. A caller that + * emits another artifact referencing the same codecs into the SAME module — entity-file's + * write-through replica view — passes a set here and hands it to that emitter, which then + * references the consts instead of re-declaring them. A duplicate module-scope `const` is + * a JS parse error, so the module would not even load. + */ + declaredEnumIntCodecs?: Set, +): Code { + const dialect = ctx.dialect; + const { tableFn, core: importModule } = dialectModule(dialect); + const tableFnSym = imp(`${tableFn}@${importModule}`); + + const tableName = obj.dbTable ?? tableNameFromEntity(obj.name, ctx.columnNamingStrategy); + const varName = ctx.collectionName(obj.name); + + // §A6/§B2 — reference `Names` instead of embedding the physical names a SECOND + // time. `namesRef` is undefined whenever the names generator is not active in this run + // (opt-in under ADR-0034 scaffold-and-own — an unconditional import would break every + // project that has not enabled it) or the object has no primary source — both PRESENCE + // guards. No per-site equality check: whenever the constant exists, it IS the name; + // `primaryRdbSource` (@metaobjectsdev/metadata) already refuses (throws) any object + // whose `@role: primary` sources disagree on a name, and BOTH branches reach it — the + // constant through `resolveObjectNames`, and `obj.dbTable` above on its own — so this + // file never has to re-check, and the literal arm is refused too rather than silently + // binding the inherited parent's table when the names generator is out of the run. + const names = namesRef(obj, ctx); + const tableNameExpr: Code = physicalNameExpr(names, tableName, obj); + + // @schema — the table binding must land in the SAME schema the migration creates the table + // in. migrate-ts qualifies every table, view, index and FK it emits (`CREATE TABLE + // "sales"."widgets"`), and under ADR-0015 it owns where a table lives; codegen dropping the + // schema meant generated queries hit `public.widgets` while the migration built + // `sales.widgets`. Whether that surfaced depended entirely on the adopter's `search_path`, + // which is the worst kind of bug — it works on the machine that has it configured. + // + // Drizzle expresses this with a DIFFERENT call shape rather than a third argument: + // `pgSchema("sales").table(name, …)`. It is built inline rather than hoisted to a `const` + // so two tables in one file sharing a schema cannot collide on a generated identifier; a + // schema object is just a descriptor, so a second call costs nothing. + // + // sqlite/d1 are excluded because SQLite has no schema concept at all — migrate already + // REFUSES a non-default @schema there (expected-schema.ts), so there is nothing to honour + // and no divergence to close. + const tableSchema = dialect === "sqlite" ? undefined : resolveTableSchema(obj); + const schemaExpr: Code | undefined = + tableSchema === undefined ? undefined + : sourceSchemaExpr(names) ?? code`${JSON.stringify(tableSchema)}`; + const tableCall: Code = schemaExpr === undefined + ? code`${tableFnSym}` + : code`${imp(`${dialect === "mysql" ? "mysqlSchema" : "pgSchema"}@${importModule}`)}(${schemaExpr}).table`; + // A column's constant, on the same terms — but resolved against the entity that DECLARES + // the field, which is not always `obj`. Under TPH the fold below emits a subtype's own + // columns into this base's table, and those columns live in the SUBTYPE's names artifact; + // looking them up in the base's finds nothing. + // + // This used to pass `names` for every column and call the resulting miss "normal". It was + // not: the base's artifact genuinely does not carry a subtype's column, so every TPH + // subtype column fell through to the literal while `Names.fields..column` sat + // right there, already imported by the subtype's own artifact. C# never had the bug + // because its emitter passes the subtype entity to `ColumnRef`; this is that shape. + // + // `fromPackage` stays `obj.package` so the import path is computed relative to the file + // being emitted (this base's), not to the subtype's own package. + // THIS entity's artifact first. It already carries every INHERITED field — a concrete + // entity's artifact spreads its abstract parent's `fields` — so an `extends` chain needs + // no redirect, and taking one would be a regression: an abstract base has no source, so + // it has no artifact of its own to redirect TO, and the column would fall back to a + // literal that `obj`'s artifact was carrying all along. + // + // A miss here means the field belongs to a different entity ENTIRELY, which under TPH is + // exactly the subtype whose columns this base's table absorbs. Only then resolve against + // the declaring entity, found through the field's own parent link. + // + // `isMetaObject`, never `instanceof`: two physical copies of @metaobjectsdev/metadata in + // one process give the class object and the instance different identities, so the check + // would return false for a real node — silently. + // `obj` never changes across this function, so its effective package is computed once + // and reused everywhere below (the `fromPackage` passed to `namesRef` here, and the + // per-field `renderColumn` calls in both the own-field and TPH-subtype-fold loops). + const pkg = effectivePackage(obj); + const columnNameExpr = (field: MetaField, dbName: string): Code => { + if (names === undefined || names.resolved.fields[field.name] !== undefined) { + return columnExpr(names, field.name, dbName); + } + const owner = field.parent; + // `fromPackage` is obj's: the import path is relative to the file being emitted (this + // base's), not to the subtype's own package. + const ownerNames = + owner !== undefined && isMetaObject(owner) && owner !== obj + ? namesRef(owner, ctx, pkg) + : undefined; + return columnExpr(ownerNames, field.name, dbName); + }; + + const primary = obj.primaryIdentity(); + const rawPkFields = primary?.attr(IDENTITY_ATTR_FIELDS); + const pkFieldsList: string[] = Array.isArray(rawPkFields) + ? rawPkFields as string[] + : typeof rawPkFields === "string" + ? rawPkFields.split(",").map((f) => f.trim()).filter(Boolean) + : []; + const pkFieldNames = new Set(pkFieldsList); + const pkGeneration = primary?.attr(IDENTITY_ATTR_GENERATION) as string | undefined; + + const fkMap = buildFkMapForEntity(obj, ctx); + + const isComposite = pkFieldNames.size > 1; + + // Collect secondary identities and the field names that need .unique() on + // their column. identity.secondary is ALWAYS unique (no @unique attr) — only + // An `identity.secondary` reaches the table ONLY as the `uniqueIndex(...)` callback + // emitted further down — never additionally as `.unique()` on its column. + // + // It used to do both for a single-column identity, under a comment describing an + // either/or ("single-column identities propagate .unique() to the column; multi-column + // ones emit a callback uniqueIndex() entry instead") that the code did not implement: + // the callback loop has no column-count condition, so a one-column secondary got a + // column-level unique AND a table-level unique index. `migrate-ts` emits only the index + // for that shape — it mirrors a `
__unique` constraint for `@unique` fields + // and nothing extra for a secondary — so the generated schema declared a constraint the + // database does not have, and `drizzle-kit push` run against it proposes creating one. + // + // `.unique()` still comes from the FIELD's own `@unique`, via the column mapper, which + // is exactly the arm migrate mirrors. + const secondaryIdentities = obj.secondaryIdentities(); + + const columnLines: Code[] = []; + // Collect CHECK constraints for enum columns; emitted as table-level check() callbacks. + const checkConstraints: Array<{ name: Code; expr: Code }> = []; + /** + * One CHECK entry, composed against `Names` when the artifact is in the run. + * + * #293 — migrate's `
__chk` is the shared convention and it is the AUTHORITY: + * its suffix form is systematic across five constraint kinds and those names are already + * in live databases, so flipping migrate instead would emit DROP/ADD CONSTRAINT churn + * against production for a cosmetic difference. Referencing the constants therefore + * composes the IDENTICAL string at runtime rather than changing the convention — the + * template literal below evaluates to exactly what the literal arm spells. + * + * The expression's column goes through `sql.raw`, not `sql.identifier`: the latter QUOTES + * the name, which changes the constraint text migrate compares against. + */ + const checkEntry = (field: MetaField, spec: ColumnSpec): { name: Code; expr: Code } => { + const colExpr = columnNameExpr(field, spec.dbName); + const values = spec.checkConstraintValues; + const sqlSym = imp("sql@drizzle-orm"); + // No artifact in this run (the documented ADR-0034 opt-out) — spell both halves, which + // is what this file did unconditionally before. + if (names === undefined || values === undefined) { + return { + name: code`${JSON.stringify(`${tableName}_${spec.dbName}_chk`)}`, + expr: code`${spec.checkConstraint ?? ""}`, + }; + } + // `\${` emits a literal `${` into the generated source: the name becomes a template + // literal and the expression an interpolation, both evaluated at run time in the + // consumer's code, where they produce the identical strings the literal arm spells. + return { + name: code`\`\${${tableNameExpr}}_\${${colExpr}}_chk\``, + expr: code`\${${sqlSym}.raw(${colExpr})} IN (${values})`, + }; + }; + // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by + // const name so a shared enum used by two fields of the SAME entity emits once. + const enumIntTypes = new Map(); + for (const child of obj.fields()) { + // #213 — a derived (origin-bearing) field is read-only, materialized on the + // read (view) side, NOT a column on the entity's write table (FR-024 §7). + // Emitting it here would declare a Drizzle column for a table column migrate + // no longer creates. + if (child.isDerived()) continue; + const isPk = pkFieldNames.has(child.name); + const fkInfo = fkMap.get(child.name); + // Compute the column spec once per field and reuse it for both the column + // line and the CHECK collection. + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // FR-017 Tier 2 — TPH single-table inheritance. When this entity is a + // discriminator base, fold every concrete subtype's own columns into this + // one table. Subtype-only columns are ALWAYS nullable (a row of any other + // subtype stores NULL there) and never carry a DB default (a default would + // stamp onto other-subtype inserts), regardless of the field's @required. + // Subtype entities emit no table of their own (the value-object path). + for (const child of collectTphSubtypeFields(obj, ctx.loadedRoot)) { + // #213 — a TPH subtype's derived field is read-only too; never a table column. + if (child.isDerived()) continue; + const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode); + if (spec.enumIntCustomType !== undefined) { + enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); + } + const fieldDocs = renderDocsFor(child); + const columnLine = renderColumn( + spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true, + ); + columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); + // Enum CHECK constraints stay valid under TPH: `NULL IN (...)` is NULL + // (not false), so other-subtype rows with NULL pass the check. + if (spec.checkConstraint !== undefined) checkConstraints.push(checkEntry(child, spec)); + } + + // Build all table callback entries + const callbackEntries: Code[] = []; + + if (isComposite && primary !== undefined) { + callbackEntries.push(buildCompositeKeyCallback(pkFieldNames, importModule)); + } + + /** + * One index callback entry, for BOTH `identity.secondary` (unique) and `index.lookup` + * (non-unique). The two shapes differ only in that boolean, and migrate treats their + * physical attributes identically — `@expr`/`@using`/`@orders`/`@where` are registered on + * both by the same provider — so they are emitted by ONE function here. They used to be + * two near-identical loops, which is how the physical attributes came to be honoured by + * neither: a rule written twice is a rule that can be forgotten twice. + * + * What was missing is a SHAPE divergence, not a naming one (the names were reconciled + * separately). An `@expr`-only index emitted NOTHING at all here, because both loops + * required a non-empty `@fields` — while migrate emits it, keying off `@expr`. And + * `@where` never reached this file, so a PARTIAL unique index read as a FULLY unique one + * in the generated schema: `drizzle-kit push` would propose replacing the database's + * partial index with a total one, and any diff written from generated source disagreed + * with the database over a difference that was not real. Same class as the sqlite + * emitter dropping these in 0.15.21. + */ + const indexEntry = ( + // `type`/`subType` as well as `name`: the index-name resolver and the artifact's + // `identities` / `indexes` collections both key off which KIND of node this is, and + // ADR-0040 put that distinction in the type rather than in an attribute. + node: { name: string; type: string; subType: string; attr: (n: string) => unknown }, + fieldNames: string[], + unique: boolean, + ): Code | undefined => { + const str = (n: string): string | undefined => { + const raw = node.attr(n); + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + }; + const expr = str(IDENTITY_ATTR_EXPR); + // An expression index keys off @expr; a plain one needs @fields. Mirrors migrate's + // `if (fieldNames.length === 0 && expr === undefined) continue`. + if (fieldNames.length === 0 && expr === undefined) return undefined; + + const sqlSym = imp("sql@drizzle-orm"); + const indexSym = imp(`${unique ? "uniqueIndex" : "index"}@${importModule}`); + + // @orders is positional against @fields; migrate only serializes it when at least one + // entry is `desc`, because an all-ascending array must be indistinguishable from no + // orders at all for diff stability. Match that exactly or the two disagree on a + // no-op. + const ordersRaw = node.attr(IDENTITY_ATTR_ORDERS); + const orders = Array.isArray(ordersRaw) + ? fieldNames.map((_, i) => (ordersRaw[i] === "desc" ? "desc" : "asc")) + : []; + const anyDesc = orders.some((o) => o === "desc"); + const colRefs = fieldNames.map((f, i) => + anyDesc ? `table.${f}.${orders[i] === "desc" ? "desc" : "asc"}()` : `table.${f}`); + + // sqlite's `.on()` accepts only columns — no SQL — so an expression index is not + // expressible there. It is skipped rather than emitted wrong; migrate still creates it, + // and a Drizzle declaration that silently indexed a COLUMN instead of the expression + // would be a worse answer than an absent one. + if (expr !== undefined && dialect === "sqlite") return undefined; + + // @using selects the access method and takes the columns itself, so it REPLACES .on(). + // Postgres-only: SQLite has no USING clause, and MySQL's (btree/hash) is an index + // option rather than an access method taking the columns. migrate omits a `btree` + // value as the default, and so does this — emitting it would be a spurious difference. + const using = dialect === "postgres" ? str(IDENTITY_ATTR_USING) : undefined; + const target: Code = + expr !== undefined ? code`${sqlSym}\`${expr}\`` + : code`${colRefs.join(", ")}`; + // The CONSTANT when the artifact is in the run, the literal otherwise — and the + // literal arm goes through `resolveIndexName` too, so the two paths answer with one + // function. Both halves matter: migrate's expected-schema resolves the same name and + // the two spelled it independently until now, which is how codegen came to declare + // `idx_
_` against an index the database actually held under + // `identity.name` (fdb4118f1); and an empty `index.lookup` name — which the loader + // accepts, unlike an identity — is refused here rather than emitting `index("")`. + const indexName = indexNameExpr(names, node); + const head: Code = + using !== undefined && using !== "btree" + ? code`${indexSym}(${indexName}).using(${JSON.stringify(using)}, ${target})` + : code`${indexSym}(${indexName}).on(${target})`; + + // @where — the partial-index predicate, on Postgres and SQLite. MySQL has no partial + // indexes, so there the index is declared over the whole table; the adopter's DDL + // cannot say otherwise either. + const where = dialect === "mysql" ? undefined : str(IDENTITY_ATTR_WHERE); + return where === undefined ? head : code`${head}.where(${sqlSym}\`${where}\`)`; + }; + + // identity.secondary — always unique (uniqueness is in the type, no boolean). + for (const sec of secondaryIdentities) { + const fields = sec.attr(IDENTITY_ATTR_FIELDS) as string[] | undefined; + // The identity's OWN name, which is what migrate puts in the database + // (`expected-schema.ts`, secondary-identity pass) and what `index.lookup` three + // blocks below already emits. This used to compose `idx_
_...` by running + // the naming strategy over the FIELD names — two defects in one expression: the name + // disagreed with migrate's (so a DROP INDEX written from generated source failed, and + // any schema diff reported a difference that was not real), and running the strategy + // over a field NAME cannot see `@column`, so a declared physical column was invisible + // to it. Gated by `secondary-index-name-parity.test.ts`, whose fixture is de-blinded + // against exactly that second defect. + // Column refs use the callback param `table` (not the outer varName) so TS does not see + // the table referencing itself inside its own initializer (TS7022/TS7024). + const entry = indexEntry(sec, Array.isArray(fields) ? fields : [], /* unique */ true); + if (entry !== undefined) callbackEntries.push(entry); + } + + // index.lookup — always non-unique. Use MetaIndex.fields() per ADR-0039. + for (const lookup of obj.lookupIndexes()) { + const entry = indexEntry(lookup, lookup.fields(), /* unique */ false); + if (entry !== undefined) callbackEntries.push(entry); + } + + // Emit table-level CHECK constraints for enum fields. Both halves are composed by + // `checkEntry` above, which owns the constant-vs-literal decision for the pair. + for (const { name, expr } of checkConstraints) { + const checkSym = imp(`check@${importModule}`); + const sqlSym = imp("sql@drizzle-orm"); + callbackEntries.push(code`${checkSym}(${name}, ${sqlSym}\`${expr}\`)`); + } + + let tableBlock: Code; + if (callbackEntries.length > 0) { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}, (table) => [ + ${joinCode(callbackEntries, { on: ",\n ", trim: false })} +]); +`; + } else { + tableBlock = code` +export const ${varName} = ${tableCall}(${tableNameExpr}, { +${joinCode(columnLines, { on: ",\n", trim: false })} +}); +`; + } + + // Emit the relations() block (returns null if no relations). + const relationsBlock = renderRelationsBlock(obj, ctx); + + // Int-backed enum codecs are declared BEFORE the table that references them. + // Sorted by const name so output is deterministic regardless of field order. + const enumIntBlocks = [...enumIntTypes.values()] + .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName)) + .map((t) => renderEnumIntCustomType(t, importModule)); + if (declaredEnumIntCodecs !== undefined) { + for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name); + } + + const blocks: Code[] = [...enumIntBlocks, tableBlock]; + if (relationsBlock !== null) blocks.push(relationsBlock); + return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" }); +} + +interface FkInfo { + targetVarName: string; // e.g., "users" + targetEntityName: string; // e.g., "User" — used for the import path + targetPkField: string; // e.g., "id" +} + +/** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */ +function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { + const result = new Map(); + // FR-017 TPH: a discriminator base's table carries every concrete subtype's folded + // columns (collectTphSubtypeFields), so it carries their FKs too — otherwise a reference + // declared on a subtype, or on an abstract level between it and the base, lands as a + // plain column. Same effective, not-already-on-the-base rule as the column fold. + // A copy: referenceIdentities() is the node's cached array, and loaded metadata is read-only. + const refs = [...obj.referenceIdentities()]; + const baseRefNames = new Set(refs.map((r) => r.name)); + for (const sub of tphConcreteSubtypes(obj, ctx.loadedRoot)) { + for (const ref of sub.referenceIdentities()) { + if (!baseRefNames.has(ref.name)) refs.push(ref); + } + } + for (const ref of refs) { + // @enforce: false → logical-only reference. Skip the .references() emission; + // the column stays plain. Drizzle's relations() block (driven by + // relation-resolver) still includes the relationship for query navigation. + if (!ref.enforce) continue; + const fkFieldNames = ref.fields; + if (fkFieldNames.length === 0) continue; + const fkField = fkFieldNames[0]!; + // Two subtypes declaring the same reference fold to one column with one FK. + if (result.has(fkField)) continue; + const targetName = ref.targetEntity; + if (!targetName) continue; + // @references may be authored bare OR package-qualified, and the loader can + // resolve it to an FQN (e.g. the YAML front-end qualifies it). Strip the + // package so the lookup matches the object's bare name — mirrors the + // relation-resolver, which already does this. + const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName)); + if (!targetObj) continue; + // A TPH subtype has no table const of its own; its rows live in the base's table. + const storage = tphStorageObject(targetObj); + const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id"; + result.set(fkField, { + targetVarName: ctx.collectionName(storage.name), + targetEntityName: storage.name, + targetPkField, + }); + } + return result; +} + +/** + * Build the `primaryKey({ columns: [table.f1, table.f2] })` Code expression + * for composite primary keys. Uses imp() so ts-poet tracks the import. + */ +function buildCompositeKeyCallback( + pkFieldNames: Set, + importModule: string, +): Code { + const primaryKeySym = imp(`primaryKey@${importModule}`); + const columnRefs = Array.from(pkFieldNames) + .map((f) => `table.${f}`) + .join(", "); + return code`${primaryKeySym}({ columns: [${columnRefs}] })`; +} + +/** Build a JS-style object literal string (not JSON.stringify which uses quoted keys). + * Array values get `as const` appended so Drizzle's text(...,{ enum: [...] }) + * narrows the inferred column type to a literal union instead of bare `string`. */ +function inlineObjectLiteral(obj: Record): string { + const entries = Object.entries(obj).map(([k, v]) => { + const lit = JSON.stringify(v); + if (Array.isArray(v)) { + return `${k}: ${lit} as const`; + } + return `${k}: ${lit}`; + }); + return `{ ${entries.join(", ")} }`; +} + +/** Render one column line (field name + Drizzle column expression). */ +/** SQLite "now" for a timestamp column, spelled like Date.toISOString(). Must stay + * byte-identical to migrate-ts `SQLITE_ISO_NOW`, which owns the DDL (ADR-0015). */ +const SQLITE_ISO_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"; + +function renderColumn( + spec: ColumnSpec, + // §A6 — the column's physical name as an EXPRESSION: either the `Names` constant + // or the same literal as before. Passed in rather than derived here so one call site owns + // the decision (and the equality check that makes it byte-safe). + dbNameExpr: Code, + field: MetaField, + ctx: RenderContext, + isPk: boolean, + pkGeneration: string | undefined, + fkInfo: FkInfo | undefined, + isComposite: boolean, + entityPackage: string | undefined = undefined, + // Name of the entity this column belongs to — used to detect a self-referential + // FK (target entity === this entity), which Drizzle emits without a self-import. + currentEntityName: string = "", + // FR-017 Tier 2 — TPH subtype-only column: force nullable (drop .notNull()) + // and suppress any DB default (other-subtype rows must stay NULL here). + forceNullable: boolean = false, +): Code { + // An int-backed field.enum's column function is a LOCAL generated const (the + // customType helper emitted into this same file), so it must not be imported + // from drizzle-orm/*-core like a built-in column type would be. + const fnSym = + spec.enumIntCustomType !== undefined + ? spec.enumIntCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); + + let baseCall: Code; + if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) { + baseCall = code`${fnSym}(${dbNameExpr}, ${inlineObjectLiteral(spec.fnOptions)})`; + } else { + baseCall = code`${fnSym}(${dbNameExpr})`; + } + + let pkSuffix = ""; + if (isPk) { + if (pkGeneration === GENERATION_INCREMENT) { + if (ctx.dialect === "sqlite") { + // Composite PKs don't use .primaryKey() per-column; table callback owns it. + pkSuffix = isComposite ? "" : ".primaryKey({ autoIncrement: true })"; + } else if (ctx.dialect === "mysql") { + // MySQL: an AUTO_INCREMENT column. The generated writes read the new key back + // with Drizzle's `$returningId()` (MySQL has no RETURNING). + pkSuffix = isComposite ? ".autoincrement()" : ".primaryKey().autoincrement()"; + } else { + // Postgres: the column KEEPS the type the column-mapper derived and + // GAINS the identity clause. It does not get a second spelling. + // + // This used to substitute `serial`/`bigserial` for the mapped + // `integer`/`bigint`, and that was the 1.0 defect wearing another hat: + // migrate emits `… GENERATED BY DEFAULT AS IDENTITY` (emit/postgres.ts + // renderColumn) for the same field, and `serial` is NOT that column. + // `serial` is historical sugar for integer + an owned sequence + a + // genuine DEFAULT nextval(...); an identity column carries neither a + // default nor a droppable sequence. So the generated Drizzle schema + // misrepresented the live PK of every integer-keyed table migrate had + // created — one question, two doors, two answers, and nothing compared + // them. migrate owns the DDL (ADR-0015), so codegen is what changes — + // the same call #293 made when the two named a CHECK constraint + // differently. + // + // The clause is gated on the MAPPED column function rather than on the + // field subtype, so the two cannot drift apart again: only Drizzle's + // int family (integer/bigint/smallint, via PgIntColumnBaseBuilder) + // carries .generatedByDefaultAsIdentity(). A subtype outside that + // family with @generation:"increment" is a combination Postgres cannot + // express at all — migrate emits `TEXT GENERATED BY DEFAULT AS + // IDENTITY` and fails at apply — and the honest thing here is to leave + // the declared type alone and let that failure be loud, rather than + // silently substituting an integer column for the declared one. + pkSuffix = isComposite ? "" : ".primaryKey()"; + if ( + spec.enumIntCustomType === undefined && + PG_IDENTITY_CAPABLE_FNS.has(spec.fnName) + ) { + pkSuffix += ".generatedByDefaultAsIdentity()"; + } + } + } else if (pkGeneration === GENERATION_UUID) { + pkSuffix = isComposite + ? "" + : ctx.dialect === "postgres" + ? ".primaryKey().defaultRandom()" + // SQLite and MySQL have no uuid default: the id is minted client-side. + : ".primaryKey().$defaultFn(() => crypto.randomUUID())"; + } else { + // No generation: natural PK. Composite hands off to table callback. + pkSuffix = isComposite ? "" : ".primaryKey()"; + } + } + + let modifiersStr = pkSuffix; + // No `isUnique` parameter here any more, and its absence is the fix rather than a tidy-up. + // A single-column `identity.secondary` used to append `.unique()` on top of the constraint + // migrate already emits under the identity's own name, so codegen declared one constraint + // the database does not have. When that producer was removed both call sites started + // passing a literal `false`, leaving a parameter whose every branch was unreachable — a + // dead switch that reads like a live one. `spec.modifiers` carries whatever uniqueness the + // column genuinely has, and the loop below is the only thing that applies it. + for (const m of spec.modifiers) { + // Single-column PKs imply notNull/unique; avoid emitting them twice. + // Composite-PK columns are NOT declared with .primaryKey(), so they DO need .notNull(). + if (isPk && !isComposite && (m === ".notNull()" || m === ".unique()")) continue; + // TPH subtype-only column: never .notNull() / .unique() — rows of other + // subtypes store NULL, so neither constraint can hold across the table. + if (forceNullable && (m === ".notNull()" || m === ".unique()")) continue; + modifiersStr += m; + } + + // sqlDefaultSegment must be a Code segment (not a raw string) so ts-poet tracks + // the `sql` import via imp(); a raw `.default(sql`...`)` would leave `sql` + // unresolved in the generated file. + let sqlDefaultSegment: Code | null = null; + if (spec.defaultExpr !== undefined && !isPk && !forceNullable) { + if (spec.defaultExpr.kind === "now") { + if (ctx.dialect === "sqlite") { + const sqlSym = imp("sql@drizzle-orm"); + // A timestamp column defaults to the ISO UTC form generated code writes, the same + // expression `meta migrate` puts in the DDL (migrate-ts SQLITE_ISO_NOW). + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(${SQLITE_ISO_NOW})\`)` + : code`.default(${sqlSym}\`CURRENT_TIMESTAMP\`)`; + } else if (ctx.dialect === "mysql") { + // MySQL's datetime builder has no defaultNow(); spell the expression default, + // at the column's millisecond precision. + const sqlSym = imp("sql@drizzle-orm"); + sqlDefaultSegment = field.subType === FIELD_SUBTYPE_TIMESTAMP + ? code`.default(${sqlSym}\`(CURRENT_TIMESTAMP(3))\`)` + : field.subType === FIELD_SUBTYPE_DATE + ? code`.default(${sqlSym}\`(CURRENT_DATE)\`)` + : code`.default(${sqlSym}\`(CURRENT_TIME)\`)`; + } else { + modifiersStr += `.defaultNow()`; + } + } else if (spec.defaultExpr.kind === "arrayLiteral") { + // isArray field: Drizzle's .array().default(x) (postgres) and + // .$type().default(x) (sqlite json) want a JS array literal, not the + // raw @default string. Elements are pre-rendered TS source literals. + // MySQL cannot give a JSON column a literal DEFAULT, so there the array is filled in + // client-side on insert. + modifiersStr += ctx.dialect === "mysql" + ? `.$defaultFn(() => [${spec.defaultExpr.elements.join(", ")}])` + : `.default([${spec.defaultExpr.elements.join(", ")}])`; + } else if (spec.defaultExpr.kind === "sqlExpr") { + const sqlSym = imp("sql@drizzle-orm"); + // Raw SQL keyword/expression — emit as sql`` for both dialects. This + // is also the always-typechecks fallback for an array @default whose shape + // parseArrayDefault couldn't safely model. + sqlDefaultSegment = code`.default(${sqlSym}\`${spec.defaultExpr.raw}\`)`; + } else { + // literal + modifiersStr += `.default(${JSON.stringify(spec.defaultExpr.value)})`; + } + } + + // FK .references() uses imp() so ts-poet tracks the cross-entity import. + let fkRefSegment: Code | null = null; + if (fkInfo !== undefined && !isPk) { + // Always annotate the .references() callback with the dialect's Any*Column return + // type. Drizzle needs this to break circular type inference — not only for + // self-referential FKs but also for cross-module circular references (table A → B + // while B → A), which otherwise surface as TS7022 ("implicitly has type 'any' + // because it does not have a type annotation and is referenced … in its own + // initializer") under `strict`. The annotation is a harmless explicit supertype + // for acyclic FKs, so emitting it unconditionally is safe. + const anyColType = dialectModule(ctx.dialect).anyColumn; + // Used only as a return-type annotation → type-only import (t:) so it emits + // `import type` and doesn't fail tsc under `verbatimModuleSyntax` (TS1484). (#165) + const anyColSym = imp(`t:${anyColType}@${spec.importModule}`); + if (fkInfo.targetEntityName === currentEntityName) { + // Self-referential FK (e.g. createdBy → this same table): reference the local + // table const directly — NOT a self-import. + fkRefSegment = code`.references((): ${anyColSym} => ${fkInfo.targetVarName}.${fkInfo.targetPkField})`; + } else { + const targetSpec = crossEntitySpecifier( + ctx.outputLayout, + entityPackage, + ctx.packageOf.get(fkInfo.targetEntityName), + fkInfo.targetEntityName, + ctx.extStyle, + ); + const targetVarSym = imp(`${fkInfo.targetVarName}@${targetSpec}`); + fkRefSegment = code`.references((): ${anyColSym} => ${targetVarSym}.${fkInfo.targetPkField})`; + } + } + + // @autoSet fields: emit a $defaultFn so Drizzle inserts stamp the server-side timestamp + // automatically. This means callers don't need to supply createdAt / updatedAt in INSERT + // calls — Drizzle fills them in. The stamp's shape must match ctx.timestampMode — the base + // column type already does (mapColumnType, above) — or a "date" column ends up with a + // $defaultFn that hands Drizzle a string, which fails to typecheck (reported against an + // adopting project: TS2322 "Type 'string' is not assignable to type 'Date | SQL'", + // cascading into every generated insert/update query touching the field). + const autoSet = field.attr(FIELD_ATTR_AUTO_SET); + const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate") + ? ctx.timestampMode === "date" + ? `.$defaultFn(() => new Date())` + : `.$defaultFn(() => new Date().toISOString())` + : ""; + + // $type() chain — emitted as Code (not a string modifier) so ts-poet can + // hoist the cross-module type import for objectRef variants. Positioned + // immediately after the baseCall so the chain reads `.text(...).$type<...>().notNull()...` + // which Drizzle accepts in any order but is conventional for "type narrowing + // first." + // Resolve a VO name → an imported type symbol (shared layout/package/extStyle-aware + // helper, so the .$type import matches the field's TS type + Zod schema). + // ADR-0044/#228 — resolve the field's @objectRef to the value object's EMITTED + // name (bare when unique in the run, package-qualified on a cross-package + // short-name collision), resolved package-locally from the FIELD's declaring + // package. `name` (the bare dollarTypeRef name) is the byte-identical fallback + // when the ref doesn't resolve to an emitted value object. + const voSym = (name: string) => { + const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); + const emitted = typeof refRaw === "string" + ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + : name; + return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); + }; + + let dollarTypeSegment: Code | string = ""; + if (spec.dollarTypeRef !== undefined) { + const ref = spec.dollarTypeRef; + if (ref.kind === "scalar") { + dollarTypeSegment = `.$type<${ref.tsType}${ref.array ? "[]" : ""}>()`; + } else if (ref.kind === "objectRef") { + const refSym = voSym(ref.name); + dollarTypeSegment = ref.array ? code`.$type<${refSym}[]>()` : code`.$type<${refSym}>()`; + } else { + // field.map → Record; V is a scalar or a hoisted value-object. + dollarTypeSegment = "scalar" in ref.value + ? `.$type>()` + : code`.$type>()`; + } + } + + const columnLine = code` ${field.name}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`; + return spec.leadingComment !== undefined + ? code` // ${spec.leadingComment}\n${columnLine}` + : columnLine; +} diff --git a/server/typescript/packages/test-generators/src/templates/entity-constants.ts b/server/typescript/packages/test-generators/src/templates/entity-constants.ts new file mode 100644 index 000000000..4a79303a4 --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/entity-constants.ts @@ -0,0 +1,196 @@ +// Entity-constants template — emits an `` const with all the +// metadata-derived strings consumers should use INSTEAD of magic strings. +// +// Shape (each non-$-prefixed key is a per-field object): +// +// export const Subscriber = { +// $entity: "Subscriber", +// $table: "subscribers", +// $path: "/subscribers", +// +// email: { +// name: "email", +// label: "Email Address", // @title on the surface's view, else @title on the field, else humanized field name +// view: "text", // MetaView subtype +// htmlType: "email", // optional; only when the view maps to a real HTML input type +// rules: { // optional; derived from validator children +// required: "Email is required", +// maxLength: { value: 255, message: "Must be 255 characters or fewer" }, +// pattern: { value: /.../, message: "Invalid email" }, +// }, +// }, +// // ... +// } as const; +// +// Consumers spread `form.input.email` from useEntityForm and never touch +// per-attribute access (placeholder, rules, etc.) by hand — the helper +// picks them up from this object automatically. + +import { code, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import { MetaObject } from "@metaobjectsdev/metadata"; +import { resolveTableName } from "@metaobjectsdev/metadata"; +import { physicalNameExpr } from "@metaobjectsdev/codegen-ts"; +import { + buildEntityUiDescriptor, + resourcePath, + type UiFieldDescriptor, + type UiRule, +} from "@metaobjectsdev/codegen-ts"; + +// `resourcePath` moved to entity-ui-descriptor.ts with the rest of the form derivation. +// Re-exported here because it is part of this module's published surface (the package +// barrel re-exports it from this path, and api-model.ts imports it from here). +export { resourcePath }; + +/** + * Render one rule entry as the RHF-shaped code fragment the descriptor emits. + * + * Order is the descriptor's, which is declaration order — see the ordering note in + * entity-ui-descriptor.ts for why that is not incidental. + */ +function renderRule(r: UiRule): string { + switch (r.kind) { + case "required": + return `required: ${JSON.stringify(r.message)}`; + case "minLength": + return `minLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "maxLength": + return `maxLength: { value: ${r.value}, message: ${JSON.stringify(r.message)} }`; + case "pattern": { + // Emit as a RegExp literal /.../ — `as const` preserves the value-ref. The + // descriptor carries the RAW pattern; forward-slash and backslash are escaped + // HERE, because closing a TypeScript regex literal is an emission concern. + const safe = r.pattern.replace(/\\/g, "\\\\").replace(/\//g, "\\/"); + return `pattern: { value: /${safe}/, message: ${JSON.stringify(r.message)} }`; + } + } +} + +/** Build one nested field-object entry like `email: { name, label, ... },`. */ +function renderFieldEntry(f: UiFieldDescriptor): string { + const entries: string[] = [ + `name: ${JSON.stringify(f.name)}`, + `label: ${JSON.stringify(f.label)}`, + `view: ${JSON.stringify(f.view)}`, + ]; + if (f.htmlType !== undefined) entries.push(`htmlType: ${JSON.stringify(f.htmlType)}`); + if (f.rules.length > 0) entries.push(`rules: { ${f.rules.map(renderRule).join(", ")} }`); + + // A `field.enum`'s member symbols. Emitted whenever the field has them, because the + // descriptor reports `view: "dropdown"` for an enum and a dropdown without options is + // a promise the descriptor cannot keep. + if (f.options !== undefined && f.options.length > 0) { + entries.push(`options: [${f.options.map((v) => JSON.stringify(v)).join(", ")}] as const`); + } + + // Currency-specific keys: only emitted for currency-subtype fields. + if (f.currency !== undefined) { + entries.push(`currency: ${JSON.stringify(f.currency.currency)}`); + entries.push(`locale: ${JSON.stringify(f.currency.locale)}`); + } + + return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`; +} + +/** + * The field-rules constant of an `object.value`: `export const = { $entity, : { name, + * label, view, rules, ... } } as const`. The same per-field shape an entity's constant carries, + * minus `$table` and `$path` — a value object has no source and no route — so a caller reads a + * limit as `..rules.maxLength.value` instead of reaching through the Zod schema's + * getters. Emitted for the value subtype only (see `renderValueObjectFile`). + */ +export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code { + const descriptor = buildEntityUiDescriptor(obj); + // The emitted name (collision-qualified, ADR-0044) matches the value object's interface. + const name = emittedName ?? descriptor.entity; + const body = joinCode( + [code` $entity: ${JSON.stringify(descriptor.entity)}`, ...descriptor.fields.map((f) => code`${renderFieldEntry(f)}`)], + { on: ",\n" }, + ); + return code` +/** + * Metadata constants for the value object ${name}: each field's name, label, view and the + * validation rules derived from its validator children (e.g. \`${name}..rules.maxLength.value\`). + */ +export const ${name} = { +${body}, +} as const; +`; +} + +export function renderEntityConstants( + obj: MetaObject, + /** + * @deprecated Accepted and IGNORED. The API base URL left the descriptor: generated + * client artifacts emit entity-relative paths and the base is supplied once at the + * client provider (`baseUrl`), because where the browser sends a request is a + * deployment fact rather than something to freeze at `meta gen` time. + * + * It must STAY in the signature: `src/reference/entity.ts` is copied verbatim into + * adopter repos (ADR-0034) and calls this positionally, so removing the parameter + * would fail to compile in every ejected copy. Removal is a separate later break. + */ + _apiPrefix = "", + // §A6. OPTIONAL, and it must stay optional: `src/reference/entity.ts` is copied + // verbatim into adopter repos by ADR-0034 scaffold-and-own and calls this with two + // arguments. A required parameter would fail to compile in every ejected copy. + names?: { readonly name: string; readonly symbol: Code } | undefined, +): Code { + // ONE derivation of the form surface, shared with the `agent/ui.md` docs page — see + // entity-ui-descriptor.ts. The descriptor already walks the RESOLVING fields(), so + // inherited fields (from extends:/super:) appear here as they always have. + const descriptor = buildEntityUiDescriptor(obj); + const entityName = descriptor.entity; + const tableName = resolveTableName(obj); + const path = descriptor.path; + + const fieldEntries: string[] = descriptor.fields.map(renderFieldEntry); + + // A6/B2 — reference the constant whenever the artifact is in the run. No equality + // guard: primaryRdbSource (@metaobjectsdev/metadata) refuses any object whose + // @role: primary sources disagree on a physical name, and both the constant and the + // literal below resolve through it, so a reference here is the single spelling. `physicalNameExpr` accepts + // any `{ symbol }`-shaped value, so it works with this function's `{ name, symbol }` + // parameter as well as `namesRef`'s own `{ resolved, symbol }`. + const tableLine: Code = code` $table: ${physicalNameExpr(names, tableName, obj)}`; + + const body = joinCode( + [ + code` $entity: ${JSON.stringify(entityName)}`, + tableLine, + code` $path: ${JSON.stringify(path)}`, + ...fieldEntries.map((e) => code`${e}`), + ], + { on: ",\n" }, + ); + + return code` +/** + * Metadata constants for ${entityName}. + * + * Use these instead of magic strings so TS catches typos and refactors stay + * coherent. Each non-dollar-prefixed key is a per-field object carrying + * name, label, view, an htmlType where the field's view maps to one, and the + * RHF-shaped validation rules derived from the field's validator children. + * + * placeholder and helpText are NOT emitted — no provider registers an attribute + * for them, so nothing here could derive one. (htmlType is emitted, but derived + * from the view subtype; the @htmlType override was removed for the same reason.) + * useEntityForm still honours placeholder if you add it: this file is generated, + * hand edits inside it survive regeneration through the three-way merge, and the + * consumers read this const rather than the metadata. That is the intended way + * to set it. + * + * Typical usage with the metaobjects React form helper: + * + * import { useEntityForm } from '@metaobjectsdev/react'; + * const form = useEntityForm(${entityName}, ${entityName}InsertSchema); + * + */ +export const ${entityName} = { +${body}, +} as const; +`; +} diff --git a/server/typescript/packages/test-generators/src/templates/entity-file.ts b/server/typescript/packages/test-generators/src/templates/entity-file.ts new file mode 100644 index 000000000..932048d7e --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/entity-file.ts @@ -0,0 +1,237 @@ +// Entity file composer — combines drizzle-schema, inferred-types, and zod-validators +// into one file with the @generated header. ts-poet deduplicates imports. +// +// Dispatch: +// isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections) +// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema) +// vanilla / write-through entity → Drizzle table path + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts"; +import { renderDrizzleSchema } from "./drizzle-schema.js"; +import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; +import { renderZodValidators, isTphSubtype, primaryIdentityFieldNames } from "./zod-validators.js"; +import { renderEntityConstants } from "./entity-constants.js"; +import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; +import { renderFilterType } from "./filter-type.js"; +import { renderTphDiscriminatorUnion, isTphDiscriminatorBase } from "./tph-discriminator.js"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "@metaobjectsdev/codegen-ts"; +import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts"; +import { renderProjectionDecl } from "./projection-decl.js"; +import { + projectionViewName, projectionViewSchema, projectionViewRole, +} from "@metaobjectsdev/codegen-ts"; +import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js"; +import { renderDocsFor } from "./jsdoc.js"; +import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts"; +import { namesRef, namesConstArg, physicalNameExpr, sourceSchemaExpr } from "@metaobjectsdev/codegen-ts"; +import { hasWritableRdbSource } from "@metaobjectsdev/codegen-ts"; +import { renderValueObjectFile } from "./value-object-file.js"; +import { isAbstract } from "@metaobjectsdev/codegen-ts"; +import { effectivePackage } from "@metaobjectsdev/codegen-ts"; + +/** + * Render-time options for the entity-file composer. + * + * `allowlists` (default `true`) controls whether the Fastify-flavored + * `FilterAllowlist` + `SortAllowlist` blocks (plus their + * `runtime-ts/drizzle-fastify` type-only imports) are emitted. Workers/Lambda + * consumers that don't mount Fastify-style server routes can pass `false` and + * drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side + * `Filter` type is always emitted — consumers still want it for typed + * client calls regardless of how the server is wired. + * + * Whether the file emits ANY server runtime binding at all (Drizzle table/view, + * the allowlists) is governed by the TARGET, not this option: `ctx.selfTarget.runtime`. + * A contract-only target (`runtime: false`) renders every object as its plain + * shape (interface + Zod) and every projection as its read schema — no + * `drizzle-orm`, no `runtime-ts`. `allowlists` is a finer Fastify-vs-Hono opt-out + * that only matters within a runtime target. + */ +export interface RenderEntityFileOpts { + readonly allowlists?: boolean; +} + +export function renderEntityFile( + entity: MetaObject, + ctx: RenderContext, + opts?: RenderEntityFileOpts, +): string { + // Contract-only target ⇒ no server runtime: no Drizzle pgView/pgTable, no + // runtime-ts allowlists. The read schema + inferred types still emit. + const runtime = ctx.selfTarget.runtime; + const allowlists = runtime ? (opts?.allowlists ?? true) : false; + + // --- Abstract path (shape only) --- + // An abstract entity contributes shape via inheritance only — it must NEVER + // produce a Drizzle table / migration footprint / filter allowlist, even when + // it carries a source.rdb child. This is the cross-port invariant (abstract → + // no instance/write artifacts, including CREATE TABLE). It still emits its + // value-object shape (interface + Zod) so subclasses/consumers can reference + // it. The entity-file generator suppresses this entirely when + // emitAbstractShapes is off; here we only guarantee "shape, never table". + if (isAbstract(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Projection path (read-only: view-backed entity with no table source) --- + // Projections intentionally get the z.enum() validator but NOT a named enum + // type alias — emitting aliases here is a deliberate v1 scope decision. + if (isProjection(entity)) { + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts builds, so the projection's view + // name + per-field dbCol reference the exact constant the names artifact exports + // (undefined when the artifact is not in this run — see names.ts). `namesRef`'s + // `{ resolved, symbol }` return is exactly the shape `ProjectionDeclOpts.names` wants. + return renderProjectionDecl(entity, ctx.loadedRoot, { + columnNamingStrategy: ctx.columnNamingStrategy, + dialect: ctx.dialect, + apiPrefix: ctx.apiPrefix, + timestampMode: ctx.timestampMode, + allowlists, + ctx, + // Contract target drops the Drizzle .existing() view decl + drizzle-orm import. + includeViewDecl: runtime, + names: namesRef(entity, ctx), + }); + } + + // --- Value-only / contract path (no Drizzle table) --- + // Reached when the entity has no writable source.rdb (in-memory / transit + // shape) OR the target is contract-only (a UI/wire package gets the read shape, + // not a DB table). Either way: interface + Zod, no migration footprint, no + // drizzle-orm. Consumers validate via the Zod schema and type via the interface. + // + // A TPH subtype (FR-017) also routes here: it inherits the base's writable + // source.rdb via extends (so hasWritableRdbSource is now true under the ADR-0039 + // resolving read), but the base owns the single shared table — the subtype must + // emit its per-subtype read schema (Schema), never its own pgTable. + if (!runtime || !hasWritableRdbSource(entity) || isTphSubtype(entity)) { + return renderValueObjectFile(entity, ctx.apiPrefix, ctx); + } + + // --- Vanilla / write-through entity path --- + const enumAliases = renderEnumTypeAliases(entity, ctx); + // FR-017 Tier 1: when this entity carries @discriminator AND has concrete + // subtypes, append the discriminated-union type alias, type guards, and + // the parse(row) dispatcher. Returns null otherwise (no subtypes, or + // not a discriminator-bearing entity); the section is suppressed cleanly. + const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot); + // FR-017: when a discriminator base also has a union block, the union owns the + // bare `` type — so the inferred Drizzle row type is emitted as + // `Row` to avoid a duplicate `export type `. + const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot); + + // #214 — a write-through entity read-view (FR-024 §7): reads route to the replica + // VIEW, so the entity file additionally declares the `.existing()` view (carrying the + // derived fields the write table omits, #213) and a read schema `Schema` + // whose `z.infer` IS the read type (dialect-agnostic — a Drizzle view is not + // a Table, so InferSelectModel/`$inferSelect` don't uniformly apply; the Zod schema's + // nullability mirrors the view columns exactly like a projection). The write table + + // Insert/Update stay derived-free. `.existing()` is a runtime-target Drizzle binding, + // and this path only runs in a runtime target (contract-only/non-writable returned above). + // A TPH discriminator base owns `export type ` via its discriminated-union block, + // so it must NOT also emit the view-schema read type (that would be a duplicate-identifier + // compile error). A base+write-through combo keeps the TPH polymorphic read path (reads + // the base table); routing its reads through a replica view is a documented non-goal. + const writeThrough = isWriteThrough(entity) && !tphBase; + // Rendered BEFORE the replica view below, for two reasons that both matter: the table + // DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate, + // and a duplicate module-scope const is a parse error), and those consts must precede the + // view's use of them in the emitted file or the view hits a TDZ ReferenceError at import. + const declaredEnumIntCodecs = new Set(); + const schemaBlock = renderDrizzleSchema(entity, ctx, declaredEnumIntCodecs); + const viewSections: Code[] = []; + if (writeThrough) { + const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1); + const fields = entity.fields(); + const entityPkg = effectivePackage(entity); + // ADR-0044/#228 — resolve a view column's `@objectRef` to the value object's + // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports + // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) + // under a cross-package short-name collision. + const voRef = (field: MetaField): { name: string; module: string } => { + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); + return { name, module }; + }; + // ONE selection of the replica source: `projectionViewName` names it, and this is the + // role its entry has in Names.sources. Hardcoding "replica" here would be a + // second derivation of the very thing extract-view-spec.ts exists to decide once. + const viewRole = projectionViewRole(entity) ?? SOURCE_ROLE_REPLICA; + const entityNames = namesRef(entity, ctx); + const viewOpts = { + dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voRef, + // PK fields type non-null in the replica-view decl + read schema even + // without @required (a PK is never NULL; see ViewDeclOpts.pkFieldNames). + pkFieldNames: new Set(primaryIdentityFieldNames(entity)) as ReadonlySet, + // §A6 — the entity's names artifact supplies this view's COLUMN constants. A + // replica view exposes the entity's own fields, so `Names.fields..column` + // is the same physical name the table side binds; a derived field the artifact + // does not carry falls back to the literal via `columnExpr`. The view's own NAME + // is passed separately below and stays a literal — the artifact holds the primary + // (table) source's name, not this one. + names: entityNames, + // The table above already declared these; the view references them by name. + declaredEnumIntCodecs, + // The replica view's OWN @schema, from the same source node projectionViewName picks + // — never the entity's, which is the WRITE TABLE's and would qualify this view with a + // schema that belongs to something else. A view and the table it replicates need not + // live in the same schema, and a name paired with someone else's schema is worse than + // no schema at all. + // + // Both this and the view's NAME are now CONSTANTS. They were literals for a real + // reason that has stopped being true: Names carried the primary source only, + // so a write-through entity's two physical names had one slot between them and the + // second was emitted in full — the same escape C#'s AppDbContext had, in a different + // language, which is what showed it was the artifact's shape and not the binding. + schema: sourceSchemaExpr(entityNames, viewRole) ?? projectionViewSchema(entity), + }; + const z = imp("z@zod"); + const docs = renderDocsFor(entity); + const docsPrefix = docs ? `${docs}\n` : ""; + viewSections.push( + renderExistingViewDecl( + fields, + physicalNameExpr(entityNames, projectionViewName(entity, ctx.columnNamingStrategy), entity, viewRole), + `${camel}View`, + viewOpts, + ), + code` +export const ${entity.name}Schema = ${renderViewReadZodObject(fields, viewOpts)}; +`, + code` +${docsPrefix}export type ${entity.name} = ${z}.infer; +`, + ); + } + + // §A6/§B2 — same `namesRef` pair drizzle-schema.ts (and this file's own projection + // branch above) build, so the descriptor's $table references the exact constant the + // names artifact exports (undefined when the artifact is not in this run). + const constantsNames = namesRef(entity, ctx); + + const sections: Code[] = [ + schemaBlock, + ...viewSections, + renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */), + ...(enumAliases !== null ? [enumAliases] : []), + renderZodValidators(entity, ctx), + renderEntityConstants(entity, ctx.apiPrefix, namesConstArg(constantsNames)), + ...(allowlists ? [renderFilterAllowlist(entity, undefined, ctx), renderSortAllowlist(entity, undefined, ctx)] : []), + renderFilterType(entity), + ...(tphBlock !== null ? [tphBlock] : []), + ]; + + // Render ts-poet body first (ts-poet hoists imp()-tracked imports to the top), + // then prepend the @generated header so it lands at line 1 — convention for + // generated files and what most tooling (overwrite-policy, IDEs) expects. + const body = joinCode(sections, { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Source metadata: ${entity.name} (${entity.fqn()})\n` + + sidecarLine(`${entity.name}.extra.ts`); + return header + body; +} diff --git a/server/typescript/packages/test-generators/src/templates/enum-int-codec.ts b/server/typescript/packages/test-generators/src/templates/enum-int-codec.ts new file mode 100644 index 000000000..cbef388d9 --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/enum-int-codec.ts @@ -0,0 +1,67 @@ +/** + * The int-backed `field.enum` codec emitter, shared by the two artifacts that can + * reference one: the entity's Drizzle table (`drizzle-schema.ts`) and any view-backed + * read model (`view-decl.ts` — a projection or an entity read-view). + * + * It lives in its own module because the codec is a MODULE-LOCAL const in whatever file + * declares the column, so every emitter that can produce such a column must be able to + * declare it too. While only the table template owned it, the view template emitted the + * call site and let the const name fall through to `imp(fnName@drizzle-orm/*-core)` — + * importing a member that package does not export, which `meta gen` reported as success + * and only `tsc` caught. + * + * The invariant this module exists to protect: the codec is declared ONCE PER MODULE, and + * it must precede every use. A write-through entity puts its table and its replica view in + * one file and both reference the same consts, so `entity-file.ts` renders the table first, + * collects the names it declared, and hands them to the view — which then references them + * instead of re-declaring. Two module-scope `const`s of one name is a JS parse error, and a + * declaration emitted after its use is a TDZ ReferenceError at import: both are worse than + * the missing-codec bug, because neither lets the module load at all. + */ +import { code, imp, type Code } from "@metaobjectsdev/codegen-ts"; +import type { EnumIntCustomType } from "@metaobjectsdev/codegen-ts"; + +/** + * Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two + * lookup maps. + * + * The codec lives HERE, in the column definition, so nothing downstream needs to + * know about it: `db.insert().values()` encodes on bind, a selected row decodes on + * read, and a filter comparison encodes because Drizzle binds through the column + * type. That is why this shape was chosen over a Zod write-transform plus a + * generated read-decode — TS's generated queries return raw Drizzle rows and have + * no decode seam, so the query-layer approach meant inventing one and wrapping + * every generated read. It is also the direct analogue of what the other four + * ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed + * `customEnumeration`, Python `ObjectManager` coercion). + * + * `fromDriver` throws on an unmapped integer rather than returning undefined: a + * value outside the map means the DB holds data the model says is impossible + * (a hand-written INSERT, or a member removed without a migration), and silently + * yielding `undefined` for a non-nullable field would surface far from the cause. + */ +export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code { + const customTypeSym = imp(`customType@${importModule}`); + const union = t.members.map((m) => JSON.stringify(m)).join(" | "); + const toEntries = t.members + .map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`) + .join(", "); + const fromEntries = t.members + .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`) + .join(", "); + return code` +const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>; +const ${t.fromIntConstName}: Record = { ${fromEntries} }; +const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({ + dataType: () => ${JSON.stringify(t.dataType)}, + toDriver: (value) => ${t.toIntConstName}[value], + fromDriver: (value) => { + const member = ${t.fromIntConstName}[value]; + if (member === undefined) { + throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`); + } + return member; + }, +}); +`; +} diff --git a/server/typescript/packages/test-generators/src/templates/enums-file.ts b/server/typescript/packages/test-generators/src/templates/enums-file.ts new file mode 100644 index 000000000..0bfac3256 --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/enums-file.ts @@ -0,0 +1,61 @@ +// FR-019 — shared enums module. +// +// Emits, ONCE per run, the materialized (non-@provided) shared enum types that +// at least one concrete entity field references via `extends` of a root-level +// abstract `field.enum`. Each enum yields: +// • `export type = "A" | "B";` — the cross-port type identity +// • `export const Enum = z.enum(["A","B"]);` — the shared Zod validator +// +// Consuming entity files import these instead of redeclaring the union inline. +// @provided enums are NEVER materialized here (they live in hand-written code, +// imported from the configured providedEnumModule). + +import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts"; +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts"; +import { materializedSharedEnums, type SharedEnum } from "@metaobjectsdev/codegen-ts"; +import { enumUnionString } from "./inferred-types.js"; + +/** Basename (no extension) of the shared-enums module emitted at the entity-module target root. */ +export const SHARED_ENUMS_BASENAME = "enums"; + +/** The exported Zod-constant name for a shared enum (`Enum`). */ +export function sharedEnumZodConstName(enumName: string): string { + return `${enumName}Enum`; +} + +/** One enum's two declarations (type alias + shared z.enum const). */ +function renderOneSharedEnum(e: SharedEnum): Code { + const z = imp("z@zod"); + const members = e.values.map((v) => JSON.stringify(v)).join(", "); + return code` +export type ${e.name} = ${enumUnionString(e.values)}; +export const ${sharedEnumZodConstName(e.name)} = ${z}.enum([${members}]); +`; +} + +/** + * The full shared-enums module body, or null when the model has no materialized + * shared enums (so the generator emits no file at all). + * + * `opts.select` (FR-023 §11.1 item 2) — the model-wide scope predicate over a + * CONSUMING entity's `resolutionKey()`: an enum extended only by entities + * `select` excludes is not materialized. This module renders once per run from + * the WHOLE loaded root (never per-generator-filtered — see #266), so it is the + * one shared-enums call site that must be threaded the scope predicate + * separately from `ctx.matches`. Omitted ⇒ every entity counts, byte-identical + * to a caller that never passes `opts` at all. + */ +export function renderSharedEnumsFile( + root: MetaRoot, + opts?: { select?: (fqn: string) => boolean }, +): string | null { + const enums = materializedSharedEnums(root, opts?.select); + if (enums.length === 0) return null; + + const body = joinCode(enums.map(renderOneSharedEnum), { on: "\n" }).toString(); + const header = + `// ${GENERATED_HEADER} — ${GENERATED_EDIT_NOTE}\n` + + `// Shared enum types (FR-019): one declaration per reused package-level enum.\n`; + return header + body; +} diff --git a/server/typescript/packages/test-generators/src/templates/field-meta.ts b/server/typescript/packages/test-generators/src/templates/field-meta.ts new file mode 100644 index 000000000..f0e666fbb --- /dev/null +++ b/server/typescript/packages/test-generators/src/templates/field-meta.ts @@ -0,0 +1,284 @@ +// Shared field-level metadata helpers — consumed by entity-constants.ts, +// projection-decl.ts, and any future generator that needs per-field inference. +// +// All helpers take a MetaField node. + +import { MetaField } from "@metaobjectsdev/metadata"; +import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata"; +import { stripPackage } from "@metaobjectsdev/metadata"; +import { + FIELD_SUBTYPE_STRING, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_TIME, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_ENUM, + FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_URI, + FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_OBJECT, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STRING_FORMAT, + FIELD_ATTR_LENIENT, + STRING_FORMAT_EMAIL, + STRING_FORMAT_HOSTNAME, + VIEW_SUBTYPE_TEXT, + VIEW_SUBTYPE_DATE, + VIEW_SUBTYPE_NUMBER, + VIEW_SUBTYPE_CHECKBOX, + VIEW_SUBTYPE_CURRENCY, + VIEW_SUBTYPE_DROPDOWN, + FIELD_ATTR_CURRENCY, + FIELD_ATTR_CURRENCY_DEFAULT, + VIEW_CURRENCY_ATTR_LOCALE, + VIEW_CURRENCY_ATTR_LOCALE_DEFAULT, +} from "@metaobjectsdev/metadata"; +import { enumValues, zodEnumExpr } from "@metaobjectsdev/codegen-ts"; +import { viewForContext } from "@metaobjectsdev/codegen-ts"; +import { ZOD_INET_EXPR } from "./net-regex.js"; + +// --------------------------------------------------------------------------- +// inferViewKind +// --------------------------------------------------------------------------- + +/** + * Resolve the cell-renderer key (view kind) for a field, for the surface named + * by `context`. + * + * An explicit view declared for THAT surface wins; field subType determines the + * default. #356: `context` is required rather than defaulting, because a caller + * that omits it is exactly the caller that renders somebody else's view. + */ +export function inferViewKind(field: MetaField, context: string): string { + // Explicit view for this surface (own or inherited via extends) has highest priority. + const viewChild = viewForContext(field, context); + if (viewChild) return viewChild.subType; + // Field subtype → default view. + return defaultViewForSubType(field.subType); +} + +function defaultViewForSubType(subType: string): string { + switch (subType) { + case FIELD_SUBTYPE_BOOLEAN: + return VIEW_SUBTYPE_CHECKBOX; + case FIELD_SUBTYPE_INT: + case FIELD_SUBTYPE_LONG: + case FIELD_SUBTYPE_DOUBLE: + case FIELD_SUBTYPE_FLOAT: + case FIELD_SUBTYPE_DECIMAL: + return VIEW_SUBTYPE_NUMBER; + case FIELD_SUBTYPE_DATE: + case FIELD_SUBTYPE_TIME: + case FIELD_SUBTYPE_TIMESTAMP: + return VIEW_SUBTYPE_DATE; + case FIELD_SUBTYPE_CURRENCY: + return VIEW_SUBTYPE_CURRENCY; + case FIELD_SUBTYPE_ENUM: + // A closed member set is a CHOICE, and the generated form has always rendered it as + // one: `codegen-ts-react`'s `viewKindFor` returns `dropdown` for an enum declaring no + // view, and emits a `