Skip to content

Commit 70e29fa

Browse files
dmealingclaude
andcommitted
fix(codegen): a view over an int-backed enum emitted a codec it never declared
A `field.enum` carrying `@intValueMap` persists as an integer column, and the entity's Drizzle table declares a module-local `customType` codec to encode and decode it. The view path emitted the CALL SITE only and let the codec's const name fall through to the drizzle core import, so a projection module opened with import { statusIntEnum } from "drizzle-orm/sqlite-core"; naming a member that package does not export. `meta gen` exited 0; only a typecheck caught it (TS2305). Both view hosts were affected — an `object.projection` and a write-through entity's replica view render through the same `renderExistingViewDecl` — and the read-view is the shape the authoring guidance recommends over a projection. The codec emitter moves to a shared `templates/enum-int-codec.ts` and the view now declares what it references. Because a write-through entity puts its table and its replica view in ONE module, the table is rendered first, reports the const names it declared, and the view references those instead of re-declaring them: a duplicate module-scope `const` is a JS parse error, and a declaration emitted after its use is a TDZ ReferenceError at import — both worse than the missing codec, since neither lets the module load at all. No fixture combined `@intValueMap` with a read-only source, which is why the suite was green. Two added tests compile the generated output with the real TS compiler, on both dialects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGmCLHbQKuLBPjjhWC43jk
1 parent 22124b7 commit 70e29fa

7 files changed

Lines changed: 405 additions & 52 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
3030

3131
### Fixed
3232

33+
- **A view-backed read model carrying an int-backed `field.enum` generated a module that did
34+
not compile.** A `@intValueMap` enum persists as an integer column, and the entity table
35+
emits a module-local Drizzle `customType` codec to encode and decode it. The view path
36+
emitted the CALL SITE and let the codec's name fall through to the drizzle core import, so
37+
the file opened with `import { statusIntEnum } from "drizzle-orm/sqlite-core"` — a member
38+
that package does not export. `meta gen` exited 0 and only a typecheck caught it
39+
(`TS2305`). The codec emitter is now shared (`templates/enum-int-codec.ts`) and the view
40+
declares the codecs it references, sorted by const name so output stays deterministic.
41+
This covered BOTH view hosts — an `object.projection` and a write-through entity's replica
42+
view render through the same function, and the read-view is the shape the authoring
43+
guidance recommends over a projection.
3344
- **`meta eject` and `meta gen --list` print an install command you can paste.** The line
3445
was documented as paste-ready and was not, on the most common selection
3546
(`meta eject entity queries routes names barrel`):

‎server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts‎

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr,
2323
} from "../names.js";
2424
import { resolveTableSchema } from "@metaobjectsdev/metadata";
25+
import { renderEnumIntCustomType } from "./enum-int-codec.js";
2526
import { renderRelationsBlock } from "./relations-block.js";
2627
import { renderDocsFor } from "./jsdoc.js";
2728
import { collectTphSubtypeFields } from "./tph-discriminator.js";
@@ -35,7 +36,18 @@ import { effectivePackage } from "../docs-paths.js";
3536
* Returns a Code object so ts-poet can deduplicate imports when this composes
3637
* with the rest of the entity file. Biome formatting runs after composition.
3738
*/
38-
export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
39+
export function renderDrizzleSchema(
40+
obj: MetaObject,
41+
ctx: RenderContext,
42+
/**
43+
* OUT param: the int-backed-enum codec const names this table DECLARES. A caller that
44+
* emits another artifact referencing the same codecs into the SAME module — entity-file's
45+
* write-through replica view — passes a set here and hands it to that emitter, which then
46+
* references the consts instead of re-declaring them. A duplicate module-scope `const` is
47+
* a JS parse error, so the module would not even load.
48+
*/
49+
declaredEnumIntCodecs?: Set<string>,
50+
): Code {
3951
const dialect = ctx.dialect;
4052
const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable";
4153
const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
@@ -381,57 +393,15 @@ ${joinCode(columnLines, { on: ",\n", trim: false })}
381393
const enumIntBlocks = [...enumIntTypes.values()]
382394
.sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
383395
.map((t) => renderEnumIntCustomType(t, importModule));
396+
if (declaredEnumIntCodecs !== undefined) {
397+
for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name);
398+
}
384399

