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

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 (57) hide show
  1. package/dist/generators/api-model.d.ts.map +1 -1
  2. package/dist/generators/api-model.js +11 -10
  3. package/dist/generators/api-model.js.map +1 -1
  4. package/dist/generators/queries-file.d.ts.map +1 -1
  5. package/dist/generators/queries-file.js +7 -2
  6. package/dist/generators/queries-file.js.map +1 -1
  7. package/dist/generators/routes-file-hono.d.ts +2 -1
  8. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  9. package/dist/generators/routes-file-hono.js +4 -3
  10. package/dist/generators/routes-file-hono.js.map +1 -1
  11. package/dist/generators/routes-file.d.ts +2 -1
  12. package/dist/generators/routes-file.d.ts.map +1 -1
  13. package/dist/generators/routes-file.js +4 -3
  14. package/dist/generators/routes-file.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/relation-resolver.d.ts +6 -1
  20. package/dist/relation-resolver.d.ts.map +1 -1
  21. package/dist/relation-resolver.js +80 -8
  22. package/dist/relation-resolver.js.map +1 -1
  23. package/dist/runner.js +1 -1
  24. package/dist/runner.js.map +1 -1
  25. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  26. package/dist/templates/drizzle-schema.js +23 -4
  27. package/dist/templates/drizzle-schema.js.map +1 -1
  28. package/dist/templates/entity-ui-descriptor.d.ts +7 -9
  29. package/dist/templates/entity-ui-descriptor.d.ts.map +1 -1
  30. package/dist/templates/entity-ui-descriptor.js +9 -16
  31. package/dist/templates/entity-ui-descriptor.js.map +1 -1
  32. package/dist/templates/relations-block.d.ts.map +1 -1
  33. package/dist/templates/relations-block.js +13 -6
  34. package/dist/templates/relations-block.js.map +1 -1
  35. package/dist/templates/routes-file.d.ts.map +1 -1
  36. package/dist/templates/routes-file.js +80 -12
  37. package/dist/templates/routes-file.js.map +1 -1
  38. package/dist/templates/zod-validators.d.ts +29 -9
  39. package/dist/templates/zod-validators.d.ts.map +1 -1
  40. package/dist/templates/zod-validators.js +65 -16
  41. package/dist/templates/zod-validators.js.map +1 -1
  42. package/package.json +6 -6
  43. package/src/generators/api-model.ts +11 -10
  44. package/src/generators/queries-file.ts +7 -2
  45. package/src/generators/routes-file-hono.ts +4 -3
  46. package/src/generators/routes-file.ts +4 -3
  47. package/src/index.ts +1 -1
  48. package/src/reference/queries.ts +4 -2
  49. package/src/reference/routes-hono.ts +2 -2
  50. package/src/reference/routes.ts +4 -3
  51. package/src/relation-resolver.ts +90 -7
  52. package/src/runner.ts +1 -1
  53. package/src/templates/drizzle-schema.ts +21 -4
  54. package/src/templates/entity-ui-descriptor.ts +9 -16
  55. package/src/templates/relations-block.ts +19 -11
  56. package/src/templates/routes-file.ts +123 -20
  57. package/src/templates/zod-validators.ts +66 -17
@@ -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,
@@ -166,9 +167,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
166
167
  // FK columns were derived from the junction's identity.reference children (the
167
168
  // SSOT) by the relation-resolver pre-pass; here we resolve them to physical
168
169
  // column names for the Drizzle two-stage join.
169
- const m2mEntries = (ctx.relationMap.get(entityName) ?? []).filter(
170
- (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined,
171
- );
170
+ const m2mEntries = m2mEntriesOf(ctx, entityName);
172
171
  // Two fastify-scope variants: under an apiPrefix the mounts live inside the
173
172
  // register-block (`instance`); otherwise they bind directly to `fastify`.
174
173
  const m2mMountsPrefixed = renderM2mMounts(m2mEntries, entity, ctx, "instance");
@@ -241,6 +240,21 @@ ${m2mMountsFlat}}
241
240
  return header + literalImports.toString() + body.toString();
