Skip to content

Latest commit

 

History

History
1024 lines (841 loc) · 49.5 KB

typescript.md

File metadata and controls

1024 lines (841 loc) · 49.5 KB

TypeScript port

The reference implementation. Published to npm at 1.1.4 as 14 @metaobjectsdev/* packages on the latest tag. Targets Node-compatible runtimes: the generated code and the CLI run on stock Node (via tsx or after a tsc build), and Bun is simply the fastest dev workflow in this repo.

Regeneration and hand edits

Three-way merge is a TypeScript feature: on the machine that generated a file, meta gen merges your edits into regenerated output. On a fresh clone or in CI the snapshot bodies are not present, so an edited file is refused by name rather than merged (commit .metaobjects/.gen-state/.hashes.json). The other ports do not merge at all.

Keep your ORM (Prisma, TypeORM, Drizzle you already have)

Adopting the TypeScript port does not mean replacing your data layer. The entity generator (entityFile()) writes a Drizzle table binding only for a target that has server runtime bindings, which is the default. Point it at a contract-only target (runtime: false) and it writes Zod schemas and inferred TypeScript types, with no drizzle-orm and no @metaobjectsdev/runtime-ts import, for entities, value objects and projections alike. Your ORM's models stay hand-written beside them, except Prisma's (below).

export default defineConfig({
  outDir: "src/generated",
  dialect: "postgres",
  targets: { contract: { outDir: "src/contract", runtime: false } },
  generators: [entityFile({ target: "contract" })],   // Zod + types only
});

Prisma. Set orm: "prisma", and the model also writes your schema.prisma, so each field is declared once. Every target defaults to runtime: false, the generators that write Drizzle code (queries, routes, routes-hono, callable) are refused, and prismaSchemaFile() writes the schema for Postgres, MySQL or SQLite, plus a schema.checks.sql holding the CHECK constraints a Prisma schema cannot declare. Prisma creates the tables. meta verify --codegen fails on a hand-edited or stale schema, and meta verify --db fails when the database Prisma pushed differs from the model. What a Prisma schema cannot express (a view, an expression or partial index, a CHECK on SQLite) is refused with an ERR_PRISMA_* code, not dropped. See the Prisma recipe.

export default defineConfig({
  orm: "prisma",
  dialect: "postgres",
  outDir: "src/generated",
  targets: { prisma: { outDir: "prisma" } },
  generators: ["entity", prismaSchemaFile({ target: "prisma" })],
});

Already on drizzle-kit and want to keep its migrations? Re-export the generated tables from your own schema file: drizzle-kit migrations.

A model of only value objects and sourceless projections needs no dialect. A sourceless object generates its type and nothing else: no table, no migration, no routes. See Own your codegen for ejecting a generator, and the fit assessment for deciding which pieces to adopt.

Install

The README's quick-start — npm i @metaobjectsdev/cli — installs just the CLI, which is enough to run meta init and meta gen. For a runnable app, install the runtime packages too (the generated code imports @metaobjectsdev/metadata and @metaobjectsdev/runtime-ts directly, so declare them — don't rely on them being hoisted transitively, which breaks under pnpm's strict node_modules):

npm install --save-dev @metaobjectsdev/cli @metaobjectsdev/codegen-ts
npm install            @metaobjectsdev/metadata @metaobjectsdev/runtime-ts

For React + TanStack codegen / runtime, add:

npm install --save-dev @metaobjectsdev/codegen-ts-react @metaobjectsdev/codegen-ts-tanstack
npm install            @metaobjectsdev/react @metaobjectsdev/tanstack @metaobjectsdev/runtime-web

@metaobjectsdev/codegen-ts declares drizzle-orm and zod as non-optional peer dependencies, so npm installs both automatically. The default Fastify routes generator (routesFile()) additionally needs fastify at runtime — it is an optional peer of @metaobjectsdev/runtime-ts (so it is not auto-installed); add it explicitly:

npm install fastify

Nothing above installs a TypeScript compiler. If you intend to run the npx tsc that every meta gen hint prints, add one:

npm install --save-dev typescript

Configure

Two config files, by design:

  • metaobjects.config.ts — TypeScript, type-checked, generator wiring.
  • .metaobjects/config.json — JSON, static project state, parseable by non-TS tooling.

meta init scaffolds both, the metaobjects/ source directory, an EMPTY codegen/generators/ with generators: [] (nothing is generated until you choose — ADR-0034 Amendment 2), and the .gitignore entries for .metaobjects/.gen-state/. Choose from the catalog with meta gen --list --probe, which reports how many files each generator would emit for your model and prints the import line that runs it as shipped. What to install and the config keys a generator reads are in the structured catalog only: meta gen --list --format json carries install.dev, install.runtime and configKeys on each row, and the text listing prints neither. Import the ones you want from their package: nothing is copied, and an upgrade is how their fixes arrive. To change what one emits, meta eject <name> copies its reference template into codegen/generators/ (ADR-0034 scaffold-and-own — yours to edit) and prints the local import to swap in; meta gen then runs that copy, not the package's. The copy comes with the code that writes the output: the template modules the generator calls land in codegen/templates/, and what stays in the package is the engine (the generator base, the model walks, the naming and type-mapping tables), which the copies import from @metaobjectsdev/codegen-ts/engine. Every stock generator ejects this way except the template primitive, which has no emit logic of its own, and docs-site, whose theme meta eject docs-site copies instead (what the copy holds).

A copy that never changed is a cost: it stays at the release it was copied from and misses every later fix. meta gen prints one line naming the owned copies that are still the shipped reference, unchanged, and meta eject --undo takes them back: with no names it removes every such copy (and the template modules only it used), and with names it removes those, refusing the whole call, and removing nothing, if a named copy differs (--force removes it anyway). It never edits metaobjects.config.ts; it prints the import to swap back to the package's. There is no meta doctor: meta eject --list is the report. A copy you changed stays yours.

meta gen loads leniently, so an attribute the registry does not know (a typo such as @sensitve) is a warning and the run goes on. meta gen --strict makes it an error, as meta verify does: it exits 1 and writes nothing.

Documentation is generated the same way, from the same list: there is no docs command (ADR-0034 Amendment 9). docsFile(), apiDocsFile(), requirementsFile(), agentDocsFile(), metamodelDocsFile() and docsSiteFile() write to the target named docs, which the config must declare (the documentation generators). A 1.0 project that ran meta docs follows docs-are-generators.md, "TypeScript".

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
// Run as shipped. Eject one (`meta eject routes`) only to change what it emits.
import { entityFile, queriesFile, routesFile, barrel, docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators";

export default defineConfig({
  outDir: "src/generated",
  dialect: "postgres",                 // "postgres" | "sqlite" | "mysql"  (Cloudflare D1: "sqlite")
  extStyle: "js",                      // ".js"-extensioned imports — nodenext- AND bundler-safe ("none" to opt out)
  apiPrefix: "/api",
  dbImport: "../db",                   // the module routesFile() imports `db` from — yours to write
  targets: { docs: { outDir: "./docs/generated" } },  // where the docs generators write
  generators: [entityFile(), queriesFile(), routesFile(), barrel(), docsFile(), apiDocsFile()],
  // providers: [yourProvider],        // optional — add custom metamodel subtypes/attrs
});

Declarative template-codegen (Mustache)

Owning a generator is one of two authoring paths, and TypeScript has both. A generator can be programmatic — a codegen/generators/*.ts file you ejected or wrote, composing this package's render* functions — or declarative: a Mustache template plus a scope, with no generator code at all.

Reach for a template when the output shape is what you are iterating on, or when you want the same output across languages; reach for a programmatic generator when the logic is gnarly or the run is hot. Tradeoff table: codegen-concepts.md §3.

There is no --template-spec flag on meta gen — deliberately. metaobjects.config.ts already takes generator values, so a template generator is declared there like any other, which is also what keeps it visible to meta verify --codegen (that gate re-runs the config's generator list, so a generator declared anywhere else would have its output reported as drift):

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import { templateGenerator } from "@metaobjectsdev/codegen-ts";
import { entityFile } from "@metaobjectsdev/codegen-ts/generators";

export default defineConfig({
  outDir: "src/generated",
  generators: [
    entityFile(),
    templateGenerator({
      name: "entity-service",              // kebab-case; surfaces in diagnostics
      template: "service/entity-service",  // → templates/service/entity-service.mustache
      scope: "perEntity",                  // "perEntity" | "perPackage" | "perModel"
      outputPattern: "{package}/{Name}Service.ts",
      // format: "text",                   // optional escaper; defaults to "text"
      // target: "api",                    // optional named output target
    }),
  ],
});

Templates resolve through the project's templates/ directory first, then the framework defaults — so template: "service/entity-service" reads <projectRoot>/templates/service/entity-service.mustache. The outputPattern placeholders are {name}, {Name} and {package} (whose :: segments become nested directories); an unknown placeholder throws. Abstract objects are excluded from every scope.

scope and walk are mutually exclusive — supply exactly one. Take a scope when one of the three walks fits; supply your own walk when it does not.

Reusing a spec authored for another port. C# and Python declare the same generators as a JSON template-spec file, because their generator registries are closed and a flag is the only seam they have. That spec is portable — parse it and spread the result into generators:

import { readFileSync } from "node:fs";
import { parseTemplateSpec, templateSpecToGenerators } from "@metaobjectsdev/codegen-ts";

const spec = parseTemplateSpec(JSON.parse(readFileSync("./template-spec.json", "utf8")));

export default defineConfig({
  generators: [entityFile(), ...templateSpecToGenerators(spec)],
});

parseTemplateSpec validates the shape and throws on a bad scope, a missing required string, or an unregistered format. Portability runs one way: TypeScript additionally accepts a target field (named output targets), which the two CLI ports reject — so a spec written here may not run there, while a spec written there always runs here. Keep target out of any spec you intend to share.

The walks, the data dict and the pattern grammar are gated byte-identical across all five ports by the shared fixtures/template-codegen-conformance/ corpus — whose own TS runner drives exactly the parseTemplateSpec → templateSpecToGenerators → generators path shown above. The data dict your template receives is documented in codegen-data-shapes.md.

Custom providers (optional)

If your app needs a metamodel subtype the core doesn't ship (e.g. template.toolcall for LLM tool-use envelopes), declare a MetaDataTypeProvider and pass it through defineConfig({ providers }):

import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
import { yourProvider } from "./codegen/your-provider";

export default defineConfig({
  // … other fields
  providers: [yourProvider],
});

The CLI threads the list into every meta gen / meta verify / meta migrate / meta prompt-snapshot invocation. At the SDK level the same list is accepted directly:

import { loadMemory } from "@metaobjectsdev/sdk";

const root = await loadMemory("./", { providers: [yourProvider] });

Default composition is [...coreProviders, ...callerProviders]. Pass { replaceDefaults: true } to skip the core bundle entirely (rare — usually only useful in tests). forgeTypesProvider left the default set at the 1.0 cut: it is TypeScript-only vocabulary no other port registers, so pass it explicitly if you want it. See ../features/extending-with-providers.md for the full contract and ../recipes/extending-metaobjects-with-providers.md for an end-to-end walkthrough.

Drop your metadata under metaobjects/:

// metaobjects/meta.blog.json
{ "metadata.root": {
    "package": "acme::blog",
    "children": [
      { "object.entity": {
        "name": "Author",
        "children": [
          { "source.rdb": { "@table": "authors" } },
          { "field.long":   { "name": "id" } },
          { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
          { "field.string": { "name": "bio", "@maxLength": 2000 } },
          { "identity.primary": { "@fields": "id", "@generation": "increment" } }
        ]
      }}
    ]
}}

Generate

meta gen                  # codegen → format → 3-way merge → write
meta gen --dry-run        # preview without writing
meta gen Author Post      # scope to named entities
meta gen --generators docs,api-docs   # only these declared entries (here: the docs)

meta verify --codegen     # committed output, docs included, vs a fresh regen

meta verify --db <url>    # live DB schema vs metadata

Schema — first migration on a brand-new database

meta migrate diffs your metadata against a committed schema snapshot, not against the live database, so --dialect is always required (it is not auto-detected for the offline diff path even when --db is present). On a brand-new project there's no snapshot yet:

meta migrate --dialect sqlite --slug init
# meta: migrate: no schema snapshot at .../.schema.sqlite.json. For a new project
#   run `meta migrate --from-db --db <url> --dialect sqlite --slug init --apply`
#   to create your tables, or `meta migrate baseline --from-db --db <url>` to
#   adopt an existing database.

The CLI points you at the right command (above). Avoid the meta migrate baseline subcommand without --from-db on a database that doesn't exist yet: an offline baseline derives the "existing" snapshot from your metadata, recording your entities' target shape as already applied. No table is ever created, and every subsequent meta migrate reports no changes (exit 0) — a silent failure that only surfaces later, at the API layer, as SQLITE_ERROR: no such table. (meta migrate baseline now refuses when it can see the target --db is empty, but the safe path for a new project is simply:)

npx meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply

This diffs metadata against what's actually in dev.sqlite (nothing), emits CREATE TABLE "authors" (...), and applies it. Reserve meta migrate baseline --from-db --db <url> for the other case — adopting metadata onto a database that already has the schema (e.g. a pre-existing non-MetaObjects setup) — where you want to record current state without emitting any DDL.

Once a real schema exists, everyday changes go through the incremental flow:

meta migrate --dialect sqlite --slug add-user-shipping           # diff vs committed snapshot; writes up/down.sql
meta migrate --dialect sqlite --slug add-user-shipping --apply   # ...and apply it
meta migrate --dialect d1                                        # Cloudflare D1 dialect

Typecheck the generated code

One prerequisite first, because the printed hint doesn't name it and a cold run hits it:

npm install --save-dev typescript      # `npx tsc` without it hits npm's guard
                                       # package ("This is not the tsc command
                                       # you are looking for") — nothing MetaObjects
                                       # installs brings a compiler.

The routesFile() generator emits import { db } from "../db.js" — the module named by dbImport in metaobjects.config.ts. MetaObjects does not scaffold that module, because writing it means picking a driver: write src/db.ts first (the "Use" section below shows a libsql one), or npx tsc reports TS2307 on the generated routes. The generated queries take db as a parameter and need nothing.

With that, npx tsc (the hint every meta gen/meta migrate prints) works out of the box with a stock tsc --init tsconfig: the generated code emits .js-extensioned relative imports (import { Author } from "./Author.js"), which resolve correctly under both Node's native ESM resolution ("module": "nodenext", the tsc --init default) and bundler-style resolution ("moduleResolution": "bundler", Vite/esbuild/tsx). The one project setting to check: package.json must have "type": "module" — MetaObjects generates ESM only, no CommonJS. (The .js extension names the compiled output; tsc/your bundler resolves it back to the .ts source — this is the standard Node-ESM TypeScript convention.)

If you prefer un-extensioned imports (some legacy bundler setups), set extStyle: "none" in metaobjects.config.ts and use "moduleResolution": "bundler".

Reports

A concrete object.report whose read source is a source.rdb of @kind: view is served like a keyless read-only projection. For a report <R> the generators you have wired write:

Generator File Holds
entityFile() <R>.ts Drizzle view binding, Zod read schema, row type, descriptor, filter and sort allowlists
queriesFile() <R>.queries.ts list<Plural> only. No by-id query
routesFile() / routesFileHono() <R>.routes.ts / <R>.routes.hono.ts GET list; POST answers 405; no /:id route
namesFile() <R>.names.ts the view and column names
tanstackQuery() <R>.hooks.ts, <R>.meta.ts the list hook (use<R>List, with the report's filter type) and its descriptor. No detail hook, no mutation
barrel() index.ts one export

Every derived field with filter operators is filterable and sortable. A derived field that can be null is .nullable() in the Zod read schema and T | null in the row type. A measure with @default, and in a @spine report a key or @required column of the spine entity, can never be null: no .nullable(), and the row type is T. A decimal column (an avg, a ratio) is a string in the read schema and on the wire, on SQLite too: SQLite computes it as a REAL, so on that dialect the generated route passes the decimal field names to the mount as decimalColumns and the mount sends each as its string. The UI tier writes the list hook and nothing else for a report (no grid, grid hook or form), and agent/ui.md lists it: the hook generator gates on servesClientHooks, which is true for a served report, and the grid generators on servesClientTier, which is false. An ejected hook generator keeps the gate it was copied with and writes no report hook until you resync it with meta eject. A report with no view source, or an abstract one, generates nothing. The contract is in reporting.

Report export

Wire reportExport() and a served report's list route also answers ?format=csv|json|xml (the contract is "Report export" in api-contract). The wiring is the generator's presence: it carries the emitsReportExport marker, the runner turns that into includeReportExport for the routes generators, and only then does a report's routes file import <R>.export and pass export: <R>Export to the mount. Without reportExport() the routes are byte-identical to before and ?format= is ignored like any unknown parameter.

Generator File Holds
reportExport() <R>.export.ts the report's columns in derived-field order and the <R>Export spec: maxRows, CSV and XML writers
reportExport() report-export-writers.ts one per project: CSV quoting, CRLF, the formula-injection guard, XML escaping and the root/row writer

Options: maxRows (default 100000; with ?format set it replaces the route's reportMaxRows, so report_too_large never answers an export), xmlNulls ("empty", the default, or "nil" for xsi:nil="true") and csvBom (default false; the generated constant EXPORT_CSV_BOM). TypeScript has no XML convention of its own, so the default is the empty element, which is also what a CSV null cell is. The writers import nothing, and meta eject report-export copies them to own. The mounts take the spec through the export option of mountReadOnlyCrudRoutes (Fastify and Hono).

Cube export

cubeModel() writes the reporting vocabulary as Cube data-model files, one model/cubes/<Cube>.yml for each concrete, table-backed entity that declares dimensions, measures or segments (plus the join-target and alias cubes its @via dimensions need), with a rollup for each served report. It is a reference helper and opt-in: meta eject cube-model copies it into codegen/generators/cube-model.ts, with the mapping (buildCubeModel) and the YAML writer (renderCubeYaml) beside it in codegen/templates/cube/, and meta verify --codegen drift-checks the files. Only TypeScript has an exporter; the files are language-neutral YAML. Point the generator at the Cube project with a target, and pass dialect when the config's is not postgres or mysql:

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import { cubeModel } from "./codegen/generators/cube-model.js";

export default defineConfig({
  outDir: "src/generated",
  targets: { cube: { outDir: "cube" } },          // files land in cube/model/cubes/
  generators: [/* ... */ cubeModel({ target: "cube", dialect: "postgres" })],
});

