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

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 (71) 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/generators/api-model.d.ts.map +1 -1
  5. package/dist/generators/api-model.js +6 -6
  6. package/dist/generators/api-model.js.map +1 -1
  7. package/dist/generators/queries-file.d.ts.map +1 -1
  8. package/dist/generators/queries-file.js +7 -2
  9. package/dist/generators/queries-file.js.map +1 -1
  10. package/dist/generators/routes-file-hono.d.ts +2 -1
  11. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  12. package/dist/generators/routes-file-hono.js +4 -3
  13. package/dist/generators/routes-file-hono.js.map +1 -1
  14. package/dist/generators/routes-file.d.ts +2 -1
  15. package/dist/generators/routes-file.d.ts.map +1 -1
  16. package/dist/generators/routes-file.js +4 -3
  17. package/dist/generators/routes-file.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  23. package/dist/projection/extract-view-spec.js +49 -3
  24. package/dist/projection/extract-view-spec.js.map +1 -1
  25. package/dist/templates/drizzle-schema.d.ts +9 -1
  26. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  27. package/dist/templates/drizzle-schema.js +37 -49
  28. package/dist/templates/drizzle-schema.js.map +1 -1
  29. package/dist/templates/entity-file.d.ts.map +1 -1
  30. package/dist/templates/entity-file.js +9 -1
  31. package/dist/templates/entity-file.js.map +1 -1
  32. package/dist/templates/enum-int-codec.d.ts +43 -0
  33. package/dist/templates/enum-int-codec.d.ts.map +1 -0
  34. package/dist/templates/enum-int-codec.js +66 -0
  35. package/dist/templates/enum-int-codec.js.map +1 -0
  36. package/dist/templates/inferred-types.d.ts.map +1 -1
  37. package/dist/templates/inferred-types.js +7 -1
  38. package/dist/templates/inferred-types.js.map +1 -1
  39. package/dist/templates/relations-block.d.ts.map +1 -1
  40. package/dist/templates/relations-block.js +13 -6
  41. package/dist/templates/relations-block.js.map +1 -1
  42. package/dist/templates/routes-file.d.ts.map +1 -1
  43. package/dist/templates/routes-file.js +21 -4
  44. package/dist/templates/routes-file.js.map +1 -1
  45. package/dist/templates/view-decl.d.ts +8 -0
  46. package/dist/templates/view-decl.d.ts.map +1 -1
  47. package/dist/templates/view-decl.js +29 -4
  48. package/dist/templates/view-decl.js.map +1 -1
  49. package/dist/templates/zod-validators.d.ts +32 -1
  50. package/dist/templates/zod-validators.d.ts.map +1 -1
  51. package/dist/templates/zod-validators.js +49 -3
  52. package/dist/templates/zod-validators.js.map +1 -1
  53. package/package.json +6 -6
  54. package/src/column-mapper.ts +13 -0
  55. package/src/generators/api-model.ts +6 -6
  56. package/src/generators/queries-file.ts +7 -2
  57. package/src/generators/routes-file-hono.ts +4 -3
  58. package/src/generators/routes-file.ts +4 -3
  59. package/src/index.ts +1 -1
  60. package/src/projection/extract-view-spec.ts +55 -6
  61. package/src/reference/queries.ts +4 -2
  62. package/src/reference/routes-hono.ts +2 -2
  63. package/src/reference/routes.ts +4 -3
  64. package/src/templates/drizzle-schema.ts +37 -50
  65. package/src/templates/entity-file.ts +9 -1
  66. package/src/templates/enum-int-codec.ts +67 -0
  67. package/src/templates/inferred-types.ts +7 -1
  68. package/src/templates/relations-block.ts +19 -11
  69. package/src/templates/routes-file.ts +29 -12
  70. package/src/templates/view-decl.ts +43 -5
  71. package/src/templates/zod-validators.ts +50 -4
@@ -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
@@ -8,6 +8,7 @@ import { type RenderContext } from "../render-context.js";
8
8
  import { crossEntitySpecifier } from "../import-path.js";
9
9
  import type { RelationEntry } from "../relation-resolver.js";
10
10
  import { effectivePackage } from "../docs-paths.js";
11
+ import { tphStorageName } from "./zod-validators.js";
11
12
 
