Skip to content

Commit 4c5a157

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): filter Drizzle table emission via source.rdb
Discriminate table-backed entities from value-only objects (in-memory / transit shapes — LLM tool_use payloads, REST request bodies, etc.) by inspecting source.rdb children. Pure metadata-driven; not a typeId discriminator — any object subtype opts out of Drizzle emission simply by omitting source.rdb. New source-detect helper (`hasWritableRdbSource`) checks for at least one writable source.rdb child. The entity-file composer routes: isProjection(entity) → renderProjectionDecl (read-only view) !hasWritableRdbSource(entity) → renderValueObjectFile (value-only) vanilla / write-through → Drizzle table path The new value-object file emitter produces the structural TS interface + Zod InsertSchema only — no sqliteTable, no Infer*Model aliases, no FilterAllowlist/SortAllowlist, no constants object. Optional fields land as `name?: T` (matching Zod `.optional()` inference `T | undefined`) rather than `name?: T | null`, so consumers don't need a residual null-bridge cast at the call site. Fixture migration: every test fixture and imperative entity builder that intended a Drizzle table now declares source.rdb explicitly. The prior implicit default (any object.entity → table) is treated as a bug, not a feature — and source.rdb-less metadata now traverses the new value-only emission path. Added test coverage: - source-detect.test.ts: 5 cases (no source / table / explicit kind=table / kind=view / mixed CQRS) - value-object-file.test.ts: 2 cases (full interface + InsertSchema + enum alias emission; required-only no-question-mark assertion) 384 tests pass (was 377 + 7 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcfffe9 commit 4c5a157

15 files changed

Lines changed: 423 additions & 11 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Source-detect helpers — discriminate table-backed entities from value-only
2+
// objects (and other in-memory / transit shapes) by inspecting source.* children.
3+
//
4+
// Used by the entity-file composer to pick a streamlined "value-only" emission
5+
// path for metaobjects that declare no writable relational source. Pure
6+
// metadata-driven, not a typeId discriminator: any object subtype can opt out
7+
// of Drizzle table emission simply by omitting source.rdb.
8+
9+
import { MetaSource } from "@metaobjectsdev/metadata";
10+
import { TYPE_SOURCE, SOURCE_SUBTYPE_RDB } from "@metaobjectsdev/metadata";
11+
import type { MetaObject } from "@metaobjectsdev/metadata";
12+
13+
/**
14+
* True when the entity declares at least one writable source.rdb child.
15+
* Discriminates table-backed entities (full Drizzle file: table + Insert/Update
16+
* schemas + filter allowlists + constants) from value-only objects (TS
17+
* interface + Zod schema only). Absence of source.rdb means in-memory /
18+
* transit data — no migration, no ORM table to point at.
19+
*/
20+
export function hasWritableRdbSource(entity: MetaObject): boolean {
21+
for (const child of entity.ownChildren()) {
22+
if (child.type !== TYPE_SOURCE) continue;
23+
if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
24+
if (!(child instanceof MetaSource)) continue;
25+
if (child.isWritable()) return true;
26+
}
27+
return false;
28+
}

server/typescript/packages/codegen-ts/src/templates/entity-file.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// into one file with the @generated header. ts-poet deduplicates imports.
33
//
44
// Dispatch:
5-
// isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections)
6-
// vanilla / write-through entity → Drizzle table path
5+
// isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections)
6+
// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema)
7+
// vanilla / write-through entity → Drizzle table path
78

89
import { joinCode, type Code } from "ts-poet";
910
import type { MetaObject } from "@metaobjectsdev/metadata";
@@ -17,6 +18,8 @@ import { renderFilterType } from "./filter-type.js";
1718
import { GENERATED_HEADER } from "../constants.js";
1819
import { isProjection } from "../projection/projection-detector.js";
1920
import { renderProjectionDecl } from "./projection-decl.js";
21+
import { hasWritableRdbSource } from "../source-detect.js";
22+
import { renderValueObjectFile } from "./value-object-file.js";
2023