The mapping, the refusals, the Cube query that reproduces a report and the cube lane that checks the output against a real Cube are in cube-export.

Decimals and null members on the wire

What the generated routes write is the project setting wire in .metaobjects/config.json (api-contract.md, "The wire setting"). With no setting, a decimal is the string the database driver returns, which keeps its scale ("12.50" on Postgres and MySQL). SQLite and D1 return a number, which is written at 15 significant digits (0.1 + 0.2 is "0.3"). A value object is written as stored: a member stored as null is null, and a member the stored value lacks is absent. "wire": { "decimals": "string" } writes the canonical string of ADR-0019 Amendment 1 ("12.5", zero "0"). "nullMembers": "emit" writes every declared member, null when unset, and "omit" leaves a null member out. Both reach arrays and maps of value objects, and nested ones. A top-level null field is always null.

Input is the same in every mode. It may be a string or a number, is read exactly, and is 400 validation when it has more fraction digits than @scale or more integer digits than @precision - @scale (no @scale beside @precision means 0). A filter operand compares exactly; a bad one is 400 invalid_filter_value. On SQLite a decimal is read at 15 significant digits. Every port runs the shared vectors in fixtures/decimal-wire/.

A generated model module's write schema carries a local decimalWrite function, so generated code imports nothing from MetaObjects. A generated route passes its mount decimalColumns, plus decimalWire: "string" and nullMembers with the value-object shapes when the setting asks for them; the route mounts in @metaobjectsdev/runtime-ts write the response and read filter operands by those options. meta migrate warns for a decimal declared with @precision above 15 on SQLite or D1. meta verify --codegen names the wire block that reproduces committed output generated under another setting.

