@metaobjectsdev/codegen-ts 1.0.4-rc.1 → 1.0.5-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/column-mapper.d.ts.map +1 -1
  2. package/dist/column-mapper.js +14 -0
  3. package/dist/column-mapper.js.map +1 -1
  4. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  5. package/dist/projection/extract-view-spec.js +49 -3
  6. package/dist/projection/extract-view-spec.js.map +1 -1
  7. package/dist/templates/drizzle-schema.d.ts +9 -1
  8. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  9. package/dist/templates/drizzle-schema.js +14 -45
  10. package/dist/templates/drizzle-schema.js.map +1 -1
  11. package/dist/templates/entity-file.d.ts.map +1 -1
  12. package/dist/templates/entity-file.js +9 -1
  13. package/dist/templates/entity-file.js.map +1 -1
  14. package/dist/templates/enum-int-codec.d.ts +43 -0
  15. package/dist/templates/enum-int-codec.d.ts.map +1 -0
  16. package/dist/templates/enum-int-codec.js +66 -0
  17. package/dist/templates/enum-int-codec.js.map +1 -0
  18. package/dist/templates/inferred-types.d.ts.map +1 -1
  19. package/dist/templates/inferred-types.js +7 -1
  20. package/dist/templates/inferred-types.js.map +1 -1
  21. package/dist/templates/view-decl.d.ts +8 -0
  22. package/dist/templates/view-decl.d.ts.map +1 -1
  23. package/dist/templates/view-decl.js +29 -4
  24. package/dist/templates/view-decl.js.map +1 -1
  25. package/dist/templates/zod-validators.d.ts +18 -1
  26. package/dist/templates/zod-validators.d.ts.map +1 -1
  27. package/dist/templates/zod-validators.js +28 -3
  28. package/dist/templates/zod-validators.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/column-mapper.ts +13 -0
  31. package/src/projection/extract-view-spec.ts +55 -6
  32. package/src/templates/drizzle-schema.ts +16 -46
  33. package/src/templates/entity-file.ts +9 -1
  34. package/src/templates/enum-int-codec.ts +67 -0
  35. package/src/templates/inferred-types.ts +7 -1
  36. package/src/templates/view-decl.ts +43 -5
  37. package/src/templates/zod-validators.ts +27 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/codegen-ts",
3
- "version": "1.0.4-rc.1",
3
+ "version": "1.0.5-rc.1",
4
4
  "description": "TypeScript codegen engine for MetaObjects — emits Drizzle, Zod, and Fastify artifacts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -111,8 +111,8 @@
111
111
  "access": "public"
112
112
  },
113
113
  "dependencies": {
114
- "@metaobjectsdev/metadata": "1.0.4-rc.1",
115
- "@metaobjectsdev/render": "1.0.4-rc.1",
114
+ "@metaobjectsdev/metadata": "1.0.5-rc.1",
115
+ "@metaobjectsdev/render": "1.0.5-rc.1",
116
116
  "@biomejs/js-api": "^0.7.0",
117
117
  "@biomejs/wasm-nodejs": "^1.9.4",
118
118
  "@toon-format/toon": "^2.3.0",
@@ -124,9 +124,9 @@
124
124
  },
