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

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.
@@ -18,6 +18,8 @@ import {
18
18
  stripPackage,
19
19
  } from "@metaobjectsdev/metadata";
20
20
  import { variableNameFromEntity } from "./naming.js";
21
+ import { hasWritableRdbSource } from "./source-detect.js";
22
+ import { tphStorageObject } from "./templates/zod-validators.js";
21
23
  import { isProjection } from "./projection/projection-detector.js";
22
24
 
23
25
  export interface RelationEntry {
@@ -55,8 +57,16 @@ export type RelationMap = Map<string, RelationEntry[]>;
55
57
  /**
56
58
  * Walk all entities, collect relationship children, and also register inverse
57
59
  * many() sides on the target entity.
60
+ *
61
+ * `onWarn`, when supplied, receives one line per M:N relationship whose junction FKs
62
+ * could not be derived: the entry is skipped (a route that mounts nothing is an
63
+ * ABSENCE, not an error), and the warning is the only thing that says so. The runner
64
+ * passes its warnings channel; callers without one keep the silent skip.
58
65
  */
59
- export function buildRelationMap(root: MetaRoot): RelationMap {
66
+ export function buildRelationMap(
67
+ root: MetaRoot,
68
+ onWarn?: (msg: string) => void,
69
+ ): RelationMap {
60
70
  const result: RelationMap = new Map();
61
71
 
62
72
  const ensure = (name: string): RelationEntry[] => {
@@ -64,12 +74,71 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
64
74
  return result.get(name)!;
65
75
  };
66
76
 
77
+ // Files a CARDINALITY-ONE entry under the entity whose module renders the
78
+ // relations() block. A TPH subtype has no module of its own — it is folded into
79
+ // the discriminator base's single table, and the block renders on the BASE's.
80
+ // So an entry must be filed under the entity that actually renders it, or it is
81
+ // silently never emitted. `tphStorageName` is the seam for exactly this (its own
82
+ // doc says "for the name-keyed relation map"); the TARGET side of relations-block
83
+ // already resolves through it — only the SOURCE side was keyed raw.
84
+ //
85
+ // Because `obj.relationships()` RESOLVES, a base-declared relationship is reached
86
+ // again through every subtype and would now land on the same key repeatedly, so an
87
+ // entry structurally identical to one already filed is skipped. A name that collides
88
+ // with a DIFFERENT shape is a real conflict the base's single block cannot express:
89
+ // it is reported through `onWarn` rather than silently overwritten, because a
90
+ // navigation that quietly resolves to another subtype's target is the worse failure.
91
+ // True when a module renders a relations() block for this storage object — the
92
+ // question the map's contract asks, not either cause of its answer. Both
93
+ // non-abstractness and a writable source.rdb are required: the entity file
94
+ // routes an object failing EITHER to renderValueObjectFile, which emits no
95
+ // block. The oracle's tableBackedObjects walk makes the same exclusion. Filing
96
+ // an entry without it would only document, in `meta docs`/api-model, a
97
+ // `<Entity>Relations` export that no module emits.
98
+ const rendersRelationsBlock = (storing: MetaObject): boolean =>
99
+ !storing.isAbstract && hasWritableRdbSource(storing);
100
+
101
+ const push = (obj: MetaObject, entry: RelationEntry): void => {
102
+ const storing = tphStorageObject(obj);
103
+ if (!rendersRelationsBlock(storing)) return;
104
+ const key = storing.name;
105
+ const entries = ensure(key);
106
+ const clash = entries.find((e) => e.name === entry.name);
107
+ if (clash !== undefined) {
108
+ const same =
109
+ clash.cardinality === entry.cardinality &&
110
+ clash.targetEntity === entry.targetEntity &&
111
+ clash.fkField === entry.fkField;
112
+ if (!same) {
113
+ onWarn?.(
114
+ `relationship "${entry.name}" on entity "${obj.name}" gets no relations() entry: ` +
115
+ `"${key}" already carries a different "${entry.name}" ` +
116
+ `(-> ${clash.targetEntity} via ${clash.fkField}), and a single-table hierarchy ` +
117
+ `renders ONE relations() block on the base, which cannot hold both. ` +
118
+ `Rename one of them.`,
119
+ );
120
+ }
121
+ return;
122
+ }
123
+ entries.push(entry);
124
+ };
125
+
67
126
  for (const obj of root.objects()) {
68
127
  // Projections (source.dbView) are view-backed; they never emit a relations()
69
128
  // block, and their inherited belongs-to relationships would otherwise register
70
129
  // a spurious inverse-many on the target entity.
71
130
  if (isProjection(obj)) continue;
72
131
 
132
+ // An ABSTRACT level's own declarations do not file. Its FK column reaches the
133
+ // base's single table only through a CONCRETE @discriminatorValue descendant
134
+ // (collectTphSubtypeFields folds effective fields per concrete subtype), and
135
+ // that descendant's RESOLVING relationships() walk reaches this same
136
+ // relationship and files the identical entry — the dedupe collapses the
137
+ // copies. With no concrete descendant there is no folded column and no rows to
138
+ // navigate, so absence is the honest output: an entry would make the base's
139
+ // relations() block name a column the table does not have.
140
+ if (obj.isAbstract) continue;
141
+
73
142
  for (const child of obj.relationships()) {
74
143
  // ADR-0039: resolving — a relationship may inherit @cardinality via extends.
75
144
  const cardinality = child.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined;
@@ -79,7 +148,12 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
79
148
  // many(junction) navigation on the source.
80
149
  // ADR-0039: resolving — @through may be inherited via extends.
81
150
  if (cardinality === CARDINALITY_MANY && child.attr(RELATIONSHIP_ATTR_THROUGH) !== undefined) {
82
- const m2m = buildM2mEntry(obj, child as MetaRelationship, root);
151
+ const m2m = buildM2mEntry(obj, child as MetaRelationship, root, onWarn);
152
+ // NOT re-keyed to the storage base: the ROUTES tier reads this map to mount
153
+ // an M:N under EACH concrete subtype's segment, so it needs the per-subtype
154
+ // entries. Collapsing them onto the base key silently reduces four mounts to
155
+ // one. Only the cardinality-one path below is re-keyed, because that is the
156
+ // one the relations() block renders on the base's module.
83
157
  if (m2m) ensure(obj.name).push(m2m);
84
158
  continue;
85
159
  }
@@ -106,7 +180,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
106
180
  const fkField = matching.fields[0];
107
181
  if (!fkField) continue;
108
182
 
109
- ensure(obj.name).push({
183
+ push(obj, {
110
184
  name: child.name,
111
185
  cardinality: "one",
112
186
  targetEntity,
@@ -131,7 +205,6 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
131
205
  for (const junctionName of collectJunctionNames(root)) {
132
206
  const junction = root.findObject(junctionName);
133
207
  if (!junction) continue;
134
- const entries = ensure(junctionName);
135
208
  for (const ref of junction.referenceIdentities()) {
136
209
  const targetRaw = ref.targetEntity;
137
210
  const fkField = ref.fields[0];
@@ -143,7 +216,7 @@ export function buildRelationMap(root: MetaRoot): RelationMap {
143
216
  const refName = ref.name && ref.name.length > 0
144
217
  ? ref.name
145
218
  : variableNameFromEntity(targetEntity);
146
- entries.push({
219
+ push(junction, {
147
220
  name: refName,
148
221
  cardinality: "one",
149
222
  targetEntity,
@@ -174,12 +247,16 @@ function collectJunctionNames(root: MetaRoot): Set<string> {
174
247
  * Build the source-side M:N navigation entry: derive the junction FK fields from
175
248
  * the junction's two identity.reference children (the SSOT), handling hetero /
176
249
  * directed-self-join / symmetric. Returns null (skips the entry) if derivation
177
- * fails the loader validation pass surfaces the actionable error separately.
250
+ * fails, reporting the derivation's own reason through `onWarn` when supplied
251
+ * the loader's rules never check subject pairing, so a model can load clean and
252
+ * still carry a junction this pass cannot pair (e.g. one whose identity.reference
253
+ * names a concrete subtype of the declaring entity).
178
254
  */
179
255
  function buildM2mEntry(
180
256
  source: MetaObject,
181
257
  rel: MetaRelationship,
182
258
  root: MetaRoot,
259
+ onWarn?: (msg: string) => void,
183
260
  ): RelationEntry | null {
184
261
  // ADR-0039: resolving — @objectRef/@through may be inherited via extends.
185
262
  const targetRaw = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
@@ -188,7 +265,13 @@ function buildM2mEntry(
188
265
  let fields;
189
266
  try {
190
267
  fields = deriveM2MFields(rel, source, root);
191
- } catch {
268
+ } catch (err) {
269
+ onWarn?.(
270
+ `M:N relationship "${rel.name}" on entity "${source.name}" gets no traversal route: its ` +
271
+ `@through junction "${stripPackage(throughRaw)}" could not be paired — ` +
272
+ `${err instanceof Error ? err.message : String(err)}. The model loads, so the run ` +
273
+ `continues, but the endpoint is absent (a 404).`,
274
+ );
192
275
  return null;
193
276
  }
194
277
  return {
package/src/runner.ts CHANGED
@@ -486,7 +486,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
486
486
 
487
487
  // 3. Build shared render state once.
488
488
  const pkMap = buildPkMap(root);
489
- const relationMap = buildRelationMap(root);
489
+ const relationMap = buildRelationMap(root, (m) => warnings.push(m));
490
490
  // ADR-0044/#228 — the ENTITY-tier collision domain is the run's EMITTED
491
491
  // `object.value` SET (NOT any per-payload closure): value-object module
492
492
  // filenames + `packageOf` are per-run/global, so the emitted-name map is built
@@ -53,7 +53,6 @@ import {
53
53
  import { inferViewKind, currencyMetaFor, labelFor, humanize, valueObjectFor } from "./field-meta.js";
54
54
  import { VIEW_CONTEXT_FORM } from "../view-context.js";
55
55
  import { enumValues } from "../enum-meta.js";
56
- import { isProjection } from "../projection/projection-detector.js";
57
56
  // `restPath` lives HERE rather than in api-surface.ts, which is where it used to sit and
58
57
  // which now re-exports it. The descriptor is what emits `$path`, so the composition has to
59
58
  // be reachable from this module; importing it back from `api-surface.js` would be a cycle,
@@ -116,30 +115,24 @@ export interface EntityUiDescriptor {
116
115
  * An object's OWN pluralized resource path — the one derivation of that spelling, and the
117
116
  * input {@link restPath} composes an address from. It is NOT `$path` on its own.
118
117
  *
119
- * A projection is KEBAB-cased and an entity is SNAKE-cased, and that split is not a
120
- * style choice: both spellings are already mounted. `renderProjectionDecl` emits
121
- * `$path: "/order-summaries"` and `renderEntityConstants` emits `$path:
122
- * "/order_summaries"`, and `routes-file.ts` mounts whichever const belongs to the object
123
- * so an address computed without the split is wrong for every multi-word projection.
124
- * It used to live in a second function inside `projection-decl.ts`, which is exactly how
125
- * `agent/ui.md` and `api/AGENT-API.md` both came to print `/order_summaries` for a
126
- * projection served at `/order-summaries`.
118
+ * ONE rule for entities and projections alike: snake_case the name, then pluralize.
119
+ * A projection used to be KEBAB-cased here while an entity was SNAKE-cased, composed in
120
+ * the opposite ORDER, and that split was grandfathered rather than designed — the only
121
+ * reason recorded for it was that the projection const had always emitted it. It is
122
+ * collapsed, so `OrderSummary` is `/order_summaries` whichever it is. That renamed every
123
+ * multi-word projection's collection URL; see CHANGELOG.
127
124
  *
128
125
  * "Subscriber" → "/subscribers"
129
126
  * "WorkoutEvent" → "/workout_events"
130
- * "ProgramSummary" → "/program-summaries" (projection)
127
+ * "ProgramSummary" → "/program_summaries" (projection — same rule)
131
128
  *
132
129
  * A TPH SUBTYPE is not addressed by this path — it is mounted under its base — so this is
133
130
  * an INPUT to {@link restPath}, not the answer. `$path` carries `restPath`; call this only
134
131
  * when you specifically want an object's own pluralized name, never to build an address.
135
132
  */
136
133
  export function resourcePath(entity: MetaData): string {
137
- // The two compositions differ in ORDER as well as separator, and both are load-bearing:
138
- // pluralize-then-snake is what the projection const has always emitted, snake-then-
139
- // pluralize is what the entity const has. Neither may be "tidied" into the other.
140
- return isProjection(entity)
141
- ? `/${toSnakeCase(pluralize(entity.name)).replace(/_/g, "-")}`
142
- : `/${pluralize(toSnakeCase(entity.name))}`;
134
+ // One rule, every object kind and every port: snake_case, then pluralize.
135
+ return `/${pluralize(toSnakeCase(entity.name))}`;
143
136
  }
144
137
 
145
138
  /**
@@ -167,9 +167,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
167
167
  // FK columns were derived from the junction's identity.reference children (the
168
168
  // SSOT) by the relation-resolver pre-pass; here we resolve them to physical
169
169
  // column names for the Drizzle two-stage join.
170
- const m2mEntries = (ctx.relationMap.get(entityName) ?? []).filter(
171
- (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
172
- );
170
+ const m2mEntries = m2mEntriesOf(ctx, entityName);
173
171
  // Two fastify-scope variants: under an apiPrefix the mounts live inside the
174
172
  // register-block (`instance`); otherwise they bind directly to `fastify`.
175
173
  const m2mMountsPrefixed = renderM2mMounts(m2mEntries, entity, ctx, "instance");
@@ -242,6 +240,21 @@ ${m2mMountsFlat}}
242
240
  return header + literalImports.toString() + body.toString();
243
241
  }
244
242
 
243
+ /**
244
+ * The M:N navigation entries of `name` — the ONE rule for which relationships get a
245
+ * traversal mount. The vanilla entity path and the TPH path both go through it, so
246
+ * they cannot drift apart: a rule change moves every mount at once, and the
247
+ * independent oracle's route rule stays checkable against both emit paths alike.
248
+ */
249
+ function m2mEntriesOf(
250
+ ctx: RenderContext,
251
+ name: string,
252
+ ): Array<RelationEntry & { junctionEntity: string }> {
253
+ return (ctx.relationMap.get(name) ?? []).filter(
254
+ (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
255
+ );
256
+ }
257
+
245
258
  /**
246
259
  * Render the M:N traversal mounts for an entity as a single Code fragment to
247
260
  * interpolate INTO the handler-body code template (so the junction/target table
@@ -255,13 +268,38 @@ function renderM2mMounts(
255
268
  source: MetaObject,
256
269
  ctx: RenderContext,
257
270
  fastifyVar: string,
271
+ tphSource?: TphM2mSource,
258
272
  ): Code | string {
259
273
  if (entries.length === 0) return "";
260
- const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar));
274
+ const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar, tphSource));
261
275
  return code`${joinCode(mounts, { on: "\n", trim: false })}
262
276
  `;
263
277
  }
264
278
 
279
+ /**
280
+ * Where a TPH M:N mount hangs, and how it proves the source id is really ITS subtype's.
281
+ *
282
+ * Only `renderTphRoutesFile` supplies this, and only for a relationship declared ON a
283
+ * subtype. The path gains that subtype's segment, and `sourceDiscriminator` gets the
284
+ * check that makes the segment mean something: the junction FK points at the shared base
285
+ * table, so without it a sibling subtype's id reaches the same junction rows and the
286
+ * segment in the URL is decorative. A relationship declared on the BASE passes nothing
287
+ * here — every row of the table is a legitimate source — so its mount is byte-identical
288
+ * to a vanilla entity's.
289
+ */
290
+ interface TphM2mSource {
291
+ /** Appended to the base entity's `$path`, e.g. `"/bridge"`. */
292
+ pathSuffix: string;
293
+ /** The base table const this subtype's rows live in. */
294
+ table: Code | string;
295
+ /** Physical PK column of the base table. */
296
+ pkColumn: Code;
297
+ /** Physical discriminator column of the base table. */
298
+ discriminatorColumn: Code;
299
+ /** This subtype's `@discriminatorValue`. */
300
+ value: string;
301
+ }
302
+
265
303
  /**
266
304
  * Render one M:N traversal mount. The junction + target Drizzle table consts are
267
305
  * imported from their sibling entity files (imp() lets ts-poet track + emit the
@@ -274,6 +312,7 @@ function renderM2mMount(
274
312
  source: MetaObject,
275
313
  ctx: RenderContext,
276
314
  fastifyVar: string,
315
+ tphSource?: TphM2mSource,
277
316
  ): Code {
278
317
  // `source` never changes across this function, so its effective package is computed
279
318
  // once and reused below (both crossEntitySpecifier calls, and the three
@@ -329,9 +368,20 @@ function renderM2mMount(
329
368
  targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },`
330
369
  : "";
331
370
 
371
+ // A subtype-declared M:N hangs under the subtype's segment; everything else hangs at
372
+ // the source entity's own path. `$path` is read from the BASE const either way — a TPH
373
+ // subtype's module exports no entity const of its own.
374
+ const pathExpr: Code = tphSource === undefined
375
+ ? code`${source.name}.$path`
376
+ : code`${source.name}.$path + ${JSON.stringify(tphSource.pathSuffix)}`;
377
+ const sourceDiscriminatorLine: Code | string = tphSource === undefined
378
+ ? ""
379
+ : code`
380
+ sourceDiscriminator: { table: ${tphSource.table}, pkColumn: ${tphSource.pkColumn}, column: ${tphSource.discriminatorColumn}, value: ${JSON.stringify(tphSource.value)} },`;
381
+
332
382
  return code` ${mountM2mRouteSym}({
333
383
  fastify: ${fastifyVar},
334
- path: ${source.name}.$path,
384
+ path: ${pathExpr},
335
385
  relationName: ${JSON.stringify(entry.name)},
336
386
  db,
337
387
  junctionTable: ${junctionVarSym},
@@ -339,7 +389,7 @@ function renderM2mMount(
339
389
  sourceColumn: ${sourceColumn},
340
390
  targetColumn: ${targetColumn},
341
391
  targetPkColumn: ${targetPkColumn},
342
- symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}
392
+ symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}${sourceDiscriminatorLine}
343
393
  });`;
344
394
  }
345
395
 
@@ -440,7 +490,20 @@ function renderTphRoutesFile(
440
490
  dialect: ${dialectLit},${polymorphicExposeLine}
441
491
  });`;
442
492
 
443
- const subtypeMounts: Code[] = plan.subtypes.map(({ entity: sub, value, routeSegment: segment }) => {
493
+ // FR-018 x FR-017 M:N traversal inside a TPH hierarchy. This file never consulted
494
+ // the relation map at all, so BOTH sides vanished from the generated API: a
495
+ // relationship declared on the base (every row of the table is a legitimate source)
496
+ // and one declared on a subtype (only that subtype's rows are). Neither is a compile
497
+ // error — a route that is never mounted is an absence — which is why the codegen
498
+ // compile gate stayed green while the endpoint 404'd.
499
+ //
500
+ // The physical columns stage 0 needs, resolved once against the base's own table.
501
+ const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id";
502
+ const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg);
503
+ const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg);
504
+ const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef);
505
+
506
+ const subtypeMounts: Code[] = plan.subtypes.flatMap(({ entity: sub, value, routeSegment: segment }) => {
444
507
  const subFileSpec = entityModuleSpecifier(
445
508
  ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle,
446
509
  );
@@ -457,7 +520,7 @@ function renderTphRoutesFile(
457
520
  // (discriminator excluded — it's pinned by this path).
458
521
  const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
459
522
  const subSortSym = imp(`${sub.name}SortAllowlist@${subFileSpec}`);
460
- return code`
523
+ const crud = code`
461
524
  ${mountCrudRoutesSym}({
462
525
  fastify: ${fastifyRef},
463
526
  path: ${baseConstSym}.$path + ${JSON.stringify("/" + segment)},
@@ -470,9 +533,32 @@ function renderTphRoutesFile(
470
533
  dialect: ${dialectLit},
471
534
  discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")}
472
535
  });`;
536
+ // This subtype's own M:N navigations, mounted beneath its segment and gated on the
537
+ // discriminator so a sibling's id yields [] instead of the sibling's relations.
538
+ //
539
+ // Every relationship this subtype RESOLVES, inherited ones included — not just the
540
+ // ones it declares. A subtype resource is a resource: `/auths/bridge/1` carries the
541
+ // same sub-resources as any other, so it carries the base's relationships too, and
542
+ // an abstract mid level's relationship has nowhere else to be served at all.
543
+ //
544
+ // The base mounts its own set separately, at its own path. The overlap is deliberate
545
+ // and not redundant: `/auths/1/tags` accepts any row of the table, while
546
+ // `/auths/bridge/1/tags` answers [] for a Copay id — the segment is a type
547
+ // assertion, which is exactly what sourceDiscriminator enforces below.
548
+ const subM2m = renderM2mMounts(m2mEntriesOf(ctx, sub.name), base, ctx, fastifyRef, {
549
+ pathSuffix: "/" + segment,
550
+ table: code`${tableSym}`,
551
+ pkColumn: basePkColumn,
552
+ discriminatorColumn: baseDiscColumn,
553
+ value,
554
+ });
555
+ return subM2m === "" ? [crud] : [crud, subM2m as Code];
473
556
  });
474
557
 
475
- const mounts = joinCode([polymorphic, ...subtypeMounts], { on: "\n" });
558
+ const mounts = joinCode(
559
+ [polymorphic, ...(baseM2mMounts === "" ? [] : [baseM2mMounts as Code]), ...subtypeMounts],
560
+ { on: "\n" },
561
+ );
476
562
  // The base path is read-only by construction (TPH_POLYMORPHIC_VERBS), but the
477
563
  // per-subtype mounts below it are full CRUD — so `expose` does narrow this file.
478
564
  const tphAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
@@ -148,26 +148,46 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
148
148
  return false;
149
149
  }
150
150
 
151
+ /** Is this field's column one the TPH BASE declares, rather than a subtype-only one?
152
+ *
153
+ * Resolving (ADR-0039) and compared by name against `base.fields()` — exactly the set
154
+ * `collectTphSubtypeFields` treats as "already emitted" when it folds the subtype
155
+ * columns into the base table, so the two answers cannot drift apart. A field declared
156
+ * on an abstract level BETWEEN the base and the subtype is subtype-only: the base's own
157
+ * field set does not carry it, and neither does the base's `.notNull()`. */
158
+ function isTphBaseOwnField(obj: MetaObject, field: MetaField): boolean {
159
+ const base = tphDiscriminatorBase(obj);
160
+ if (base === undefined) return false;
161
+ return base.fields().some((f) => f.name === field.name);
162
+ }
163
+
151
164
  /**
152
165
  * Is this field NULL-tolerant in a TPH subtype's READ shape?
153
166
  *
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.
167
+ * Answered from the PHYSICAL column, because that is the only thing a read can return.
168
+ * A TPH subtype shares one table with its siblings, so a column only some subtypes
169
+ * declare is NULL on every other subtype's row and `drizzle-schema.ts` drops its
170
+ * `.notNull()` whatever `@required` says (its `forceNullable` fold). A column the base
171
+ * itself declares carries `.notNull()` precisely when the field is required. The PRIMARY
172
+ * KEY is the shared base table's key and is present on every row.
173
+ *
174
+ * `@default` is deliberately NOT consulted, and that is the fix rather than an omission.
175
+ * A default decides whether an INSERT may leave the value out; it says nothing about what
176
+ * a READ can see. Asking `fieldWillBeOptional` here widened a `NOT NULL DEFAULT` column to
177
+ * `| null` and — through the `.optional()` that same predicate mirrors — to `| undefined`,
178
+ * which the declared interface did not admit: `parse<Base>()` returned a value not
179
+ * assignable to the base union and the generated module failed to compile (TS2322).
159
180
  *
160
181
  * ONE predicate, because TWO emitters answer this question about the same field: the
161
182
  * 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;
183
+ * (`renderValueObjectInterface`). A second answer to one question is the defect;
165
184
  * keeping the two call sites pointed here is the fix.
166
185
  */
167
186
  export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
168
187
  if (!isTphSubtype(obj)) return false;
169
- if (!fieldWillBeOptional(field)) return false;
170
- return !primaryIdentityFieldNames(obj).includes(field.name);
188
+ if (primaryIdentityFieldNames(obj).includes(field.name)) return false;
189
+ if (!isTphBaseOwnField(obj, field)) return true;
190
+ return !isRequired(field);
171
191
  }
172
192
 
173
193
  /**
@@ -191,10 +211,16 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
191
211
  fieldLines.push(code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`);
192
212
  continue;
193
213
  }
194
- const expr = zodFieldExpr(child, obj, ctx);
195
- // zodFieldExpr already appends `.optional()` for non-required fields; add
196
- // `.nullable()` on top so a NULL column value parses cleanly. The declared
197
- // interface widens the SAME fields see isTphReadNullTolerant.
214
+ // forceRequired: a row selected from the table carries every column as a KEY —
215
+ // a nullable one arrives as `null`, never absent so nothing with a COLUMN in a
216
+ // read shape is `.optional()`. Letting zodFieldExpr append it made the inferred
217
+ // type `T | undefined` while the declared interface said `T`, and the two
218
+ // disagreed. A derived (origin-bearing) field is the one member of obj.fields()
219
+ // with no column — drizzle-schema.ts emits none, and the TPH queries path selects
220
+ // the bare base table — so its key is genuinely absent from every parsed row and
221
+ // it alone keeps `.optional()`, matching the interface's `?: T | null`.
222
+ // Null-tolerance is added below, from the column, by isTphReadNullTolerant.
223
+ const expr = zodFieldExpr(child, obj, ctx, !child.isDerived());
198
224
  fieldLines.push(
199
225
  isTphReadNullTolerant(obj, child)
200
226
  ? code` ${child.name}: ${expr}.nullable()`
@@ -606,8 +632,9 @@ function zodFieldExpr(
606
632
  field: MetaField,
607
633
  owner?: MetaObject,
608
634
  ctx?: RenderContext,
609
- /** Suppress the trailing `.optional()` see assignedPkFieldNames. Set ONLY
610
- * by the insert-shape emitters; the update/read shapes must stay optional. */
635
+ /** Suppress the trailing `.optional()`. Set by the insert-shape emitters for an
636
+ * assigned PK (see assignedPkFieldNames) and by the TPH read shape, where every
637
+ * column is a present key. The UPDATE shape must stay optional (PATCH semantics). */
611
638
  forceRequired = false,
612
639
  ): Code {
613
640
  // `@dbColumnType: jsonb` on a scalar (legal only on field.string) is the