Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 —
`<group>/<source>.jinja`, `.j2` or `.jinja2` — instead of a `.mustache` one. Nothing in the
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` | 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 <url>` | 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 |
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions agent-context/skills/metaobjects-codegen/references/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>]` (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.
4 changes: 2 additions & 2 deletions agent-context/skills/metaobjects-prompts/references/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <metadataDir> --templates <dir>` (or `--codegen --out <dir>`) |
| 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`
Expand Down
6 changes: 4 additions & 2 deletions client/web/packages/runtime-web/src/filter-qs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,7 +31,7 @@ export function buildFilterQs(filter: Record<string, unknown>): 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<string, unknown> = { ...fields };
if (or !== undefined) filterObj.or = or;
Expand Down
11 changes: 11 additions & 0 deletions client/web/packages/runtime-web/test/filter-qs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(=|&|$)/);
Expand Down
73 changes: 54 additions & 19 deletions docs/features/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
// <auto-generated/>
// 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; }
}
```

Expand All @@ -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
`<Name>Create` and a `<Name>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
Expand Down
70 changes: 40 additions & 30 deletions docs/features/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, V>` | `Map<String, V>` | `Map<String, V>` | `Dictionary<string, V>` | `dict[str, V]` | `jsonb` |

That is the whole registered vocabulary — 17 concrete subtypes. `field.base` is an
Expand Down Expand Up @@ -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`:
Expand All @@ -382,25 +395,22 @@ modelBuilder.Entity<Author>()
### 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
Expand Down
Loading
Loading