2124
/**
2225
* Render-time options for the entity-file composer.
@@ -51,6 +54,14 @@ export function renderEntityFile(
5154
});
5255
}
5356

57+
// --- Value-only path (no writable source.rdb: in-memory / transit shape) ---
58+
// No Drizzle table, no migration footprint. Consumers that need to validate
59+
// the shape (LLM tool_use input_schema, REST body parsing) use the Zod
60+
// schema; consumers that need the type use the interface.
61+
if (!hasWritableRdbSource(entity)) {
62+
return renderValueObjectFile(entity);
63+
}
64+
5465
// --- Vanilla / write-through entity path ---
5566
const enumAliases = renderEnumTypeAliases(entity);
5667
const sections: Code[] = [

server/typescript/packages/codegen-ts/src/templates/inferred-types.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,34 @@
11
// Inferred types template — emits Drizzle's InferSelectModel / InferInsertModel type aliases,
22
// plus named union types for field.enum fields.
3+
//
4+
// Also emits the structural TS interface for value-only objects (metaobjects
5+
// with no writable source.rdb). That path side-steps Drizzle entirely: the
6+
// interface is computed directly from the field tree, with `name?: T` for
7+
// optional fields (matching Zod's `.optional()` inference — `T | undefined` —
8+
// without the superfluous `| null` that Drizzle nullable columns introduce).
39

4-
import { code, imp, type Code } from "ts-poet";
5-
import type { MetaObject } from "@metaobjectsdev/metadata";
6-
import { FIELD_SUBTYPE_ENUM } from "@metaobjectsdev/metadata";
10+
import { code, imp, joinCode, type Code } from "ts-poet";
11+
import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
12+
import {
13+
FIELD_SUBTYPE_ENUM,
14+
FIELD_SUBTYPE_OBJECT,
15+
FIELD_SUBTYPE_STRING,
16+
FIELD_SUBTYPE_INT,
17+
FIELD_SUBTYPE_SHORT,
18+
FIELD_SUBTYPE_BYTE,
19+
FIELD_SUBTYPE_LONG,
20+
FIELD_SUBTYPE_DOUBLE,
21+
FIELD_SUBTYPE_FLOAT,
22+
FIELD_SUBTYPE_DECIMAL,
23+
FIELD_SUBTYPE_CURRENCY,
24+
FIELD_SUBTYPE_BOOLEAN,
25+
FIELD_SUBTYPE_DATE,
26+
FIELD_SUBTYPE_TIME,
27+
FIELD_SUBTYPE_TIMESTAMP,
28+
FIELD_SUBTYPE_CLASS,
29+
FIELD_ATTR_REQUIRED,
30+
FIELD_ATTR_OBJECT_REF,
31+
} from "@metaobjectsdev/metadata";
732
import { variableNameFromEntity, toPascalCase } from "../naming.js";
833
import { enumValues } from "../enum-meta.js";
934
import { renderDocsFor } from "./jsdoc.js";
@@ -53,3 +78,92 @@ export function renderEnumTypeAliases(entity: MetaObject): Code | null {
5378

5479
return lines.length > 0 ? code`${lines.join("\n")}` : null;
5580
}
81+
82+
// ---------------------------------------------------------------------------
83+
// Value-object interface emitter
84+
// ---------------------------------------------------------------------------
85+
86+
const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
87+
[FIELD_SUBTYPE_STRING]: "string",
88+
[FIELD_SUBTYPE_CLASS]: "string",
89+
[FIELD_SUBTYPE_INT]: "number",
90+
[FIELD_SUBTYPE_SHORT]: "number",
91+
[FIELD_SUBTYPE_BYTE]: "number",
92+
[FIELD_SUBTYPE_LONG]: "number",
93+
[FIELD_SUBTYPE_DOUBLE]: "number",
94+
[FIELD_SUBTYPE_FLOAT]: "number",
95+
[FIELD_SUBTYPE_DECIMAL]: "number",
96+
[FIELD_SUBTYPE_CURRENCY]: "number",
97+
[FIELD_SUBTYPE_BOOLEAN]: "boolean",
98+
[FIELD_SUBTYPE_DATE]: "string",
99+
[FIELD_SUBTYPE_TIME]: "string",
100+
[FIELD_SUBTYPE_TIMESTAMP]: "string",
101+
};
102+
103+
/** Type-alias name for a field.enum, mirroring renderEnumTypeAliases. */
104+
function enumTypeAliasName(entity: MetaObject, field: MetaField): string {
105+
const superField = field.resolveSuper();
106+
return superField !== undefined
107+
? toPascalCase(superField.name)
108+
: `${entity.name}${toPascalCase(field.name)}`;
109+
}
110+
111+
/**
112+
* One-line TS type expression for a field on a value-only object.
113+
* Returns a `Code` so cross-module `field.object` refs can be hoisted via
114+
* ts-poet `imp(...)` — matching how the Zod emitter hoists `<Ref>InsertSchema`.
115+
*/
116+
function valueObjectFieldType(entity: MetaObject, field: MetaField): Code {
117+
// field.object: import the referenced TS interface from its sibling module
118+
// so ts-poet hoists the import. Mirrors zod-validators.ts's `<Ref>InsertSchema`
119+
// import strategy, just for the type alias instead of the schema constant.
120+
if (field.subType === FIELD_SUBTYPE_OBJECT) {
121+
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
122+
if (typeof ref === "string" && ref.length > 0) {
123+
const refImp = imp(`${ref}@./${ref}.js`);
124+
return field.isArray ? code`${refImp}[]` : code`${refImp}`;
125+
}
126+
return field.isArray ? code`unknown[]` : code`unknown`;
127+
}
128+
129+
// field.enum: use the same type-alias name as renderEnumTypeAliases emits.
130+
if (field.subType === FIELD_SUBTYPE_ENUM) {
131+
const values = enumValues(field);
132+
if (values !== undefined) {
133+
const alias = enumTypeAliasName(entity, field);
134+
return field.isArray ? code`${alias}[]` : code`${alias}`;
135+
}
136+
return field.isArray ? code`string[]` : code`string`;
137+
}
138+
139+
const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown";
140+
return field.isArray ? code`${scalar}[]` : code`${scalar}`;
141+
}
142+
143+
/**
144+
* Emit a structural `interface <Name> { ... }` for a value-only object.
145+
*
146+
* Optional fields use `name?: T` (matching the Zod `.optional()` inference
147+
* `T | undefined`) instead of `name?: T | null`. Value objects never round-
148+
* trip through Drizzle nullable columns, so the null-bridge is unnecessary
149+
* here — and forces consumers into a residual cast at the call site.
150+
*/
151+
export function renderValueObjectInterface(entity: MetaObject): Code {
152+
const docs = renderDocsFor(entity);
153+
const docsPrefix = docs ? `${docs}\n` : "";
154+
155+
const lines: Code[] = [];
156+
for (const field of entity.fields()) {
157+
const required = field.ownAttr(FIELD_ATTR_REQUIRED) === true;
158+
const optional = required ? "" : "?";
159+
const tsType = valueObjectFieldType(entity, field);
160+
lines.push(code` ${field.name}${optional}: ${tsType};`);
161+
}
162+
163+
// joinCode with "\n" interpolates each Code segment on its own line and
164+
// keeps the imp() registrations intact so ts-poet hoists the imports.
165+
return code`${docsPrefix}export interface ${entity.name} {
166+
${joinCode(lines, { on: "\n" })}
167+
}
168+
`;
169+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Value-object file composer — emits a streamlined "value-only" module for
2+
// metaobjects with no writable source.rdb child. Output is just the TS
3+
// interface + the Zod schema (and enum type aliases when applicable); no
4+
// Drizzle table, no Infer*Model aliases, no filter allowlists, no constants
5+
// object.
6+
//
7+
// Dispatch: entity-file.ts routes here when hasWritableRdbSource(entity) is
8+
// false. The entity may still have read-only source.* children (those are
9+
// handled by the projection path before this one is reached).
10+
11+
import { joinCode, type Code } from "ts-poet";
12+
import type { MetaObject } from "@metaobjectsdev/metadata";
13+
import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js";
14+
import { renderInsertSchemaOnly } from "./zod-validators.js";
15+
import { GENERATED_HEADER } from "../constants.js";
16+
17+
export function renderValueObjectFile(obj: MetaObject): string {
18+
const enumAliases = renderEnumTypeAliases(obj);
19+
const sections: Code[] = [
20+
renderValueObjectInterface(obj),
21+
...(enumAliases !== null ? [enumAliases] : []),
22+
renderInsertSchemaOnly(obj),
23+
];
24+
const body = joinCode(sections, { on: "\n" }).toString();
25+
const header =
26+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
27+
`// Source metadata: ${obj.name} (${obj.fqn()})\n` +
28+
`// Customize via ${obj.name}.extra.ts in this directory.\n`;
29+
return header + body;
30+
}

server/typescript/packages/codegen-ts/src/templates/zod-validators.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,64 @@ import {
2626
import { enumValues, zodEnumExpr } from "../enum-meta.js";
2727
import { renderDocsFor } from "./jsdoc.js";
2828

29-
export function renderZodValidators(obj: MetaObject): Code {
30-
const z = imp("z@zod");
29+
/** Auto-generated PK field names that should be omitted from InsertSchema. */
30+
function autoGenPkFieldNames(obj: MetaObject): Set<string> {
31+
const out = new Set<string>();
3132
const primary = obj.primaryIdentity();
32-
const autoGenPkFields = new Set<string>();
3333
if (primary) {
3434
const generation = primary.ownAttr(IDENTITY_ATTR_GENERATION);
3535
if (generation === GENERATION_INCREMENT || generation === GENERATION_UUID) {
3636
const fields = primary.ownAttr(IDENTITY_ATTR_FIELDS);
3737
const fieldsList = Array.isArray(fields) ? fields : (typeof fields === "string" ? [fields] : []);
38-
for (const f of fieldsList) autoGenPkFields.add(String(f));
38+
for (const f of fieldsList) out.add(String(f));
39+
}
40+
}
41+
return out;
42+
}
43+
44+
/**
45+
* Emit ONLY the `<Name>InsertSchema`. Used by the value-object file emitter
46+
* for metaobjects with no writable source.rdb — those have no PATCH/update
47+
* semantics, so emitting an UpdateSchema would be misleading.
48+
*
49+
* The schema name is kept as `<Name>InsertSchema` even for pure value objects
50+
* so consumer imports don't churn. A future polish PR could add a `<Name>Schema`
51+
* alias for clarity.
52+
*/
53+
export function renderInsertSchemaOnly(obj: MetaObject): Code {
54+
const z = imp("z@zod");
55+
const autoGenPkFields = autoGenPkFieldNames(obj);
56+
57+
const insertFieldLines: Code[] = [];
58+
for (const child of obj.fields()) {
59+
if (autoGenPkFields.has(child.name)) continue;
60+
61+
const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
62+
63+
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
64+
insertFieldLines.push(
65+
code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
66+
);
67+
} else {
68+
insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child)}`);
3969
}
4070
}
4171

72+
const insertSchemaName = `${obj.name}InsertSchema`;
73+
const docs = renderDocsFor(obj);
74+
const docsPrefix = docs ? `${docs}\n` : "";
75+
76+
return code`
77+
${docsPrefix}export const ${insertSchemaName} = ${z}.object({
78+
${joinCode(insertFieldLines, { on: ",\n" })}
79+
});
80+
`;
81+
}
82+
83+
export function renderZodValidators(obj: MetaObject): Code {
84+
const z = imp("z@zod");
85+
const autoGenPkFields = autoGenPkFieldNames(obj);
86+
4287
const insertFieldLines: Code[] = [];
4388
const updateFieldLines: Code[] = [];
4489
for (const child of obj.fields()) {

server/typescript/packages/codegen-ts/test/_meta-build.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
IDENTITY_SUBTYPE_REFERENCE,
2323
IDENTITY_SUBTYPE_PRIMARY,
2424
IDENTITY_SUBTYPE_SECONDARY,
25+
SOURCE_SUBTYPE_RDB,
26+
SOURCE_ATTR_TABLE,
2527
MetaRoot,
2628
MetaObject,
2729
MetaField,
@@ -100,3 +102,18 @@ export function metaObject(subType: string, name: string): MetaObject {
100102
export function metaField(subType: string, name: string): MetaField {
101103
return meta(new TypeId(TYPE_FIELD, subType), name) as MetaField;
102104
}
105+
106+
/**
107+
* Attach a writable source.rdb child (table-backed source) to the given entity.
108+
* Use in imperative test builders that need the entity to flow through the
109+
* Drizzle code path — the value-only emission path is taken when no writable
110+
* source.rdb child is present, so tests that hand-build entities without one
111+
* now bypass the Drizzle output. Helper centralizes the wiring so the table
112+
* name and source-attr shape stay consistent with the YAML/JSON fixtures.
113+
*/
114+
export function attachRdbSource(entity: MetaObject, table: string): MetaObject {
115+
const source = meta(new TypeId(TYPE_SOURCE, SOURCE_SUBTYPE_RDB), "source") as MetaSource;
116+
source.setAttr(SOURCE_ATTR_TABLE, table);
117+
entity.addChild(source);
118+
return entity;
119+
}

server/typescript/packages/codegen-ts/test/fixtures/filter-fixture.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"object.entity": {
66
"name": "Subscriber",
77
"children": [
8+
{ "source.rdb": { "@table": "subscribers" } },
89
{ "field.long": { "name": "id" } },
910
{ "field.string": { "name": "email", "@filterable": true } },
1011
{ "field.string": { "name": "firstName", "@filterable": true } },
@@ -27,6 +28,7 @@
2728
"object.entity": {
2829
"name": "Product",
2930
"children": [
31+
{ "source.rdb": { "@table": "products" } },
3032
{ "field.long": { "name": "id" } },
3133
{ "field.string": { "name": "title" } },
3234
{ "field.decimal": { "name": "price", "@sortable": true } },

server/typescript/packages/codegen-ts/test/fixtures/packaged-shape.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"name": "Order",
77
"package": "shop::commerce",
88
"children": [
9+
{ "source.rdb": { "@table": "orders" } },
910
{ "field.long": { "name": "id" } },
1011
{ "field.long": { "name": "customerId", "@required": true } },
1112
{ "field.long": { "name": "productId", "@required": true } },
@@ -23,6 +24,7 @@
2324
"name": "Product",
2425
"package": "shop::commerce",
2526
"children": [
27+
{ "source.rdb": { "@table": "products" } },
2628
{ "field.long": { "name": "id" } },
2729
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
2830
{ "field.long": { "name": "tagId", "@required": true } },
@@ -37,6 +39,7 @@
3739
"name": "Customer",
3840
"package": "shop::users",
3941
"children": [
42+
{ "source.rdb": { "@table": "customers" } },
4043
{ "field.long": { "name": "id" } },
4144
{ "field.string": { "name": "email", "@required": true, "@maxLength": 255 } },
4245
{ "identity.primary": { "name": "primary", "@fields": ["id"], "@generation": "increment" } }
@@ -47,6 +50,7 @@
4750
"object.entity": {
4851
"name": "Tag",
4952
"children": [
53+
{ "source.rdb": { "@table": "tags" } },
5054
{ "field.long": { "name": "id" } },
5155
{ "field.string": { "name": "label", "@required": true, "@maxLength": 100 } },
5256
{ "field.long": { "name": "customerId", "@required": true } },

server/typescript/packages/codegen-ts/test/fixtures/single-entity.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"object.entity": {
77
"name": "Post",
88
"children": [
9+
{ "source.rdb": { "@table": "posts" } },
910
{ "field.long": { "name": "id" } },
1011
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
1112
{ "field.string": { "name": "body" } },

0 commit comments

Comments
 (0)