Skip to content

Latest commit

 

History

History
551 lines (410 loc) · 62.2 KB

File metadata and controls

551 lines (410 loc) · 62.2 KB

MetaObjects — Claude Context

What this project is

MetaObjects is a cross-language metadata standard for declaring typed entity models that drive code generation, runtime metadata access, and drift detection — across TypeScript, C#, Java, Python, and Kotlin (Kotlin runs on the JVM via metadata-ktx + codegen-kotlin).

The metamodel is the durable spine; generated code is the disposable artifact. Substrate is local-first: typed metadata lives in your repo, generated code is idiomatic per-language output that runs without any MetaObjects dependency at runtime. If @metaobjectsdev/* disappears tomorrow, you keep working code.

Four pillars

Equal weight — all four ship per-language today across the five ports (TS / C# / Java / Python / Kotlin), with cross-port conformance corpora verifying byte-identical behavior:

  1. Codegen — emit idiomatic per-language code (Drizzle/Zod + Fastify for TS, EF Core + ASP.NET for C#, Spring REST + DTO + Repository for Java via codegen-spring, Pydantic + FastAPI for Python, KotlinPoet + Exposed + Spring for Kotlin via codegen-kotlin). Hand-edit-preserving regen via three-way merge.
  2. Runtime metadata — load metadata at runtime, drive behavior dynamically (CRUD, validation, relationships, dynamic admin UIs, LLM tool registration). On Kysely (TS), a DB-API 2 driver via ObjectManager (Python), modernized JDBC + Spring-tx via OMDB (Java), Exposed (Kotlin), EF Core (C#).
  3. Drift detectionmeta verify catches divergence between code and metadata (covers entity codegen, prompt templates, output parsers, schema). Quality-of-life on top of codegen + runtime.
  4. Prompt construction — a prompt is code, not a string scattered across services. Declare a prompt's payload as a typed projection (payload bloat becomes a diff), keep its text external and provider-resolved, and render it deterministically: snapshot-testable, cache-stable (no whitespace change silently breaking exact-prefix prompt-cache hits), and drift-checked at build time so a renamed field can't degrade a prompt. Conformance-gated, so the guarantee holds in every language port. Render + payload-VO codegen + verify + template.output parser-on-receipt (FR-006) + the output-format prompt fragment & tolerant extract parser (FR-010) ship in all five ports today — the library-side building blocks of the pillar are complete. The one remaining library-side piece is MCP exposure of declared prompts/tools (see spec/roadmap.md); the application-level consolidation (eval harness, end-to-end declared-prompt orchestration) and consumer adoption are exercised in adopter projects, not in this library repo. Designed in docs/superpowers/specs/2026-05-22-fr-004-cross-language-prompt-construction-design.md.

Status

Last refreshed 2026-07-20.

TypeScript reference implementation is published to npm at 0.19.1 (14 @metaobjectsdev/* publish candidates on the latest tag, full lockstep; the two angular packages are on their own 0.6.x line). C# at 0.19.1 (NuGet); Python at 0.19.2 (PyPI — the Python line runs one ahead after its own 0.19.1 hotfix); Java / Kotlin at 7.11.1 (Maven Central). The 0.19.1 line is a coordinated patch (no breaking changes, no new vocabulary): an explicitly authored validator.length @min is now authoritative over the FR-036 Pin 1 implicit non-empty floor — every port previously clamped an authored @min with max(@min, 1) and silently discarded it, so @min: 0 on a @required string was inexpressible; now @min: 0 restores "must be provided, may be empty" and any explicit @min always wins (#224); the @required vocabulary (spec files, embedded definitions, registry manifest, metaobjects-authoring skill) now matches that shipped behavior (#224 §A5); and meta verify --dialect d1 --d1 <binding> [--remote] can target Cloudflare D1 through the existing wrangler transport, with --db file: into .wrangler/state/**/d1/** now warning it is verifying the local database rather than silently doing so (#225). See CHANGELOG.md [0.19.1] (and the separate PyPI [0.19.1] entry for the Python-only hotfix folded into this line's 0.19.2). The prior 0.19.0 / 7.11.0 line was a coordinated additive minor (no breaking changes): image support — a metadata-driven view.image form control on field.string (storage is an opaque key; no image bytes cross the wire), whose generated <Entity>Form (codegen-ts-react) renders an upload/crop <ImageUpload> widget via a react-hook-form <Controller>, backed by a consumer-supplied ImageUploadAdapter (upload/imageUrl) over React context — plus a new metaobjects-ui-web TS-applied concern provider (spec/metamodel/ui-web.json) that now owns view.image's five presentation attrs (@aspectRatio, @maxEdge, @store, @accept, @maxBytes) and the previously-deferred @rows on view.textarea (core view.json still registers zero view attrs, so the FR-033 invariant holds; non-TS ports mirror the spec file but never apply it — TS-web-only). New client surface: @metaobjectsdev/runtime-web (canvasToJpegBlob/reencodeJpeg + adapter types) and @metaobjectsdev/react (<ImageUpload>, <ImageUploadAdapterProvider>/useImageUploadAdapter(), cropToBlob, an optional ./form.css export; react-easy-crop is an optional lazy-loaded peer). Also folds in a 0.18.0 test-only fixup (the @formExclude registration had left two full-suite tests red on main; regenerated, no re-release — published 0.18.0 product code was correct). See CHANGELOG.md [0.19.0]. The prior 0.18.0 / 7.10.0 line was a coordinated additive minor (no breaking changes): view-kind form-control dispatch in codegen-ts-react — the generated <Entity>Form now renders the right control per field view (enum→<select>, view.textarea<textarea>, view.checkbox, view.radio; styled submit wrapper; aria-labels) instead of a bare <input> for every scalar — plus @formExclude registered cross-port on the field.* wildcard: the form template read it but core never registered it (strict verify rejected it as ERR_UNKNOWN_ATTR), now first-class in all five ports via the data-driven UI provider. Configurable @rows was deferred (no clean cross-port home for an attribute on a TS-only view subtype — ui.json extends throws where view.textarea is deregistered, core view.json breaks the FR-033 "core owns zero view attrs" invariant; textareas use a fixed rows={4}). See CHANGELOG.md [0.18.0]. The prior npm 0.17.1 was an npm-only patch on top of the 0.17.0 line (PyPI/NuGet/Maven unchanged): it fixes the write-through-entity generated REST routes to read through the replica view so read-your-writes returns the derived origin.passthrough field — the #214 read-half had shipped in the query layer + the view, but the routes layer was left mounting vanilla table CRUD (a readView on runtime-ts mountCrudRoutes + codegen-ts routes wiring), now gated by a cross-port api-contract-conformance write-through corpus (TS/C#/Kotlin). See CHANGELOG.md [0.17.1]. The 0.17.0 / 7.9.0 line is a coordinated additive minor bundling #195 projection read-model origins (origin.aggregate @agg: any|all|collect, origin.computed @expr, origin.first), #207 projection row-scope @filter (view outer WHERE), #208 DDL-ownership escape valves (source.rdb @sql hand-written body + @unmanaged marker; ADR-0043), #214 entity read-view codegen READ half (all five ports), and Program D value-object jsonb-column PATCH parity — plus a full documentation + agent-context skills refresh (the seven meta init skills accuracy-passed + Fable-reviewed; runtime-ui gained its Python + C# language refs). No breaking changes. See CHANGELOG.md [0.17.0]. The prior 0.16.0 / 7.8.0 line was a coordinated BREAKING release (FR-036) bundling the held FR-035 present-key PATCH tristate (absent≠null: absent→untouched / present-null→clears / present-null-on-@required→400) with FR-036 cross-port constraint-validation enforcement + semantic pins: @required string = NON-EMPTY (reject null/"", accept whitespace); validator.regex @pattern = FULL-MATCH; @maxLength×validator.length @max = strictest-wins; C# and Python now ENFORCE field constraints over HTTP (both were decorative at the wire tier); POST + PATCH validate present values on all five ports, vanilla AND TPH per-subtype. A missing @required value-type POST now 400s everywhere; a server-defaulted/@autoSet/auto-gen-PK @required field is correctly optional on POST. All conformance-gated (validation-conformance + api-contract, both lanes) — an xhigh review before release caught 11 cross-port divergences, all fixed + gated. See CHANGELOG.md [0.16.0]. The prior 0.15.21 / 7.7.11 line was a bug-fix release (npm 0.15.21 · PyPI 0.15.13 · Maven 7.7.11; NuGet unchanged at 0.15.10 — the C# port needed no fix and was the reference the other three were corrected against). No metadata changes, no new vocabulary. It closes a family of migrate-engine defects that were destructive or silent: a wrong-row write/delete in every writable mount (a numeric-looking string id on a TEXT/uuid PK hit a different row); every incremental meta migrate rebuilding every uuid-PK table; @autoSet emitting DEFAULT now() on SQLite/D1 (un-appliable migration); field.enum @values changes never migrating on SQLite (stale CHECK → production insert failures); the SQLite emitter dropping index @expr/@where/@orders (a partial-UNIQUE silently becoming fully UNIQUE); an auto-allowed drop-view; @kind: storedProc projections crashing meta migrate; and quoted boolean defaults stored as TEXT so = 0 matched nothing. It also fixes hardcoded primary-key types in the Java, Kotlin and Python generators — a @generation: uuid entity got broken output (Kotlin's did not compile) while its own DTO correctly used UUID. Every migrate fix is now gated by an emit → apply to a real engine → introspect → re-diff must be EMPTY round-trip plus value-semantics probes; the absence of that gate (nothing ever ran the pipeline twice against a real DB) is what let the class survive 5000+ tests. The prior 0.15.19 / 7.7.9 line was a coordinated release across all four registries (additive, non-breaking): origin.aggregate @filter (a scoping filter — an attr.filter object — rendered as SQL FILTER (WHERE …) / SQLite CASE WHEN) is now registered in all five ports + registry-conformance (with an origin-aggregate-filtered fixture), closing the "projections can't express my scoped aggregate" gap; and the agent-context skills + docs make the projection/DB-view contract explicit — a projection's CREATE VIEW is generated from its origin.* children, so a hand-written view is drift (and, being unmanaged, invisible to meta verify --db), with the "custom SQL views" hand-write exception bounded to genuinely-irreducible views, a new audit view-necessity signature + a VOCAB CANDIDATE new-subtype-opportunity hunt, and a new docs/features/downstream-metadata-decisions.md adopter guide. npm 0.15.18 was an npm-only, non-breaking patch on top of the coordinated 0.15.17 line (PyPI/NuGet/Maven unchanged — TS-only fixes; advances the 1.0 quiet period): the codegen-ts promptRender FQN @objectRef fix (a prompt payload value-object nesting an @objectRef to another value-object emitted invalid pkg::Name TypeScript — now package-stripped to the bare name, matching entityFile) plus a batch of agent-context skills fixes (four of six metaobjects-* skills had invalid YAML front-matter so they never intent-triggered; wrong C# reference flags; the reference-fragment install reverted from deploy-all to stack-scoped; deprecated codegen-ts/generators imports + singular tanstack paths; ADR-0040 index.lookup vocabulary; and a stale Kotlin fixture that had left agent-context-conformance red on main). A cross-port hunt confirmed the promptRender FQN leak was TS-only (Java/C#/Kotlin/Python payload codegen already strip); PR #190 added the missing Python + Kotlin regression tests. The 0.15.17 line is a coordinated release across all four registries: (1) BREAKING — origin.passthrough is type-preserving (a passthrough field must match its @from source's field.<subType> + array-ness or fail load with ERR_PASSTHROUGH_TYPE_MISMATCH; opt out with @convert: true; retires the narrow ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH) (#185/#186); (2) typed value-object jsonb columns end-to-end all ports — single + @isArray array-of-VO round-trip through every port's write/read codec; Kotlin codegen moved off kotlinx @Serializable to a generated per-package Jackson MetaJsonbMapper (no serialization compiler plugin), C#/Java/Python array-of-VO codecs fixed (#187); (3) load-order-independent super-resolution — a dotted extends: Owner.member to an inherited member now resolves regardless of file load order (was a Node-vs-Bun readdir build-portability bug) via on-demand memoized resolution (#188/#189). The 0.15.6 line is a coordinated cross-port loader-ordering fix (all four registries bumped together): an overlay-only file could be merged before the base file declaring the entity it re-opens, so an object.projection overlay re-open (and any overlay reaching a not-yet-loaded extends/origin target) failed super-resolution with spurious ERR_INVALID_ORIGIN / ERR_UNRESOLVED_SUPER / ERR_MISSING_REQUIRED_ATTR — surfacing as a TS-vs-Python divergence but latent in every port. Each loader now stable-partitions overlay-only sources/roots to merge last, deterministically, cross-port conformance-gated (projection-overlay-abstract-identity) (#160). The prior 0.15.5 / PyPI 0.15.6 line shipped two consumer-provider CLI fixes (offline meta migrate now threads metaobjects.config.ts providers #157; a Python metaobjects … --provider module:symbol hook #158). The 0.15.4 line fixes cross-package same-name root nodes being wrongly merged by the TS/C#/Python loaders (#155). 2500+ tests passing across the workspace. (The 0.15.1 / 7.7.1 line shipped the index.* type + identity.secondary key-purity [ADR-0040]: identity.secondary is now a unique alternate key with @unique removed — a legacy @unique fails load with ERR_UNKNOWN_ATTR — and a new index.lookup subtype carries non-unique retrieval [@fields required; @using/@expr/@where/@orders are RDB-physical escapes], cross-port conformance-gated; breaking, migration in docs/features/migrations/identity-secondary-to-index-lookup.md. This is on top of the 0.15.0 metamodel-1.0 vocabulary program + the ADR-0039 own-accessor fix.)

All five ports ship loader + canonical serializer + conformance + codegen + render + payload-VO + verify:

  • TypeScriptcodegen-ts (Vite-style plugins; Drizzle, Zod, Fastify) + runtime-ts + migrate-ts + the universal web client packages (runtime-web, react, tanstack).
  • C#MetaObjects (loader + canonical serializer + conformance) + MetaObjects.Render (Mustache + payload-VO + verify) + MetaObjects.Codegen (EF Core entities + AppDbContext + CRUD minimal-API routes). Schema migrations are TS-owned (ADR-0015): the C# migrate engine and the migrate/--from-db CLI surface were removed; the C# CLI is gen/verify only, packaged as a .NET tool invoked dotnet meta (not a bare meta — that name belongs to the Node schema CLI). EF Core runtime data-access stays per-port.
  • Javametadata + omdb + om + dynamic + core-spring + metadata-ktx (Kotlin facade) + codegen-spring (Spring controllers + DTOs + repositories + filter allowlists + payload records + output parsers) + codegen-mustache + codegen-plantuml + render + maven-plugin (meta:gen/meta:editor + a meta:verify codegen-drift goal — distinct from the removed live-DB meta:verify; Kotlin generators run through meta:gen via the shared SPI). FR-003 (OMDB runtime persistence + binding registry + typed jsonb + Spring-tx + source/origin metamodel) fully shipped, including Plan 4 (engine-debt remediation: atomic mapping cache, JDBC codec registry, inTransaction template). Schema migrations are owned by the TypeScript toolchain (@metaobjectsdev/cli migrate); the Java port's diff-and-converge migration engine and its meta:migrate / live-DB-drift meta:verify Maven goals were removed, and per ADR-0015 Decision 2 the dev/test runtime auto-create path (MetaClassDBValidatorService + the drivers' createTable/createIndex/createForeignKey/createSequence DDL) is also removed — OMDB is pure data-access (CRUD/query/codec/transactions only).
  • Pythonmetaobjects (loader + canonical serializer + conformance + render + verify + codegen) + an ObjectManager runtime layer. The migrate module was removed (schema is TS-owned, ADR-0015); Python is pure data-access (codegen + ObjectManager runtime), with a metaobjects console-script (gen/verify codegen — no migrate). All five conformance corpora green.
  • Kotlincodegen-kotlin (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. integration-tests-kotlin runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.

Cross-port conformance corpora (every port runs the shared corpus):

  • Metamodel: fixtures/conformance/ (~90 fixtures). TS / C# / Java / Python all green.
  • Render: fixtures/render-conformance/. TS / C# / Java / Kotlin / Python byte-identical.
  • Persistence: fixtures/persistence-conformance/. Query scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced canonical/schema.postgres.sql (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The migration scenarios are exercised by TS only (TS owns schema migrations). The corpus now gates WRITES, not just reads (SP-H): an op: roundtrip scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The AllTypes entity (roundtrip-all-types.yaml) carries one field of every persistable field.* subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an array-of-VO field.object @isArray @storage:jsonb column (labels, written as 2-element / empty-[] / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (field.byte/field.short/field.class were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see fixtures/registry-conformance/README.md → "Per-subtype write-round-trip matrix".)
  • API-contract: fixtures/api-contract-conformance/. TS / C# / Java / Kotlin / Python all 20/20 — each port runs two lanes: a hand-rolled reference server AND its generated API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.
  • YAML / verify corpora green across the ports that ship those layers.

Key cross-language features shipped: FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + 10 filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core UsingEntity / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal GET /<source-plural>/{id}/<relation> + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete field.* subtype write+read round-trips cross-port via the persistence op: roundtrip gate; cut field.byte/field.short/field.class non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen).

Current release line. 0.18.x (npm / NuGet / PyPI) · 7.10.x (Maven Central). The 0.15.0 / 7.7.0 release is a coordinated minor with breaking changes: (1) the metamodel-1.0 vocabulary programfield.uri / field.inet subtypes + a @stringFormat attribute (ADR-0036/0037), field.timestamp instant-by-default with an @localTime naive opt-out, @dbColumnType slim-and-derive (array-ness + text derived; uuid_array/text_array/@kind:text dropped), and reverse-navigation via generated explicit FK finders (ADR-0038); (2) the ADR-0039 own-accessor correctness fix — resolving/effective accessors are the default everywhere, so a concrete field/entity that extends an abstract parent now correctly inherits its properties and members in codegen + runtime (an entire latent bug class, cross-port conformance-gated). Breaking: instant-default timestamps and the @dbColumnType slim. See CHANGELOG.md for the per-version detail. GA promotion is the next release move.

See spec/roadmap.md for the active + planned work picture.

Public repository hygiene

This repository is PUBLIC. Never commit references to other/private projects or to a developer's local environment. In any committed file — specs, plans, code, docs, fixtures — before every commit:

  • No other-project names. Do not name private or sibling consumer projects. Use generic terms: "a downstream consumer", "the reference web consumer", "a C# adopter", "a sibling project".
  • No absolute local paths. Never commit a developer's home path (e.g. /home/<user>/… or a ~/-rooted path). Use repo-relative paths or placeholders (<repo-root>, <consumer-repo>) in command examples.
  • Scan the staged diff for both of the above and genericize anything found before committing.

A local pre-commit hook enforces this (.githooks/pre-commit). The committed hook names no private project: it enforces generic structural patterns (absolute home paths) and loads a private-name denylist from a path you configure (kept in a private repo, never here). One-time per clone:

git config core.hooksPath .githooks
git config hooks.denyListPath /path/to/your/private/denylist.txt

It blocks commits whose added lines match (git commit --no-verify bypasses, discouraged); the npm author email is the one allowed exception. Guard a new private name by editing that private denylist (single source of truth) — never add real names to this public repo or the committed hook.

Pre-push typecheck gate (.githooks/pre-push, same core.hooksPath): bun test transpiles per-file and does NOT typecheck, so type-broken code can ship green on the test suite while the CI typecheck job goes red — and a direct admin push to main bypasses branch protection. This hook closes that hole locally: when a push touches server/typescript/ or client/web/, it runs the same bun run --filter '*' build && … typecheck gate CI runs and blocks the push when it is red (~6s on a clean tree; skipped entirely for non-TS pushes). Bypass in an emergency with git push --no-verify or SKIP_TS_TYPECHECK=1 git push. The Java/C#/Python compile+conformance gates do NOT run on PRs (hosted CI runs them on release tags + manual dispatch only, for cost). Instead, every push to main triggers local-ci.yml on the maintainer's self-hosted runner: affected ports only (via scripts/ci-affected-ports.sh), parallel per-port jobs, each running scripts/ci-local.sh --only <port> --strict-toolchains; a nightly dispatch runs the full matrix. PRs get the leak-scan only — run scripts/ci-local.sh --quick locally before opening one.

Monorepo layout

This repo holds all implementations of the standard, organized by deployment target → language/platform → framework integration:

metaobjects/
├── spec/                          # canonical metamodel docs (target-agnostic)
├── fixtures/conformance/          # cross-language test fixtures
│
├── server/                        # runs on a server
│   ├── typescript/   java/   python/   csharp/
│
└── client/                        # runs on an end-user device
    ├── web/                       # browser (TS-only — the browser is TS-native)
    └── ios/  android/             # future

TypeScript plays two distinct roles, and the layout reflects that:

  • Server-side TS is a peer port to Java/Python/C# at server/typescript/.
  • Universal web client TS at client/web/ is consumed by ALL backends (a Java backend serving React still uses the TS client packages).

Where does a new package go?

  1. Server-side or client-side? → top-level dir.
  2. What language/platform? → second-level dir.
  3. What framework integration? → package name at the third level.

Worked examples: a Drizzle TS-server integration → server/typescript/packages/...; an Angular browser integration → client/web/packages/angular/; future iOS SwiftUI → client/ios/packages/swiftui/.

TS package layout

Server-side (server/typescript/packages/):

  • metadata/ (@metaobjectsdev/metadata) — metamodel loader, types, constants
  • codegen-ts/ (@metaobjectsdev/codegen-ts) — framework-neutral TS codegen engine (entityFile, queriesFile, routesFile, barrel)
  • codegen-ts-react/ (@metaobjectsdev/codegen-ts-react) — React codegen (formFile)
  • codegen-ts-tanstack/ (@metaobjectsdev/codegen-ts-tanstack) — TanStack codegen (tanstackQuery, tanstackGrid, tanstackGridHook)
  • runtime-ts/ (@metaobjectsdev/runtime-ts) — Node-side runtime (Kysely, Drizzle, Fastify helpers)
  • migrate-ts/ (@metaobjectsdev/migrate-ts) — migration tooling
  • sdk/ (@metaobjectsdev/sdk) — workspace memory, path helpers
  • cli/ (@metaobjectsdev/cli, binary meta) — CLI commands: init, gen, migrate

Client-side / universal web (client/web/packages/):

  • runtime-web/ (@metaobjectsdev/runtime-web) — pure framework-agnostic browser core (currency, filter-qs, EntityFetcher contract, GridConfig). Zero React, zero TanStack.
  • react/ (@metaobjectsdev/react) — React runtime: useEntityForm, <CurrencyInput>.
  • tanstack/ (@metaobjectsdev/tanstack) — TanStack runtime: EntityFetcherProvider, <EntityGrid>, default cell renderers.
  • Future: angular/, svelte/, react-native/.

Framework integration: separate codegen and runtime packages

Each framework integration ships as a pair of packages — one for codegen (server-side, runs at meta gen time) and one for runtime (browser-side, runs in the user's app). Mirrors Prisma (prisma + @prisma/client), Apollo (@apollo/codegen-cli + @apollo/client), and Drizzle (drizzle-kit + drizzle-orm).

Integration Codegen Runtime
React @metaobjectsdev/codegen-ts-react @metaobjectsdev/react
TanStack (depends on React) @metaobjectsdev/codegen-ts-tanstack @metaobjectsdev/tanstack

Each codegen package emits imports that target its matching runtime package. Codegen packages live under server/typescript/packages/ because they execute server-side, even though their output targets the browser. Runtime packages live under client/web/packages/ and have zero Node-only deps.

Two disjoint dependency trees:

Runtime side (browser):              Codegen side (server):

  @metaobjectsdev/runtime-web ←┐         @metaobjectsdev/codegen-ts ←┐
        ↑                    \              ↑                   \
        └── @metaobjectsdev/react ┐             ├── @metaobjectsdev/codegen-ts-react
                ↑               \            └── @metaobjectsdev/codegen-ts-tanstack
                └── @metaobjectsdev/tanstack

Future framework integrations (Angular, Svelte, React Native) follow the same two-package pattern.

A user's metaobjects.config.ts:

import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack";

export default defineConfig({
  generators: [entityFile(), queriesFile(), routesFile(), formFile(), tanstackQuery(), tanstackGrid(), barrel()],
});

A consumer's React component:

import { formatCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput, useEntityForm } from "@metaobjectsdev/react";
import { EntityFetcherProvider, EntityGrid } from "@metaobjectsdev/tanstack";

Other conventions

  • TS runtime: Bun-first for development (zero-config TS, native test runner). Node-compatible for distribution; users install via npm/pnpm/bun without lock-in to Bun's runtime.
  • Module system: ESM only. No CommonJS, no transpile step required.
  • Storage format: JSON files in metaobjects/meta.<concept>.json at project root. .metaobjects/.gen-state/ (gitignored) holds the codegen merge base.
  • Codegen substrate: ts-poet for greenfield emit, ts-morph for in-place edits, Biome for format pass, git merge-file --diff3 for hand-edit-preserving regen.
  • Runtime substrate: Kysely for TS (user-provided connection, async-only).
  • Migration substrate: Postgres + SQLite for TS v0.3.

Explicitly out of scope

  • A custom DSL. Plain typed metadata only — Wasp's seven-year DSL-tax is the cautionary tale.
  • A spec-driven workflow like Kiro / Spec Kit. Humans don't author rich specs; Claude proposes metadata, humans review.
  • A proprietary runtime. All generated code runs without MetaObjects installed; runtime libraries are normal language-native packages.
  • A prompt-to-app builder (not Lovable, Bolt, or v0). MetaObjects generates entity-shaped boilerplate; users hand-write the interesting business logic.
  • Replacing CLAUDE.md, cursor rules, or other prompt-engineering surfaces — MetaObjects complements them.
  • An LLM provider. The MCP integration is model-agnostic.
  • An AI agent platform. (Codegen Inc. died Jan 2026 trying that.)

Working with Claude on this project

  • For new features or non-trivial changes, prefer the brainstorming → plan → implementation flow. Don't jump to implementation without a plan.
  • Entity records are prescriptive (drive codegen + runtime). The other record types (decision, principle, convention, glossary, failure) are descriptive (supporting context for reasoning).
  • Confidence and provenance are first-class on memory records. Bias toward under-flagging on drift checks (false-positive rate >15% is a kill criterion).
  • Templates are user-owned plain TS. Anything inside a generated file is fair game to hand-edit; three-way merge preserves it.
  • TDD discipline throughout implementation.
  • Cross-language conformance fixtures live at fixtures/conformance/. Adding new metamodel behavior means adding a conformance fixture so every language port (TS, Java, Python, C#) automatically verifies it. See spec/conformance-tests.md for the fixture format and canonical serializer contract.

File organization

Default convention: one file per domain concept under metaobjects/. Multiple objects per file when they share a domain. Projections (source.dbView) live inline with their base entity.

project-root/
├── metaobjects/                       # VISIBLE — entity declarations
│   ├── meta.common.json               # shared abstracts (BaseEntity)
│   ├── meta.commerce.json             # Program, Purchase, ProgramSummary
│   ├── meta.users.json                # Subscriber
│   └── meta.content.json             # Video, Week, Workout, Exercise
├── .metaobjects/                      # HIDDEN — tool state
│   ├── config.json                    # static project state
│   └── .gen-state/                    # codegen merge base (gitignored)
└── metaobjects.config.ts              # runtime config

File-naming: meta.<concept>.json. Each file declares its package:

{ "metadata.root": {
    "package": "myapp::commerce",
    "children": [
      { "object.entity": { "name": "Program", ... }},
      { "object.entity": { "name": "Purchase", ... }}
    ]
}}

Optional layered overlay pattern (for larger projects with team-level concern boundaries):

metaobjects/
├── meta.user.json                     # STRUCTURAL (always present)
├── meta.user.ui.json                  # UI overlay (views, layouts)
└── meta.user.db.json                  # DB overlay (sources, dbColumns)

All three share the same package and object name. The Loader merges them. Use only when team-level concerns justify the file proliferation. Default to single-file-per-domain.

BaseEntity pattern: shared abstract bases live in meta.common.json. Concrete entities use extends: "BaseEntity" to inherit id + createdAt without redeclaring.

URL prefix policy

apiPrefix in metaobjects.config.ts flows through codegen to both route registration and hook fetch URLs.

export default defineConfig({
  apiPrefix: "/api",        // generated routes mount under /api; hooks fetch /api/<entity>
});

Codegen architecture (Vite-style plugins)

@metaobjectsdev/codegen-ts follows a Vite-style plugin model.

Core interface — every emitter implements Generator:

import type { Generator, GenContext, EmittedFile } from "@metaobjectsdev/codegen-ts";

interface Generator {
  name: string;                          // kebab-case; surfaces in diagnostics
  filter?: (entity: MetaData) => boolean;
  generate(ctx: GenContext): EmittedFile[] | Promise<EmittedFile[]>;
}

Helpers perEntity() and oncePerRun() cover the common "file per entity" / "one-shot" cases.

Built-in factories: entityFile, queriesFile, routesFile, formFile, barrel. Per ADR-0034 (scaffold-and-own), meta init copies the entityFile/queriesFile/routesFile/barrel reference templates into the consumer repo at codegen/generators/*.ts and the scaffolded config imports those owned local copies; importing them from @metaobjectsdev/codegen-ts/generators still works but is deprecated (removal in a future major).

User wiring (metaobjects.config.ts):

import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";

export default defineConfig({
  outDir: "packages/database/src/generated",
  dialect: "sqlite",
  apiPrefix: "/api",
  generators: [entityFile(), queriesFile(), routesFile(), barrel()],
});

Per-target output directories. Each generator can write to its own directory/package via a named targets registry + per-generator target, so generated code lands with its runtime concern (model → database package, routes → API app, hooks/forms/grids → web app). A target is { outDir, importBase?, outputLayout?, dbImport? }; the top-level outDir is the implicit default (entity-module) target. Cross-target references to the entity module are emitted as extension-less importBase package paths (@acme/database/generated/acme/commerce/Program) while same-target references stay relative; the entity-module target must set importBase when any generator routes elsewhere. With no targets, output is byte-identical to a single-outDir project. Full config reference: @metaobjectsdev/cli README, "Multiple output targets".

Two config files, by design:

  • metaobjects.config.ts (TypeScript) — generator wiring, type-checked.
  • .metaobjects/config.json (JSON) — static project state. Parseable by non-TS tooling (CI scripts, etc.).

The runner runGen() (1) loads metadata, (2) resolves targets + derives the entity-module target, (3) precomputes shared render state once, (4) runs each generator with a per-target RenderContext, (5) errors on duplicate full output paths, unknown target, missing importBase for cross-target imports, or any generator throw, (6) writes each file under its target's outDir (overwriting only files carrying the @generated header; refusing others).

Filter syntax + sort (Project D)

Generated CRUD endpoints support a typed, metadata-driven filter + sort layer:

URL grammar (bracketed qs): ?filter[field][op]=value&sort=field:asc|desc&limit=N&offset=N. Bare value is sugar for eq.

Eight operators, gated by field subtype:

  • eq, ne, gt, gte, lt, lte, in, like, isNull
  • Strings get eq/ne/in/like/isNull; numbers + dates get eq/ne/gt/gte/lt/lte/in/isNull; booleans get eq/isNull.

Authoring: mark fields with @filterable: true. @sortable inherits from @filterable by default.

Generated artifacts per entity:

  • <Entity>FilterAllowlist — server-side allowlist
  • <Entity>SortAllowlist — server-side sort allowlist
  • <Entity>Filter — client TS filter type

Client usage:

import { useSubscribers } from "./generated/Subscriber.hooks";

const { data } = useSubscribers({
  email: { like: "%@example.com" },
  subscribed: true,
  sort: "createdAt:desc",
  limit: 25,
});

Server validation: every request validated against the allowlist. Unknown field / disallowed op / invalid value → 400 with structured error code.

Architecture: parseFilterParams (in @metaobjectsdev/runtime-ts/drizzle-fastify) translates parsed qs into a Drizzle expression tree. buildFilterQs (in @metaobjectsdev/runtime-web and @metaobjectsdev/tanstack) serializes a typed filter object back to a bracketed qs URL.

Source-aware entities + projections (Project E)

source is a top-level metadata type describing where an object's data lives. Subtypes: dbTable (writable, default) and dbView (read-only).

Authoring a projection:

{ "object.entity": {
    "name": "ProgramSummary",
    "extends": "Program",
    "children": [
      { "source.dbView": { "@name": "v_program_summary" }},
      { "field.int": { "name": "weekCount", "children": [
        { "origin.aggregate": {
            "@agg": "count", "@of": "Week.id", "@via": "Program.weeks" }}
      ]}},
      { "identity.primary": { "@fields": ["id"] }}
    ]
}}

origin subtypes: passthrough (cross-entity field reference) and aggregate (count/sum/avg/min/max). Origins drive view DDL.

Source-aware codegen dispatch:

  • Projection (dbView only) → read-only Zod, read-only routes, read-only hooks.
  • Write-through (dbTable + dbView) → mutations target table, queries target view.
  • Vanilla entity → standard behavior.

columnNamingStrategy in metaobjects.config.ts: snake_case (default) | literal | kebab-case.

Currency (Project F)

field.currency declares "this column stores money as integer minor units."

{ "field.currency": {
    "name": "priceCents",
    "@currency": "USD",
    "children": [
      { "view.currency": { "@locale": "en-US" }}
    ]
}}

Storage: integer minor units (cents for USD, yen for JPY). Wire format is unchanged from long. Server never formats currency; all formatting is client-side via Intl.NumberFormat.

Runtime imports (browser-safe sub-paths):

import { formatCurrency, parseCurrency } from "@metaobjectsdev/runtime-web";
import { CurrencyInput } from "@metaobjectsdev/react";

Cross-language ports must preserve the wire contract: integer minor-unit storage, @currency (ISO 4217), @locale (BCP 47) attrs.

TanStack codegen + metadata-driven grids (Project B)

@metaobjectsdev/codegen-ts-tanstack ships two generators:

  • tanstackQuery() — emits <Entity>.hooks.ts per entity (5 hooks: useEntity, useEntities, useCreate/Update/Delete<Entity>).
  • tanstackGrid() — emits <Entity>.columns.tsx per entity with a layout.dataGrid child.

Grid metadata:

{ "layout.dataGrid": {
    "name": "default",
    "@columns": ["email", "firstName", "subscribed", "createdAt"],
    "@defaultSortField": "createdAt",
    "@defaultSortOrder": "desc",
    "@pageSize": 25
}}

Runtime surface (@metaobjectsdev/runtime-web and @metaobjectsdev/tanstack):

  • <EntityFetcherProvider value={fetcher}> — supplies the fetcher function.
  • <CellRendererProvider value={{...}}> — renderer overrides keyed by view subtype.
  • <EntityGrid columns={...} grid={...} data={...} /> — opinionated TanStack Table component.

Per-entity opt-out: @emitTanstack: false on an entity skips both hook and column files.

Cross-language porting

Preserve the following contracts exactly across all language ports:

Metamodel subtype vocabularies (must be identical across languages): the registry-conformance gate (fixtures/registry-conformance/) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to expected-registry.json. All five ports (TS / C# / Java / Kotlin / Python) are live + green (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See fixtures/registry-conformance/README.md.

  • Filter operators: eq, ne, gt, gte, lt, lte, in, like, isNull
  • Object subtypes: entity (owns data: own identity, writable sources, lifecycle), value (pure shape: NO identity, NO source, ever; constructed — by caller/assembly/embedding — never populated; may extends entity fields for shape), projection (derived read-only representation: fields extends-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only @kinds; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying origin.* is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable @kind (read-only kinds only in read role). See ADR-0028. (FR-024 Phase E — object.projection/value are registered in expected-registry.json and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, @via inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The B4b entity-primary-source-readonly cutover [the "writable @kind" clause above — ERR_ENTITY_PRIMARY_SOURCE_READONLY] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections; api-docs label object.projection units as projection and document their generated <Name>Dto) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.)
  • Source subtypes: rdb (paradigm; ADR-0007). The pre-v2 dbTable/dbView subtypes are RETIRED — source.rdb + @kind: table|view|materializedView|storedProc|tableFunction is the form, with read-only-ness derived from @kind. Multi-source via @role (exactly one primary per object). Source physical name = @table (NOT @name); field physical name = @column (renamed from @dbColumn). Referential actions on relationships: @onDelete / @onUpdate.
  • Origin subtypes: passthrough, aggregate
  • Relationship subtypes: association, aggregation, composition. Cardinality via @cardinality: one|many; target via @objectRef. M:N (FR-018) slim vocabulary: @cardinality: "many" + @objectRef (target) + @through (the junction/through entity — a third entity that MUST declare two identity.reference children, one per FK side). The relationship's FK fields are derived from those references (the identity.reference SSOT for FK direction), never restated. @sourceRefField (optional) disambiguates a directed self-join by naming the source-side FK field on the junction (the other reference is the target side). @symmetric (optional boolean) marks an undirected self-join (union-on-read) — valid only when @objectRef == the declaring entity, and mutually exclusive with @sourceRefField. The pre-FR-018 @joinEntity/@joinFields attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → ERR_BAD_ATTR_VALUE; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → ERR_INVALID_RELATIONSHIP.
  • Index subtypes: index.lookup (non-unique retrieval index; uniqueness is encoded in the type: identity.secondary = unique alternate key, index.lookup = non-unique; @unique is REMOVED from identity.secondaryERR_UNKNOWN_ATTR on any legacy @unique). RDB-physical escapes @using/@expr/@where/@orders are registered by the db provider on both identity.secondary and index.lookup. index.fulltext / index.vector / index.spatial are reserved on the subtype axis — documented, NOT registered (YAGNI + 1.0 vocab freeze). See ADR-0040.
  • Layout subtypes: dataGrid
  • API subtypes: api.base / api.operational (request/response surface; subtype axis = interaction model, NEVER protocol — protocol lives in binding.* per operation: rest now, messaging/grpc reserved). Children: operation.query (outputRef → object.projection) / operation.command (inputRef → object.value, may also outputRef). Derived CRUD (FR-008/009) stays the zero-config default; declared api extends it. Org-tier modeling (application/service/network/deployment) stays OUT of core — provider SPI, FQN references. See ADR-0030. (FR-024 declared-API — planned; not yet in expected-registry.json; the remaining third of FR-024 after the projection/value taxonomy + validation parity.)
  • Currency attrs: @currency (ISO 4217), @locale (BCP 47)
  • Schema attrs: @schema on source.rdb (DB schema name; Postgres default public, SQLite rejects non-default values)
  • Storage attrs: @storage on field.object (with @objectRef) — values flattened / jsonb / subdocument. Unifies "owned types" (flattened storage) and "structured JSONB" (jsonb storage). Defaults to single-jsonb-column when absent (back-compat).
  • Enum: field.enum is a first-class field subtype (peer of currency), string-backed. Required @values string-array attr (member symbols). Members must be a non-empty set, each matching ^[A-Za-z_][A-Za-z0-9_]*$, no duplicates — every port's loader enforces this (own-only) emitting ERR_BAD_ATTR_VALUE (missing @valuesERR_MISSING_REQUIRED_ATTR). Reuse via abstract field.enum + extends. Codegen: TS union + z.enum, C# enum + EF HasConversion<string>(), DB varchar + CHECK. Int-backed values, display labels, and native PG enum are deferred (see docs/superpowers/specs/2026-05-23-enum-datatype-design.md).
  • Documentation common attrs (any node): description, title, notes, deprecated, replacedBy, seeAlso, aliases. Registered via the cross-language commonAttrs registry hook (registerCommonAttrs / RegisterCommonAttrs / register_common_attrs / Java MetaDataRegistry.registerCommonAttribute — wired in all four ports). notes is the internal-only rationale slot — never emitted to user-facing doc-gen (JSDoc / XML-doc / Postgres COMMENT ON / Mermaid prose). TS doc-gen ships all three tiers; C# ships XML-doc + COMMENT ON. See docs/superpowers/specs/2026-05-24-documentation-provider-design.md.

Wire format:

  • Currency: integer minor units on the wire always. Float arithmetic for money is forbidden.
  • Pagination: ?limit=N&offset=N — identical across all generated endpoints.
  • Runtime return types: a port's runtime ObjectManager returns native in-process language types (field.decimalBigDecimal/decimal/Decimal, TS string; temporal→native; jsonb→native map). Wire canonicalization (the bullets above + normalization.md) is applied at the serialization boundary, never inside the runtime query path. See ADR-0019.

Grammar:

  • Dotted-path syntax for @via: "Program.weeks" or "Program.weeks.workouts".
  • Dotted-path syntax for @of: "Week.id".
  • extends may target a nested child to ANY depth: Customer.id, cross-package acme::sales::Customer.id, triple-nest Customer.priceCents.display (object → field → view). Addressing model: a package qualifies the ROOT-level node only; each subsequent dotted segment traverses CHILD NAMES (nested children carry BARE names — packages never fold onto non-root nodes). Intermediate segments resolve by unique name (cross-type collision = unresolved); the FINAL segment is type-scoped to the referrer (a field resolves fields; an identity identities; a view views). extends is THE inheritance mechanism; origin.* never inherits. @via lives on origin.* only and may be omitted ONLY when exactly one single-hop relationship leads from the base entity to the from/of entity (multi-hop always explicit). See ADR-0029.
  • Package segments: :: separator — acme::common::id.
  • Canonical JSON: reserved structural keywords are bare (name/package/extends/abstract/overlay/isArray/children/value); inline attributes are @-prefixed. @-prefixing a reserved word (e.g. @isArray) is invalid (ERR_RESERVED_ATTR). YAML authoring is sigil-free — bare attrs; the desugar re-adds @ when lowering to canonical JSON; canonical JSON is the on-disk interchange (YAML is the universal authoring front-end across ports). See ADR-0006.

Loader pipeline:

  • extends: resolution happens after all files are loaded (deferred, not eager).
  • Overlay/merge: same package + same object name across multiple files → merged. Last-writer-wins on attr conflicts; structural children accumulate.
  • Default scan path: metaobjects/**/*.json (recursive).