Types exported per entity

Code that talks to a generated entity imports these names, so they are a contract. Each entity module (<Entity>.ts) exports six types. entityTypeNames(obj), exported from @metaobjectsdev/codegen-ts, returns the same six spellings for an owned generator, so it need not rebuild them from the entity name.

Type Derived from Use it for
<Entity> the Drizzle table, InferSelectModel a row read back from the database: what find<Entity>ById and create<Entity> return
<Entity>Insert the Drizzle table, InferInsertModel a row as the database accepts it, before validation
<Entity>Update Partial<<Entity>Insert> the same, every column optional
<Entity>Create z.input of <Entity>InsertSchema the argument of create<Entity>, and what a client posts
<Entity>Patch z.input of <Entity>UpdateSchema the argument of update<Entity>, and what a client patches
<Entity>Filter the @filterable and @sortable fields the filter, sort, limit and offset options of list<Entity> and the generated hooks

Create and Patch are the pair to use at a call site. They come from the Zod schemas the functions validate with, so a renamed or dropped field is a compile error at every call. Insert and Update come from the table and describe what the database takes. They are looser than the validators, so prefer Create and Patch at the API boundary.

When the entity is a TPH discriminator base, the row type is <Entity>Row, because <Entity> is the discriminated union over its subtypes. A read-only projection has no table to insert into, so do not expect Create or Patch there; read its generated module for what it exports. test/entity-type-names.test.ts in codegen-ts checks the table against the golden Subscriber module.

