diff --git a/CHANGELOG.md b/CHANGELOG.md index e75772e92f..b9ffd7a041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,26 @@ here.** ### Docs +- **The `@formExclude` description says what the attribute does since #484.** The registry text + said only "omitted from generated forms"; it now also says the generated create, PATCH and PUT + routes ignore a value sent for the field and that reads of it are unchanged. The sentence is the + same in `spec/metamodel/ui.json`, the TypeScript, Python and C# registry sources (Java reads the + spec), `expected-registry.json`, the metamodel-docs fixture and the reference site. No + behavior changes. + +- **Verify tables, the capability matrix and the entity samples now match what the ports emit.** + The per-port drift tables in `migrations-and-drift.md` and `templates-and-payloads.md` give + each port's real bare command (`meta verify`, `mvn metaobjects:verify`, `dotnet meta verify`, + `metaobjects verify`), what a bare run checks, the config each check needs and how to run one + alone, and where a bare run skips a check versus fails (a bare `dotnet meta verify` with no + `prompts/` or `templates/` directory exits 2); `python -m metaobjects.render.verify` (a module with no entry point) and the Node `meta` + as the C# binary are gone from every copy. The README capability matrix splits "DB-drift + verify" (TypeScript-only, the other four ports go through the TypeScript CLI) from + "Codegen-drift verify". The C# entity is a mutable `class`, not a `record`, and the Python + entity is a Pydantic `BaseModel`, not a `@dataclass`: the entity, field-type, source-kind and + relationship pages now show output generated from the example models. The Python + relationship section no longer calls reverse-finder and M:N codegen pending. + - **Writing your own generator is documented as the normal path.** `llms.txt`, `llms-full.txt` and the README now say a team should expect to write generators, and that documentation is a generator. New recipe, [a typed client for the reference routes](docs/recipes/typed-client-for-the-reference-routes.md), @@ -25,6 +45,13 @@ here.** ### Added +- **`docsSiteFile({ assetBase })` loads the docs site's CSS and JS from a base you choose.** The + page chrome loads daisyUI, Tailwind's browser build, Mermaid and svg-pan-zoom from jsDelivr, so a + site opened without network access rendered unstyled with dead diagrams. `assetBase` (also an + option of `generateSite`) replaces `https://cdn.jsdelivr.net/npm` in those five tags; the default + is unchanged and the default output is byte-identical. The `@metaobjectsdev/docs-site` README + lists the five files and how to serve them. Copying them into the output is not done for you. + - **Jinja templates in Python and TypeScript, held to the same payload check (ADR-0061, closes #483).** A `template.prompt` or `template.output` body may be a Jinja file — `/.jinja`, `.j2` or `.jinja2` — instead of a `.mustache` one. Nothing in the @@ -212,6 +239,25 @@ here.** ### Fixed +- **Generated Python routers pass `mypy --strict`.** The repository `Protocol` declares a method + named `list`, so every later `list[...]` annotation in its body resolved to that method + (`valid-type`, 81 errors on the fitness corpus); the annotations now use an + `from builtins import list as _list` alias, and the method keeps its name. The delete handlers + returned a `JSONResponse` from `-> None` and the M:N traversal handlers returned `Any` from + `-> list[Any]` (`return-value`, `no-any-return`); delete is now `-> Response` and returns the same + 204, and the traversal handlers bind the rows to a typed local. HTTP behavior is unchanged + (the generated api-contract lane passes). A new test runs `mypy --strict` over the routers + generated from the fitness corpus. `mypy` joins the Python dev group. + +- **`buildFilterQs({ withCount: true })` sends `withCount=1`.** It sent `withCount=true`, which + every server's list route ignores (the cross-port contract is `withCount=1`), so the caller got + a bare array instead of `{ rows, total }`. `false` and `0` now send nothing. + +- **`metaobjects agent-docs` says what it does.** Its help claimed to scaffold the agent context; + the command only points at `npx meta agent-docs --server python` and exits 1. The help now says + so. The three flags it never read (`--server`, `--client`, `--out`) are still accepted but + ignored and hidden from help, so the old invocation still reaches the pointer. + - **`@default: "now"` is the current time on Postgres, not the table's creation time.** The expected schema stored it as the literal `'now'`, and Postgres converts that literal to a timestamp once, when it creates the column's `DEFAULT`, so every row got the same value. It diff --git a/README.md b/README.md index 673b168b96..263eee84f2 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,10 @@ first-week wedge plan — and `meta init` picks up from there. | Templates + render (FR-004) | Yes | Yes | Yes (wraps Java) | Yes | Yes | | Payload-VO codegen (the value object's own type, ADR-0056) | Yes (`entityFile()`) | Yes (`SpringValueObjectGenerator`) | Yes (`KotlinEntityGenerator`) | Yes (`EntityGenerator`) | Yes (`entity`) | | Migration emission | `meta migrate` (Postgres / SQLite / D1) | Via TS toolchain (`@metaobjectsdev/cli migrate`) | Via TS toolchain (`@metaobjectsdev/cli migrate`) | Via TS toolchain (ADR-0015) | Via TS toolchain (ADR-0015) | -| DB-drift verify | `meta verify --db ` | Template-drift: `Verify.check`; schema-drift is TS-owned (ADR-0015) | Template-drift: `Verify.check`; startup: `MetadataStartupValidator` | `dotnet meta verify` (codegen-drift) | Schema-drift is TS-owned (ADR-0015) | +| DB-drift verify | `meta verify --db ` | Through the TypeScript CLI (ADR-0015) | Through the TypeScript CLI (ADR-0015) | Through the TypeScript CLI (ADR-0015) | Through the TypeScript CLI (ADR-0015) | +| Codegen-drift verify | `meta verify` | `mvn metaobjects:verify` | `mvn metaobjects:verify` (via Java) | `dotnet meta verify` | `metaobjects verify` | | Prisma schema (`orm: "prisma"`, #479) | `prismaSchemaFile()` writes `schema.prisma` (Postgres / MySQL / SQLite); `verify --codegen` and `verify --db` gate it | No | No | No | No | -| Template-drift verify | Yes | Yes (`Verify.check`) | Yes (via Java) | Yes (`dotnet meta verify`) | Yes (`metaobjects.render.verify`) | +| Template-drift verify | Yes | Yes (`Verify.check`) | Yes (via Java) | Yes (`dotnet meta verify`) | Yes (`metaobjects verify`) | | YAML authoring (sigil-free → JSON) | Yes | Yes | Yes (via Java) | Yes | Yes | | Capability requirements (`requirement.*`) | `meta verify` gate + authoring lint; `requirementTests()` stubs | `mvn metaobjects:verify` gate; JUnit Jupiter `requirement-tests` | Gate via the Java Maven goal; JUnit Jupiter `requirement-tests` | `dotnet meta verify` gate; xUnit `requirement-tests` | `metaobjects verify` gate; pytest `requirement-tests` | | Reporting vocabulary (`dimension` / `measure` / `segment` / `object.report`, FR-044) | Registered; a view-backed report becomes a SQL view in `meta migrate`, is read by `ObjectManager`, and gets generated read-only list routes and a TanStack list hook (no grid or form); the `cube-model` generator writes the vocabulary as Cube data-model files | Registered; OMDB reads a view-backed report; generates a read-only Spring controller, DTO, repository and filter allowlist | Registered (via Java); generates an Exposed table object, a row data class, a filter allowlist and a read-only Spring controller per view-backed report | Registered; generates a keyless EF Core row type, read-only routes and a filter allowlist per view-backed report | Registered; `ObjectManager` reads a view-backed report; generates a Pydantic row model, a read-only FastAPI router and a filter allowlist | @@ -241,7 +242,7 @@ sixth ships two libraries at their own stability labels: [Write your own generator](docs/recipes/write-your-own-generator.md) has every port's 20-line shape plus JSON Schema and OpenAPI examples to copy. The reference generators (Drizzle/Zod + Fastify for TS, Spring REST + DTO + repository for Java, `data class` + - Exposed for Kotlin, EF Core record + ASP.NET routes for C#, Pydantic + FastAPI for + Exposed for Kotlin, EF Core entity class + ASP.NET routes for C#, Pydantic + FastAPI for Python) are starting points: run one as shipped when it already emits what you want, or eject the one that is close, change it, and regenerate. The engine that runs generators is core; their output is yours. diff --git a/agent-context/skills/metaobjects-codegen/references/python.md b/agent-context/skills/metaobjects-codegen/references/python.md index 7f5934bb52..cb785d6624 100644 --- a/agent-context/skills/metaobjects-codegen/references/python.md +++ b/agent-context/skills/metaobjects-codegen/references/python.md @@ -270,6 +270,6 @@ rather than editing `render_wire(..., _WIRE)` in a router. `metaobjects verify` ## Re-scaffold this context -`metaobjects agent-docs --server python [--out ]` (re)scaffolds the slim always-on -Markdown + these `metaobjects-*` skills into the project — the Python tool bundles the -agent-context tree, so a Python consumer needs no Node `meta`. +`metaobjects agent-docs` does not scaffold anything: it prints a pointer and exits 1. +Run `npx meta agent-docs --server python` (the Node `meta` CLI) to (re)scaffold the slim +always-on Markdown + these `metaobjects-*` skills into the project. diff --git a/agent-context/skills/metaobjects-prompts/references/csharp.md b/agent-context/skills/metaobjects-prompts/references/csharp.md index 046ee3eb63..e6e210e2c8 100644 --- a/agent-context/skills/metaobjects-prompts/references/csharp.md +++ b/agent-context/skills/metaobjects-prompts/references/csharp.md @@ -5,8 +5,8 @@ For every RESPONDING `template.prompt` — one declaring `@responseRef` — a model's reply against that shape. ADR-0052: the tier binds `@responseRef`, never `@payloadRef` (which types the request the prompt renders outbound), and a `template.output` gets no parser at all. This is the receive side only — codegen emits -**no** provider/LLM-call layer; you compose the call yourself. C# names records after the -resolved VALUE OBJECT, so the response record simply IS that VO's record — no second +**no** provider/LLM-call layer; you compose the call yourself. C# names its types after the +resolved VALUE OBJECT, so the response type simply IS that VO's class — no second naming convention, and the parser and the record can't silently drift. ## Contents diff --git a/agent-context/skills/metaobjects-verify/references/requirements.md b/agent-context/skills/metaobjects-verify/references/requirements.md index 6927d37f24..e859076180 100644 --- a/agent-context/skills/metaobjects-verify/references/requirements.md +++ b/agent-context/skills/metaobjects-verify/references/requirements.md @@ -26,7 +26,7 @@ text (a shared corpus pins them). Run the one for the project's server language: | TypeScript | `meta verify` | | Python | `metaobjects verify` | | Java, Kotlin | `mvn metaobjects:verify` (one goal for both) | -| C# | `dotnet meta verify --templates ` (or `--codegen --out `) | +| C# | `dotnet meta verify` | The gate is core: it is not a generator and cannot be ejected or replaced. What differs by port is the **authoring lint** further down this page, which only the Node `meta verify` diff --git a/client/web/packages/runtime-web/src/filter-qs.ts b/client/web/packages/runtime-web/src/filter-qs.ts index 0ca0bd104d..584c80fdd9 100644 --- a/client/web/packages/runtime-web/src/filter-qs.ts +++ b/client/web/packages/runtime-web/src/filter-qs.ts @@ -18,7 +18,9 @@ import qs from "qs"; * correctly for the server's parser. * * `withCount` is the opt-in flag for the `{ rows, total }` list-response - * envelope (see runtime-ts mountListRoute). Pass `1` or `true` to enable. + * envelope (see runtime-ts mountListRoute). Pass `1` or `true` to enable; the wire form is always + * `withCount=1`, the cross-port contract every server's list route reads. `false`, `0` and + * `undefined` emit nothing. * * `search` is a free-text term that the server ORs as `like('%term%')` across * all @filterable string fields, AND-combined with any explicit filter WHERE. @@ -29,7 +31,7 @@ export function buildFilterQs(filter: Record): string { if (limit !== undefined) top.limit = limit; if (offset !== undefined) top.offset = offset; if (sort !== undefined) top.sort = sort; - if (withCount !== undefined) top.withCount = withCount; + if (withCount === true || withCount === 1 || withCount === "1" || withCount === "true") top.withCount = 1; if (search !== undefined) top.search = search; const filterObj: Record = { ...fields }; if (or !== undefined) filterObj.or = or; diff --git a/client/web/packages/runtime-web/test/filter-qs.test.ts b/client/web/packages/runtime-web/test/filter-qs.test.ts index 2172d867a6..f41566593a 100644 --- a/client/web/packages/runtime-web/test/filter-qs.test.ts +++ b/client/web/packages/runtime-web/test/filter-qs.test.ts @@ -62,6 +62,17 @@ describe("buildFilterQs", () => { expect(decoded(result)).not.toContain("filter[withCount]"); }); + test("withCount: true is sent as withCount=1, the value the server's list routes read", () => { + const result = buildFilterQs({ limit: 10, withCount: true }); + expect(result).toContain("withCount=1"); + expect(result).not.toContain("withCount=true"); + }); + + test("withCount: false or 0 emits no withCount key", () => { + expect(buildFilterQs({ limit: 10, withCount: false })).not.toMatch(/withCount/); + expect(buildFilterQs({ limit: 10, withCount: 0 })).not.toMatch(/withCount/); + }); + test("omits withCount when not present (no key, not 'withCount=undefined')", () => { const result = buildFilterQs({ limit: 10 }); expect(result).not.toMatch(/(^|&)withCount(=|&|$)/); diff --git a/docs/features/entities.md b/docs/features/entities.md index af59da2558..74f96a2a36 100644 --- a/docs/features/entities.md +++ b/docs/features/entities.md @@ -3,7 +3,7 @@ An **entity** is a typed, named record shape declared in metadata. It is the unit of codegen, the unit of persistence, and the unit of API exposure. Every port turns the same `object.entity` declaration into idiomatic native code — a TS interface + Zod -schema, a Java POJO, a Kotlin `data class`, a C# `record`, a Python `@dataclass`. +schema, a Java POJO, a Kotlin `data class`, a C# entity class, a Python Pydantic model. The entity is metadata-first: you don't write the class, the loader reads the declaration, the codegen emits the class, and the runtime layer reads the same @@ -233,19 +233,38 @@ object AuthorTable : Table("authors") { ### C# -`MetaObjects.Codegen` (via `dotnet meta gen`) emits an EF Core entity record, a -`DbSet` on the generated `AppDbContext`, and a `CREATE TABLE` in the migration -emitted by the Node `meta migrate` (schema is Node-owned, ADR-0015). +`MetaObjects.Codegen` (via `dotnet meta gen`) emits an EF Core entity class (a mutable +`class` with data-annotation attributes, not a `record`), a `DbSet` on the generated +`AppDbContext`, and a `CREATE TABLE` in the migration emitted by the Node `meta migrate` +(schema is Node-owned, ADR-0015). The sample below is the output of `EntityGenerator` with +`--namespace Acme.Blog`. ```csharp -// generated/Acme/Blog/Author.cs +// generated/Author.g.cs +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + namespace Acme.Blog; -public record Author +[Table("authors")] +public class Author { - public long Id { get; set; } - public string Name { get; set; } = string.Empty; // required, maxLength=200 - public string? Bio { get; set; } // optional, maxLength=2000 + [Key] + [Column("id")] + public long Id { get; set; } + [Column("name")] + [Required(AllowEmptyStrings = true)] + [MaxLength(200)] + [MinLength(1)] + public string Name { get; set; } = default!; + [Column("bio")] + [MaxLength(2000)] + public string? Bio { get; set; } } ``` @@ -259,21 +278,37 @@ public partial class AppDbContext : DbContext ### Python -`metaobjects.codegen` emits a Python `@dataclass` per entity, plus a shipped +`metaobjects.codegen` emits one Pydantic `BaseModel` per entity (not a `@dataclass`), with a +`Create` and a `Patch` input model beside it, plus a shipped FastAPI `APIRouter` (`router_generator`, FR-008 §2.3) and a shipped `ObjectManager` runtime (`metaobjects.runtime.object_manager`) for CRUD against the same metadata. ```python -# generated/acme/blog/author.py -from dataclasses import dataclass -from typing import Optional - -@dataclass -class Author: - id: int - name: str # required, max_length=200 - bio: Optional[str] = None +# generated/Author.py +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Author(BaseModel): + id: int | None = None + name: str = Field(max_length=200) + bio: str | None = Field(default=None, max_length=2000) + + +class AuthorCreate(BaseModel): + """GENERATED — CREATE input: auto-gen PK / @mutability readOnly omitted (writeOnce is settable here, once); @default/@autoSet optional; present values validated (FR-036).""" + + name: str = Field(min_length=1, max_length=200) + bio: str | None = Field(default=None, max_length=2000) + + +class AuthorPatch(BaseModel): + """GENERATED — PATCH input: all fields optional (PK, @mutability readOnly and writeOnce excluded); present values validated (FR-036).""" + + name: str | None = Field(default=None, min_length=1, max_length=200) + bio: str | None = Field(default=None, max_length=2000) ``` ```python diff --git a/docs/features/field-types.md b/docs/features/field-types.md index f11c8fd23e..8995c8d7f7 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -25,7 +25,7 @@ across ports — a `field.currency` is integer minor units everywhere; a | `field.uri` | `string` | `java.net.URI` | `java.net.URI` | `Uri` | `AnyUrl` | `text` | | `field.inet` | `string` | `InetAddress` | `InetAddress` | `IPAddress` | `IPvAnyAddress` | `inet` | | `field.enum` | union + `z.enum` | `Enum` | `enum class` | `enum` | `Enum` | `varchar` + `CHECK` | -| `field.object` | nested type | nested class | nested data class | nested record | nested dataclass | per `@storage` | +| `field.object` | nested type | nested class | nested data class | nested class | nested Pydantic model | per `@storage` | | `field.map` | `Record` | `Map` | `Map` | `Dictionary` | `dict[str, V]` | `jsonb` | That is the whole registered vocabulary — 17 concrete subtypes. `field.base` is an @@ -357,18 +357,31 @@ object AuthorTable : Table("authors") { ### C# ```csharp -// generated/Acme/Blog/Author.cs -public record Author +// generated/Author.g.cs (usings and header elided) +namespace Acme.Blog; + +[Table("authors")] +public class Author { - public long Id { get; set; } - public string Name { get; set; } = string.Empty; - public string? Bio { get; set; } - public long PriceCents { get; set; } // currency: minor units - public AuthorStatus Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } + public enum AuthorStatus { DRAFT, PUBLISHED, ARCHIVED } + [Key] + [Column("id")] + public long Id { get; set; } + [Column("name")] + [Required(AllowEmptyStrings = true)] + [MaxLength(200)] + [MinLength(1)] + public string Name { get; set; } = default!; + [Column("bio")] + [MaxLength(2000)] + public string? Bio { get; set; } + [Column("price_cents")] + public long PriceCents { get; set; } // currency: minor units + [Column("status")] + public AuthorStatus Status { get; set; } + [Column("created_at")] + public DateTimeOffset CreatedAt { get; set; } } - -public enum AuthorStatus { Draft, Published, Archived } ``` EF Core enum-as-string is wired in `AppDbContext`: @@ -382,25 +395,22 @@ modelBuilder.Entity() ### Python ```python -# generated/acme/blog/author.py -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from typing import Optional - -class AuthorStatus(str, Enum): - DRAFT = "DRAFT" - PUBLISHED = "PUBLISHED" - ARCHIVED = "ARCHIVED" - -@dataclass -class Author: - id: int - name: str - bio: Optional[str] = None - price_cents: int = 0 # currency: minor units - status: AuthorStatus = AuthorStatus.DRAFT - created_at: Optional[datetime] = None +# generated/Author.py (the Create and Patch input models are elided) +from __future__ import annotations + +import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +class Author(BaseModel): + id: int | None = None + name: str = Field(max_length=200) + bio: str | None = Field(default=None, max_length=2000) + priceCents: int # currency: minor units + status: Literal["DRAFT", "PUBLISHED", "ARCHIVED"] + createdAt: datetime.datetime ``` ## Verified by diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index 20e187339a..2009e3d753 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -372,13 +372,30 @@ the re-diff is empty afterwards. Postgres keeps `IS [NOT] DISTINCT FROM`. ## Drift verify commands per port -| Port | Command | What it does | -|---|---|---| -| TypeScript | `meta verify --db` | Introspects the live DB; reports DB-vs-metadata drift. | -| Java | `mvn metaobjects:verify -Dmeta.verify.mode=codegen\|templates` (`metaobjects:verify` Maven goal) + `Renderer.verify` (build-time) | The codegen/template-drift `metaobjects:verify` Maven goal is alive and is how Java gates drift in CI (`codegen` mode regens + fails on drift vs committed output; `templates` mode drift-checks `{{...}}` references against the payload VO via `Renderer.verify`). Only the *live-DB-schema* `metaobjects:verify` goal was removed — that's TS-owned now (`meta verify --db`) — along with the runtime auto-create validator (ADR-0015). | -| Kotlin | `mvn metaobjects:verify -Dmeta.verify.mode=codegen\|templates` (`metaobjects:verify` Maven goal — same goal covers both Java + Kotlin) + `MetadataStartupValidator` (startup) | Same as Java — the `metaobjects:verify` Maven goal remains, plus template-drift and startup validation. | -| C# | `meta verify ./metadata --templates ./prompts` | Drift-checks templates against their payload VOs (FR-004 prompt-drift). | -| Python | `python -m metaobjects.render.verify` | Same as C# verify — template-vs-payload drift. | +A bare `verify` runs the same default gate set in every port: **codegen drift**, +**prompt-template drift** and the **requirement ledger**. What a check does when the run has +no configuration for it differs by port. TypeScript, Java and Python skip it with a one-line +note and do not fail: TypeScript skips the codegen gate when there is no `metaobjects.config.ts` +(and its template gate passes with a note when the model has no `template.*` nodes), Java skips +the template gate when `templateRoot` is unset, and Python skips the template gate when there +is no `metaobjects.config.yaml` and no `--prompts`. C# does not skip: a bare `dotnet meta verify` +needs a `prompts/` (else `templates/`) directory, or `--templates `, and exits 2 with +`templates directory not found` when neither exists (full contract: +[cli.md, "`verify` is one verb with explicit subverbs"](cli.md#verify-is-one-verb-with-explicit-subverbs-adr-0021-d2)). + +| Port | Bare command | Config each check needs | One check alone | +|---|---|---|---| +| TypeScript | `meta verify` | Codegen: `metaobjects.config.ts`. Templates: the `template.*` nodes in the model. | `meta verify --codegen`, `meta verify --templates` | +| Java | `mvn metaobjects:verify` | Codegen: the `` in the plugin configuration. Templates: `-Dmeta.verify.templateRoot`. | `-Dmeta.verify.mode=codegen`, `-Dmeta.verify.mode=templates` | +| Kotlin | `mvn metaobjects:verify` (the same goal as Java) | As Java. `MetadataStartupValidator` adds a startup check. | As Java | +| C# | `dotnet meta verify` | Codegen: an output directory (`csharp.outDir` in `.metaobjects/config.json`, or `--out `) and `codegen/Generators.cs`. Templates: `--templates `, or a `prompts/` (else `templates/`) directory in the working directory; with neither, a bare run exits 2. | `dotnet meta verify --codegen`, `dotnet meta verify --templates ` | +| Python | `metaobjects verify` | Codegen: `metaobjects.config.yaml`. Templates: `metaobjects.config.yaml` or `--prompts `. | `metaobjects verify --codegen`, `metaobjects verify --templates` | + +Schema drift is a separate, opt-in check and Node-only (ADR-0015): `meta verify --db ` +introspects the live database and reports database-versus-metadata drift. The other four +ports reject `--db` (C#, Python and the Maven goal exit 2 or fail with a pointer to the +TypeScript CLI); the live-DB-schema `metaobjects:verify` goal was removed along with the +runtime auto-create validator. ## Generated-vs-runtime: Kotlin startup validator diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 34df4097de..c6d10a9d2f 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -314,6 +314,12 @@ That writes the Mustache templates to `codegen/docs-site/templates/` and the CSS packaged one. The site prints no date, so its pages depend on the model alone and `meta verify --codegen` can check them; `docsSiteFile({ stamp })` adds footer text. +The page chrome loads daisyUI, Tailwind's browser build, Mermaid and svg-pan-zoom from +jsDelivr, so a site opened offline renders unstyled and its diagrams do not work. +`docsSiteFile({ assetBase: "/vendor/npm" })` replaces that base; serving the five files from +it is on you. The files and how to fetch them are in the `@metaobjectsdev/docs-site` README, +"Offline use". + ### The ER diagram comes from `docsFile()` The model pages carry the Mermaid diagrams: the whole model on the overview page diff --git a/docs/features/relationships.md b/docs/features/relationships.md index 8c7dabb780..3e493d7df1 100644 --- a/docs/features/relationships.md +++ b/docs/features/relationships.md @@ -558,17 +558,26 @@ ALTER TABLE "posts" ### Python The Python loader recognizes `relationship.composition` and `identity.reference` -and exposes them on the navigation API. Relationship-navigation codegen (emitting -the FK-derived accessors into the generated Pydantic + FastAPI code) is still -pending; the runtime is the shipped DB-API-2 `ObjectManager`. +and exposes them on the navigation API. The generated Pydantic models carry the +foreign key as a plain field and no navigation accessors; reverse traversal is the +explicit FK finder pair (ADR-0038) on the router's repository `Protocol`, the same +shape every port emits. A M:N relationship also emits the nested collection and a +`GET //{id}/` traversal route. The runtime is the shipped +DB-API-2 `ObjectManager`. ```python -# generated/acme/blog/post.py — current state -@dataclass -class Post: - id: int +# generated/Post.py +class Post(BaseModel): + id: int | None = None title: str - author_id: int # FK to Author.id — relationship-as-runtime is on the roadmap + authorId: int # FK to Author.id, a plain field: no navigation accessor on the model +``` + +```python +# generated/post_router.py (excerpt of the repository Protocol) +class PostRepository(Protocol): + def find_posts_by_author(self, author_id: Any) -> _list[Any]: ... + def find_posts_by_author_in(self, author_id_values: _list[Any]) -> _list[Any]: ... ``` ## Verified by diff --git a/docs/features/requirements.md b/docs/features/requirements.md index 5a77314809..721c2ed79e 100644 --- a/docs/features/requirements.md +++ b/docs/features/requirements.md @@ -347,10 +347,12 @@ message text of each diagnostic, and the summary counts. | TypeScript | `meta verify` | `meta verify — requirements:` | | Python | `metaobjects verify` | `metaobjects verify — requirements:` | | Java, Kotlin | `mvn metaobjects:verify` (one goal for both, in either mode) | `metaobjects:verify — requirements:` | -| C# | `dotnet meta verify ./metadata --templates ./prompts` | `dotnet meta verify — requirements:` | +| C# | `dotnet meta verify` | `dotnet meta verify — requirements:` | -The C# tool has to be given one of its drift gates (`--templates `, or `--codegen ---out `); the requirement gate then runs beside whichever one it was given. Maven logs +A bare `dotnet meta verify` runs the requirement gate with the other default gates, and needs a +`prompts/` or `templates/` directory (else it exits 2 before any gate runs); naming +one of them (`--templates `, or `--codegen --out `) runs that gate and the +requirement gate beside it. Maven logs the summary at `info`, warnings as warnings and errors as errors. **What it reports**, checked per requirement in this order: @@ -428,7 +430,7 @@ more than it links. A project whose ledger has caught up can make it fail the bu | TypeScript | `meta verify --require-implementers` | `META_REQUIRE_IMPLEMENTERS=1` | | Python | `metaobjects verify --require-implementers` | `META_REQUIRE_IMPLEMENTERS=1` | | Java, Kotlin | `mvn metaobjects:verify -Dmeta.verify.requireImplementers=true` | `META_REQUIRE_IMPLEMENTERS=1` | -| C# | `dotnet meta verify ./metadata --templates ./prompts --require-implementers` | `META_REQUIRE_IMPLEMENTERS=1` | +| C# | `dotnet meta verify --require-implementers` | `META_REQUIRE_IMPLEMENTERS=1` | **Recommended once the ledger is clean:** set the switch in the project's verify script or pre-push hook. Every adopter ledger we measured passed the default gate, because the gate diff --git a/docs/features/source-kinds.md b/docs/features/source-kinds.md index 416efffda6..629118f5ea 100644 --- a/docs/features/source-kinds.md +++ b/docs/features/source-kinds.md @@ -357,16 +357,15 @@ coverage; see [`ports/csharp.md`](../ports/csharp.md) for current support. ### Python The Python loader recognizes `source.rdb` and all `@kind` values; the codegen -emits a `@dataclass` either way (read-only-ness is a runtime concern). Persistence +emits a Pydantic `BaseModel` either way (read-only-ness is a runtime concern). Persistence + migration codegen for non-`table` kinds are in progress. ```python -# generated/acme/blog/author_view.py — emitted same as a table-kind entity -@dataclass -class AuthorView: - id: int +# generated/AuthorView.py — a read-only model, shaped like a table-kind entity's +class AuthorView(BaseModel): + id: int | None = None name: str - post_count: int + postCount: int | None = None ``` ## Verified by diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index a225b77afa..fb6d8316a3 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -714,13 +714,19 @@ and checks each one exists on the payload VO. If a template references body (Python and TypeScript) is checked the same way; see [Jinja templates](#jinja-templates-python-and-typescript). -| Port | Command | -|---|---| -| TypeScript | `meta verify` (CLI) | -| Java | `mvn metaobjects:verify` (Maven goal) | -| Kotlin | `mvn metaobjects:verify` (same Maven goal) | -| C# | `dotnet meta verify --templates ` | -| Python | `python -m metaobjects.render.verify` | +A bare `verify` also checks generated code and the requirement ledger; name the template +check to run it alone. Java and Python skip this check with a note when no template root is +configured; C# does not skip, and a bare run with no `prompts/` or `templates/` directory +(and no `--templates `) exits 2 +([cli.md](cli.md#verify-is-one-verb-with-explicit-subverbs-adr-0021-d2)). + +| Port | Bare command (runs this check) | This check alone | +|---|---|---| +| TypeScript | `meta verify` | `meta verify --templates` | +| Java | `mvn metaobjects:verify` (needs `-Dmeta.verify.templateRoot`) | `-Dmeta.verify.mode=templates` | +| Kotlin | `mvn metaobjects:verify` (same Maven goal) | `-Dmeta.verify.mode=templates` | +| C# | `dotnet meta verify` (needs a `prompts/` or `templates/` directory, else exits 2) | `dotnet meta verify --templates ` | +| Python | `metaobjects verify` (needs `metaobjects.config.yaml` or `--prompts `) | `metaobjects verify --templates` | ## Determinism contract diff --git a/docs/ports/python.md b/docs/ports/python.md index 96207834f2..a1fae38a40 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -943,7 +943,7 @@ import lines are stable. | Output parser codegen (FR-006) | Yes (`output_parser_generator` — Pydantic throw-only; imports the `@responseRef` value object's model) | | Declarative template-codegen | Yes — a target's `templateSpec` in `metaobjects.config.yaml` (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with C#) | | Migrations | TS-only by design (ADR-0015) — no Python `migrate` command; consume the canonical `schema.postgres.sql` | -| Drift verify | Yes — template / payload drift (`metaobjects.render.verify`); `verify --codegen` regenerates the config's list and diffs each target's `outDir` | +| Drift verify | Yes — template / payload drift (`metaobjects verify`); `verify --codegen` regenerates the config's list and diffs each target's `outDir` | | API reference docs | Yes — `api-docs` in a target's `generators`, options in the top-level `apiDocs` block (`apiSubDir`, `projectName`, `modelBaseUrl`); `metaobjects docs` was removed in 1.1.0 ([migration](../features/migrations/docs-are-generators.md#python)) | | Requirement gate + requirement tests | Yes (`metaobjects verify`, on every run; `requirement-tests` emits pytest and is ejectable) | | Runtime metadata | Yes (`metaobjects.runtime.ObjectManager` — DB-API 2 drivers: Postgres via pg8000/psycopg, `SqliteDriver` over the standard library's `sqlite3`, `MySqlDriver` over PyMySQL with the `mysql` extra) + loader API + render engine | diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index 7350da834c..447c9713ae 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -10,7 +10,7 @@ "generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5", "generated/python/subscriber_filter_allowlist.py": "b0414cc95ff3982c80a0e1a06839f090c0b423e41f5a94275df93426cea09df5", "generated/python/subscriber_names.py": "269637a02bf9b46f55658b58cb42f83196c03a877e1e2101337847fd406c1561", - "generated/python/subscriber_router.py": "0d04c1d7286fba33459454e1cd8a8acb2de38b9309cdb1015cb4438c3881daeb", + "generated/python/subscriber_router.py": "5a354e50b1aab48239a4249b670a6d2351c1816cbca6d710ea688f252e9612e5", "generated/ts/Subscriber.queries.ts": "9f1a459c57fafc98bf074b4443b506d4a1e7023ed188f293021ae89c298d4562", "generated/ts/Subscriber.routes.ts": "147d32ae658f985e024c3de6e8894ca7359f767eeaa765cec70756802e3bd300", "generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96", diff --git a/examples/showcase/generated/python/subscriber_router.py b/examples/showcase/generated/python/subscriber_router.py index 294f9506a0..47aaaab7ce 100644 --- a/examples/showcase/generated/python/subscriber_router.py +++ b/examples/showcase/generated/python/subscriber_router.py @@ -14,10 +14,11 @@ from __future__ import annotations import datetime as _dt +from builtins import list as _list from typing import Annotated, Any, Protocol from fastapi import APIRouter, Depends, Query, Request, status -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from metaobjects.codegen.runtime.constraint_errors import ( classify_constraint_error, ) @@ -116,9 +117,9 @@ def list( limit: int, offset: int, sort: _SortClause | None, - filters: list[FilterPredicate], - ) -> list[Any]: ... - def count(self, filters: list[FilterPredicate]) -> int: ... + filters: _list[FilterPredicate], + ) -> _list[Any]: ... + def count(self, filters: _list[FilterPredicate]) -> int: ... def find_by_id(self, id: int) -> Any | None: ... def create(self, dto: Any) -> Any: ... def update(self, id: int, dto: Any) -> Any | None: ... @@ -258,7 +259,7 @@ def update_subscriber( def delete_subscriber( subscriber_id: int, repo: Annotated[SubscriberRepository, Depends(get_repository)], -) -> None: +) -> Response: try: deleted = repo.delete(subscriber_id) except Exception as exc: @@ -268,3 +269,4 @@ def delete_subscriber( return JSONResponse(status_code=failure.status, content=failure.body()) if not deleted: return JSONResponse(status_code=404, content={"error": "not_found"}) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md index 7f5934bb52..cb785d6624 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md @@ -270,6 +270,6 @@ rather than editing `render_wire(..., _WIRE)` in a router. `metaobjects verify` ## Re-scaffold this context -`metaobjects agent-docs --server python [--out ]` (re)scaffolds the slim always-on -Markdown + these `metaobjects-*` skills into the project — the Python tool bundles the -agent-context tree, so a Python consumer needs no Node `meta`. +`metaobjects agent-docs` does not scaffold anything: it prints a pointer and exits 1. +Run `npx meta agent-docs --server python` (the Node `meta` CLI) to (re)scaffold the slim +always-on Markdown + these `metaobjects-*` skills into the project. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/requirements.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/requirements.md index 6927d37f24..e859076180 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/requirements.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/requirements.md @@ -26,7 +26,7 @@ text (a shared corpus pins them). Run the one for the project's server language: | TypeScript | `meta verify` | | Python | `metaobjects verify` | | Java, Kotlin | `mvn metaobjects:verify` (one goal for both) | -| C# | `dotnet meta verify --templates ` (or `--codegen --out `) | +| C# | `dotnet meta verify` | The gate is core: it is not a generator and cannot be ejected or replaced. What differs by port is the **authoring lint** further down this page, which only the Node `meta verify` diff --git a/fixtures/metamodel-docs/expected/types/field.md b/fixtures/metamodel-docs/expected/types/field.md index d51cdb8ba8..f087e964f3 100644 --- a/fixtures/metamodel-docs/expected/types/field.md +++ b/fixtures/metamodel-docs/expected/types/field.md @@ -26,7 +26,7 @@ Abstract base field — the shared root subtype that concrete field subtypes spe | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -59,7 +59,7 @@ True/false flag. Binds to the native boolean type; DB column is BOOLEAN. | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -95,7 +95,7 @@ Stores money as integer minor units (cents). Binds to long; the client formats v | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -129,7 +129,7 @@ Calendar date (no time-of-day). Binds to the native date/temporal type; DB colum | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -164,7 +164,7 @@ Precision-exact decimal (use @precision/@scale). Native TS binding is string (lo | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@precision` | int | no | | | metaobjects-core-types | Total number of significant digits for decimal-typed fields. | @@ -199,7 +199,7 @@ Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/nu | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -237,7 +237,7 @@ String-backed enumeration constrained to a closed set of member symbols (@values | `@enumDoc` | properties | no | | | metaobjects-prompt | Map of enum member to a human-readable description; shown per-member in the FR-010 'guide'-style prompt fragment. | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@intValueMap` | intMap | no | | | metaobjects-core-types | Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | @@ -272,7 +272,7 @@ Single-precision floating point. Binds to the native double/number type (TS has | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -305,7 +305,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@lenient` | boolean | no | | | metaobjects-core-types | #234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | @@ -339,7 +339,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -372,7 +372,7 @@ An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native t | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -407,7 +407,7 @@ An open-keyed map (Record / dict[str,V]) stored in a single jsonb colu | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@objectRef` | string | no | | | metaobjects-core-types | Name (or FQN) of the value-object for a value-object-valued map. Mutually exclusive with @valueType; exactly one of the two must be set. | @@ -444,7 +444,7 @@ A nested structured value (set @objectRef to the target object). Storage is gove | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@objectRef` | string | no | | | metaobjects-core-types | Name (or FQN) of the target object an object-typed field nests — drives nested-object (de)serialization. | @@ -479,7 +479,7 @@ Variable-length text. Binds to the native string type; DB column is VARCHAR/TEXT | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@maxLength` | int | no | | | metaobjects-core-types | Maximum character length for string-typed fields (drives VARCHAR(n)). | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | @@ -513,7 +513,7 @@ Time-of-day (no calendar date). Binds to the native date/temporal type; DB colum | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | @@ -547,7 +547,7 @@ Date + time-of-day instant (optionally with timezone). Binds to the native date/ | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@localTime` | boolean | no | | | metaobjects-db | When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | @@ -581,7 +581,7 @@ A URI/URL string (ADR-0036/0037 — a concept with a native type + behavior, so | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@lenient` | boolean | no | | | metaobjects-core-types | #234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | @@ -615,7 +615,7 @@ Logical UUID identity scalar. A bare scalar (no required attrs, no loader value- | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | -| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 3d8130ccdf..e0fe66f7ef 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -195,7 +195,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -337,7 +337,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -487,7 +487,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -640,7 +640,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -783,7 +783,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -939,7 +939,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1103,7 +1103,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1277,7 +1277,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1419,7 +1419,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1568,7 +1568,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1710,7 +1710,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -1853,7 +1853,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2010,7 +2010,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2171,7 +2171,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2341,7 +2341,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2494,7 +2494,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2643,7 +2643,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", @@ -2792,7 +2792,7 @@ "valueType": "boolean", "isArray": false, "required": false, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." }, { "name": "instruction", diff --git a/server/csharp/MetaObjects/SpecMetamodel/ui.json b/server/csharp/MetaObjects/SpecMetamodel/ui.json index 6af62e9ced..3ce6ca59d8 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/ui.json +++ b/server/csharp/MetaObjects/SpecMetamodel/ui.json @@ -8,7 +8,7 @@ { "type": "attr", "subType": "boolean", "name": "filterable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer)." }, { "type": "attr", "subType": "boolean", "name": "sortable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out." }, { "type": "attr", "subType": "string", "name": "sortableDefaultOrder", "min": 0, "max": 1, "allowedValues": ["asc", "desc"], "description": "Direction a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'." }, - { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." } + { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." } ] }, { diff --git a/server/python/pyproject.toml b/server/python/pyproject.toml index 1212021e4d..6750dd1c32 100644 --- a/server/python/pyproject.toml +++ b/server/python/pyproject.toml @@ -119,6 +119,10 @@ dev = [ # cli-registry corpus runs codegen, so without ruff in the group `uv run pytest` # fails at format time with "No module named ruff". "ruff>=0.6", + # The generated-router mypy gate (tests/codegen/test_router_mypy_strict.py) type-checks + # the emitted FastAPI routers under `mypy --strict`. The framework those routers import + # comes from `--extra integration`, which the python lane always passes. + "mypy>=1.10", # The Jinja engine and its conformance corpora (fixtures/jinja-*-conformance/) run in the # default suite, so the optional `jinja` extra's dependency is in the dev group too. "jinja2>=3.1", diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 23e02fe02f..79204d894d 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -3148,29 +3148,12 @@ def _build_parser() -> argparse.ArgumentParser: agent_docs = sub.add_parser( "agent-docs", help=( - "scaffold the slim MetaObjects Claude Code agent context " - "(.metaobjects/AGENTS.md + CLAUDE.md + the metaobjects-* skills)" + "moved to the Node CLI: run `npx meta agent-docs --server python` " + "(this command scaffolds nothing and exits 1)" ), ) - agent_docs.add_argument( - "--server", - action="append", - default=None, - metavar="LANG", - help="server language (repeatable): typescript|java|kotlin|csharp|python", - ) - agent_docs.add_argument( - "--client", - action="append", - default=None, - metavar="FRAMEWORK", - help="client framework (repeatable): react|tanstack|angular", - ) - agent_docs.add_argument( - "--out", - default=None, - help="output project root (default: cwd)", - ) + for ignored_flag in ("--server", "--client", "--out"): + agent_docs.add_argument(ignored_flag, help=argparse.SUPPRESS) agent_docs.set_defaults(func=_cmd_agent_docs) return parser diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 7af00fe223..227346832c 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -87,6 +87,12 @@ ) from ..runtime.shared_predicates import emits_router, is_read_only_routed +#: The repository ``Protocol`` declares a method named ``list`` (the cross-port contract keeps +#: that name), and inside the class body a later bare ``list[...]`` annotation resolves to +#: that method, not the builtin — ``mypy --strict`` reports ``valid-type``. The Protocol's +#: annotations spell the builtin ``_list`` through this alias instead. +_BUILTIN_LIST_ALIAS_IMPORT = "from builtins import list as _list" + #: Where the emitted router imports its helper runtime (``filter_parser``, #: ``constraint_errors``) from. ``False`` — the packaged generator — imports the copy #: installed with ``metaobjects``: ``from metaobjects.codegen.runtime. import ...``. @@ -778,9 +784,9 @@ def _emit_repository_protocol( " limit: int,", " offset: int,", " sort: _SortClause | None,", - " filters: list[FilterPredicate],", - " ) -> list[Any]: ...", - " def count(self, filters: list[FilterPredicate]) -> int: ...", + " filters: _list[FilterPredicate],", + " ) -> _list[Any]: ...", + " def count(self, filters: _list[FilterPredicate]) -> int: ...", f" def find_by_id(self, id: {pk_type}) -> Any | None: ...", " def create(self, dto: Any) -> Any: ...", f" def update(self, id: {pk_type}, dto: Any) -> Any | None: ...", @@ -790,7 +796,7 @@ def _emit_repository_protocol( for d in m2m: lines.append( f" def find_related_{d.relation_name}(self, id: {pk_type}" - f"{_target_subtype_param(discs[d.relation_name])}) -> list[Any]: ..." + f"{_target_subtype_param(discs[d.relation_name])}) -> _list[Any]: ..." ) return lines @@ -810,9 +816,9 @@ def _emit_reverse_finders(self, entity: MetaObject) -> list[str]: single = reverse_finder_fn(entity_name, rfk.fk_field) batched = reverse_finder_in_fn(entity_name, rfk.fk_field) arg = _snake_case(rfk.fk_field) - lines.append(f" def {single}(self, {arg}: Any) -> list[Any]: ...") + lines.append(f" def {single}(self, {arg}: Any) -> _list[Any]: ...") lines.append( - f" def {batched}(self, {arg}_values: list[Any]) -> list[Any]: ..." + f" def {batched}(self, {arg}_values: _list[Any]) -> _list[Any]: ..." ) return lines @@ -1132,12 +1138,13 @@ def _emit_route_handler( f"def delete_{snake}(", f" {pk_param}: {pk_type},", f" repo: Annotated[{repo_class}, Depends(get_repository)],", - ") -> None:", + ") -> Response:", " try:", f" deleted = repo.delete({pk_param})", *_CONSTRAINT_EXCEPT, " if not deleted:", ' return JSONResponse(status_code=404, content={"error": "not_found"})', + " return Response(status_code=status.HTTP_204_NO_CONTENT)", ] raise ValueError(f"unknown route handler '{name}'") @@ -1157,9 +1164,10 @@ def _emit_m2m_route( f" {pk_param}: {pk_type},", f" repo: Annotated[{repo_class}, Depends(get_repository)],", ") -> list[Any]:", - f" return render_wire(repo.find_related_{d.relation_name}" + f" rows: list[Any] = render_wire(repo.find_related_{d.relation_name}" f"({pk_param}{_target_subtype_arg(d, {d.target_discriminator})}), " f"{_m2m_wire_name(d.target_entity)})", + " return rows", ] def _emit_tph_m2m_route( @@ -1215,10 +1223,11 @@ def _emit_tph_m2m_route( " return []", ] lines.append( - f" return render_wire(repo.find_related_{d.relation_name}" + f" rows: list[Any] = render_wire(repo.find_related_{d.relation_name}" f"({subtype_expr}, {pk_param}{_target_subtype_arg(d, target_discs)}), " f"{_m2m_wire_name(d.target_entity)})" ) + lines.append(" return rows") return lines def _emit_tph_list_body( @@ -1407,10 +1416,11 @@ def _render_tph_router( parts.append(import_line) if pk.imports: parts.append("") + parts.append(_BUILTIN_LIST_ALIAS_IMPORT) parts.append("from typing import Annotated, Any, Protocol") parts.append("") parts.append("from fastapi import APIRouter, Depends, Query, Request, status") - parts.append("from fastapi.responses import JSONResponse") + parts.append("from fastapi.responses import JSONResponse, Response") parts.append("from pydantic import BaseModel, ValidationError") parts.append("") parts.append(f"from {_runtime_module('constraint_errors')} import (") @@ -1483,9 +1493,9 @@ def _render_tph_router( parts.append(" limit: int,") parts.append(" offset: int,") parts.append(" sort: _SortClause | None,") - parts.append(" filters: list[FilterPredicate],") - parts.append(" ) -> list[Any]: ...") - parts.append(" def count(self, subtype: str | None, filters: list[FilterPredicate]) -> int: ...") + parts.append(" filters: _list[FilterPredicate],") + parts.append(" ) -> _list[Any]: ...") + parts.append(" def count(self, subtype: str | None, filters: _list[FilterPredicate]) -> int: ...") parts.append(f" def find_by_id(self, subtype: str | None, id: {pk_type}) -> Any | None: ...") parts.append(" def create(self, subtype: str, dto: Any) -> Any: ...") parts.append(f" def update(self, subtype: str, id: {pk_type}, dto: Any) -> Any | None: ...") @@ -1497,7 +1507,7 @@ def _render_tph_router( for d in m2m_union: parts.append( f" def find_related_{d.relation_name}(self, subtype: str | None, id: {pk_type}" - f"{_target_subtype_param(m2m_target_discs[d.relation_name])}) -> list[Any]: ..." + f"{_target_subtype_param(m2m_target_discs[d.relation_name])}) -> _list[Any]: ..." ) parts.append("") parts.append("") @@ -1595,12 +1605,13 @@ def list_sig(fn: str, route: str) -> list[str]: parts.append(f"def delete_{plural}_{sfx}(") parts.append(f" {pk_param}: {pk_type},") parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],") - parts.append(") -> None:") + parts.append(") -> Response:") parts.append(" try:") parts.append(f' deleted = repo.delete("{val}", {pk_param})') parts.extend(_CONSTRAINT_EXCEPT) parts.append(" if not deleted:") parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})') + parts.append(" return Response(status_code=status.HTTP_204_NO_CONTENT)") parts.append("") parts.append("") @@ -1797,10 +1808,11 @@ def render_router( parts.append(import_line) if pk.imports: parts.append("") + parts.append(_BUILTIN_LIST_ALIAS_IMPORT) parts.append("from typing import Annotated, Any, Protocol") parts.append("") parts.append("from fastapi import APIRouter, Depends, Query, Request, status") - parts.append("from fastapi.responses import JSONResponse") + parts.append("from fastapi.responses import JSONResponse, Response") parts.append("from pydantic import BaseModel, ValidationError") parts.append("") parts.append(f"from {_runtime_module('constraint_errors')} import (") @@ -2018,6 +2030,7 @@ def _render_readonly_router( parts.append(import_line) if pk_imports: parts.append("") + parts.append(_BUILTIN_LIST_ALIAS_IMPORT) parts.append("from typing import Annotated, Any, Protocol") parts.append("") parts.append("from fastapi import APIRouter, Depends, Query, Request") @@ -2080,9 +2093,9 @@ def _render_readonly_router( parts.append(" limit: int,") parts.append(" offset: int,") parts.append(" sort: _SortClause | None,") - parts.append(" filters: list[FilterPredicate],") - parts.append(" ) -> list[Any]: ...") - parts.append(" def count(self, filters: list[FilterPredicate]) -> int: ...") + parts.append(" filters: _list[FilterPredicate],") + parts.append(" ) -> _list[Any]: ...") + parts.append(" def count(self, filters: _list[FilterPredicate]) -> int: ...") if item_route: parts.append(f" def find_by_id(self, id: {pk_type}) -> Any | None: ...") parts.append("") diff --git a/server/python/src/metaobjects/spec_metamodel/ui.json b/server/python/src/metaobjects/spec_metamodel/ui.json index 6af62e9ced..3ce6ca59d8 100644 --- a/server/python/src/metaobjects/spec_metamodel/ui.json +++ b/server/python/src/metaobjects/spec_metamodel/ui.json @@ -8,7 +8,7 @@ { "type": "attr", "subType": "boolean", "name": "filterable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer)." }, { "type": "attr", "subType": "boolean", "name": "sortable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out." }, { "type": "attr", "subType": "string", "name": "sortableDefaultOrder", "min": 0, "max": 1, "allowedValues": ["asc", "desc"], "description": "Direction a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'." }, - { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." } + { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." } ] }, { diff --git a/server/python/tests/codegen/test_cli_surface.py b/server/python/tests/codegen/test_cli_surface.py index 900fd2a530..0f8839e46c 100644 --- a/server/python/tests/codegen/test_cli_surface.py +++ b/server/python/tests/codegen/test_cli_surface.py @@ -74,3 +74,35 @@ def test_metadata_that_does_not_load_exits_1(argv: list[str], tmp_path: Path) -> args = [a.replace("OUT", str(out)) for a in argv] argv = [args[0], str(meta), *args[1:]] assert main(argv if args[0] == "fmt" else as_config(argv)) == 1 + + +def test_agent_docs_redirects_to_the_node_cli_and_exits_non_zero( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["agent-docs"]) != 0 + assert "npx meta agent-docs" in capsys.readouterr().err + + +def test_agent_docs_old_invocation_with_ignored_flags_still_reaches_the_redirect( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + out_dir = str(tmp_path / "ctx") + assert main(["agent-docs", "--server", "python", "--client", "react", "--out", out_dir]) == 1 + assert "npx meta agent-docs --server python" in capsys.readouterr().err + assert not (tmp_path / "ctx").exists() + + +def test_agent_docs_help_says_it_moved_and_does_not_claim_to_scaffold( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exit_info: + main(["--help"]) + assert exit_info.value.code == 0 + top_level = capsys.readouterr().out + assert "npx meta agent-docs" in top_level + assert "scaffold the slim" not in top_level + with pytest.raises(SystemExit): + main(["agent-docs", "--help"]) + sub_help = capsys.readouterr().out + for ignored_flag in ("--server", "--client", "--out"): + assert ignored_flag not in sub_help diff --git a/server/python/tests/codegen/test_m2m_codegen.py b/server/python/tests/codegen/test_m2m_codegen.py index a197d81baf..946b9a4637 100644 --- a/server/python/tests/codegen/test_m2m_codegen.py +++ b/server/python/tests/codegen/test_m2m_codegen.py @@ -225,8 +225,8 @@ def test_router_repository_protocol_declares_related_finders() -> None: out = render_router(_ENTITIES["Person"], _INDEX) assert out is not None # The consumer seam grows a typed finder per M:N navigation. - assert "def find_related_following(self, id: int) -> list[Any]: ..." in out - assert "def find_related_friends(self, id: int) -> list[Any]: ..." in out + assert "def find_related_following(self, id: int) -> _list[Any]: ..." in out + assert "def find_related_friends(self, id: int) -> _list[Any]: ..." in out def test_router_without_object_index_is_crud_only() -> None: diff --git a/server/python/tests/codegen/test_reverse_finders.py b/server/python/tests/codegen/test_reverse_finders.py index a6fb2e25d3..9d41f9a829 100644 --- a/server/python/tests/codegen/test_reverse_finders.py +++ b/server/python/tests/codegen/test_reverse_finders.py @@ -212,9 +212,9 @@ def test_router_reverse_finder_name_unaffected_by_dotted_references() -> None: index = build_object_index(list(entities.values())) src = render_router(entities["Match"], index) assert src is not None - assert "def find_matches_by_team_fk(self, team_fk: Any) -> list[Any]: ..." in src + assert "def find_matches_by_team_fk(self, team_fk: Any) -> _list[Any]: ..." in src assert ( - "def find_matches_by_team_fk_in(self, team_fk_values: list[Any]) -> list[Any]: ..." + "def find_matches_by_team_fk_in(self, team_fk_values: _list[Any]) -> _list[Any]: ..." in src ) @@ -261,7 +261,7 @@ def test_player_fk_also_gets_a_finder_pair() -> None: def test_single_finder_signature_shape() -> None: src = _game_session_router() assert ( - "def find_game_sessions_by_current_scene(self, current_scene_id: Any) -> list[Any]: ..." + "def find_game_sessions_by_current_scene(self, current_scene_id: Any) -> _list[Any]: ..." in src ) @@ -269,7 +269,7 @@ def test_single_finder_signature_shape() -> None: def test_batched_finder_signature_shape() -> None: src = _game_session_router() assert ( - "def find_game_sessions_by_current_scene_in(self, current_scene_id_values: list[Any]) -> list[Any]: ..." + "def find_game_sessions_by_current_scene_in(self, current_scene_id_values: _list[Any]) -> _list[Any]: ..." in src ) diff --git a/server/python/tests/codegen/test_router_generator.py b/server/python/tests/codegen/test_router_generator.py index ecc534faaf..e57aaa8deb 100644 --- a/server/python/tests/codegen/test_router_generator.py +++ b/server/python/tests/codegen/test_router_generator.py @@ -248,7 +248,29 @@ def test_router_list_handler_calls_parse_filter() -> None: assert "repo.list(actual_limit, actual_offset, sort_clause, predicates)" in out assert "repo.count(predicates)" in out # Repository protocol gains the filters parameter on list + count. - assert "filters: list[FilterPredicate]" in out + assert "filters: _list[FilterPredicate]" in out + + +def test_repository_protocol_annotates_with_the_builtin_alias_not_the_list_method() -> None: + """The ``Protocol`` declares a method named ``list``; a bare ``list[...]`` annotation after + it resolves to that method (mypy ``valid-type``). Its annotations use the ``_list`` alias.""" + out = _require(_author()) + assert "from builtins import list as _list" in out + protocol = out[out.index("class AuthorRepository(Protocol):"):] + protocol = protocol[: protocol.index("def get_repository")] + assert "-> _list[Any]: ..." in protocol + assert "filters: _list[FilterPredicate]" in protocol + assert "list[" not in protocol.replace("_list[", "") + + +def test_delete_handler_is_annotated_response_and_returns_the_204() -> None: + """The 404 path returns a ``JSONResponse``, so ``-> None`` fails ``mypy --strict`` + (``return-value``); the handler is ``-> Response`` and the success path returns the 204.""" + out = _require(_author()) + handler = out[out.index("def delete_author("):] + assert ") -> Response:" in handler.split("try:", 1)[0] + assert "return Response(status_code=status.HTTP_204_NO_CONTENT)" in handler + assert "from fastapi.responses import JSONResponse, Response" in out # --------------------------------------------------------------------------- @@ -379,7 +401,7 @@ def test_uuid_pk_types_every_path_param_and_protocol_site_as_uuid() -> None: assert "def find_by_id(self, id: uuid.UUID) -> Any | None: ..." in out assert "def update(self, id: uuid.UUID, dto: Any) -> Any | None: ..." in out assert "def delete(self, id: uuid.UUID) -> bool: ..." in out - assert "def find_related_tags(self, id: uuid.UUID) -> list[Any]: ..." in out + assert "def find_related_tags(self, id: uuid.UUID) -> _list[Any]: ..." in out # No int-typed pk leaks anywhere. assert "gadget_id: int" not in out assert "id: int)" not in out diff --git a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py index bbd6f833c7..383a3bc2d6 100644 --- a/server/python/tests/codegen/test_router_generator_m2m_target_tph.py +++ b/server/python/tests/codegen/test_router_generator_m2m_target_tph.py @@ -127,7 +127,7 @@ def _render() -> str: def test_protocol_seam_gains_target_subtype_param_for_a_tph_target() -> None: out = _render() assert ( - "def find_related_bridgeAuths(self, id: int, target_subtype: str) -> list[Any]: ..." + "def find_related_bridgeAuths(self, id: int, target_subtype: str) -> _list[Any]: ..." ) in out @@ -139,7 +139,7 @@ def test_route_body_passes_the_resolved_discriminator_value_literal() -> None: " sponsor_id: int,\n" " repo: Annotated[SponsorRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return render_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"), _WIRE_BRIDGE_AUTH)' + ' rows: list[Any] = render_wire(repo.find_related_bridgeAuths(sponsor_id, "Bridge"), _WIRE_BRIDGE_AUTH)' ) in out @@ -152,5 +152,5 @@ def test_no_bare_two_arg_form_leaks_alongside_the_widened_one() -> None: # test_router_generator.py::test_uuid_pk_types_every_path_param_and_protocol_site_as_uuid # — byte-identical before and after this change, since Tag is never TPH. out = _render() - assert "def find_related_bridgeAuths(self, id: int) -> list[Any]: ..." not in out + assert "def find_related_bridgeAuths(self, id: int) -> _list[Any]: ..." not in out assert "return repo.find_related_bridgeAuths(sponsor_id)\n" not in out diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m.py b/server/python/tests/codegen/test_router_generator_tph_m2m.py index e8c05493ce..5038576b99 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m.py @@ -254,7 +254,7 @@ def test_base_declared_m2m_mounted_at_base_path_with_no_subtype_gate() -> None: " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - " return render_wire(repo.find_related_tags(None, auth_id), _WIRE_TAG)" + " rows: list[Any] = render_wire(repo.find_related_tags(None, auth_id), _WIRE_TAG)" ) in out # No gate anywhere near this handler — find_by_id is never called with None. assert 'repo.find_by_id(None, auth_id) is None' not in out @@ -284,7 +284,7 @@ def _gated_body( return ( f' if repo.find_by_id("{subtype}", {pk_param}) is None:\n' " return []\n" - f' return render_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}), ' + f' rows: list[Any] = render_wire(repo.find_related_{relation}("{subtype}", {pk_param}{tail}), ' f"{_TARGET_WIRE[relation]})" ) @@ -332,7 +332,7 @@ def test_priority_tickets_target_discriminator_independent_of_source_gate() -> N " auth_id: int,\n" " repo: Annotated[AuthRepository, Depends(get_repository)],\n" ") -> list[Any]:\n" - ' return render_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' + ' rows: list[Any] = render_wire(repo.find_related_priorityTickets(None, auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' ) in out # Inherited by every subtype too (rule b) — each subtype's OWN gate applies, # while the target literal stays fixed at "Priority" regardless. @@ -366,15 +366,15 @@ def test_repository_protocol_declares_one_seam_per_relation_name() -> None: # Subtype-keyed like every other TPH seam method (`find_by_id(subtype, id)`, ...): # `subtype` is None from the base route, the @discriminatorValue from a subtype # route — the consumer's repo verifies the id is really that subtype's. - assert out.count("def find_related_tags(self, subtype: str | None, id: int) -> list[Any]: ...") == 1 + assert out.count("def find_related_tags(self, subtype: str | None, id: int) -> _list[Any]: ...") == 1 # linkedAuths' and priorityTickets' targets are TPH subtypes — their seam # signatures gain target_subtype (FW-8 follow-up). assert out.count( - "def find_related_linkedAuths(self, subtype: str | None, id: int, target_subtype: str) -> list[Any]: ..." + "def find_related_linkedAuths(self, subtype: str | None, id: int, target_subtype: str) -> _list[Any]: ..." ) == 1 - assert out.count("def find_related_auditors(self, subtype: str | None, id: int) -> list[Any]: ...") == 1 + assert out.count("def find_related_auditors(self, subtype: str | None, id: int) -> _list[Any]: ...") == 1 assert out.count( - "def find_related_priorityTickets(self, subtype: str | None, id: int, target_subtype: str) -> list[Any]: ..." + "def find_related_priorityTickets(self, subtype: str | None, id: int, target_subtype: str) -> _list[Any]: ..." ) == 1 diff --git a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py index 32f0d57ea9..07036c3e07 100644 --- a/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py +++ b/server/python/tests/codegen/test_router_generator_tph_m2m_shadowed.py @@ -167,10 +167,10 @@ def test_shadowed_name_seam_threads_target_subtype_for_the_whole_name() -> None: out = _render() assert out.count( "def find_related_helpers(self, subtype: str | None, id: int" - ", target_subtype: str | None) -> list[Any]: ..." + ", target_subtype: str | None) -> _list[Any]: ..." ) == 1 assert ( - "def find_related_helpers(self, subtype: str | None, id: int) -> list[Any]: ..." + "def find_related_helpers(self, subtype: str | None, id: int) -> _list[Any]: ..." ) not in out assert ( "def find_related_helpers(self, subtype: str | None, id: int, target_subtype: str)" @@ -181,18 +181,18 @@ def test_shadowed_name_seam_threads_target_subtype_for_the_whole_name() -> None: def test_every_mount_of_the_shadowed_name_passes_the_threaded_parameter() -> None: # Base mount (rule a — ungated): explicit None literal, not a dropped argument. out = _render() - assert " return render_wire(repo.find_related_helpers(None, auth_id, None), _WIRE_TAG)" in out + assert " rows: list[Any] = render_wire(repo.find_related_helpers(None, auth_id, None), _WIRE_TAG)" in out # Non-shadowing subtype (inherits the base's vanilla target): gated, None literal. assert ( ' if repo.find_by_id("Copay", auth_id) is None:\n' " return []\n" - ' return render_wire(repo.find_related_helpers("Copay", auth_id, None), _WIRE_TAG)' + ' rows: list[Any] = render_wire(repo.find_related_helpers("Copay", auth_id, None), _WIRE_TAG)' ) in out # Shadowing subtype: its OWN target's discriminator literal. assert ( ' if repo.find_by_id("Bridge", auth_id) is None:\n' " return []\n" - ' return render_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' + ' rows: list[Any] = render_wire(repo.find_related_helpers("Bridge", auth_id, "Priority"), _WIRE_PRIORITY_TICKET)' ) in out diff --git a/server/python/tests/codegen/test_router_mypy_strict.py b/server/python/tests/codegen/test_router_mypy_strict.py new file mode 100644 index 0000000000..b22b45be70 --- /dev/null +++ b/server/python/tests/codegen/test_router_mypy_strict.py @@ -0,0 +1,81 @@ +"""Generated FastAPI routers type-check under ``mypy --strict``. + +``metaobjects gen`` over the shared fitness corpus used to emit routers with 129 strict +errors: the repository ``Protocol`` declares a method named ``list``, so every later +``list[...]`` annotation in the class body resolved to that method (``valid-type``), and the +delete / M:N handlers returned a ``JSONResponse`` or ``Any`` from a function declared +``-> None`` / ``-> list[Any]`` (``return-value`` / ``no-any-return``). An adopter running +``mypy --strict`` was the first thing to notice, which is what the codegen-compile gate +(``test_codegen_compile_conformance.py``) exists to prevent for the tiers it covers; it +excludes the framework-bound router tier, so this closes that for Python. + +Only errors in the emitted ``*_router.py`` modules are asserted. The model tier has its own +strict findings that are not this gate's concern. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("mypy") +pytest.importorskip("fastapi") + +from metaobjects import load_uris # noqa: E402 +from metaobjects.codegen.config import GenConfig # noqa: E402 +from metaobjects.codegen.generators.entity_model import entity_model # noqa: E402 +from metaobjects.codegen.generators.filter_allowlist_generator import ( # noqa: E402 + filter_allowlist_generator, +) +from metaobjects.codegen.generators.router_generator import router_generator # noqa: E402 +from metaobjects.codegen.runner import run_gen # noqa: E402 + +# tests/codegen/ -> tests -> python -> server -> repo root +CORPUS = ( + Path(__file__).parents[4] + / "fixtures" + / "persistence-conformance" + / "canonical" + / "meta.fitness.json" +) + + +def test_generated_routers_pass_mypy_strict(tmp_path: Path) -> None: + loaded = load_uris([CORPUS.as_uri()]) + assert not loaded.errors + out_dir = tmp_path / "generated" + run_gen( + GenConfig(out_dir=str(out_dir), emit_package_init=True), + loaded.root, + generators=[entity_model(), filter_allowlist_generator(), router_generator()], + ) + routers = sorted(out_dir.glob("*_router.py")) + assert routers, "the corpus generated no routers at all" + + config = tmp_path / "mypy.ini" # empty: never inherit the repo's or the user's settings + config.write_text("[mypy]\n") + proc = subprocess.run( + [ + sys.executable, "-m", "mypy", + "--strict", + "--config-file", str(config), + "--cache-dir", str(tmp_path / ".mypy_cache"), + "--no-error-summary", + str(out_dir), + ], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert proc.returncode in (0, 1), f"mypy did not run: {proc.stdout}{proc.stderr}" # 2 = crash + router_errors = [ + line + for line in proc.stdout.splitlines() + if "_router.py" in line.split(":", 1)[0] and ": error:" in line + ] + assert not router_errors, ( + f"{len(router_errors)} `mypy --strict` error(s) in the generated routers:\n" + + "\n".join(router_errors) + ) diff --git a/server/python/uv.lock b/server/python/uv.lock index a3a0e53631..8215c4ad79 100644 --- a/server/python/uv.lock +++ b/server/python/uv.lock @@ -429,6 +429,7 @@ mysql = [ [package.dev-dependencies] dev = [ { name = "jinja2" }, + { name = "mypy" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-xdist" }, @@ -455,6 +456,7 @@ provides-extras = ["dev", "integration", "mysql", "jinja"] [package.metadata.requires-dev] dev = [ { name = "jinja2", specifier = ">=3.1" }, + { name = "mypy", specifier = ">=1.10" }, { name = "pydantic", specifier = ">=2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-xdist", specifier = ">=3.6" }, diff --git a/server/typescript/packages/cli/test/gen-docs-site.test.ts b/server/typescript/packages/cli/test/gen-docs-site.test.ts index 73d0ab00a0..ee722f1a0c 100644 --- a/server/typescript/packages/cli/test/gen-docs-site.test.ts +++ b/server/typescript/packages/cli/test/gen-docs-site.test.ts @@ -105,6 +105,15 @@ describe("meta gen with docsSiteFile()", () => { expect(existsSync(join(root, "docs-out", "site", "acme", "geo", "Place.html"))).toBe(true); }); + test("docsSiteFile({ assetBase }) points the page chrome at that base instead of jsDelivr", async () => { + const cfg = config("").replace("docsSiteFile()", `docsSiteFile({ assetBase: "/vendor/npm" })`); + const root = await project(META, cfg); + expect(await run(["gen", "--cwd", root])).toBe(0); + const html = await readFile(join(root, "docs-out", "site", "index.html"), "utf8"); + expect(html).not.toContain("cdn.jsdelivr.net"); + expect(html).toContain(''); + }); + test("two declared sources sharing a basename are both in the site", async () => { const workspace = await mkdtemp(join(tmpdir(), "meta-gen-site-multi-")); dirs.push(workspace); diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts b/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts index 44bb3cb49d..213ce576bf 100644 --- a/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts @@ -36,6 +36,11 @@ export interface DocsSiteFileOpts { * depend on the model alone and `meta verify --codegen` can hold them to it. A date here * makes every day's run differ from the last. */ stamp?: string; + /** Where the pages load their third-party CSS and JS from (daisyUI, Tailwind's browser build, + * Mermaid, svg-pan-zoom). Default `https://cdn.jsdelivr.net/npm`, which needs network access + * to render; set it to your own mirror, or to a path you serve the five files from. Give an + * absolute URL or a root-relative path. See `@metaobjectsdev/docs-site` for the files. */ + assetBase?: string; /** Named output target. Default `"docs"`, which `targets` must declare. */ target?: string; } @@ -84,6 +89,7 @@ export const docsSiteFile = function docsSiteFile(opts?: DocsSiteFileOpts): Gene stamp: opts?.stamp ?? "", commit: "", core: { n: 15 }, + ...(opts?.assetBase !== undefined ? { assetBase: opts.assetBase } : {}), ...(ctx.providers !== undefined ? { extraProviders: ctx.providers } : {}), ...(existsSync(ownedTemplates) ? { templatesDir: ownedTemplates } : {}), ...(existsSync(ownedAssets) ? { assetsDir: ownedAssets } : {}), diff --git a/server/typescript/packages/docs-site/README.md b/server/typescript/packages/docs-site/README.md index 3ee2499431..af95f2c5aa 100644 --- a/server/typescript/packages/docs-site/README.md +++ b/server/typescript/packages/docs-site/README.md @@ -26,7 +26,37 @@ export default defineConfig({ whatever other documentation the config lists. Options: `subDir` (default `site`), `prompts` (an extra directory of prompt `.mustache` sources), `title`, `stamp` (footer text; default none, so the pages depend on the model alone and `meta verify --codegen` -can check them) and `target`. +can check them), `assetBase` (see below) and `target`. + +## Offline use: `assetBase` + +The page chrome loads five third-party files from jsDelivr (`https://cdn.jsdelivr.net/npm`): +daisyUI's two stylesheets, Tailwind's browser build, Mermaid and svg-pan-zoom. A site opened +without network access therefore renders unstyled, and the Mermaid diagrams and pan/zoom do +not work. `assetBase` replaces that base in all five tags; the default is unchanged. + +```ts +docsSiteFile({ assetBase: "/vendor/npm" }) +``` + +(`generateSite({ assetBase })` takes the same option.) Give an absolute URL or a root-relative +path, with or without a trailing slash. The builder does not copy the files into the output: +download these five and serve them from the base you chose, keeping the paths below the base. + +| Path under `assetBase` | Package | +|---|---| +| `daisyui@5.5.19/daisyui.css` | `daisyui@5.5.19`, file `daisyui.css` | +| `daisyui@5.5.19/themes.css` | `daisyui@5.5.19`, file `themes.css` | +| `@tailwindcss/browser@4.2.4/dist/index.global.js` | `@tailwindcss/browser@4.2.4` | +| `mermaid@11/dist/mermaid.min.js` | `mermaid@11` | +| `svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js` | `svg-pan-zoom@3.6.2` | + +One way to get them: `npm pack daisyui@5.5.19 @tailwindcss/browser@4.2.4 mermaid@11 svg-pan-zoom@3.6.2`, +unpack each tarball's `package/` directory into `vendor/npm/@/`, and serve +`vendor/npm` at `/vendor/npm` (or run a jsDelivr-compatible mirror and pass its URL). The +site is generated output, so the files stay outside it. If you own the templates +(`meta eject docs-site`), edit `chrome-head.mustache` directly; the `{{{assetBase}}}` in the +packaged copy is what the option fills. ## Usage (as a library) diff --git a/server/typescript/packages/docs-site/src/index.ts b/server/typescript/packages/docs-site/src/index.ts index 06ce1064a0..47ab07b969 100644 --- a/server/typescript/packages/docs-site/src/index.ts +++ b/server/typescript/packages/docs-site/src/index.ts @@ -1,4 +1,4 @@ -export { generateSite } from "./site.js"; +export { generateSite, DEFAULT_ASSET_BASE } from "./site.js"; export type { SiteOptions, SiteResult } from "./site.js"; export { SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "./scaffold.js"; export type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; diff --git a/server/typescript/packages/docs-site/src/link-check.ts b/server/typescript/packages/docs-site/src/link-check.ts index 517b02f258..14fcd55977 100644 --- a/server/typescript/packages/docs-site/src/link-check.ts +++ b/server/typescript/packages/docs-site/src/link-check.ts @@ -12,7 +12,9 @@ export function checkLinks(outDir: string, pages: string[]): string[] { const html = readFileSync(join(outDir, page), "utf8"); for (const m of html.matchAll(/href="([^"]+)"/g)) { const href = m[1]!; - if (/^(https?:|mailto:|#$)/.test(href)) continue; + // A root-relative href is served from outside the site (an `assetBase` mirror); the site's + // own links are all relative. + if (/^(https?:|mailto:|#$|\/)/.test(href)) continue; const [file = "", anchor] = href.split("#"); const target = file === "" ? page : normalize(join(dirname(page), file)); const abs = join(outDir, target); diff --git a/server/typescript/packages/docs-site/src/site.ts b/server/typescript/packages/docs-site/src/site.ts index be343d160e..0c64e57968 100644 --- a/server/typescript/packages/docs-site/src/site.ts +++ b/server/typescript/packages/docs-site/src/site.ts @@ -39,8 +39,17 @@ export interface SiteOptions { * (core-types + db + doc + prompt + ui) so a site can document metadata that * uses custom field/view/object subtypes. Mirrors loadMemory's `providers`. */ extraProviders?: readonly MetaDataTypeProvider[]; + /** Where the page chrome loads its third-party CSS and JS from (daisyUI, Tailwind's browser + * build, Mermaid, svg-pan-zoom): the base the package paths are appended to, e.g. + * `/mermaid@11/dist/mermaid.min.js`. Default: {@link DEFAULT_ASSET_BASE}. Give an + * absolute URL or a root-relative path (`/vendor/npm`) the pages can reach; a trailing slash + * is ignored. The builder does not copy the files in. */ + assetBase?: string; } +/** The base the chrome loads its third-party assets from unless `assetBase` says otherwise. */ +export const DEFAULT_ASSET_BASE = "https://cdn.jsdelivr.net/npm"; + export interface SiteResult { pages: string[]; coverage: CoverageReport; @@ -244,6 +253,7 @@ export async function generateSite(opts: SiteOptions): Promise { stamp: opts.stamp, commit: opts.commit, relRoot, + assetBase: (opts.assetBase ?? DEFAULT_ASSET_BASE).replace(/\/+$/, ""), navHtml, tocHtml, legendHtml: legend, diff --git a/server/typescript/packages/docs-site/templates/chrome-head.mustache b/server/typescript/packages/docs-site/templates/chrome-head.mustache index 1bdf827526..276a63043b 100644 --- a/server/typescript/packages/docs-site/templates/chrome-head.mustache +++ b/server/typescript/packages/docs-site/templates/chrome-head.mustache @@ -3,11 +3,11 @@ {{title}} - - - - - + + + + + diff --git a/server/typescript/packages/docs-site/test/site.test.ts b/server/typescript/packages/docs-site/test/site.test.ts index fc7ff50cd7..ace5dc97ce 100644 --- a/server/typescript/packages/docs-site/test/site.test.ts +++ b/server/typescript/packages/docs-site/test/site.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { existsSync, readFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { generateSite } from "../src/site"; @@ -64,3 +64,47 @@ test("nav lists members only for the current page's package", async () => { expect(aiNav).toContain("shop/index.html"); expect(aiNav).not.toContain("Order.html"); }); + +// ─── assetBase ──────────────────────────────────────────────────────────────── + +const htmlFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? htmlFiles(join(dir, e.name)) : e.name.endsWith(".html") ? [join(dir, e.name)] : [], + ); + +test("assetBase replaces the jsDelivr base in every page's stylesheet and script tags", async () => { + const out = mkdtempSync(join(tmpdir(), "site-assetbase-")); + await generateSite({ + sourceDirs: [join(import.meta.dir, "fixture/input/acme")], + outDir: out, title: "Fixture", stamp: "2026-01-01", commit: "abc1234", + assetBase: "https://assets.example.test/npm/", + }); + const pages = htmlFiles(out); + expect(pages.length).toBeGreaterThan(10); + for (const page of pages) { + const html = readFileSync(page, "utf8"); + expect(html).not.toContain("cdn.jsdelivr.net"); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + } +}); + +test("a root-relative assetBase is written as given, at every page depth", async () => { + const out = mkdtempSync(join(tmpdir(), "site-assetbase-abs-")); + await generateSite({ + sourceDirs: [join(import.meta.dir, "fixture/input/acme")], + outDir: out, title: "Fixture", stamp: "2026-01-01", commit: "abc1234", + assetBase: "/vendor", + }); + for (const page of ["index.html", "acme/shop/Order.html"]) + expect(readFileSync(join(out, page), "utf8")).toContain(''); +}); + +test("the default assetBase is jsDelivr", async () => { + const out = mkdtempSync(join(tmpdir(), "site-assetbase-default-")); + await generateSite({ sourceDirs: [join(import.meta.dir, "fixture/input/acme")], outDir: out, title: "Fixture", stamp: "2026-01-01", commit: "abc1234" }); + expect(readFileSync(join(out, "index.html"), "utf8")).toContain(''); +}); diff --git a/server/typescript/packages/metadata/src/core/field/field-constants.ts b/server/typescript/packages/metadata/src/core/field/field-constants.ts index cd4ba7b617..1e470bf7ee 100644 --- a/server/typescript/packages/metadata/src/core/field/field-constants.ts +++ b/server/typescript/packages/metadata/src/core/field/field-constants.ts @@ -125,7 +125,7 @@ export const FIELD_ATTR_FILTERABLE = "filterable"; export const FIELD_ATTR_SORTABLE = "sortable"; export const FIELD_ATTR_SORTABLE_DEFAULT_ORDER = "sortableDefaultOrder"; -/** When true, the field is omitted from generated forms. */ +/** When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it. */ export const FIELD_ATTR_FORM_EXCLUDE = "formExclude"; /** Auto-set semantics on a timestamp field. Values: "onCreate" | "onUpdate". */ diff --git a/server/typescript/packages/metadata/src/presentation/ui/ui-definition.embedded.ts b/server/typescript/packages/metadata/src/presentation/ui/ui-definition.embedded.ts index c91a5a75f6..536e6baa7b 100644 --- a/server/typescript/packages/metadata/src/presentation/ui/ui-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/presentation/ui/ui-definition.embedded.ts @@ -47,7 +47,7 @@ export const UI_DEFINITION: ProviderDefinition = { "name": "formExclude", "min": 0, "max": 1, - "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." } ] }, diff --git a/site-reference/types/field.html b/site-reference/types/field.html index f658b9e461..0b53d4a050 100644 --- a/site-reference/types/field.html +++ b/site-reference/types/field.html @@ -127,7 +127,7 @@