D1 is TS-only. Cloudflare D1 is a peer of sqlite/postgres in TS's dialect vocabulary. It is SQLite at the SQL level — Java/Python/C# don't have an analogue (Cloudflare Workers run JS). When adding cross-language vocabulary, D1 doesn't constrain anything: its uniqueness is wrangler-CLI transport + Wrangler-native file layout (migrations/<seq>_<slug>.sql), both of which are TS-only concerns.

Constants discipline:

  • TS: named constants in packages/metadata/src/constants.ts. Never inline metamodel strings as literals in code.
  • New type or subtype names: add to TS constants first; add the parallel in other language implementations.

Design judgment (durable principles)

These are the load-bearing principles that have emerged through implementation. Apply them every time.

  • Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037) — driven by semantic behavior, not surface storage. Don't ask "is X a string/number/date?"; ask "what does X do?" Ordered test: (0) derivable from existing subtype + attrs (isArray/@maxLength) + structure? → derive in codegen, add nothing; (1) physical-only (native type/meaning unchanged)? → the @dbColumnType escape hatch, not first-class vocab; (2) logical — does X have its own native type, behavior, or attributes (a thing that owns custom logic)? → subtype (the extension point: field.uuid, field.uri, field.inet); a structural variant within a subtype (changes generated shape, shares native type)? → @kind (the one chartered structural-variant axis: source table/view, uri url/urn — never a catch-all, never on a plain string); otherwise X just modifies/validates/configures an existing type → attribute (boolean flag @localTime; validation @stringFormat email/hostname; config @maxLength). The "string formats" set splits by behavior: url/uri→field.uri, ip→field.inet (native types + behavior), only email/hostname→@stringFormat (plain validated strings); uuid is already field.uuid. Self-documentation over economy; no same-name overloads (hence @stringFormat, not a third @format). Consult it every time. See ADR-0037.

  • Pattern-derivable from metadata = codegen, never hand-code. This is the metaobjects raison d'être. If you find yourself proposing that users hand-write something the metadata fully describes (FK references, basic CRUD, validator chains, type-safe finders, relations() blocks), stop. Codegen it. The only exception is what metadata genuinely cannot express (custom SQL views, regex patterns from outside metadata, business logic). When in doubt, generate.

  • Study reference implementations for subtle pipeline behavior; don't re-derive from spec. For complex orchestration (loader, parser, super resolution, overlay/override merging, registry lifecycle), the spec describes WHAT but the implementation captures HOW — including edge cases and error handling. When porting, read existing implementations first. First-principles reasoning produces subtly wrong behavior that breaks cross-language interop.

  • "Validated by spike" ≠ "right design". A spike proves a technique works under a specific test. It doesn't prove it is the best production choice. Always ask "what's the UX cost?" alongside "does this work?"

  • NEVER call own*() accessors by default — resolving/effective is the default; own breaks extends (ADR-0039). extends is a super-reference, not a flatten: inherited attrs/children live on the parent, reachable only via the resolving accessors (attr()/children()/getMetaAttr(name) / Python attrs().get()). Reading a field/node's effective property (isArray, subType, @maxLength, @precision, @default, @column, @objectRef, @storage, …) or iterating its member set through an own-only accessor (ownAttr/ownChildren/ownFields/field.isArray native flag / Java ,false) silently drops everything inherited via extends — corrupting codegen and runtime. The one legitimate own*() use: codegen emitting a generated subclass, iterating ownFields() so inherited members aren't re-emitted (the generated base already has them). The metamodel-internal siblings (own-mode canonical serializer, overlay-merge, super-resolution walks) use the same "emit only the declared-here layer" principle. The one attr deliberately own-only is @dbColumnType (physical, never inherited). Every own*() call must carry a comment naming its sanctioned case; any other own read is a bug. Watch the naming inversion: Python attr() is OWN, TS attr() resolves. See ADR-0039.

  • Bind metadata→native types at build time, never runtime reflection. Resolving an object's native class/module from its FQN must happen in generated code (static imports for data-oriented ports; a domain-sliced, FQN-keyed registry for OO ports), not via Class.forName/Type.GetType/importlib — runtime reflection is impossible in TS and breaks under GraalVM native-image / .NET AOT. See ADR-0001.

  • Record significant cross-cutting decisions as ADRs. Durable, cross-language/cross-feature architectural contracts live in spec/decisions/ (Nygard format). Consult them before changing a cross-language contract; add a new ADR when you make one. Feature-level decisions stay in the FR spec; this file holds only the one-line rule + the pointer.

  • Strict metadata provenance — never invent a metamodel attribute (ADR-0023). Every type/subtype/attribute the loader accepts in THIS repo must come from a registered metamodel provider; the library boots strict + seals the registry after the defined-provider bootstrap, so any undeclared attr is ERR_UNKNOWN_ATTR and any post-bootstrap registration is ERR_REGISTRY_SEALED (codegen "making up" an attr is a hard build failure). Before adding ANY new metamodel attribute: (1) prove it cannot be computed from existing metadata — if a generator can derive it (FK refs, column types, validator chains, naming), derive it, don't add an attr; (2) get explicit human agreement and write the can't-be-computed justification into the FR spec / ADR; (3) add it to a registered provider AND a registry-conformance fixture so all five ports gate it. Downstream apps may add their own providers or loosen strictness — but the library's tests never permit an unregistered attr. See ADR-0023. The legitimate escape hatch for arbitrary author-supplied properties is the registered attr.properties bag (exempt from the strict-attr check), not a new first-class attr.