125
125
  "devDependencies": {
126
126
  "@biomejs/biome": "^1.9.0",
127
- "@metaobjectsdev/codegen-ts-react": "1.0.4-rc.1",
128
- "@metaobjectsdev/migrate-ts": "1.0.4-rc.1",
129
- "@metaobjectsdev/runtime-ts": "1.0.4-rc.1",
127
+ "@metaobjectsdev/codegen-ts-react": "1.0.5-rc.1",
128
+ "@metaobjectsdev/migrate-ts": "1.0.5-rc.1",
129
+ "@metaobjectsdev/runtime-ts": "1.0.5-rc.1",
130
130
  "bun-types": "latest",
131
131
  "drizzle-orm": "^0.38.0",
132
132
  "hono": "^4.6.0",
@@ -468,6 +468,19 @@ export function mapColumnType(
468
468
  // SQLite has no native array type; serialize as JSON in a text column.
469
469
  fnName = "text";
470
470
  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" };
471
484
  } else {
472
485
  switch (subType) {
473
486
  case FIELD_SUBTYPE_BOOLEAN:
@@ -570,6 +570,50 @@ function sourceColumnNameFor(
570
570
  return columnNameFromField(entityField.name, ctx.columnNamingStrategy);
571
571
  }
572
572
 
573
+ /**
574
+ * The BASE table's column an extends-bound passthrough field SELECTS.
575
+ *
576
+ * A projection field may RENAME what it exposes
577
+ * (`{ field.string: { name: bookingRef, extends: "Shipment.reference" } }`). The view's
578
+ * OUTPUT alias is the projection field's own; the column it reads from the base table is
579
+ * the extends TARGET's. Deriving both from the projection field emits
580
+ * `SELECT s.booking_ref` against a table whose column is `reference` — SQL that SQLite
581
+ * accepts at CREATE VIEW and only fails at the first SELECT, and that Postgres rejects
582
+ * outright at migrate time.
583
+ *
584
+ * Caller contract: this is for a PLAIN projection only. A read-view HOST (`base ===
585
+ * projection`) reads its own table, so its passthrough source is always the field's own
586
+ * column and there is no rename to resolve — and a host field may legally `extends` a
587
+ * SIBLING field of the same entity for shape reuse, which this would otherwise read as a
588
+ * rename and silently serve the sibling's data under this field's name.
589
+ *
590
+ * Only a DOTTED extends naming the BASE entity redirects the source column:
591
+ *
592
+ * - `extends: "Shipment.reference"` — binds a base column; the target's column wins.
593
+ * - `extends: "Code24"` (bare, a package-level abstract) — SHAPE REUSE, not a binding.
594
+ * `refNamedOwner` returns undefined, so the field's own name stays the source.
595
+ * - `extends: "Other.foo"` — names an entity that is not the base. The caller emits this
596
+ * column against `joinTree.baseAlias`, so borrowing another entity's column name would
597
+ * be a second wrong answer; keep the field's own.
598
+ *
599
+ * The alias is not always the projection's spelling: `sourceColumnNameFor` reads `@column`
600
+ * with the RESOLVING accessor, so a renaming field whose base carries an explicit `@column`
601
+ * inherits it and both sides land on that physical name. The rename then holds on the TS /
602
+ * wire tier only. That is pre-existing and self-consistent — the Drizzle view decl binds
603
+ * the same column — but it is not a promise this function makes.
604
+ */
605
+ function baseColumnNameFor(
606
+ field: MetaField,
607
+ base: MetaObject,
608
+ root: MetaRoot,
609
+ ctx: ExtractContext,
610
+ ): string {
611
+ const target = field.superData;
612
+ if (target === undefined) return sourceColumnNameFor(field, ctx);
613
+ if (refNamedOwner(field, root) !== base) return sourceColumnNameFor(field, ctx);
614
+ return sourceColumnNameFor(target, ctx);
615
+ }
616
+
573
617
  /**
574
618
  * Physical column for a join FK/PK field — resolves @column + naming strategy the
575
619
  * same way passthrough columns do (EFFECTIVE fields, so inherited PKs resolve).
@@ -976,22 +1020,27 @@ function buildSelectSpec(
976
1020
  // fields — the declared set IS the exposure (FR-024/ADR-0028). Either way, each
977
1021
  // field's own origin decides passthrough-from-base vs derived-from-join. origin.*
978
1022
  // NEVER inherits (ADR-0029), so the origin reads below are own (category 4).
979
- const declaredFields: MetaField[] =
980
- base === projection
981
- ? base.fields()
982
- : projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
1023
+ // A read-view HOST reads its OWN table (base === projection), so every passthrough
1024
+ // sources from the field's own column; a plain projection reads the base's, which an
1025
+ // extends-bound field may rename. The flag decides both the field set and the source.
1026
+ const isReadViewHost = base === projection;
1027
+ const declaredFields: MetaField[] = isReadViewHost
1028
+ ? base.fields()
1029
+ : projection.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD);
983
1030
  for (const field of declaredFields) {
984
1031
  const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN);
985
1032
  const dbCol = sourceColumnNameFor(field, ctx);
986
1033
 
987
1034
  if (!origin) {
988
- // Declared on projection but no origin — passthrough from base table.
1035
+ // Declared on projection but no origin — passthrough from base table. The alias is
1036
+ // this field's own column; the SOURCE is the extends target's, which differs
1037
+ // whenever the projection renames the base field.
989
1038
  columns.push({
990
1039
  kind: "passthrough",
991
1040
  fieldName: field.name,
992
1041
  dbColAlias: dbCol,
993
1042
  sourceAlias: joinTree.baseAlias,
994
- sourceColumn: dbCol,
1043
+ sourceColumn: isReadViewHost ? dbCol : baseColumnNameFor(field, base, root, ctx),
995
1044
  });
996
1045
  continue;
997
1046
  }
@@ -22,6 +22,7 @@ import {
22
22
  namesRef, physicalNameExpr, sourceSchemaExpr, indexNameExpr, columnExpr,
23
23
  } from "../names.js";
24
24
  import { resolveTableSchema } from "@metaobjectsdev/metadata";
25
+ import { renderEnumIntCustomType } from "./enum-int-codec.js";
25
26
  import { renderRelationsBlock } from "./relations-block.js";
26
27
  import { renderDocsFor } from "./jsdoc.js";
27
28
  import { collectTphSubtypeFields } from "./tph-discriminator.js";
@@ -35,7 +36,18 @@ import { effectivePackage } from "../docs-paths.js";
35
36
  * Returns a Code object so ts-poet can deduplicate imports when this composes
36
37
  * with the rest of the entity file. Biome formatting runs after composition.
37
38
  */
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 {
39
51
  const dialect = ctx.dialect;
40
52
  const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable";
41
53
  const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
@@ -381,57 +393,15 @@ ${joinCode(columnLines, { on: ",\n", trim: false })}
381
393
  const enumIntBlocks = [...enumIntTypes.values()]
382
394
  .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
383
395
  .map((t) => renderEnumIntCustomType(t, importModule));
396
+ if (declaredEnumIntCodecs !== undefined) {
397
+ for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name);
398
+ }
384
399
 
385
400
  const blocks: Code[] = [...enumIntBlocks, tableBlock];
386
401
  if (relationsBlock !== null) blocks.push(relationsBlock);
387
402
  return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" });
388
403
  }
389
404
 
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
-
435
405
  interface FkInfo {
436
406
  targetVarName: string; // e.g., "users"
437
407
  targetEntityName: string; // e.g., "User" — used for the import path
@@ -136,6 +136,12 @@ export function renderEntityFile(
136
136
  // compile error). A base+write-through combo keeps the TPH polymorphic read path (reads
137
137
  // the base table); routing its reads through a replica view is a documented non-goal.
138
138
  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);
139
145
  const viewSections: Code[] = [];
140
146
  if (writeThrough) {
141
147
  const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
@@ -168,6 +174,8 @@ export function renderEntityFile(
168
174
  // is passed separately below and stays a literal — the artifact holds the primary
169
175
  // (table) source's name, not this one.
170
176
  names: entityNames,
177
+ // The table above already declared these; the view references them by name.
178
+ declaredEnumIntCodecs,
171
179
  // The replica view's OWN @schema, from the same source node projectionViewName picks
172
180
  // — never the entity's, which is the WRITE TABLE's and would qualify this view with a
173
181
  // 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
206
214
  const constantsNames = namesRef(entity, ctx);
207
215
 
208
216
  const sections: Code[] = [
209
- renderDrizzleSchema(entity, ctx),
217
+ schemaBlock,
210
218
  ...viewSections,
211
219
  renderInferredTypes(entity, tphBase, ctx, writeThrough /* skipRow — read type is the view schema */),
212
220
  ...(enumAliases !== null ? [enumAliases] : []),
@@ -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
+ }
@@ -39,6 +39,7 @@ import { stripPackage } from "@metaobjectsdev/metadata";
39
39
  import { enumValues } from "../enum-meta.js";
40
40
  import { sharedEnumZodConstName } from "./enums-file.js";
41
41
  import { renderDocsFor } from "./jsdoc.js";
42
+ import { isTphReadNullTolerant } from "./zod-validators.js";
42
43
  import { sharedEnumForField } from "../enum-shared.js";
43
44
  import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js";
44
45
  import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
@@ -387,7 +388,12 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte
387
388
  const required = field.attr(FIELD_ATTR_REQUIRED) === true;
388
389
  const optional = required ? "" : "?";
389
390
  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};`);
391
397
  }
392
398
 
393
399
  // joinCode with "\n" interpolates each Code segment on its own line and
@@ -12,7 +12,8 @@ import {
12
12
  type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF,
13
13
  } from "@metaobjectsdev/metadata";
14
14
  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";
16
17
  import { zodTypeFor } from "./field-meta.js";
17
18
  import { columnExpr, type ObjectNames } from "../names.js";
18
19
 
@@ -21,6 +22,14 @@ export interface ViewDeclOpts {
21
22
  readonly columnNamingStrategy: ColumnNamingStrategy;
22
23
  /** Drives the timestamp column TS type (Date vs string) in the view declaration. */
23
24
  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>;
24
33
  /**
25
34
  * ADR-0044/#228 — resolve a `field.object` / `field.map`'s `@objectRef` to the
26
35
  * value object's EMITTED name (bare when unique in the run, package-qualified on
@@ -72,10 +81,26 @@ export interface ViewDeclOpts {
72
81
  * so `db.select().from(<view>)` is typed. Honors `@dbColumnType`; `.existing()`
73
82
  * views carry type + physical name only (no PK/default/notNull DDL modifiers).
74
83
  */
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 {
76
90
  const { dialect, columnNamingStrategy, timestampMode } = opts;
77
91
  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}`);
79
104
  const optsArg =
80
105
  spec.fnOptions && Object.keys(spec.fnOptions).length > 0
81
106
  ? `, ${JSON.stringify(spec.fnOptions)}`
@@ -137,7 +162,8 @@ export function renderExistingViewDecl(
137
162
  const viewFn = opts.dialect === "postgres" ? "pgView" : "sqliteView";
138
163
  const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
139
164
  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));
141
167
  const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName;
142
168
 
143
169
  // @schema — a view lands in a schema exactly as a table does, and migrate qualifies the
@@ -153,7 +179,16 @@ export function renderExistingViewDecl(
153
179
  const viewCall: Code = viewSchemaExpr === undefined
154
180
  ? code`${viewSym}`
155
181
  : 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`
157
192
  // View declaration — Drizzle uses this for typed SELECT queries.
158
193
  // The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
159
194
  // not to attempt DDL for this declaration.
@@ -161,6 +196,9 @@ export const ${viewVar} = ${viewCall}(${viewNameExpr}, {
161
196
  ${joinCode(viewColumnLines, { on: ",\n" })}
162
197
  }).existing();
163
198
  `;
199
+ return enumIntBlocks.length === 0
200
+ ? viewBlock
201
+ : joinCode([...enumIntBlocks, viewBlock], { on: "\n" });
164
202
  }
165
203
 
166
204
  /**
@@ -126,6 +126,28 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
126
126
  return false;
127
127
  }
128
128
 
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
+
129
151
  /**
130
152
  * FR-017 Tier 2 — the per-subtype FULL read schema `<Sub>Schema`. Unlike the
131
153
  * insert schema, this includes every effective field (PK included) so a raw DB
@@ -149,10 +171,12 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
149
171
  }
150
172
  const expr = zodFieldExpr(child, obj, ctx);
151
173
  // 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.
154
176
  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}`,
156
180
  );
157
181
  }
158
182