385400
const blocks: Code[] = [...enumIntBlocks, tableBlock];
386401
if (relationsBlock !== null) blocks.push(relationsBlock);
387402
return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" });
388403
}
389404

390-
/**
391-
* Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
392-
* lookup maps.
393-
*
394-
* The codec lives HERE, in the column definition, so nothing downstream needs to
395-
* know about it: `db.insert().values()` encodes on bind, a selected row decodes on
396-
* read, and a filter comparison encodes because Drizzle binds through the column
397-
* type. That is why this shape was chosen over a Zod write-transform plus a
398-
* generated read-decode — TS's generated queries return raw Drizzle rows and have
399-
* no decode seam, so the query-layer approach meant inventing one and wrapping
400-
* every generated read. It is also the direct analogue of what the other four
401-
* ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
402-
* `customEnumeration`, Python `ObjectManager` coercion).
403-
*
404-
* `fromDriver` throws on an unmapped integer rather than returning undefined: a
405-
* value outside the map means the DB holds data the model says is impossible
406-
* (a hand-written INSERT, or a member removed without a migration), and silently
407-
* yielding `undefined` for a non-nullable field would surface far from the cause.
408-
*/
409-
function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
410-
const customTypeSym = imp(`customType@${importModule}`);
411-
const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
412-
const toEntries = t.members
413-
.map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
414-
.join(", ");
415-
const fromEntries = t.members
416-
.map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
417-
.join(", ");
418-
return code`
419-
const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
420-
const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
421-
const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
422-
dataType: () => ${JSON.stringify(t.dataType)},
423-
toDriver: (value) => ${t.toIntConstName}[value],
424-
fromDriver: (value) => {
425-
const member = ${t.fromIntConstName}[value];
426-
if (member === undefined) {
427-
throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
428-
}
429-
return member;
430-
},
431-
});
432-
`;
433-
}
434-
435405
interface FkInfo {
436406
targetVarName: string; // e.g., "users"
437407
targetEntityName: string; // e.g., "User" — used for the import path

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ export function renderEntityFile(
136136
// compile error). A base+write-through combo keeps the TPH polymorphic read path (reads
137137
// the base table); routing its reads through a replica view is a documented non-goal.
138138
const writeThrough = isWriteThrough(entity) && !tphBase;
139+
// Rendered BEFORE the replica view below, for two reasons that both matter: the table
140+
// DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate,
141+
// and a duplicate module-scope const is a parse error), and those consts must precede the
142+
// view's use of them in the emitted file or the view hits a TDZ ReferenceError at import.
143+
const declaredEnumIntCodecs = new Set<string>();
144+
const schemaBlock = renderDrizzleSchema(entity, ctx, declaredEnumIntCodecs);
139145
const viewSections: Code[] = [];
140146
if (writeThrough) {
141147
const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
@@ -168,6 +174,8 @@ export function renderEntityFile(
168174
// is passed separately below and stays a literal — the artifact holds the primary
169175
// (table) source's name, not this one.
170176
names: entityNames,
177+
// The table above already declared these; the view references them by name.
178+
declaredEnumIntCodecs,
171179
// The replica view's OWN @schema, from the same source node projectionViewName picks
172180
// — never the entity's, which is the WRITE TABLE's and would qualify this view with a
173181
// schema that belongs to something else. A view and the table it replicates need not
@@ -206,7 +214,7 @@ ${docsPrefix}export type ${entity.name} = ${z}.infer<typeof ${entity.name}Schema
206214
const constantsNames = namesRef(entity, ctx);
207215

208216
const sections: Code[] = [
209-
renderDrizzleSchema(entity, ctx),
217+
schemaBlock,
210218
...viewSections,
211219
renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */),
212220
...(enumAliases !== null ? [enumAliases] : []),
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* The int-backed `field.enum` codec emitter, shared by the two artifacts that can
3+
* reference one: the entity's Drizzle table (`drizzle-schema.ts`) and any view-backed
4+
* read model (`view-decl.ts` — a projection or an entity read-view).
5+
*
6+
* It lives in its own module because the codec is a MODULE-LOCAL const in whatever file
7+
* declares the column, so every emitter that can produce such a column must be able to
8+
* declare it too. While only the table template owned it, the view template emitted the
9+
* call site and let the const name fall through to `imp(fnName@drizzle-orm/*-core)` —
10+
* importing a member that package does not export, which `meta gen` reported as success
11+
* and only `tsc` caught.
12+
*
13+
* The invariant this module exists to protect: the codec is declared ONCE PER MODULE, and
14+
* it must precede every use. A write-through entity puts its table and its replica view in
15+
* one file and both reference the same consts, so `entity-file.ts` renders the table first,
16+
* collects the names it declared, and hands them to the view — which then references them
17+
* instead of re-declaring. Two module-scope `const`s of one name is a JS parse error, and a
18+
* declaration emitted after its use is a TDZ ReferenceError at import: both are worse than
19+
* the missing-codec bug, because neither lets the module load at all.
20+
*/
21+
import { code, imp, type Code } from "ts-poet";
22+
import type { EnumIntCustomType } from "../column-mapper.js";
23+
24+
/**
25+
* Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
26+
* lookup maps.
27+
*
28+
* The codec lives HERE, in the column definition, so nothing downstream needs to
29+
* know about it: `db.insert().values()` encodes on bind, a selected row decodes on
30+
* read, and a filter comparison encodes because Drizzle binds through the column
31+
* type. That is why this shape was chosen over a Zod write-transform plus a
32+
* generated read-decode — TS's generated queries return raw Drizzle rows and have
33+
* no decode seam, so the query-layer approach meant inventing one and wrapping
34+
* every generated read. It is also the direct analogue of what the other four
35+
* ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
36+
* `customEnumeration`, Python `ObjectManager` coercion).
37+
*
38+
* `fromDriver` throws on an unmapped integer rather than returning undefined: a
39+
* value outside the map means the DB holds data the model says is impossible
40+
* (a hand-written INSERT, or a member removed without a migration), and silently
41+
* yielding `undefined` for a non-nullable field would surface far from the cause.
42+
*/
43+
export function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
44+
const customTypeSym = imp(`customType@${importModule}`);
45+
const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
46+
const toEntries = t.members
47+
.map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
48+
.join(", ");
49+
const fromEntries = t.members
50+
.map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
51+
.join(", ");
52+
return code`
53+
const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
54+
const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
55+
const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
56+
dataType: () => ${JSON.stringify(t.dataType)},
57+
toDriver: (value) => ${t.toIntConstName}[value],
58+
fromDriver: (value) => {
59+
const member = ${t.fromIntConstName}[value];
60+
if (member === undefined) {
61+
throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
62+
}
63+
return member;
64+
},
65+
});
66+
`;
67+
}

‎server/typescript/packages/codegen-ts/src/templates/view-decl.ts‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF,
1313
} from "@metaobjectsdev/metadata";
1414
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
15-
import { mapColumnType } from "../column-mapper.js";
15+
import { mapColumnType, type EnumIntCustomType } from "../column-mapper.js";
16+
import { renderEnumIntCustomType } from "./enum-int-codec.js";
1617
import { zodTypeFor } from "./field-meta.js";
1718
import { columnExpr, type ObjectNames } from "../names.js";
1819

@@ -21,6 +22,14 @@ export interface ViewDeclOpts {
2122
readonly columnNamingStrategy: ColumnNamingStrategy;
2223
/** Drives the timestamp column TS type (Date vs string) in the view declaration. */
2324
readonly timestampMode: "date" | "string";
25+
/**
26+
* Int-backed-enum codec const names ALREADY declared in the module this view lands in.
27+
* A write-through entity's table and its replica view share one file, and both reference
28+
* the same codecs — so the view references these by name and declares only what the table
29+
* did not (a derived field's codec, which the write table omits). Absent = the view owns
30+
* every codec it needs, which is the projection case.
31+
*/
32+
readonly declaredEnumIntCodecs?: ReadonlySet<string>;
2433
/**
2534
* ADR-0044/#228 — resolve a `field.object` / `field.map`'s `@objectRef` to the
2635
* value object's EMITTED name (bare when unique in the run, package-qualified on
@@ -72,10 +81,26 @@ export interface ViewDeclOpts {
7281
* so `db.select().from(<view>)` is typed. Honors `@dbColumnType`; `.existing()`
7382
* views carry type + physical name only (no PK/default/notNull DDL modifiers).
7483
*/
75-
function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code {
84+
function viewColumnLine(
85+
f: MetaField,
86+
opts: ViewDeclOpts,
87+
/** Codecs this view needs, collected for the caller to DECLARE — see below. */
88+
enumIntTypes: Map<string, EnumIntCustomType>,
89+
): Code {
7690
const { dialect, columnNamingStrategy, timestampMode } = opts;
7791
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
78-
const colSym = imp(`${spec.fnName}@${spec.importModule}`);
92+
// An int-backed field.enum's column function is a LOCAL generated const (the
93+
// customType helper the caller emits into this same file), so it must not be
94+
// imported from drizzle-orm/*-core like a built-in column type would be. Same
95+
// rule the table template applies in `renderColumn`; a view reads the very same
96+
// integer column and needs the very same decode on the way out.
97+
if (spec.enumIntCustomType !== undefined) {
98+
enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
99+
}
100+
const colSym =
101+
spec.enumIntCustomType !== undefined
102+
? spec.enumIntCustomType.fnConstName
103+
: imp(`${spec.fnName}@${spec.importModule}`);
79104
const optsArg =
80105
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
81106
? `, ${JSON.stringify(spec.fnOptions)}`
@@ -137,7 +162,8 @@ export function renderExistingViewDecl(
137162
const viewFn = opts.dialect === "postgres" ? "pgView" : "sqliteView";
138163
const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
139164
const viewSym = imp(`${viewFn}@${viewModule}`);
140-
const viewColumnLines = fields.map((f) => viewColumnLine(f, opts));
165+
const enumIntTypes = new Map<string, EnumIntCustomType>();
166+
const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes));
141167
const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName;
142168

143169
// @schema — a view lands in a schema exactly as a table does, and migrate qualifies the
@@ -153,14 +179,26 @@ export function renderExistingViewDecl(
153179
const viewCall: Code = viewSchemaExpr === undefined
154180
? code`${viewSym}`
155181
: code`${imp(`pgSchema@${viewModule}`)}(${viewSchemaExpr}).view`;
156-
return code`
182+
// Int-backed enum codecs are declared BEFORE the view that references them, sorted
183+
// by const name so output is deterministic regardless of field order — the same
184+
// contract the table template holds.
185+
const alreadyDeclared = opts.declaredEnumIntCodecs;
186+
const enumIntBlocks = [...enumIntTypes.values()]
187+
.filter((t) => alreadyDeclared === undefined || !alreadyDeclared.has(t.fnConstName))
188+
.sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
189+
.map((t) => renderEnumIntCustomType(t, viewModule));
190+
191+
const viewBlock = code`
157192
// View declaration — Drizzle uses this for typed SELECT queries.
158193
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
159194
// not to attempt DDL for this declaration.
160195
export const ${viewVar} = ${viewCall}(${viewNameExpr}, {
161196
${joinCode(viewColumnLines, { on: ",\n" })}
162197
}).existing();
163198
`;
199+
return enumIntBlocks.length === 0
200+
? viewBlock
201+
: joinCode([...enumIntBlocks, viewBlock], { on: "\n" });
164202
}
165203

166204
/**

0 commit comments

Comments
 (0)