Skip to content

Commit 4118986

Browse files
dmealingclaude
andauthored
feat(codegen-ts): add copyable reference template library (#86)
* feat(codegen-ts): reference template library (ADR-0034 scaffold-and-own, step 1) Adds src/reference/{entity,queries,routes,barrel}.ts — self-contained COPYABLE reference generators a consumer copies into their repo and owns, importing only the public engine (@metaobjectsdev/codegen-ts) + ts-poet + @metaobjectsdev/metadata. Each carries a use-when / emits / customize / composes-with header. entity relocates the full renderEntityFile composition (full ownership); queries owns the vanilla-CRUD composition and delegates TPH/projection variants; barrel relocates renderBarrel; routes stays generator-level (its vanilla composition pulls in internal M:N junction logic — renderM2mMounts — that we deliberately do NOT promote; the header documents how to copy renderRoutesFile's body for deeper ownership). Promotes the composition's assembly helpers to public (re-exports only — already exported from their modules): renderTphDiscriminatorUnion, hasWritableRdbSource, renderSharedEnumsFile, SHARED_ENUMS_BASENAME, and the queries CRUD-block renderers (renderFindByIdFn/List/Create/Update/DeleteById + getPkInfo). Purely additive — no existing generator or export removed (deprecation + the meta init scaffolding + guidance rewrite are later steps). reference/ excluded from the tsc build (scaffold assets). Verified: byte-identical built-in-vs-reference over 5 fixtures (68 assertions), full codegen-ts suite 831 pass, tsc --noEmit strict of reference/* against the public API clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky * no-mistakes(document): document codegen-ts reference template library in CHANGELOG --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1ff59ca commit 4118986

8 files changed

Lines changed: 500 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Added
11+
- **codegen-ts — reference template library (ADR-0034 scaffold-and-own, step 1):**
12+
new in-repo, copyable reference generators under `src/reference/`
13+
(`entity` / `queries` / `routes` / `barrel`) — self-contained starting points a
14+
consumer copies into their repo and owns, importing only the public engine
15+
(`@metaobjectsdev/codegen-ts`) plus `ts-poet` and `@metaobjectsdev/metadata`.
16+
Each carries a `use-when / emits / customize / composes-with` header. Purely
17+
additive — no existing generator or export was removed; the templates are
18+
scaffold assets excluded from the package build. To keep a copied generator on
19+
public imports only, the engine now also re-exports the assembly helpers those
20+
templates use: `renderTphDiscriminatorUnion`, `hasWritableRdbSource`,
21+
`renderSharedEnumsFile` / `SHARED_ENUMS_BASENAME`, and the queries CRUD-block
22+
renderers (`renderFindByIdFn`, `renderListFn`, `renderCreateFn`,
23+
`renderUpdateFn`, `renderDeleteByIdFn`, `getPkInfo`). (`meta init` scaffolding,
24+
generator-export deprecation, and the guidance rewrite are later steps.)
25+
1026
### Fixed
1127
- **cli — `meta init` gitignore hardening:** the scaffolded
1228
`.metaobjects/.gitignore` previously ignored only `.gen-state/`, so a

server/typescript/packages/codegen-ts/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ export { isTphDiscriminatorBase, tphConcreteSubtypes, collectTphSubtypeFields, t
6868
export type { TphPlan, TphSubtypePlan } from "./templates/tph-discriminator.js";
6969
export { isTphSubtype, tphDiscriminatorPin } from "./templates/zod-validators.js";
7070

71+
// ADR-0034 reference-template composition helpers. Promoted to the public engine
72+
// surface so a COPIED reference generator (src/reference/*.ts → consumer's
73+
// codegen/generators/*.ts) imports only `@metaobjectsdev/codegen-ts`, never a
74+
// package-internal relative path. These are the assembly pieces the built-in
75+
// entity/queries composers use; the reference templates relocate that assembly.
76+
export { renderTphDiscriminatorUnion } from "./templates/tph-discriminator.js";
77+
export { hasWritableRdbSource } from "./source-detect.js";
78+
export { renderSharedEnumsFile, SHARED_ENUMS_BASENAME } from "./templates/enums-file.js";
79+
export {
80+
renderFindByIdFn,
81+
renderListFn,
82+
renderCreateFn,
83+
renderUpdateFn,
84+
renderDeleteByIdFn,
85+
getPkInfo,
86+
} from "./templates/queries.js";
87+
7188
// Built-in template render functions — the composition seam for adopters who
7289
// want to call a built-in template, then post-process / append to its output
7390
// from their own Generator (added to `generators: [...]`) WITHOUT forking the
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/barrel.ts) and own it.
2+
// Then import it LOCALLY in metaobjects.config.ts instead of from the package:
3+
// import { barrel } from "./codegen/generators/barrel";
4+
//
5+
// use-when: you want a single `index.ts` re-exporting every generated entity module.
6+
// emits: <target>/index.ts with one `export * from "./<Entity>"` per entity, alphabetical.
7+
// customize: the export form (star vs named), ordering, grouping by package, what to include/exclude.
8+
// composes-with: entity.ts (this re-exports the files entity.ts emits).
9+
//
10+
// Everything below imports ONLY from `@metaobjectsdev/codegen-ts` (the stable engine).
11+
// The composition (`renderBarrel`) is inlined here so you own it — change it freely.
12+
13+
import {
14+
oncePerRun,
15+
type Generator,
16+
type GeneratorFactory,
17+
type ExtStyle,
18+
type ResolvedTarget,
19+
barrelModuleSpecifier,
20+
formatTs,
21+
GENERATED_HEADER,
22+
} from "@metaobjectsdev/codegen-ts";
23+
24+
interface BarrelEntry {
25+
name: string;
26+
package: string | undefined;
27+
}
28+
29+
// --- composition (OWNED) — the barrel file body. Customize freely. ---
30+
function renderBarrel(
31+
entries: BarrelEntry[],
32+
extStyle: ExtStyle,
33+
selfTarget: ResolvedTarget,
34+
entityModuleTarget: ResolvedTarget,
35+
): string {
36+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
37+
const exports = sorted
38+
.map((e) => `export * from ${JSON.stringify(barrelModuleSpecifier(selfTarget, entityModuleTarget, e.package, e.name, extStyle))};`)
39+
.join("\n");
40+
return `// ${GENERATED_HEADER} — DO NOT EDIT.\n${exports}\n`;
41+
}
42+
43+
export interface BarrelOpts {
44+
target?: string;
45+
}
46+
47+
export const barrel = function barrel(opts?: BarrelOpts): Generator {
48+
const generator: Generator = {
49+
name: "barrel",
50+
generate: oncePerRun(async (entities, ctx) => ({
51+
path: "index.ts",
52+
content: await formatTs(
53+
renderBarrel(
54+
entities.map((e) => ({ name: e.name, package: e.package })),
55+
ctx.renderContext!.extStyle,
56+
ctx.renderContext!.selfTarget,
57+
ctx.renderContext!.entityModuleTarget,
58+
),
59+
),
60+
})),
61+
};
62+
if (opts?.target) {
63+
generator.target = opts.target;
64+
}
65+
return generator;
66+
} as GeneratorFactory<BarrelOpts>;
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/entity.ts) and own it.
2+
// Then import it LOCALLY in metaobjects.config.ts:
3+
// import { entityFile } from "./codegen/generators/entity";
4+
//
5+
// use-when: ALWAYS — this is the entity-module generator. It owns the shape of each
6+
// generated <Entity>.ts (the Drizzle table, Zod schemas, inferred types,
7+
// constants, filter allowlists). Start here and adapt the assembly.
8+
// emits: <target>/<Entity>.ts per concrete object — and the shared-enums module once.
9+
// Dispatches: abstract/value → interface + Zod; projection → read-only view decl;
10+
// write-through entity → full Drizzle table path.
11+
// customize: reorder/drop sections in `sections` below; change the header; swap a sub-renderer
12+
// for your own (each render* is an engine primitive you call). To deeply own one
13+
// section (e.g. the Drizzle emit), copy/replace that sub-render call with your code.
14+
// composes-with: queries.ts, routes.ts, barrel.ts (they import the files this emits).
15+
//
16+
// The composition (`renderEntity`) is the relocated body of the built-in entity composer —
17+
// byte-identical to start, now YOURS to change. It imports only public engine primitives.
18+
19+
import { joinCode, type Code } from "ts-poet";
20+
import type { MetaObject } from "@metaobjectsdev/metadata";
21+
import {
22+
perEntity,
23+
type EmittedFile,
24+
type GenContext,
25+
type Generator,
26+
type GeneratorFactory,
27+
type RenderContext,
28+
// sub-renderers (engine primitives) — the LEGO blocks this composition assembles:
29+
renderDrizzleSchema,
30+
renderInferredTypes,
31+
renderEnumTypeAliases,
32+
renderZodValidators,
33+
renderEntityConstants,
34+
renderFilterAllowlist,
35+
renderSortAllowlist,
36+
renderFilterType,
37+
renderTphDiscriminatorUnion,
38+
isTphDiscriminatorBase,
39+
renderProjectionDecl,
40+
renderValueObjectFile,
41+
renderSharedEnumsFile,
42+
SHARED_ENUMS_BASENAME,
43+
// predicates + helpers:
44+
isProjection,
45+
isAbstract,
46+
hasWritableRdbSource,
47+
// engine plumbing:
48+
formatTs,
49+
entityOutputPath,
50+
GENERATED_HEADER,
51+
} from "@metaobjectsdev/codegen-ts";
52+
53+
export interface RenderEntityOpts {
54+
readonly allowlists?: boolean;
55+
}
56+
57+
// --- composition (OWNED) — assembles one <Entity>.ts. Change this to change the output. ---
58+
function renderEntity(entity: MetaObject, ctx: RenderContext, opts?: RenderEntityOpts): string {
59+
const runtime = ctx.selfTarget.runtime;
60+
const allowlists = runtime ? (opts?.allowlists ?? true) : false;
61+
62+
// Abstract → shape only (interface + Zod), never a table.
63+
if (isAbstract(entity)) {
64+
return renderValueObjectFile(entity, ctx.apiPrefix, ctx);
65+
}
66+
// Projection → read-only view declaration + read schema.
67+
if (isProjection(entity)) {
68+
return renderProjectionDecl(entity, ctx.loadedRoot, {
69+
columnNamingStrategy: ctx.columnNamingStrategy,
70+
dialect: ctx.dialect,
71+
apiPrefix: ctx.apiPrefix,
72+
timestampMode: ctx.timestampMode,
73+
allowlists,
74+
ctx,
75+
includeViewDecl: runtime,
76+
});
77+
}
78+
// Value-only / contract target → interface + Zod, no Drizzle table.
79+
if (!runtime || !hasWritableRdbSource(entity)) {
80+
return renderValueObjectFile(entity, ctx.apiPrefix, ctx);
81+
}
82+
83+
// Write-through entity → the full Drizzle table file. Reorder/drop sections freely.
84+
const enumAliases = renderEnumTypeAliases(entity, ctx);
85+
const tphBlock = renderTphDiscriminatorUnion(entity, ctx.loadedRoot);
86+
const tphBase = tphBlock !== null && isTphDiscriminatorBase(entity, ctx.loadedRoot);
87+
const sections: Code[] = [
88+
renderDrizzleSchema(entity, ctx),
89+
renderInferredTypes(entity, tphBase, ctx),
90+
...(enumAliases !== null ? [enumAliases] : []),
91+
renderZodValidators(entity, ctx),
92+
renderEntityConstants(entity, ctx.apiPrefix),
93+
...(allowlists ? [renderFilterAllowlist(entity), renderSortAllowlist(entity)] : []),
94+
renderFilterType(entity),
95+
...(tphBlock !== null ? [tphBlock] : []),
96+
];
97+
98+
const body = joinCode(sections, { on: "\n" }).toString();
99+
const header =
100+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
101+
`// Source metadata: ${entity.name} (${entity.fqn()})\n` +
102+
`// Customize via ${entity.name}.extra.ts in this directory.\n`;
103+
return header + body;
104+
}
105+
106+
export interface EntityFileOpts {
107+
filter?: (entity: MetaObject) => boolean;
108+
target?: string;
109+
allowlists?: boolean;
110+
}
111+
112+
export const entityFile = function entityFile(opts?: EntityFileOpts): Generator {
113+
const allowlists = opts?.allowlists ?? true;
114+
const perEntityEmit = perEntity(async (entity, ctx) => {
115+
if (!ctx.renderContext) {
116+
throw new Error("entity-file: renderContext is required (provided by runGen)");
117+
}
118+
if (isAbstract(entity) && !ctx.renderContext.emitAbstractShapes) {
119+
return [];
120+
}
121+
return {
122+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.ts`),
123+
content: await formatTs(renderEntity(entity, ctx.renderContext, { allowlists })),
124+
};
125+
});
126+
127+
const generator: Generator = {
128+
name: "entity-file",
129+
emitsEntityModule: true,
130+
generate: async (ctx: GenContext): Promise<EmittedFile[]> => {
131+
const files = await perEntityEmit(ctx);
132+
// FR-019: emit the shared-enums module once per run (null → no file).
133+
const sharedEnums = renderSharedEnumsFile(ctx.loadedRoot);
134+
if (sharedEnums !== null) {
135+
files.push({ path: `${SHARED_ENUMS_BASENAME}.ts`, content: await formatTs(sharedEnums) });
136+
}
137+
return files;
138+
},
139+
};
140+
if (opts?.filter) {
141+
generator.filter = opts.filter;
142+
}
143+
if (opts?.target) {
144+
generator.target = opts.target;
145+
}
146+
return generator;
147+
} as GeneratorFactory<EntityFileOpts>;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/queries.ts) and own it.
2+
// Then import it LOCALLY in metaobjects.config.ts:
3+
// import { queriesFile } from "./codegen/generators/queries";
4+
//
5+
// use-when: you want generated typed CRUD finders (find<E>ById, list<E>s, create/update/delete)
6+
// over Drizzle. Drop it if you hand-write your data access.
7+
// emits: <target>/<Entity>.queries.ts per write-through entity.
8+
// customize: the vanilla CRUD assembly below is OWNED — reorder, drop verbs (e.g. no delete),
9+
// change the Db type alias, add your own finders. The render<Verb>Fn primitives emit
10+
// each block; call your own instead to change a verb's body.
11+
// composes-with: entity.ts (imports the table + InsertSchema it emits).
12+
//
13+
// NOTE: the advanced TPH-base + projection variants delegate to the engine's composer
14+
// (`renderQueriesFile`) — they're rarely customized. To own those too, copy their branches
15+
// out of the package source. The vanilla path here is byte-identical to the built-in.
16+
17+
import { code, joinCode, type Code } from "ts-poet";
18+
import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
19+
import {
20+
perEntity,
21+
type Generator,
22+
type GeneratorFactory,
23+
type RenderContext,
24+
entityModuleSpecifier,
25+
renderFindByIdFn,
26+
renderListFn,
27+
renderCreateFn,
28+
renderUpdateFn,
29+
renderDeleteByIdFn,
30+
isTphDiscriminatorBase,
31+
isProjection,
32+
isTphSubtype,
33+
renderQueriesFile, // engine composer — used for the delegated variants
34+
formatTs,
35+
entityOutputPath,
36+
GENERATED_HEADER,
37+
} from "@metaobjectsdev/codegen-ts";
38+
39+
// --- composition (OWNED for the common case) ---
40+
function renderQueries(obj: MetaObject, ctx: RenderContext): string {
41+
// Advanced variants delegate to the engine (byte-identical). Own them by copying their source.
42+
if (isTphDiscriminatorBase(obj, ctx.loadedRoot) || isProjection(obj)) {
43+
return renderQueriesFile(obj, ctx);
44+
}
45+
46+
const entityName = obj.name;
47+
const entityFileName = entityModuleSpecifier(
48+
ctx.selfTarget,
49+
ctx.entityModuleTarget,
50+
obj.package,
51+
entityName,
52+
ctx.extStyle,
53+
);
54+
const varName = ctx.collectionName(entityName);
55+
56+
// `db` is parameter-passed into every finder (ADR-0008). Emit the dialect-correct
57+
// Drizzle type alias so signatures typecheck without the consumer constructing one.
58+
const dbTypeImport =
59+
ctx.dialect === "postgres"
60+
? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
61+
: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
62+
const dbTypeAlias =
63+
ctx.dialect === "postgres"
64+
? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
65+
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
66+
67+
const literalImports = code`
68+
${dbTypeImport}
69+
${dbTypeAlias}
70+
71+
import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
72+
`;
73+
74+
const sections: Code[] = [
75+
literalImports,
76+
renderFindByIdFn(obj, ctx),
77+
renderListFn(obj, ctx),
78+
renderCreateFn(obj, ctx),
79+
renderUpdateFn(obj, ctx),
80+
renderDeleteByIdFn(obj, ctx),
81+
];
82+
83+
const body = joinCode(sections, { on: "\n" }).toString();
84+
const header =
85+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
86+
`// Source metadata: ${entityName} (${obj.fqn()})\n` +
87+
`// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
88+
return header + body;
89+
}
90+
91+
export interface QueriesFileOpts {
92+
filter?: (entity: MetaObject) => boolean;
93+
target?: string;
94+
}
95+
96+
// value objects have no identity (findById/updateById would target a non-existent column),
97+
// and TPH subtypes emit no standalone queries file — both are skipped unconditionally.
98+
const skipNonQueryable = (e: MetaObject): boolean =>
99+
e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
100+
101+
export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
102+
const userFilter = opts?.filter;
103+
const filter: (e: MetaObject) => boolean = userFilter
104+
? (e) => skipNonQueryable(e) && userFilter(e)
105+
: skipNonQueryable;
106+
107+
const generator: Generator = {
108+
name: "queries-file",
109+
filter,
110+
generate: perEntity(async (entity, ctx) => {
111+
if (!ctx.renderContext) {
112+
throw new Error("queries-file: renderContext is required (provided by runGen)");
113+
}
114+
return {
115+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.queries.ts`),
116+
content: await formatTs(renderQueries(entity, ctx.renderContext)),
117+
};
118+
}),
119+
};
120+
if (opts?.target) {
121+
generator.target = opts.target;
122+
}
123+
return generator;
124+
} as GeneratorFactory<QueriesFileOpts>;

0 commit comments

Comments
 (0)