Skip to content

Commit 0ed6a60

Browse files
dmealingclaude
andcommitted
feat(codegen): gate that generated code COMPILES, and fix the three defects it found
Four "generated code does not compile" defects shipped in 1.0.4 — two in TS (a view over an int-backed enum, a renamed projection field), one in C# (FK config through `nameof`), one in Java (the scalar extract mapper). Every gate was green for all four, because `meta gen` exits 0 and the metamodel, render, persistence, api-contract and registry corpora all gate BEHAVIOR. None of them asks whether the emitted code builds. The adopter's build was the first thing that did, which made the adopter the gate. This adds the missing verb to the TypeScript lane: generate from the SHARED cross-port corpus and compile every module with the real TS compiler, on both dialects, zero diagnostics. The corpus is reused rather than invented — it already carries 16 entities, two view-backed projections, two M:N through-junctions incl. a self-join, a 4-entity TPH hierarchy, jsonb storage, isArray, currency, decimal, and an AllTypes entity with every persistable field subtype. A second kitchen sink would drift from the first. It found three defects on its first run, all fixed here: 1. A TPH subtype's interface and its read schema were emitted by two rules that answered one question differently — the schema widened the PRIMARY KEY to nullable, the interface would not admit null for a genuinely-nullable subtype-only column. Either way `parse<Base>()` returned a value that was not assignable to the base union. Both now route through isTphReadNullTolerant. 2. `@dbColumnType: jsonb` fell through to a plain `text()` on SQLite while its Zod/TS type stayed `unknown`, so the queries did not compile — and had they, the value would have read back as a JSON-encoded string, contradicting the wire contract the api-contract jsonb corpus pins. Now text({ mode: "json" }). 3. Composing the harness also surfaced that a naive runner reports a generator's own deliberate exclusions as emit bugs; the gate composes `matches` from each generator's filter exactly as runner.ts does, or it measures itself. Generated output changes for TPH subtypes and for SQLite jsonb columns. The peer lanes for C#, Java, Kotlin and Python are still to come. A port without this gate keeps precisely the bug class it closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGmCLHbQKuLBPjjhWC43jk
1 parent de91c18 commit 0ed6a60

5 files changed

Lines changed: 216 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,38 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
2828
also stop saying the `verify` checks run in every port: the vocabulary loads and
2929
validates in all five, and the checks run in the Node `meta` CLI.
3030

31+
### Added
32+
33+
- **Codegen-compile conformance (TypeScript lane).** A new gate generates from the shared
34+
cross-port corpus (`fixtures/persistence-conformance/canonical/meta.fitness.json` — 16
35+
entities, 2 view-backed projections, two M:N through-junctions, a 4-entity TPH hierarchy,
36+
jsonb storage, `isArray`, currency, decimal, and an `AllTypes` entity carrying every
37+
persistable field subtype) and compiles every emitted module with the real TS compiler, on
38+
both dialects. The existing corpora gate BEHAVIOR; none of them asked whether the emitted
39+
code builds, which is why four separate "generated code does not compile" defects shipped
40+
in 1.0.4 with every gate green. It found three more on its first run — all three are fixed
41+
below. Peer lanes for the other ports are still to come; a port without this gate keeps
42+
exactly the bug class it closes.
43+
3144
### Fixed
3245

46+
- **A TPH subtype's declared type and its read schema disagreed, so `parse<Base>()` did not
47+
compile.** The generated `<Sub>` interface and the `<Sub>Schema` the polymorphic
48+
dispatcher parses through were emitted by two rules that answered the same question
49+
differently. The schema made the PRIMARY KEY `.nullable()` — it is the shared base table's
50+
key and is present on every row — while the interface did not admit `null` for a
51+
subtype-only column, which genuinely is NULL on a sibling's row. Either way the value
52+
`parse<Base>()` returns was not assignable to the base union (`TS2322`). Both emitters now
53+
route through one predicate, `isTphReadNullTolerant`. **Generated output changes**: a TPH
54+
subtype's interface widens its null-tolerant fields to `| null`, and its read schema stops
55+
widening the PK.
56+
- **`@dbColumnType: jsonb` emitted a SQLite column that did not match its own type.** The
57+
open-JSON-bag escape hatch types the field `unknown` in Zod and TS, because the value
58+
round-trips as a PARSED JSON value on every dialect. On SQLite the attribute fell through
59+
to a plain `text()` column typed `string`, so the generated queries did not compile — and
60+
had they compiled, the value would have come back as a JSON-encoded string. It now emits
61+
`text(..., { mode: "json" })`, the same idiomatic form `field.object` and `field.map`
62+
already take on that dialect. Postgres is unchanged. **Generated output changes** on SQLite.
3363
- **A view-backed read model carrying an int-backed `field.enum` generated a module that did
3464
not compile.** A `@intValueMap` enum persists as an integer column, and the entity table
3565
emits a module-local Drizzle `customType` codec to encode and decode it. The view path