12
13
  /**
13
14
  * Render the relations() block for one entity.
@@ -31,7 +32,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co
31
32
 
32
33
  const thisEntityPackage = effectivePackage(entity);
33
34
  const lines: Code[] = entries.map((entry) =>
34
- renderRelationEntry(entry, ctx, varName, thisEntityPackage),
35
+ renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage),
35
36
  );
36
37
 
37
38
  return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({
@@ -43,6 +44,7 @@ ${joinCode(lines, { on: ",\n", trim: false })}
43
44
  function renderRelationEntry(
44
45
  entry: RelationEntry,
45
46
  ctx: RenderContext,
47
+ thisEntityName: string,
46
48
  thisVarName: string,
47
49
  thisEntityPackage: string | undefined,
48
50
  ): Code {
@@ -63,18 +65,24 @@ function renderRelationEntry(
63
65
  return code` ${entry.name}: many(${junctionVarSym})`;
64
66
  }
65
67
 
66
- // Use imp() for cross-entity references so ts-poet tracks and emits the import.
67
- const targetSpec = crossEntitySpecifier(
68
- ctx.outputLayout,
69
- thisEntityPackage,
70
- ctx.packageOf.get(entry.targetEntity),
71
- entry.targetEntity,
72
- ctx.extStyle,
73
- );
74
- const targetVarSym = imp(`${ctx.collectionName(entry.targetEntity)}@${targetSpec}`);
68
+ // Bind to the table that stores the target's rows: a TPH subtype's module has no
69
+ // table const, so a navigation onto `Carrier` binds `parties` from `Party`.
70
+ const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot);
71
+ // A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of
72
+ // its subtypes) names the local const: importing it from this very module clashes with
73
+ // its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import.
74
+ const targetVarSym = targetTableEntity === thisEntityName
75
+ ? thisVarName
76
+ : imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier(
77
+ ctx.outputLayout,
78
+ thisEntityPackage,
79
+ ctx.packageOf.get(targetTableEntity),
80
+ targetTableEntity,
81
+ ctx.extStyle,
82
+ )}`);
75
83
 
76
84
  if (entry.cardinality === CARDINALITY_ONE) {
77
- const pkInfo = ctx.pkMap.get(entry.targetEntity);
85
+ const pkInfo = ctx.pkMap.get(targetTableEntity);
78
86
  const targetPkField = pkInfo?.fieldName ?? "id";
79
87
  return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`;
80
88
  }
@@ -31,6 +31,7 @@ import type { RelationEntry } from "../relation-resolver.js";
31
31
  import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js";
32
32
  import { authSeamJsDoc, type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
33
33
  import { effectivePackage } from "../docs-paths.js";
34
+ import { tphDiscriminatorPin, tphStorageObject } from "./zod-validators.js";
34
35
 
35
36
  export function renderRoutesFile(
36
37
  entity: MetaObject,
@@ -288,18 +289,30 @@ function renderM2mMount(
288
289
  ctx.extStyle,
289
290
  )}`,
290
291
  );
291
- const targetVarSym = imp(
292
- `${ctx.collectionName(entry.targetEntity)}@${crossEntitySpecifier(
293
- ctx.outputLayout,
294
- sourcePkg,
295
- ctx.packageOf.get(entry.targetEntity),
296
- entry.targetEntity,
297
- ctx.extStyle,
298
- )}`,
299
- );
292
+ // An M:N onto a TPH subtype traverses into its discriminator BASE's table — the
293
+ // subtype has no table const, and the junction FK can only point at the base table —
294
+ // and filters the rows to the subtype, because a Broker id in that FK column is not a
295
+ // Carrier. Every other target binds to itself, and emits no filter.
296
+ const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity);
297
+ const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget);
298
+ const targetTableEntity = target?.name ?? entry.targetEntity;
299
+ const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget);
300
+ // A self-join's target table IS the source table, which this file already imports from
301
+ // the entity module; a second import of the same binding is TS2300, and a SyntaxError
302
+ // when Node loads the module.
303
+ const targetVarSym = targetTableEntity === source.name
304
+ ? ctx.collectionName(source.name)
305
+ : imp(
306
+ `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier(
307
+ ctx.outputLayout,
308
+ sourcePkg,
309
+ ctx.packageOf.get(targetTableEntity),
310
+ targetTableEntity,
311
+ ctx.extStyle,
312
+ )}`,
313
+ );
300
314
  const mountM2mRouteSym = imp("mountM2mRoute@@metaobjectsdev/runtime-ts/drizzle-fastify");
301
315
  const junction = ctx.loadedRoot.findObject(entry.junctionEntity);
302
- const target = ctx.loadedRoot.findObject(entry.targetEntity);
303
316
  // fromPackage = source.package: this routes file is SOURCE's own module, never the
304
317
  // junction's or the target's — see resolveJunctionColumn's doc comment (B1).
305
318
  const sourceColumn: Code = junction
@@ -309,8 +322,12 @@ function renderM2mMount(
309
322
  ? resolveJunctionColumn(junction, entry.targetJoinField!, ctx, sourcePkg)
310
323
  : code`${JSON.stringify(entry.targetJoinField!)}`;
311
324
  const targetPkColumn: Code = target
312
- ? resolveJunctionColumn(target, ctx.pkMap.get(entry.targetEntity)?.fieldName ?? "id", ctx, sourcePkg)
325
+ ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg)
313
326
  : code`${JSON.stringify("id")}`;
327
+ const discriminatorLine: Code | string = pin !== undefined && target !== undefined
328
+ ? code`
329
+ targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },`
330
+ : "";
314
331
 
315
332
  return code` ${mountM2mRouteSym}({
316
333
  fastify: ${fastifyVar},
@@ -322,7 +339,7 @@ function renderM2mMount(
322
339
  sourceColumn: ${sourceColumn},
323
340
  targetColumn: ${targetColumn},
324
341
  targetPkColumn: ${targetPkColumn},
325
- symmetric: ${entry.symmetric ? "true" : "false"},
342
+ symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}
326
343
  });`;
327
344
  }
328
345
 
@@ -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
  /**
@@ -10,7 +10,7 @@
10
10
  // downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.
11
11
 
12
12
  import { code, joinCode, imp, type Code } from "ts-poet";
13
- import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
13
+ import { MetaObject, MetaField, type MetaRoot, stripPackage } from "@metaobjectsdev/metadata";
14
14
  import {
15
15
  FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
16
16
  FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
@@ -75,6 +75,28 @@ export function tphDiscriminatorBase(obj: MetaObject): MetaObject | undefined {
75
75
  return tphDiscriminatorLevel(obj)?.base;
76
76
  }
77
77
 
78
+ /**
79
+ * The object whose TABLE stores `obj`'s rows: the discriminator base for anything beneath
80
+ * a TPH base — a concrete subtype or an abstract level in between — otherwise `obj`.
81
+ *
82
+ * Neither a subtype's module nor an abstract level's emits a table const, so anything
83
+ * that binds to "the other side" of a reference or relationship — an FK's `.references()`,
84
+ * a `relations()` entry, an M:N traversal's target table — must bind here and never to the
85
+ * target's own name. Binding to the subtype imported `carriers` from a `Carrier.ts` that
86
+ * does not export it.
87
+ */
88
+ export function tphStorageObject(obj: MetaObject): MetaObject {
89
+ if (declaresTphDiscriminator(obj)) return obj;
90
+ return tphDiscriminatorBase(obj) ?? obj;
91
+ }
92
+
93
+ /** {@link tphStorageObject} by entity name, for the name-keyed relation map. A name the
94
+ * root does not resolve is returned unchanged. */
95
+ export function tphStorageName(entityName: string, root: MetaRoot): string {
96
+ const obj = root.findObject(entityName);
97
+ return obj === undefined ? entityName : tphStorageObject(obj).name;
98
+ }
99
+
78
100
  /**
79
101
  * True when this object itself DECLARES `@discriminator` — the level of a TPH hierarchy
80
102
  * that owns the discriminator, whether or not any concrete subtype extends it yet.
@@ -126,6 +148,28 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
126
148
  return false;
127
149
  }
128
150
 
151
+ /**
152
+ * Is this field NULL-tolerant in a TPH subtype's READ shape?
153
+ *
154
+ * A TPH subtype shares one physical table with its siblings, so a column only one
155
+ * subtype declares is NULL on every other subtype's row, and a non-`@required` column
156
+ * of this subtype's own is NULL when unset. Either way the value read back is `null`,
157
+ * not `undefined`. The PRIMARY KEY is the exception — it is the shared base table's
158
+ * key and is present on every row.
159
+ *
160
+ * ONE predicate, because TWO emitters answer this question about the same field: the
161
+ * Zod read schema (`renderTphSubtypeReadSchema`) and the declared TS type
162
+ * (`renderValueObjectInterface`). They answered it differently, so the value
163
+ * `parse<Base>()` returns was not assignable to the base union and the generated
164
+ * module did not compile (TS2322). A second answer to one question is the defect;
165
+ * keeping the two call sites pointed here is the fix.
166
+ */
167
+ export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
168
+ if (!isTphSubtype(obj)) return false;
169
+ if (!fieldWillBeOptional(field)) return false;
170
+ return !primaryIdentityFieldNames(obj).includes(field.name);
171
+ }
172
+
129
173
  /**
130
174
  * FR-017 Tier 2 — the per-subtype FULL read schema `<Sub>Schema`. Unlike the
131
175
  * insert schema, this includes every effective field (PK included) so a raw DB
@@ -149,10 +193,12 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
149
193
  }
150
194
  const expr = zodFieldExpr(child, obj, ctx);
151
195
  // 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.
196
+ // `.nullable()` on top so a NULL column value parses cleanly. The declared
197
+ // interface widens the SAME fields — see isTphReadNullTolerant.
154
198
  fieldLines.push(
155
- fieldWillBeOptional(child) ? code` ${child.name}: ${expr}.nullable()` : code` ${child.name}: ${expr}`,
199
+ isTphReadNullTolerant(obj, child)
200
+ ? code` ${child.name}: ${expr}.nullable()`
201
+ : code` ${child.name}: ${expr}`,
156
202
  );
157
203
  }
158
204