@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
@@ -118,8 +118,7 @@ import { responseShape } from "../templates/find-inbound.js";
118
118
  import { isTphSubtype } from "../templates/zod-validators.js";
119
119
  import { isTphDiscriminatorBase } from "../templates/tph-discriminator.js";
120
120
  import { isCallableEntity } from "../templates/callable-file.js";
121
- import { hasAnyRdbSource } from "../source-detect.js";
122
- import { servedPath } from "../api-surface.js";
121
+ import { servedPath, servesReadApi } from "../api-surface.js";
123
122
  import { isProjection } from "../projection/projection-detector.js";
124
123
  import { buildPkMap } from "../pk-resolver.js";
125
124
  import { buildRelationMap, type RelationEntry, type RelationMap } from "../relation-resolver.js";
@@ -356,8 +355,9 @@ function stripTs(path: string): string {
356
355
  // ---------------------------------------------------------------------------
357
356
 
358
357
  /** Mirror of the queries generator's filter (queries-file.ts `skipNonQueryable`
359
- * = `hasAnyRdbSource(e) && !isTphSubtype(e)`, #248 R2). A queryable object is
360
- * any source-backed, non-TPH-subtype object:
358
+ * = `servesReadApi(e) && !isTphSubtype(e)`, #248 R2). A queryable object is
359
+ * any source-backed, non-abstract, non-TPH-subtype object:
360
+ * • An abstract object has no table of its own, only a type-only shape.
361
361
  * • An object with no declared/inherited source.rdb (of ANY kind) isn't
362
362
  * backed by any store → the queries/routes/validation generators emit no
363
363
  * CRUD for it. Value objects are subsumed here: value purity (ADR-0028)
@@ -370,7 +370,7 @@ function stripTs(path: string): string {
370
370
  * per-subtype polymorphic helpers + subpaths are a documented deferral — see
371
371
  * the module header.) */
372
372
  function isQueryable(obj: MetaObject): boolean {
373
- return hasAnyRdbSource(obj) && !isTphSubtype(obj);
373
+ return servesReadApi(obj) && !isTphSubtype(obj);
374
374
  }
375
375
 
376
376
  function buildEntityUnit(
@@ -405,7 +405,7 @@ function buildEntityUnit(
405
405
  symbols.push(...dataAccessSymbols(obj, ctx, root, layout));
406
406
  symbols.push(...validationSymbols(obj, entityMod));
407
407
  // REST needs no gate of its own: the routes generator's built-in filter is
408
- // `hasAnyRdbSource && !isTphSubtype` — exactly isQueryable — so every queryable
408
+ // `servesReadApi && !isTphSubtype` — exactly isQueryable — so every queryable
409
409
  // object gets routes. (Its `filter` option can narrow that further; this builder
410
410
  // reads the model, not the wired generator set, and cannot see it. See the module
411
411
  // header.)
@@ -415,10 +415,11 @@ function buildEntityUnit(
415
415
  if (includeHono) symbols.push(...restHonoSymbols(obj, layout, apiPrefix));
416
416
  }
417
417
 
418
- // --- relation: the drizzle relations() export, when the resolver derives a
419
- // relations() block for this entity (1:N belongs-to + inverse many, M:N
420
- // @through). Independent of isQueryable a relations() block is emitted by
421
- // the entity file regardless. ---
418
+ // --- relation: the drizzle relations() export, when the resolver files entries
419
+ // under this entity (1:N belongs-to + inverse many, M:N @through). The map
420
+ // keys by the entity whose module renders the block the discriminator
421
+ // base, for a TPH subtype; an abstract entity emits a value-object module
422
+ // and never carries one. Independent of isQueryable. ---
422
423
  const relationSym = relationSymbol(obj, entityMod, relationMap);
423
424
  if (relationSym !== undefined) symbols.push(relationSym);
424
425
 
@@ -2,7 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
2
2
  import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
3
  import { renderQueriesFile } from "../templates/queries-file.js";
4
4
  import { isTphSubtype } from "../templates/zod-validators.js";
5
- import { hasAnyRdbSource } from "../source-detect.js";
5
+ import { servesReadApi } from "../api-surface.js";
6
6
  import { formatTs } from "../format.js";
7
7
  import { entityOutputPath } from "../import-path.js";
8
8
  import { effectivePackage } from "../docs-paths.js";
@@ -25,7 +25,12 @@ export interface QueriesFileOpts {
25
25
  // FR-017 Tier 2: TPH subtypes are ALSO skipped — they emit no standalone
26
26
  // queries file. Their per-subtype CRUD helpers live in the discriminator
27
27
  // base's queries file (which targets the single shared table).
28
- const skipNonQueryable = (e: MetaObject): boolean => hasAnyRdbSource(e) && !isTphSubtype(e);
28
+ //
29
+ // Abstract objects are skipped as well (`servesReadApi`): an abstract level inherits its
30
+ // base's source but has no table of its own — only a type-only shape — so its queries
31
+ // module imported a table and schemas that do not exist. The hooks tier already gated on
32
+ // this predicate; the queries and routes tiers now agree with it.
33
+ const skipNonQueryable = (e: MetaObject): boolean => servesReadApi(e) && !isTphSubtype(e);
29
34
 
30
35
  export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
31
36
  const userFilter = opts?.filter;
@@ -1,7 +1,7 @@
1
1
  import type { MetaObject } from "@metaobjectsdev/metadata";
2
2
  import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
3
  import { renderRoutesFileHono } from "../templates/routes-file-hono.js";
4
- import { hasAnyRdbSource } from "../source-detect.js";
4
+ import { servesReadApi } from "../api-surface.js";
5
5
  import { formatTs } from "../format.js";
6
6
  import { entityOutputPath } from "../import-path.js";
7
7
  import { isTphSubtype } from "../templates/zod-validators.js";
@@ -41,7 +41,8 @@ export interface RoutesFileHonoOpts {
41
41
  * them (ERR_UNKNOWN_ATTR).
42
42
  *
43
43
  * #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
44
- * backed by any store — gated by `hasAnyRdbSource` (does NOT add TPH handling;
44
+ * backed by any store — gated by `servesReadApi`, which also excludes abstract objects
45
+ * (no table of their own to mount) (does NOT add TPH handling;
45
46
  * that gap is pre-existing and out of scope here).
46
47
  */
47
48
  export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts): Generator {
@@ -51,7 +52,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
51
52
  // written out twice a later edit to one silently makes an entity either stop emitting
52
53
  // without being named as held back, or get warned about while still emitting.
53
54
  const passesOtherGates = (e: MetaObject): boolean =>
54
- hasAnyRdbSource(e) && userFilter(e);
55
+ servesReadApi(e) && userFilter(e);
55
56
  const generator: Generator = {
56
57
  name: "routes-file-hono",
57
58
  // Marks this as the Hono routes generator so the runner can aggregate
@@ -2,7 +2,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata";
2
2
  import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
3
  import { renderRoutesFile } from "../templates/routes-file.js";
4
4
  import { isTphSubtype } from "../templates/zod-validators.js";
5
- import { hasAnyRdbSource } from "../source-detect.js";
5
+ import { servesReadApi } from "../api-surface.js";
6
6
  import { formatTs } from "../format.js";
7
7
  import { entityOutputPath } from "../import-path.js";
8
8
  import { resolveExpose, type ExposeOption } from "../routes-expose.js";
@@ -33,7 +33,8 @@ export interface RoutesFileOpts {
33
33
  *
34
34
  * #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
35
35
  * backed by any store — routes against it would import Drizzle table/allowlist
36
- * exports the entity-file generator never emits. Gated by `hasAnyRdbSource`.
36
+ * exports the entity-file generator never emits. Gated by `servesReadApi`, which also
37
+ * excludes abstract objects: an abstract level has no table of its own to mount.
37
38
  *
38
39
  * FR-017 Tier 2: TPH subtypes get no standalone routes file — their per-subtype
39
40
  * route set lives in the discriminator base's routes file.
@@ -44,7 +45,7 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
44
45
  name: "routes-file",
45
46
  // Always set: AND-composes the built-in gates with the optional user filter.
46
47
  filter: (e: MetaObject) =>
47
- hasAnyRdbSource(e) && !isTphSubtype(e) && userFilter(e),
48
+ servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
48
49
  generate: perEntity(async (entity, ctx) => {
49
50
  if (!ctx.renderContext) {
50
51
  throw new Error("routes-file: renderContext is required (provided by runGen)");
package/src/index.ts CHANGED
@@ -138,7 +138,7 @@ export { renderEntityMetaFile, entityMetaFileName, entityMetaSpecifier } from ".
138
138
  // react) to dispatch polymorphic/per-subtype emission and skip subtype files.
139
139
  export { isTphDiscriminatorBase, tphConcreteSubtypes, collectTphSubtypeFields, tphPlan, tphRouteSegment } from "./templates/tph-discriminator.js";
140
140
  export type { TphPlan, TphSubtypePlan } from "./templates/tph-discriminator.js";
141
- export { isTphSubtype, tphDiscriminatorBase, tphDiscriminatorPin } from "./templates/zod-validators.js";
141
+ export { isTphSubtype, tphDiscriminatorBase, tphDiscriminatorPin, tphStorageName, tphStorageObject } from "./templates/zod-validators.js";
142
142
 
143
143
  // The ONE sortability rule. It builds the server-side `<Entity>SortAllowlist` and the
144
144
  // client-side sort union, and it is public so a UI-tier generator (a data-grid column
@@ -50,7 +50,7 @@ import {
50
50
  isProjection,
51
51
  isWriteThrough,
52
52
  isTphSubtype,
53
- hasAnyRdbSource,
53
+ servesReadApi,
54
54
  hasAutoSetFields,
55
55
  renderQueriesFile, // engine composer — used for the delegated variants
56
56
  formatTs,
@@ -146,7 +146,9 @@ export interface QueriesFileOpts {
146
146
  // loadable value ever has hasAnyRdbSource === true). TPH subtypes emit no
147
147
  // standalone queries file either — their per-subtype CRUD helpers live in the
148
148
  // discriminator base's queries file (which targets the single shared table).
149
- const skipNonQueryable = (e: MetaObject): boolean => hasAnyRdbSource(e) && !isTphSubtype(e);
149
+ // Abstract objects are skipped too (`servesReadApi`): an abstract level has no table of
150
+ // its own, only a type-only shape, so a queries module for it cannot compile.
151
+ const skipNonQueryable = (e: MetaObject): boolean => servesReadApi(e) && !isTphSubtype(e);
150
152
 
151
153
  export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
152
154
  const userFilter = opts?.filter;
@@ -35,7 +35,7 @@ import {
35
35
  resolveExpose,
36
36
  type ExposeOption,
37
37
  isTphSubtype,
38
- hasAnyRdbSource,
38
+ servesReadApi,
39
39
  formatTs,
40
40
  entityOutputPath,
41
41
  effectivePackage,
@@ -64,7 +64,7 @@ export const routesFileHono = function routesFileHono(opts?: RoutesFileHonoOpts)
64
64
  // without being named as held back, or get warned about while still emitting.
65
65
  // (Same shape as tanstack's grid generator, which factors it the same way.)
66
66
  const passesOtherGates = (e: MetaObject): boolean =>
67
- hasAnyRdbSource(e) && userFilter(e);
67
+ servesReadApi(e) && userFilter(e);
68
68
  const generator: Generator = {
69
69
  name: "routes-file-hono",
70
70
  // Marks this as the Hono routes generator so the runner can aggregate
@@ -43,7 +43,7 @@ import {
43
43
  resolveExpose,
44
44
  type ExposeOption,
45
45
  isTphSubtype,
46
- hasAnyRdbSource,
46
+ servesReadApi,
47
47
  formatTs,
48
48
  entityOutputPath,
49
49
  effectivePackage,
@@ -72,9 +72,10 @@ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator
72
72
  // base's); AND-composed with your filter.
73
73
  // #248 R2: an object with no declared/inherited source.rdb (of ANY kind) isn't
74
74
  // backed by any store — routes against it would import Drizzle table/allowlist
75
- // exports the entity file never emits. Gated by hasAnyRdbSource.
75
+ // exports the entity file never emits. Gated by servesReadApi, which also skips
76
+ // abstract objects: an abstract level has no table of its own to mount.
76
77
  filter: (e: MetaObject) =>
77
- hasAnyRdbSource(e) && !isTphSubtype(e) && userFilter(e),
78
+ servesReadApi(e) && !isTphSubtype(e) && userFilter(e),
78
79
  generate: perEntity(async (entity, ctx) => {
79
80
  if (!ctx.renderContext) {
80
81
  throw new Error("routes-file: renderContext is required (provided by runGen)");
@@ -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
@@ -25,7 +25,8 @@ import { resolveTableSchema } from "@metaobjectsdev/metadata";
25
25
  import { renderEnumIntCustomType } from "./enum-int-codec.js";
26
26
  import { renderRelationsBlock } from "./relations-block.js";
27
27
  import { renderDocsFor } from "./jsdoc.js";
28
- import { collectTphSubtypeFields } from "./tph-discriminator.js";
28
+ import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js";
29
+ import { tphStorageObject } from "./zod-validators.js";
29
30
  import { effectivePackage } from "../docs-paths.js";
30
31
 
31
32
  /**
@@ -411,7 +412,19 @@ interface FkInfo {
411
412
  /** Pre-pass: map fkFieldName → FkInfo for this entity's effective (own + inherited) identity.reference children. */
412
413
  function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, FkInfo> {
413
414
  const result = new Map<string, FkInfo>();
414
- for (const ref of obj.referenceIdentities()) {
415
+ // FR-017 TPH: a discriminator base's table carries every concrete subtype's folded
416
+ // columns (collectTphSubtypeFields), so it carries their FKs too — otherwise a reference
417
+ // declared on a subtype, or on an abstract level between it and the base, lands as a
418
+ // plain column. Same effective, not-already-on-the-base rule as the column fold.
419
+ // A copy: referenceIdentities() is the node's cached array, and loaded metadata is read-only.
420
+ const refs = [...obj.referenceIdentities()];
421
+ const baseRefNames = new Set(refs.map((r) => r.name));
422
+ for (const sub of tphConcreteSubtypes(obj, ctx.loadedRoot)) {
423
+ for (const ref of sub.referenceIdentities()) {
424
+ if (!baseRefNames.has(ref.name)) refs.push(ref);
425
+ }
426
+ }
427
+ for (const ref of refs) {
415
428
  // @enforce: false → logical-only reference. Skip the .references() emission;
416
429
  // the column stays plain. Drizzle's relations() block (driven by
417
430
  // relation-resolver) still includes the relationship for query navigation.
@@ -419,6 +432,8 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
419
432
  const fkFieldNames = ref.fields;
420
433
  if (fkFieldNames.length === 0) continue;
421
434
  const fkField = fkFieldNames[0]!;
435
+ // Two subtypes declaring the same reference fold to one column with one FK.
436
+ if (result.has(fkField)) continue;
422
437
  const targetName = ref.targetEntity;
423
438
  if (!targetName) continue;
424
439
  // @references may be authored bare OR package-qualified, and the loader can
@@ -427,10 +442,12 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map<string, F
427
442
  // relation-resolver, which already does this.
428
443
  const targetObj = ctx.loadedRoot.findObject(stripPackage(targetName));
429
444
  if (!targetObj) continue;
445
+ // A TPH subtype has no table const of its own; its rows live in the base's table.
446
+ const storage = tphStorageObject(targetObj);
430
447
  const targetPkField = ref.resolvedTargetPkField(ctx.loadedRoot) ?? "id";
431
448
  result.set(fkField, {
432
- targetVarName: ctx.collectionName(targetObj.name),
433
- targetEntityName: targetObj.name,
449
+ targetVarName: ctx.collectionName(storage.name),
450
+ targetEntityName: storage.name,
434
451
  targetPkField,
435
452
  });
436
453
  }
@@ -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
  /**
@@ -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
  }