‎server/typescript/packages/codegen-ts/src/column-mapper.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,19 @@ export function mapColumnType(
468468
// SQLite has no native array type; serialize as JSON in a text column.
469469
fnName = "text";
470470
fnOptions = { mode: "json" };
471+
} else if (field.attr(FIELD_ATTR_DB_COLUMN_TYPE) === DB_COLUMN_TYPE_JSONB) {
472+
// `@dbColumnType: jsonb` — the open JSON bag. SQLite has no native jsonb, but
473+
// the attribute is not merely physical here: the wire contract says the value
474+
// round-trips as a PARSED JSON value on every dialect (the api-contract jsonb
475+
// corpus: "POST a JSON object, read it back as an object — never a
476+
// JSON-encoded string"), and the generated Zod/TS type is `unknown` for that
477+
// reason. Falling through to a plain `text()` gave that column a `string` type
478+
// while the entity type stayed `unknown`, so the generated queries did not
479+
// compile — and had they compiled, the value would have come back as a string.
480+
// text(..., { mode: "json" }) is the same idiomatic form field.object and
481+
// field.map already take on this dialect, and it keeps both halves agreeing.
482+
fnName = "text";
483+
fnOptions = { mode: "json" };
471484
} else {
472485
switch (subType) {
473486
case FIELD_SUBTYPE_BOOLEAN:

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { stripPackage } from "@metaobjectsdev/metadata";
3939
import { enumValues } from "../enum-meta.js";
4040
import { sharedEnumZodConstName } from "./enums-file.js";
4141
import { renderDocsFor } from "./jsdoc.js";
42+
import { isTphReadNullTolerant } from "./zod-validators.js";
4243
import { sharedEnumForField } from "../enum-shared.js";
4344
import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js";
4445
import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
@@ -387,7 +388,12 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte
387388
const required = field.attr(FIELD_ATTR_REQUIRED) === true;
388389
const optional = required ? "" : "?";
389390
const tsType = valueObjectFieldType(entity, field, ctx);
390-
lines.push(code` ${field.name}${optional}: ${tsType};`);
391+
// A TPH subtype's read schema accepts `null` for a column that is NULL on a
392+
// sibling subtype's row; the declared type has to admit the same values, or
393+
// `parse<Base>()` returns something this interface rejects. One predicate
394+
// answers it for both emitters (ADR-style single source, see its docblock).
395+
const nullable = isTphReadNullTolerant(entity, field) ? " | null" : "";
396+
lines.push(code` ${field.name}${optional}: ${tsType}${nullable};`);
391397
}
392398

393399
// joinCode with "\n" interpolates each Code segment on its own line and

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,28 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
126126
return false;
127127
}
128128