field.base

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -277,7 +277,7 @@

field.boolean

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -437,7 +437,7 @@

field.currency

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -596,7 +596,7 @@

field.date

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -747,7 +747,7 @@

field.decimal

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -915,7 +915,7 @@

field.double

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1093,7 +1093,7 @@

field.enum

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1278,7 +1278,7 @@

field.float

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1428,7 +1428,7 @@

field.inet

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1587,7 +1587,7 @@

field.int

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1737,7 +1737,7 @@

field.long

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -1888,7 +1888,7 @@

field.map

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2057,7 +2057,7 @@

field.object

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2225,7 +2225,7 @@

field.string

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2401,7 +2401,7 @@

field.time

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2560,7 +2560,7 @@

field.timestamp

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2719,7 +2719,7 @@

field.uri

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction @@ -2878,7 +2878,7 @@

field.uuid

metaobjects-ui -When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields). +When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). @instruction diff --git a/spec/metamodel/ui.json b/spec/metamodel/ui.json index 6af62e9ced..3ce6ca59d8 100644 --- a/spec/metamodel/ui.json +++ b/spec/metamodel/ui.json @@ -8,7 +8,7 @@ { "type": "attr", "subType": "boolean", "name": "filterable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer)." }, { "type": "attr", "subType": "boolean", "name": "sortable", "min": 0, "max": 1, "description": "When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out." }, { "type": "attr", "subType": "string", "name": "sortableDefaultOrder", "min": 0, "max": 1, "allowedValues": ["asc", "desc"], "description": "Direction a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'." }, - { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms. Inert on fields for which no form is generated (e.g. projection/derived fields)." } + { "type": "attr", "subType": "boolean", "name": "formExclude", "min": 0, "max": 1, "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." } ] }, {