Coding discipline (TS)

  • Named constants for metamodel strings — always. Type names, subtype names, reserved JSON keys, special attribute names, structural separators, and wildcards live in packages/metadata/src/constants.ts — import and use them. Gets you compile-time typo safety.
  • Use as const arrays + type unions for closed sets (e.g., FIELD_SUBTYPES = [...] as const; type FieldSubType = (typeof FIELD_SUBTYPES)[number]).
  • String literals OK only for: error message text, instance/entity names that are user data, and test data values that aren't metamodel-level concepts.
  • No backwards-compat hacks.
  • No any escape hatches. Use unknown and narrow.

Useful commands

meta init                             # scaffold metaobjects/, .metaobjects/, codegen/generators/, metaobjects.config.ts, .gitignore
meta gen [<entity>...]                # codegen: render templates → format → three-way merge → write
meta gen --dry-run                    # preview without writing
meta migrate                          # diff metadata vs DB schema; emit migration SQL
meta migrate --dry-run                # preview without writing migration file

The above is the Node meta (schema + TS codegen). Each non-TS port runs codegen through its own build tool — dotnet meta gen/verify (C#), mvn metaobjects:generate/:verify (Java/Kotlin), metaobjects gen/verify (Python). Schema (migrate, verify --db) is Node-meta-only. Full matrix + rationale: docs/features/cli.md (locked CLI architecture, ADR-0015).

Running tests

The Bun workspace root is the repository root (/package.json), which globs server/typescript/packages/* and client/web/packages/*. Java/Python/C# live outside the JS workspace (not globbed). Run bun install once at the repo root. Run bun test scopedcd server/typescript && bun test for the server suite (this also picks up server/typescript/bunfig.toml's test preload), and per-package for client/web. Never run a bare bun test at the repo root: it walks java/, python/, csharp/, and fixtures/ looking for test files, turning a fast per-package run into many minutes.

bun install                                        # once, at the repo root
cd server/typescript && bun test                   # server suite (per-package; a bare run over the whole server suite now exceeds 5 min — scope it)
cd client/web/packages/<pkg> && bun test           # a single client package
bun run --filter '*' typecheck                     # whole workspace, from repo root
bun run --filter '*' build                         # whole workspace, from repo root

How to contribute

PRs welcome. When contributing:

  • Follow the TDD discipline: write tests first, then implementation.
  • Use named constants for all metamodel strings — never inline "field", "object", etc.
  • No any — use unknown and narrow.
  • Run bun test in the relevant package before opening a PR. All tests must pass.
  • For cross-language changes, ensure the wire format and vocabulary are preserved exactly.
  • Look at existing generator implementations before adding a new one — the pattern is intentionally consistent.

For significant new features or architectural changes, open an issue first to discuss the approach.

Publishing to npm: see docs/RELEASING.md — the procedure (RC → smoke-test → promote) plus the non-obvious gotchas (publish with bun, regen the lockfile after every version bump, runtime imports must be dependencies, verify a real external install in npm and pnpm).

Roadmap pointer

See spec/roadmap.md for current and planned library work. (Consumer-adoption validation and the application-level prompt-pillar consolidation are pursued in adopter projects, not tracked in this repo.)

Open questions

  • [TECHNICAL] Field-type → Drizzle-column-type mapping table (needed for complete TS codegen coverage).
  • [TECHNICAL] ObjectManagerDB further modernization. FR-003 Plan 4 (2026-05-27) closed the three engine-debt anti-patterns. The Spring Boot 3 starter + OMDB autoconfiguration + virtual-thread audit shipped 2026-05-30 (metaobjects-spring-boot-starter). jOOQ migration is a closed non-goal: jOOQ's OSS edition excludes Oracle/SQL Server/DB2 (commercial license required), which would paywall OMDB's commercial-DB drivers in a public OSS project, and jOOQ generates code from a schema — the inverse of MetaObjects' metadata-is-the-spine model.
  • [TECHNICAL] Payload origin.* resolution in codegen-spring (Day-1 deferral — see server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md). Kotlin's KotlinPayloadGenerator is the cross-port reference.
  • [TECHNICAL] WARN envelope-shape assertion on cross-port expected-warnings.json (closed 2026-05-27 — runners now assert envelope shape on warnings; legacy string-list path retired).