<Entity>Names — the physical names, as constants

meta init scaffolds a names generator, so a new project gets <Entity>.names.ts without configuring anything. The artifact mirrors the metadata that declared it: every node carries its own type, subType and name, and a physical name sits under the key that says what kind of database object it is.

export const ProgramNames = {
  type: "object",
  subType: "entity",
  name: "Program",                      // the OBJECT's name, not the table's
  sources: {
    primary: { type: "source", subType: "rdb", kind: "table", table: "programs" },
  },
  fields: {
    createdAt: { name: "createdAt", column: "created_at" },
    id:        { name: "id",        column: "id" },
  },
  identities: {
    pk:            { type: "identity", subType: "primary",   name: "pk" },
    uq_prog_slug:  { type: "identity", subType: "secondary", name: "uq_prog_slug",
                     index: "uq_prog_slug" },
  },
  indexes: {
    ix_prog_owner: { type: "index", subType: "lookup", name: "ix_prog_owner",
                     index: "ix_prog_owner" },
  },
} as const;
key means
type / subType the metamodel type of this node — object.entity, object.projection, …
name the object's metamodel name
sources.<role> one source.rdb child, keyed by its @role (primary | replica)
sources.<role>.kind the source's @kind — table, view, materializedView, storedProc, tableFunction
sources.<role>.table / .view / .proc / … the physical name, under the alias for that kind
sources.<role>.schema the DB schema, emitted only when one is declared
fields.<field>.name the logical name — the field as your code and the wire call it
fields.<field>.column the physical column — @column if declared, else name through the project's naming.columns rule (.metaobjects/config.json)
identities.<name> / indexes.<name> one identity.* / index.* child, keyed by its metamodel name
…​.index the database index name — on identity.secondary and index.lookup only