242
241
  }
243
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
+
244
258
  /**
245
259
  * Render the M:N traversal mounts for an entity as a single Code fragment to
246
260
  * interpolate INTO the handler-body code template (so the junction/target table
@@ -254,13 +268,38 @@ function renderM2mMounts(
254
268
  source: MetaObject,
255
269
  ctx: RenderContext,
256
270
  fastifyVar: string,
271
+ tphSource?: TphM2mSource,
257
272
  ): Code | string {
258
273
  if (entries.length === 0) return "";
259
- const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar));
274
+ const mounts = entries.map((e) => renderM2mMount(e, source, ctx, fastifyVar, tphSource));
260
275
  return code`${joinCode(mounts, { on: "\n", trim: false })}
261
276
  `;
262
277
  }
263
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
+
264
303
  /**
265
304
  * Render one M:N traversal mount. The junction + target Drizzle table consts are
266
305
  * imported from their sibling entity files (imp() lets ts-poet track + emit the
@@ -273,6 +312,7 @@ function renderM2mMount(
273
312
  source: MetaObject,
274
313
  ctx: RenderContext,
275
314
  fastifyVar: string,
315
+ tphSource?: TphM2mSource,
276
316
  ): Code {
277
317
  // `source` never changes across this function, so its effective package is computed
278
318
  // once and reused below (both crossEntitySpecifier calls, and the three
@@ -288,18 +328,30 @@ function renderM2mMount(
288
328
  ctx.extStyle,
289
329
  )}`,
290
330
  );
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
- );
331
+ // An M:N onto a TPH subtype traverses into its discriminator BASE's table — the
332
+ // subtype has no table const, and the junction FK can only point at the base table —
333
+ // and filters the rows to the subtype, because a Broker id in that FK column is not a
334
+ // Carrier. Every other target binds to itself, and emits no filter.
335
+ const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity);
336
+ const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget);
337
+ const targetTableEntity = target?.name ?? entry.targetEntity;
338
+ const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget);
339
+ // A self-join's target table IS the source table, which this file already imports from
340
+ // the entity module; a second import of the same binding is TS2300, and a SyntaxError
341
+ // when Node loads the module.
342
+ const targetVarSym = targetTableEntity === source.name
343
+ ? ctx.collectionName(source.name)
344
+ : imp(
345
+ `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier(
346
+ ctx.outputLayout,
347
+ sourcePkg,
348
+ ctx.packageOf.get(targetTableEntity),
349
+ targetTableEntity,
350
+ ctx.extStyle,
351
+ )}`,
352
+ );
300
353
  const mountM2mRouteSym = imp("mountM2mRoute@@metaobjectsdev/runtime-ts/drizzle-fastify");
301
354
  const junction = ctx.loadedRoot.findObject(entry.junctionEntity);
302
- const target = ctx.loadedRoot.findObject(entry.targetEntity);
303
355
  // fromPackage = source.package: this routes file is SOURCE's own module, never the
304
356
  // junction's or the target's — see resolveJunctionColumn's doc comment (B1).
305
357
  const sourceColumn: Code = junction
@@ -309,12 +361,27 @@ function renderM2mMount(
309
361
  ? resolveJunctionColumn(junction, entry.targetJoinField!, ctx, sourcePkg)
310
362
  : code`${JSON.stringify(entry.targetJoinField!)}`;
311
363
  const targetPkColumn: Code = target
312
- ? resolveJunctionColumn(target, ctx.pkMap.get(entry.targetEntity)?.fieldName ?? "id", ctx, sourcePkg)
364
+ ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg)
313
365
  : code`${JSON.stringify("id")}`;
366
+ const discriminatorLine: Code | string = pin !== undefined && target !== undefined
367
+ ? code`
368
+ targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },`
369
+ : "";
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)} },`;
314
381
 
315
382
  return code` ${mountM2mRouteSym}({
316
383
  fastify: ${fastifyVar},
317
- path: ${source.name}.$path,
384
+ path: ${pathExpr},
318
385
  relationName: ${JSON.stringify(entry.name)},
319
386
  db,
320
387
  junctionTable: ${junctionVarSym},
@@ -322,7 +389,7 @@ function renderM2mMount(
322
389
  sourceColumn: ${sourceColumn},
323
390
  targetColumn: ${targetColumn},
324
391
  targetPkColumn: ${targetPkColumn},
325
- symmetric: ${entry.symmetric ? "true" : "false"},
392
+ symmetric: ${entry.symmetric ? "true" : "false"},${discriminatorLine}${sourceDiscriminatorLine}
326
393
  });`;
327
394
  }
328
395
 
@@ -423,7 +490,20 @@ function renderTphRoutesFile(
423
490
  dialect: ${dialectLit},${polymorphicExposeLine}
424
491
  });`;
425
492
 
426
- 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 }) => {
427
507
  const subFileSpec = entityModuleSpecifier(
428
508
  ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle,
429
509
  );
@@ -440,7 +520,7 @@ function renderTphRoutesFile(
440
520
  // (discriminator excluded — it's pinned by this path).
441
521
  const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
442
522
  const subSortSym = imp(`${sub.name}SortAllowlist@${subFileSpec}`);
443
- return code`
523
+ const crud = code`
444
524
  ${mountCrudRoutesSym}({
445
525
  fastify: ${fastifyRef},
446
526
  path: ${baseConstSym}.$path + ${JSON.stringify("/" + segment)},
@@ -453,9 +533,32 @@ function renderTphRoutesFile(
453
533
  dialect: ${dialectLit},
454
534
  discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")}
455
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];
456
556
  });
457
557
 
458
- const mounts = joinCode([polymorphic, ...subtypeMounts], { on: "\n" });
558
+ const mounts = joinCode(
559
+ [polymorphic, ...(baseM2mMounts === "" ? [] : [baseM2mMounts as Code]), ...subtypeMounts],
560
+ { on: "\n" },
561
+ );
459
562
  // The base path is read-only by construction (TPH_POLYMORPHIC_VERBS), but the
460
563
  // per-subtype mounts below it are full CRUD — so `expose` does narrow this file.
461
564
  const tphAuthJsDoc = authSeamJsDoc({ framework: "fastify", handlerName, narrowable: true });
@@ -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,26 +148,46 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
126
148
  return false;
127
149
  }
128
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
+
129
164
  /**
130
165
  * Is this field NULL-tolerant in a TPH subtype's READ shape?
131
166
  *
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.
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).
137
180
  *
138
181
  * ONE predicate, because TWO emitters answer this question about the same field: the
139
182
  * 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;
183
+ * (`renderValueObjectInterface`). A second answer to one question is the defect;
143
184
  * keeping the two call sites pointed here is the fix.
144
185
  */
145
186
  export function isTphReadNullTolerant(obj: MetaObject, field: MetaField): boolean {
146
187
  if (!isTphSubtype(obj)) return false;
147
- if (!fieldWillBeOptional(field)) return false;
148
- 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);
149
191
  }
150
192
 
151
193
  /**
@@ -169,10 +211,16 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
169
211
  fieldLines.push(code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`);
170
212
  continue;
171
213
  }
172
- const expr = zodFieldExpr(child, obj, ctx);
173
- // zodFieldExpr already appends `.optional()` for non-required fields; add
174
- // `.nullable()` on top so a NULL column value parses cleanly. The declared
175
- // 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());
176
224
  fieldLines.push(
177
225
  isTphReadNullTolerant(obj, child)
178
226
  ? code` ${child.name}: ${expr}.nullable()`
@@ -584,8 +632,9 @@ function zodFieldExpr(
584
632
  field: MetaField,
585
633
  owner?: MetaObject,
586
634
  ctx?: RenderContext,
587
- /** Suppress the trailing `.optional()` see assignedPkFieldNames. Set ONLY
588
- * 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). */
589
638
  forceRequired = false,
590
639
  ): Code {
591
640
  // `@dbColumnType: jsonb` on a scalar (legal only on field.string) is the