@metaobjectsdev/codegen-ts
TypeScript codegen for the metaobjects metamodel — emits Drizzle schema +
inferred types + Zod validators + typed CRUD queries from a loaded MetaData.
Install
npm install @metaobjectsdev/codegen-ts @metaobjectsdev/metadata drizzle-orm zodUsage
import { MetaDataLoader } from "@metaobjectsdev/metadata";
import { FileSource } from "@metaobjectsdev/metadata/core";
import { generate } from "@metaobjectsdev/codegen-ts";
const { root } = await new MetaDataLoader().load([
new FileSource("metaobjects/meta.blog.json"),
]);
const result = await generate({
metadata: root,
outDir: "./src/db/entities",
dialect: "sqlite",
dbImport: "~/server/db", // path to your { db } export
});
for (const f of result.files) console.log(f.status, f.path);Output
Per entity, codegen emits two files:
<Entity>.ts— Drizzle table definition (with FK.references()+relations()blocks auto-emitted from metadata relationships) + Drizzle-inferred types + Zod validators<Entity>.queries.ts— typed CRUD query functions (findPostById,listPosts,createPost,updatePost,deletePostById)
Plus a barrel index.ts re-exporting from each <Entity>.ts.
// Use the entity types and table:
import { posts, type Post, PostInsertSchema } from './db/entities/Post.js';
// Use the typed CRUD queries:
import { findPostById, createPost } from './db/entities/Post.queries.js';
const post = await findPostById(42);
const newPost = await createPost({ title: 'Hello', body: 'World', authorId: 1 });Generated files carry an @generated by @metaobjectsdev/codegen-ts header.
codegen-ts overwrites files with the header but refuses to touch files
without it. Hand-customizations live in sibling <Entity>.extra.ts files
(for custom queries, derived-column indexes, etc. that metadata can't express).
Allowlists opt-in
By default, every generated <Entity>.ts file emits a <Entity>FilterAllowlist
and <Entity>SortAllowlist block, which type-only-imports FilterAllowlist /
SortAllowlist from @metaobjectsdev/runtime-ts/drizzle-fastify. These power
the Fastify-flavored CRUD routes emitted by routesFile().
Worker / Lambda / edge consumers that don't mount Fastify-style server routes
can opt out — the entity file then has no runtime-ts/drizzle-fastify imports
at all, and @metaobjectsdev/runtime-ts can be dropped from the consumer's
dependency tree entirely:
// metaobjects.config.ts
import { defineConfig } from "@metaobjectsdev/cli";
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { barrel } from "./codegen/generators/barrel";
export default defineConfig({
generators: [entityFile({ allowlists: false }), queriesFile(), barrel()],
});The entityFile / queriesFile / routesFile / barrel factories are imported
from the owned local copies that meta init scaffolds into
codegen/generators/ (ADR-0034 scaffold-and-own). Importing them from
@metaobjectsdev/codegen-ts/generators still works but is deprecated — own a
copy instead; the package export will be removed in a future major. The engine and
primitives (runGen, the scope helpers perEntity / perPackage / perModel
(oncePerRun is a soft-deprecated alias of perModel), RenderContext, the loader and
render helpers) remain the stable, versioned import from @metaobjectsdev/codegen-ts,
and an owned generator imports them from there.
The client-side <Entity>Filter type is still emitted regardless — it has zero
runtime-ts dependency and consumers want it for typed client calls. Default is
true for back-compat with existing projects.
If you keep routesFile() wired in, leave allowlists at its default — the
generated routes reference the allowlists by name and won't compile without
them.
Consumer wiring
Generated query helpers accept a Drizzle db instance as their first
parameter. See wiring-generated-queries.md
for per-dialect setup, edge (Workers / D1) examples, and a 0.6.0 → 0.7.0
migration guide. The cross-language design decision is in
ADR-0008.
Output targets
Each generator can be routed to its own output directory via a named target
(see the CLI's defineConfig — @metaobjectsdev/cli README, "Multiple output
targets"). The runner gives every generator a RenderContext carrying its own
selfTarget and the shared entityModuleTarget (where entityFile() output
lives), then writes each emitted file under its target's outDir (collisions are
keyed on the resolved full path, so the same filename in two targets is fine).
Templates resolve the entity-module import through entityModuleSpecifier(selfTarget, entityModuleTarget, pkg, name, extStyle):
- same target → relative (
./Program), honoringextStyle - cross target → extension-less package path from the entity-module target's
importBase(@acme/database/generated/acme/commerce/Program)
Companion helpers: siblingSpecifier (same-target sibling module, e.g.
<Entity>.columns) and barrelModuleSpecifier (barrel re-exports). A generator
declares it produces the entity module with emitsEntityModule: true (set by
entityFile()); the runner derives the entity-module target from it. With a single
target, every specifier takes the relative branch, so output is unchanged.
Dialects
sqlite— emitssqliteTable,text,integer, etc. fromdrizzle-orm/sqlite-corepostgres— emitspgTable,varchar,bigint, etc. fromdrizzle-orm/pg-core
Driver compatibility
The type Db = ... alias at the top of each generated <Entity>.queries.ts is
the base Drizzle class every driver of that dialect extends, so any
compatible Drizzle instance type-checks:
- Postgres →
PgDatabase<PgQueryResultHKT, ...>— acceptsnode-postgres(pg),postgres.js,@neondatabase/serverless,@vercel/postgres, andpglite. - SQLite →
BaseSQLiteDatabase<"sync" | "async", unknown>— accepts both the sync driver (better-sqlite3) and the async ones (libsql/ Turso / D1).
Reads (find*ById, list*, and a projection's read-only queries) work on every
one of those drivers.
Write caveat (create* / update*). Those functions use Drizzle's
.returning() API, which needs native RETURNING support. Every Postgres driver
has it, as do libsql / Turso / D1. It does not work on better-sqlite3 or
bun:sqlite (no native RETURNING) — code still compiles against the Db type,
but create* / update* fail at runtime. On those two drivers, override the
affected functions in the sibling <Entity>.extra.ts file with a
non-.returning() form, or switch to an async SQLite driver.
outputParser() — typed parsers for template.output
For every template.output declared in your metadata, outputParser() emits
<TemplateName>.output.ts containing a Zod schema + dual-API parser:
// 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 { barrel } from "./codegen/generators/barrel";
// Prompt/output generators have no reference template yet — package import.
import { promptRender, outputParser } from "@metaobjectsdev/codegen-ts/generators";
export default defineConfig({
generators: [entityFile(), queriesFile(), barrel(), promptRender(), outputParser()],
});For a template.output named NpcResponseOutput with @payloadRef: "NpcResponsePayload":
// Generated NpcResponseOutput.output.ts — self-contained, no cross-file imports
import { z } from "zod";
const NpcResponseOutputSchema = z.object({
name: z.string(),
age: z.number().int(),
});
export type NpcResponseOutputData = z.infer<typeof NpcResponseOutputSchema>;
export type NpcResponseOutputValidationError = z.ZodError;
/** Throws ZodError on validation failure. */
export function parseNpcResponseOutput(text: string): NpcResponseOutputData { ... }
/** Result-style; never throws. */
export function safeParseNpcResponseOutput(text: string):
| { success: true; data: NpcResponseOutputData }
| { success: false; error: NpcResponseOutputValidationError } { ... }parseXxx and safeParseXxx return the local <Name>Data type, derived from
the schema via z.infer<>. It is structurally identical to the payload-VO
interface emitted by promptRender() (e.g., NpcResponsePayload in
prompts.ts) — consumers who wire both generators can assign back and forth
between NpcResponseOutputData and NpcResponsePayload interchangeably. The
output-parser file is intentionally self-contained so it compiles standalone
even when promptRender() is not wired in.
Consumer usage:
import { parseNpcResponseOutput, safeParseNpcResponseOutput } from "./generated/NpcResponseOutput.output";
const npc = parseNpcResponseOutput(llmResponseText); // throws on bad shape
const r = safeParseNpcResponseOutput(llmResponseText);
if (!r.success) { /* handle r.error (a ZodError) */ } else { /* use r.data */ }Field-type → Zod-type mapping:
| Field subtype | Emitted Zod |
|---|---|
field.string, field.class |
z.string() |
field.int, field.long |
z.number().int() |
field.double, field.float |
z.number() |
field.boolean |
z.boolean() |
field.object (with @objectRef) |
nested z.object({ ... }) |
isArray: true on any of the above |
wrapped in z.array(...) |
Options:
outputParser({
outDir: "src/generated/outputs", // default: emits at the target's root
target: "default", // default: "default" (the entity-module target)
})meta verify integration: when meta verify runs, every template.output's
@payloadRef resolution is checked. Unresolved refs fail the build (exit 1)
with a (output) prefix on the diagnostic. See ADR-0010
for the cross-language design rationale.
Naming conventions: camelCase TS snake_case SQL
@metaobjectsdev/codegen-ts maps snake_case metadata field names to
camelCase TS property names by default. The underlying SQL column stays
snake_case.
// Metadata
{ "field.long": { "name": "council_id" } }// Generated TS — property is camelCase
import { councils } from "./generated/Council";
const id = council.councilId;
db.select().from(councils).where(eq(councils.councilId, "abc"));-- Generated DDL — column stays snake_case
CREATE TABLE councils (
council_id TEXT NOT NULL PRIMARY KEY,
...
);To override the SQL column name per-field, use @dbColumn:
{ "field.long": { "name": "councilId", "@dbColumn": "council_uuid" } }The mapping policy is project-wide via columnNamingStrategy in
metaobjects.config.ts: snake_case (default) | literal | kebab-case.
License
Apache-2.0.