Why the physical name is not just name. One key cannot hold a table, a view and a stored procedure and still mean something. In a single run it used to:

LedgerNames.name          = "TBL_LDG_ENTRY"    kind: "table"
CustomerSummaryNames.name = "V_CUST_ROLLUP"    kind: "view"
RollupOutNames.name       = "SP_CUST_ROLLUP"   kind: "storedProc"

A consumer reading .name could not know what it had without reading a second key, and in none of the three did name hold the object's own name. Now the key says what the thing is, and as const enforces it: LedgerNames.sources.replica.table is a compile error, because that source is a view.

A write-through entity has two physical names, and both have a home. It writes to a table and reads through a replica view; keying sources by role is what gives the view a slot:

sources: {
  primary: { type: "source", subType: "rdb", kind: "table", table: "TBL_LDG_ENTRY" },
  replica: { type: "source", subType: "rdb", kind: "view",  view:  "V_LDG_ENTRY_RO" },
},

Both name and column are always present on a field, and createdAt / created_at is why. They are two different names for one field, they differ the moment a case boundary or a @column appears, and a consumer that has only one of them cannot recover the other: @column is free-form, so callPurpose may map to purpose_code, which is neither the logical name nor any transformation of it.

readOnly is not carried. It was a derivation over @kind, not something anyone declared, and nothing in any port read it. Ask sources.primary.kind — that is what the author actually wrote.

Existing projects (scaffolded before this generator existed) add one line:

import { namesFile } from "./codegen/generators/names";
export default defineConfig({ generators: [/* … */, namesFile()] });

The generated entity module reads these constants rather than embedding the same physical names a second time, so the constant and the table binding cannot drift apart. With the generator in the run, no generated TypeScript spells a physical name at all — table, view, procedure, column, schema and index name all travel as references.

Prefer a typed handle where one exists. If you are writing a Drizzle query, use the column object (programs.createdAt) — it is type-checked against the schema, and swapping it for ProgramNames.fields.createdAt.column trades a compile error for a runtime one. These constants are for the places that have no typed handle: raw SQL, a migration script, a log line, an external system's column mapping.