129+
/**
130+
* Is this field NULL-tolerant in a TPH subtype's READ shape?
131+
*
132+
* A TPH subtype shares one physical table with its siblings, so a column only one
133+
* subtype declares is NULL on every other subtype's row, and a non-`@required` column
134+
* of this subtype's own is NULL when unset. Either way the value read back is `null`,
135+
* not `undefined`. The PRIMARY KEY is the exception — it is the shared base table's
136+
* key and is present on every row.
137+
*
138+
* ONE predicate, because TWO emitters answer this question about the same field: the
139+
* Zod read schema (`renderTphSubtypeReadSchema`) and the declared TS type
140+
* (`renderValueObjectInterface`). They answered it differently, so the value
141+
* `parse<Base>()` returns was not assignable to the base union and the generated
142+
* module did not compile (TS2322). A second answer to one question is the defect;
143+
* keeping the two call sites pointed here is the fix.
144+
*/
145+
export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
146+
if (!isTphSubtype(obj)) return false;
147+
if (!fieldWillBeOptional(field)) return false;
148+
return !primaryIdentityFieldNames(obj).includes(field.name);
149+
}
150+
129151
/**
130152
* FR-017 Tier 2 — the per-subtype FULL read schema `<Sub>Schema`. Unlike the
131153
* insert schema, this includes every effective field (PK included) so a raw DB
@@ -149,10 +171,12 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
149171
}
150172
const expr = zodFieldExpr(child, obj, ctx);
151173
// zodFieldExpr already appends `.optional()` for non-required fields; add
152-
// `.nullable()` on top so a NULL column value (the TPH default for any
153-
// subtype-only column) parses cleanly.
174+
// `.nullable()` on top so a NULL column value parses cleanly. The declared
175+
// interface widens the SAME fields — see isTphReadNullTolerant.
154176
fieldLines.push(
155-
fieldWillBeOptional(child) ? code` ${child.name}: ${expr}.nullable()` : code` ${child.name}: ${expr}`,
177+
isTphReadNullTolerant(obj, child)
178+
? code` ${child.name}: ${expr}.nullable()`
179+
: code` ${child.name}: ${expr}`,
156180
);
157181
}
158182

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// CODEGEN-COMPILE CONFORMANCE (TypeScript lane)
2+
//
3+
// Generate from the SHARED cross-port corpus — fixtures/persistence-conformance/
4+
// canonical/meta.fitness.json — and compile every emitted module with the real TS
5+
// compiler. Zero diagnostics or the lane is red.
6+
//
7+
// WHY THIS EXISTS. Four defects shipped in 1.0.4 that every existing gate was blind to,
8+
// because each one produced output that PARSES and GENERATES cleanly and only fails when
9+
// somebody builds it:
10+
//
11+
// - a view over an int-backed enum imported a codec drizzle does not export (TS);
12+
// - a renamed projection field selected a column that does not exist (TS);
13+
// - a DbContext named FK config through `nameof` on a member that is not there (C#);
14+
// - an extract mapper did not compile for most scalar subtypes (Java).
15+
//
16+
// `meta gen` exits 0 in all four cases. The metamodel, render, persistence, api-contract
17+
// and registry corpora all stay green — they gate BEHAVIOR, and none of them asks whether
18+
// the emitted code builds. The adopter's build is the first thing that does, which makes
19+
// the adopter the gate. This closes that.
20+
//
21+
// WHY THE FITNESS CORPUS rather than a fixture of its own: it is already the shared
22+
// cross-port model (16 entities, 2 view-backed projections, 2 value objects, two M:N
23+
// through-junctions incl. a self-join, a 4-entity TPH hierarchy, jsonb storage, isArray,
24+
// field.currency, field.decimal, and an AllTypes entity carrying every persistable field
25+
// subtype incl. @intValueMap). A second kitchen sink would drift from the first, and the
26+
// coverage this gate has is exactly the coverage the other corpora already maintain.
27+
//
28+
// The peer lanes are the same test in each port. If one port drops out, that port keeps
29+
// precisely the bug class this exists to catch — so a skip here is never "just this lane".
30+
31+
import { describe, test, expect } from "bun:test";
32+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
33+
import { join, resolve, dirname } from "node:path";
34+
import { fileURLToPath, pathToFileURL } from "node:url";
35+
import ts from "typescript";
36+
import { loadUris, type MetaRoot, type MetaObject } from "@metaobjectsdev/metadata";
37+
import { entityFile } from "../src/generators/entity-file.js";
38+
import { namesFile } from "../src/generators/names-file.js";
39+
import { queriesFile } from "../src/generators/queries-file.js";
40+
import { barrel } from "../src/generators/barrel.js";
41+
import { makeRenderContext } from "../src/render-context.js";
42+
import { buildPkMap } from "../src/pk-resolver.js";
43+
import { buildRelationMap } from "../src/relation-resolver.js";
44+
import type { GenContext } from "../src/generator.js";
45+
46+
// test → codegen-ts → packages → typescript → server → repo root
47+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "..");
48+
const CORPUS = resolve(
49+
REPO_ROOT,
50+
"fixtures",
51+
"persistence-conformance",
52+
"canonical",
53+
"meta.fitness.json",
54+
);
55+
56+
async function loadCorpus(): Promise<MetaRoot> {
57+
const result = await loadUris([pathToFileURL(CORPUS).href]);
58+
if (result.errors.length > 0) {
59+
throw new Error(
60+
`${CORPUS} did not load cleanly:\n` +
61+
result.errors.map((e) => ` ${(e as { code?: string }).code ?? "ERROR"}: ${e.message}`).join("\n"),
62+
);
63+
}
64+
return result.root;
65+
}
66+
67+
describe("codegen-compile conformance — the shared fitness corpus", () => {
68+
for (const dialect of ["postgres", "sqlite"] as const) {
69+
test(`${dialect}: every generated module compiles with zero diagnostics`, async () => {
70+
const root = await loadCorpus();
71+
const dir = mkdtempSync(join(import.meta.dir, `tmp-codegen-compile-${dialect}-`));
72+
try {
73+
const renderContext = makeRenderContext({
74+
dialect,
75+
loadedRoot: root,
76+
outDir: dir,
77+
dbImport: "~/db",
78+
pkMap: buildPkMap(root),
79+
relationMap: buildRelationMap(root),
80+
});
81+
// `matches` is composed from each generator's OWN filter, exactly as runner.ts
82+
// does it (`matches: (e) => generator.filter?.(e) ?? true`). Hardcoding
83+
// `() => true` here would run every generator over every object and report the
84+
// generator's own deliberate exclusions — a value object has no table, a TPH
85+
// subtype no standalone one — as if they were emit bugs. The gate has to run the
86+
// generators the way the runner runs them or it measures the harness.
87+
const genCtx = (generator: { filter?: (e: MetaObject) => boolean }): GenContext => ({
88+
entities: root.objects(),
89+
loadedRoot: root,
90+
matches: (e) => generator.filter?.(e) ?? true,
91+
projectRoot: dir,
92+
config: { outDir: dir, extStyle: "none", dbImport: "~/db", dialect } as never,
93+
renderContext,
94+
warn: () => {},
95+
});
96+
97+
// The model tier plus its consumers. `allowlists: false` keeps the program over
98+
// drizzle + zod alone, so a diagnostic is about OUR emit and not a runtime-ts
99+
// resolution problem; routes are excluded for the same reason C#'s compile test
100+
// excludes them — the server framework's types would drown the signal.
101+
const generators = [
102+
entityFile({ allowlists: false }),
103+
namesFile(),
104+
queriesFile(),
105+
barrel(),
106+
];
107+
const files = (
108+
await Promise.all(generators.map((g) => g.generate(genCtx(g))))
109+
).flat();
110+
expect(files.length).toBeGreaterThan(0);
111+
for (const f of files) writeFileSync(join(dir, f.path), f.content);
112+
113+
const program = ts.createProgram(
114+
files.map((f) => join(dir, f.path)),
115+
{
116+
strict: true,
117+
noEmit: true,
118+
target: ts.ScriptTarget.ES2022,
119+
module: ts.ModuleKind.ESNext,
120+
moduleResolution: ts.ModuleResolutionKind.Bundler,
121+
skipLibCheck: true,
122+
},
123+
);
124+
const diagnostics = ts.getPreEmitDiagnostics(program).map((d) => {
125+
const where =
126+
d.file && d.start !== undefined
127+
? `${d.file.fileName.slice(dir.length + 1)}:${
128+
d.file.getLineAndCharacterOfPosition(d.start).line + 1
129+
} `
130+
: "";
131+
return `${where}${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
132+
});
133+
expect(diagnostics).toEqual([]);
134+
} finally {
135+
rmSync(dir, { recursive: true, force: true });
136+
}
137+
});
138+
}
139+
});

0 commit comments

Comments
 (0)