It follows extends, so a name you do not find here is on the parent. An artifact whose object extends another spreads the parent's collections rather than restating them:

export const CopayAuthNames = {
  type: "object",
  subType: "entity",
  name: "CopayAuth",
  sources: { ...AuthNames.sources },     // the SHARED table, named once on the base
  fields: {
    ...AuthNames.fields,
    copayAmount: { name: "copayAmount", column: "copay_cents" },
  },
  identities: { ...AuthNames.identities },
  indexes:    { ...AuthNames.indexes },
} as const;

Two forms, and which one you get is structural. An object with its OWN source declares that source and spreads only the inherited collections. One that INHERITS its source — a TPH subtype, which shares its base's single table — spreads sources too, so the table name is stated once, on the base. Either way each object keeps its own name/type/subType, which is what a single-table hierarchy legitimately has two of.

An abstract base a persisted entity extends gets an artifact of its own, carrying the columns it declares and an empty sources — it has no table, and must never acquire one. Reading CopayAuthNames.sources.primary.table still works: the spread resolves it, with the literal types intact.

Use

The entity module imports only Drizzle and Zod; the generated routes mount through @metaobjectsdev/runtime-ts, an ordinary Apache-2.0 package, and Drizzle, Zod and Fastify are direct user-app deps. With the default (flat) outputLayout, entity output lands directly under outDir — src/generated/Author.ts, Author.queries.ts, Author.routes.ts — with no per-package subdirectory.

The generated Author.routes.ts imports a module-level db singleton from the path configured by dbImport in metaobjects.config.ts (the generated queries take db as a parameter instead). Write that module with a real connection — see docs/recipes/wiring-generated-queries.md for the per-dialect setup (SQLite/libsql, Cloudflare D1, Postgres, multi-tenant):

// src/db.ts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

export const db = drizzle(createClient({ url: "file:dev.sqlite" }));
// src/server.ts
import Fastify from "fastify";
import { authorRoutes } from "./generated/Author.routes.js";

const app = Fastify();
await app.register(authorRoutes);   // mounts GET/POST/PATCH/PUT/DELETE for Author

await app.listen({ port: 3000 });

authorRoutes is a Fastify plugin — async function authorRoutes(fastify: FastifyInstance) — registered with fastify.register(...); db is the module-level singleton wired above, so server.ts never passes it explicitly. (apiPrefix in metaobjects.config.ts wraps the registration in a fastify.register(..., { prefix }) block when set — see features/api-contract.md.) The per-verb query helpers (findAuthorById, ...) live in the sibling ./generated/Author.queries file and take db as an explicit first argument — useful directly in request handlers, tests, or any runtime where a module singleton doesn't fit (see the recipe above).

Prove it works

Boot the server:

bun src/server.ts          # Bun
npx tsx src/server.ts      # stock Node

Node's built-in TypeScript support is not enough here — node src/server.ts (type stripping, on by default in Node 24; --experimental-strip-types before that) strips the types but deliberately does not rewrite module specifiers, so the generated import … from "./generated/Author.routes.js" fails with ERR_MODULE_NOT_FOUND. Run it through tsx as above, or compile with npx tsc first and run the emitted JavaScript.

Now exercise the generated routes. Three calls are enough to show codegen, persistence and validation are all live:

# 1. create -> 201 with the persisted row (id assigned by the DB)
curl -isS -X POST localhost:3000/authors \
  -H 'content-type: application/json' \
  -d '{"name":"Ada Lovelace"}' | head -1
# HTTP/1.1 201 Created

# 2. read it back -> 200 and a JSON array containing the row
curl -sS localhost:3000/authors

# 3. violate a declared constraint -> 400, NOT a 500
curl -isS -X POST localhost:3000/authors \
  -H 'content-type: application/json' \
  -d '{"name":""}' | head -1
# HTTP/1.1 400 Bad Request

A 400 on step 3 is the point: @required in the metadata became a real wire-tier rejection, with no hand-written validation. If you set apiPrefix in metaobjects.config.ts, prefix the paths accordingly (/api/authors).

The runtime-ts package supplies the helpers the generated routes lean on (mountCrudRoutes, parseFilterParams, the ObjectManager for full-runtime CRUD).

Hono variant (Workers / Bun / edge)

For Cloudflare Workers, Bun servers, and any other Hono-flavored runtime, swap routesFile() for routesFileHono(). Same five CRUD verbs, same cross-port wire contract (envelopes, status codes, filter / sort / withCount semantics — see features/api-contract.md); the only difference is the framework adapter the emitted code talks to.

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
// Run as shipped, imported from the package. `routesFileHono` has the same reference
// template and ownership route as the others: `meta eject routes-hono` copies it to
// codegen/generators/routes-hono.ts when you need to change what it emits.
import { entityFile, queriesFile, routesFileHono, barrel } from "@metaobjectsdev/codegen-ts/generators";

export default defineConfig({
  outDir: "src/generated",
  dialect: "sqlite",                    // or "postgres" / "mysql". Cloudflare D1 is "sqlite"; "d1" is refused
  extStyle: "js",
  apiPrefix: "/api",
  generators: [entityFile(), queriesFile(), routesFileHono(), barrel()],
});

Generated Author.routes.hono.ts:

// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
import {
  Author,
  authors,
  AuthorInsertSchema,
  AuthorUpdateSchema,
  AuthorFilterAllowlist,
  AuthorSortAllowlist,
} from "./Author.js";
import { mountCrudRoutes } from "@metaobjectsdev/runtime-ts/hono";
import type { Hono } from "hono";

export function registerAuthorRoutes(
  app: Hono<any, any, any>,
  deps: { db: unknown },
): void {
  mountCrudRoutes({
    app,
    path: `/api${Author.$path}`,
    db: deps.db,
    table: authors,
    insertSchema: AuthorInsertSchema,
    updateSchema: AuthorUpdateSchema,
    filterAllowlist: AuthorFilterAllowlist,
    sortAllowlist: AuthorSortAllowlist,
    dialect: "sqlite",
  });
}

Consumer wiring (Workers example). The routes take db when they are mounted, and a D1 binding exists only on env, so build the client and mount inside fetch:

import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { registerAuthorRoutes } from "./generated/Author.routes.hono.js";

interface Env { DB: D1Database }

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const app = new Hono<{ Bindings: Env }>();
    registerAuthorRoutes(app, { db: drizzle(env.DB) });   // this request's binding
    return app.fetch(request, env, ctx);
  },
};

The full Workers recipe (wrangler binding, queries, why dialect is "sqlite" for D1) is in Cloudflare Workers. To put these routes behind your own authentication, see Guarding generated routes.

The Hono flavor differs from Fastify in two intentional ways, both reflecting Hono idioms rather than contract drift:

  1. The exported function is register<Entity>Routes(app, deps) (deps-injected) rather than <entity>Routes(fastify) (module-singleton db import). Hono apps typically pull their persistence client off a per-request c.env.DB, not a module-level singleton — passing deps.db keeps that pattern intact.

  2. apiPrefix composes into the resource path (`${apiPrefix}${$path}`) rather than wrapping the registration (fastify.register(..., { prefix })). Hono has no prefix-wrapping primitive at the verb level, and string concatenation produces the same URL grammar.

Jinja templates

@metaobjectsdev/render renders Mustache by default and Jinja through nunjucks (ADR-0061), which the package depends on. A body is Jinja when its file is <group>/<source>.jinja (or .j2, .jinja2); @textRef does not change. FilesystemProvider reads .mustache first, then the Jinja extensions, and engineOf(ref) reports the engine of the file it found. For an InMemoryProvider, pass { engine: "jinja" } to its constructor, or pass engine: "jinja" to render and verify.

meta verify --templates and the renderHelper() generator check a Jinja body against the payload as they check Mustache, walking the parsed template, and the drift line names the .jinja file and line. ERR_TEMPLATE_NOT_STATIC marks a reference they cannot settle without running the template; ERR_TEMPLATE_SYNTAX a body that does not parse. A generated helper over a Jinja body passes engine: "jinja" to render, so it renders as Jinja through any provider, and the runtime check runs on it. Output matches the Python port (jinja2) on fixtures/jinja-render-conformance/; the engine differences outside it are in docs/features/templates-and-payloads.md.

FR-006 — response parsing

For every template.prompt declaring @responseRef, outputParser() (from @metaobjectsdev/codegen-ts/generators) emits <PromptName>.response.ts with a Zod-backed dual-API: parseXxx(text) throws on bad input; safeParseXxx(text) returns a { success, data | error } discriminated union — matches Zod's idiomatic shape.

ADR-0052: the shape parsed INTO is @responseRef, never @payloadRef (which types the request the prompt renders outbound), and template.output gets no parser at all. The strict tier is JSON-only — an @responseFormat: xml reply gets the tolerant extract and nothing strict.

// metaobjects.config.ts (additions)
import { promptRender, outputParser } from "@metaobjectsdev/codegen-ts/generators";

export default defineConfig({
  generators: [
    entityFile(), queriesFile(), routesFile(), barrel(),
    promptRender(),    // renderXxx() per template.prompt
    outputParser(),    // parseXxx() / safeParseXxx() per responding template.prompt
  ],
});
// generated/NpcResponse.response.ts
import { z } from "zod";

const NpcResponseSchema = z.object({
  name: z.string(),
  level: z.number().int(),
  role: z.enum(["merchant", "guard", "elder"]),
});

export type NpcResponseData = z.infer<typeof NpcResponseSchema>;
export type NpcResponseValidationError = z.ZodError;  // alias for consumer error-handlers

export function parseNpcResponse(text: string): NpcResponseData {
  return NpcResponseSchema.parse(JSON.parse(text));  // throws ZodError
}

export function safeParseNpcResponse(text: string):
  | { success: true; data: NpcResponseData }
  | { success: false; error: z.ZodError } { ... }

Consumer wiring:

import { parseNpcResponse, safeParseNpcResponse } from "./generated/NpcResponse.output.js";

const llmResponse = await myLlmProvider.call(promptText);

// Throwing path
const npc = parseNpcResponse(llmResponse);

// Result-style
const r = safeParseNpcResponse(llmResponse);
if (!r.success) log.warn("LLM returned malformed payload", r.error);
else handle(r.data);

meta verify walks both template subtypes, catching payload ↔ template drift at build time. Cross-port design is at ADR-0010; the feature reference is at features/templates-and-payloads.md.

Consumer dependency. The emitted parser imports zod. It's likely already in your dependencies (Drizzle / @metaobjectsdev/runtime-ts both lean on it); if not, npm i zod.

Requirement tests

requirementTests() writes one test stub per requirement the model claims: a live or partial requirement gets a stub that fails until you write its assertion, a planned or retired one a skipped stub. It is a reference helper and a recommended approach, not a contract. Run it from the package, or own it: meta eject requirement-tests copies the generator and its default stub renderer into codegen/generators/requirement-tests.ts. The requirement walk, each test's identity and the claim digest are engine, shared with the requirement checks in meta verify, which are core and are not ejectable.

// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import { requirementTests } from "@metaobjectsdev/codegen-ts/generators";

export default defineConfig({
  outDir: "src/generated",
  generators: [
    requirementTests({
      grain: "member",                                                    // default: "concern"
      filter: (r) => r.subType === "functional" && r.status === "live",   // REPLACES the default
      warnUncovered: false,                                               // default: true
    }),
  ],
});
Option Meaning
grain "concern" (default): one stub per distinct <type>.<subType> a requirement claims, at requirements/<path>.<concern>.test.ts. "member": one per distinct @implementedBy reference that resolves. Anything else is refused when the generator is built.
filter A predicate over the requirement view (subType, level, status, path, package, implementedByTypes) that replaces the default of functional requirements at L4 and L5. level is undefined on a requirement that declares none.
warnUncovered One warning naming up to five requirements the filter left out. false silences it.
renderers, resolveRenderer Replace the stub text per concern. A renderer receives the stub's identity too: package, unit, id, witnessKey, skip and digest, the same record every other port generates from.
path, owns Where stubs land, and which paths the generator may clean up.

The stub is yours to fill in, and the three-way merge keeps what you wrote. A stub whose requirement is gone is removed on the next run (refused by name when it carries a hand edit). The other four ports do not merge: their generated tests call a witness you own. See Generated requirement tests and witnesses.

meta verify --require-implementers (or META_REQUIRE_IMPLEMENTERS=1) raises WARN_REQUIREMENT_NOTHING_IMPLEMENTS to an error; once the ledger is clean, put it in the project's verify script or pre-push hook. See Requiring implementers.

Known gaps

TypeScript is the reference port and has no separate gaps file; the capability snapshot below is its list. The limits worth knowing before you commit: meta migrate and meta verify --db cover Postgres, SQLite and D1 only (MySQL gets codegen and runtime, not migrations). Oracle and SQL Server are not supported targets: meta migrate will not manage their schema, and the generated Drizzle tier does not target them. The contract-only output of the other ports (Java records, sourceless C# POCOs, Pydantic models) never touches the database, so a team on Oracle or SQL Server can still use it, and manages its schema itself. Hand edits survive regeneration through the three-way merge only on the machine that generated the file. The other ports' gaps are listed on their own pages: C#, Java, Kotlin, Python.

Capability snapshot

Feature Status
Entities + fields Yes
Relationships + FK Yes
Source kinds (table / view / storedProc) Yes
field.currency / field.enum / field.object + @storage Yes
Templates + render (FR-004) Yes (Mustache, and Jinja via nunjucks: a .jinja / .j2 / .jinja2 body, ADR-0061)
Output parser codegen (FR-006) Yes (outputParser() — Zod dual API)
Requirement gate + requirement tests Yes (meta verify; requirementTests(), ejectable)
Cube export (FR-044) Yes (cubeModel(), ejectable; TypeScript only)
Payload-VO codegen Yes (via projection codegen)
Migrations meta migrate (Postgres / SQLite / D1)
Drift verify meta verify (--codegen generated code and docs, --db schema, --templates prompts)
Documentation Yes (docsFile(), apiDocsFile(), requirementsFile(), agentDocsFile(), metamodelDocsFile(), docsSiteFile(); generators like the rest)
Prompt-drift verify Yes (@metaobjectsdev/render; Mustache and Jinja bodies)
Web client packages Yes (@metaobjectsdev/react, @metaobjectsdev/tanstack)

Client-side

The browser-side TypeScript tier — React forms, TanStack hooks + grids, the framework-agnostic browser core — is documented separately and is universal: it consumes any backend (TS / Java / Kotlin / C# / Python) that speaks the cross-port REST contract.

  • typescript-client.md — the browser tier (@metaobjectsdev/runtime-web, @metaobjectsdev/react, @metaobjectsdev/tanstack + the matching codegen packages).
  • ../features/api-contract.md — the URL grammar + wire format the browser client speaks.

Test counts

  • Server suite: cd server/typescript && bun test (scope it per package — a whole-suite run is slow).
  • Persistence-conformance (Docker-required): runnable via scripts/integration-test.sh ts.
  • Which conformance corpus gates what: docs/CONFORMANCE.md.

See also