@metaobjectsdev/codegen-ts 0.24.5 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +37 -12
  2. package/dist/api-surface.d.ts +29 -0
  3. package/dist/api-surface.d.ts.map +1 -1
  4. package/dist/api-surface.js +47 -0
  5. package/dist/api-surface.js.map +1 -1
  6. package/dist/column-mapper.d.ts +12 -0
  7. package/dist/column-mapper.d.ts.map +1 -1
  8. package/dist/column-mapper.js +1 -0
  9. package/dist/column-mapper.js.map +1 -1
  10. package/dist/constants.d.ts +24 -11
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +54 -19
  13. package/dist/constants.js.map +1 -1
  14. package/dist/db-emitting.d.ts +6 -0
  15. package/dist/db-emitting.d.ts.map +1 -0
  16. package/dist/db-emitting.js +26 -0
  17. package/dist/db-emitting.js.map +1 -0
  18. package/dist/generator-registry.d.ts.map +1 -1
  19. package/dist/generator-registry.js +14 -1
  20. package/dist/generator-registry.js.map +1 -1
  21. package/dist/generator.d.ts +5 -0
  22. package/dist/generator.d.ts.map +1 -1
  23. package/dist/generator.js.map +1 -1
  24. package/dist/generators/agent-docs-file.d.ts +19 -0
  25. package/dist/generators/agent-docs-file.d.ts.map +1 -0
  26. package/dist/generators/agent-docs-file.js +257 -0
  27. package/dist/generators/agent-docs-file.js.map +1 -0
  28. package/dist/generators/agent-requirements-page.d.ts +22 -0
  29. package/dist/generators/agent-requirements-page.d.ts.map +1 -0
  30. package/dist/generators/agent-requirements-page.js +175 -0
  31. package/dist/generators/agent-requirements-page.js.map +1 -0
  32. package/dist/generators/agent-schema-input.d.ts +69 -0
  33. package/dist/generators/agent-schema-input.d.ts.map +1 -0
  34. package/dist/generators/agent-schema-input.js +23 -0
  35. package/dist/generators/agent-schema-input.js.map +1 -0
  36. package/dist/generators/agent-schema-page.d.ts +21 -0
  37. package/dist/generators/agent-schema-page.d.ts.map +1 -0
  38. package/dist/generators/agent-schema-page.js +241 -0
  39. package/dist/generators/agent-schema-page.js.map +1 -0
  40. package/dist/generators/agent-ui-page.d.ts +22 -0
  41. package/dist/generators/agent-ui-page.d.ts.map +1 -0
  42. package/dist/generators/agent-ui-page.js +253 -0
  43. package/dist/generators/agent-ui-page.js.map +1 -0
  44. package/dist/generators/api-docs-file.d.ts.map +1 -1
  45. package/dist/generators/api-docs-file.js +13 -6
  46. package/dist/generators/api-docs-file.js.map +1 -1
  47. package/dist/generators/api-model.d.ts +6 -0
  48. package/dist/generators/api-model.d.ts.map +1 -1
  49. package/dist/generators/api-model.js +54 -35
  50. package/dist/generators/api-model.js.map +1 -1
  51. package/dist/generators/callable-file.js +1 -1
  52. package/dist/generators/callable-file.js.map +1 -1
  53. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  54. package/dist/generators/docs-data-builder.js +47 -2
  55. package/dist/generators/docs-data-builder.js.map +1 -1
  56. package/dist/generators/docs-data.d.ts +6 -2
  57. package/dist/generators/docs-data.d.ts.map +1 -1
  58. package/dist/generators/index.d.ts +3 -8
  59. package/dist/generators/index.d.ts.map +1 -1
  60. package/dist/generators/index.js +19 -14
  61. package/dist/generators/index.js.map +1 -1
  62. package/dist/generators/names-file.d.ts +17 -0
  63. package/dist/generators/names-file.d.ts.map +1 -0
  64. package/dist/generators/names-file.js +95 -0
  65. package/dist/generators/names-file.js.map +1 -0
  66. package/dist/generators/requirements-markdown.d.ts +18 -1
  67. package/dist/generators/requirements-markdown.d.ts.map +1 -1
  68. package/dist/generators/requirements-markdown.js +11 -9
  69. package/dist/generators/requirements-markdown.js.map +1 -1
  70. package/dist/generators/routes-file-hono.d.ts +17 -2
  71. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  72. package/dist/generators/routes-file-hono.js +9 -11
  73. package/dist/generators/routes-file-hono.js.map +1 -1
  74. package/dist/generators/routes-file.d.ts +17 -2
  75. package/dist/generators/routes-file.d.ts.map +1 -1
  76. package/dist/generators/routes-file.js +10 -8
  77. package/dist/generators/routes-file.js.map +1 -1
  78. package/dist/generators/template-generator.d.ts.map +1 -1
  79. package/dist/generators/template-generator.js +20 -6
  80. package/dist/generators/template-generator.js.map +1 -1
  81. package/dist/index.d.ts +20 -5
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +43 -4
  84. package/dist/index.js.map +1 -1
  85. package/dist/metaobjects-config.d.ts +38 -1
  86. package/dist/metaobjects-config.d.ts.map +1 -1
  87. package/dist/metaobjects-config.js +9 -1
  88. package/dist/metaobjects-config.js.map +1 -1
  89. package/dist/names.d.ts +275 -0
  90. package/dist/names.d.ts.map +1 -0
  91. package/dist/names.js +433 -0
  92. package/dist/names.js.map +1 -0
  93. package/dist/overwrite-policy.d.ts.map +1 -1
  94. package/dist/overwrite-policy.js +15 -3
  95. package/dist/overwrite-policy.js.map +1 -1
  96. package/dist/projection/extract-view-spec.d.ts +25 -1
  97. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  98. package/dist/projection/extract-view-spec.js +79 -6
  99. package/dist/projection/extract-view-spec.js.map +1 -1
  100. package/dist/reference-templates.d.ts +1 -1
  101. package/dist/reference-templates.d.ts.map +1 -1
  102. package/dist/reference-templates.js +1 -1
  103. package/dist/reference-templates.js.map +1 -1
  104. package/dist/render-context.d.ts +12 -1
  105. package/dist/render-context.d.ts.map +1 -1
  106. package/dist/render-context.js +1 -0
  107. package/dist/render-context.js.map +1 -1
  108. package/dist/retired-codegen-attrs.d.ts +32 -0
  109. package/dist/retired-codegen-attrs.d.ts.map +1 -0
  110. package/dist/retired-codegen-attrs.js +47 -0
  111. package/dist/retired-codegen-attrs.js.map +1 -0
  112. package/dist/routes-expose.d.ts +63 -0
  113. package/dist/routes-expose.d.ts.map +1 -0
  114. package/dist/routes-expose.js +57 -0
  115. package/dist/routes-expose.js.map +1 -0
  116. package/dist/runner.d.ts +18 -0
  117. package/dist/runner.d.ts.map +1 -1
  118. package/dist/runner.js +109 -7
  119. package/dist/runner.js.map +1 -1
  120. package/dist/templates/callable-file.d.ts +7 -2
  121. package/dist/templates/callable-file.d.ts.map +1 -1
  122. package/dist/templates/callable-file.js +54 -9
  123. package/dist/templates/callable-file.js.map +1 -1
  124. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  125. package/dist/templates/drizzle-schema.js +236 -65
  126. package/dist/templates/drizzle-schema.js.map +1 -1
  127. package/dist/templates/entity-constants.d.ts +15 -6
  128. package/dist/templates/entity-constants.d.ts.map +1 -1
  129. package/dist/templates/entity-constants.js +85 -161
  130. package/dist/templates/entity-constants.js.map +1 -1
  131. package/dist/templates/entity-file.d.ts.map +1 -1
  132. package/dist/templates/entity-file.js +38 -4
  133. package/dist/templates/entity-file.js.map +1 -1
  134. package/dist/templates/entity-meta-file.d.ts +15 -1
  135. package/dist/templates/entity-meta-file.d.ts.map +1 -1
  136. package/dist/templates/entity-meta-file.js +12 -2
  137. package/dist/templates/entity-meta-file.js.map +1 -1
  138. package/dist/templates/entity-ui-descriptor.d.ts +112 -0
  139. package/dist/templates/entity-ui-descriptor.d.ts.map +1 -0
  140. package/dist/templates/entity-ui-descriptor.js +238 -0
  141. package/dist/templates/entity-ui-descriptor.js.map +1 -0
  142. package/dist/templates/field-meta.d.ts +46 -9
  143. package/dist/templates/field-meta.d.ts.map +1 -1
  144. package/dist/templates/field-meta.js +95 -21
  145. package/dist/templates/field-meta.js.map +1 -1
  146. package/dist/templates/names-decl.d.ts +38 -0
  147. package/dist/templates/names-decl.d.ts.map +1 -0
  148. package/dist/templates/names-decl.js +104 -0
  149. package/dist/templates/names-decl.js.map +1 -0
  150. package/dist/templates/projection-decl.d.ts +21 -0
  151. package/dist/templates/projection-decl.d.ts.map +1 -1
  152. package/dist/templates/projection-decl.js +53 -29
  153. package/dist/templates/projection-decl.js.map +1 -1
  154. package/dist/templates/queries-file.d.ts.map +1 -1
  155. package/dist/templates/queries-file.js +17 -2
  156. package/dist/templates/queries-file.js.map +1 -1
  157. package/dist/templates/requirement-test.d.ts.map +1 -1
  158. package/dist/templates/requirement-test.js +13 -2
  159. package/dist/templates/requirement-test.js.map +1 -1
  160. package/dist/templates/routes-file-hono.d.ts +2 -1
  161. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  162. package/dist/templates/routes-file-hono.js +11 -2
  163. package/dist/templates/routes-file-hono.js.map +1 -1
  164. package/dist/templates/routes-file.d.ts +2 -1
  165. package/dist/templates/routes-file.d.ts.map +1 -1
  166. package/dist/templates/routes-file.js +54 -21
  167. package/dist/templates/routes-file.js.map +1 -1
  168. package/dist/templates/value-object-file.d.ts.map +1 -1
  169. package/dist/templates/value-object-file.js +15 -1
  170. package/dist/templates/value-object-file.js.map +1 -1
  171. package/dist/templates/view-decl.d.ts +37 -1
  172. package/dist/templates/view-decl.d.ts.map +1 -1
  173. package/dist/templates/view-decl.js +27 -3
  174. package/dist/templates/view-decl.js.map +1 -1
  175. package/dist/templates/zod-validators.d.ts +21 -0
  176. package/dist/templates/zod-validators.d.ts.map +1 -1
  177. package/dist/templates/zod-validators.js +40 -5
  178. package/dist/templates/zod-validators.js.map +1 -1
  179. package/dist/view-context.d.ts +24 -0
  180. package/dist/view-context.d.ts.map +1 -0
  181. package/dist/view-context.js +81 -0
  182. package/dist/view-context.js.map +1 -0
  183. package/package.json +6 -6
  184. package/src/api-surface.ts +53 -0
  185. package/src/column-mapper.ts +13 -0
  186. package/src/constants.ts +91 -16
  187. package/src/db-emitting.ts +31 -0
  188. package/src/generator-registry.ts +14 -4
  189. package/src/generator.ts +5 -0
  190. package/src/generators/agent-docs-file.ts +297 -0
  191. package/src/generators/agent-requirements-page.ts +199 -0
  192. package/src/generators/agent-schema-input.ts +88 -0
  193. package/src/generators/agent-schema-page.ts +297 -0
  194. package/src/generators/agent-ui-page.ts +290 -0
  195. package/src/generators/api-docs-file.ts +13 -6
  196. package/src/generators/api-model.ts +63 -34
  197. package/src/generators/callable-file.ts +1 -1
  198. package/src/generators/docs-data-builder.ts +46 -2
  199. package/src/generators/docs-data.ts +6 -2
  200. package/src/generators/index.ts +25 -14
  201. package/src/generators/names-file.ts +98 -0
  202. package/src/generators/requirements-markdown.ts +36 -2
  203. package/src/generators/routes-file-hono.ts +19 -10
  204. package/src/generators/routes-file.ts +20 -7
  205. package/src/generators/template-generator.ts +20 -6
  206. package/src/index.ts +64 -4
  207. package/src/metaobjects-config.ts +48 -2
  208. package/src/names.ts +629 -0
  209. package/src/overwrite-policy.ts +15 -3
  210. package/src/projection/extract-view-spec.ts +85 -6
  211. package/src/reference/entity.ts +21 -2
  212. package/src/reference/names.ts +125 -0
  213. package/src/reference/queries.ts +19 -3
  214. package/src/reference/routes-hono.ts +19 -9
  215. package/src/reference/routes.ts +25 -9
  216. package/src/reference-templates.ts +1 -1
  217. package/src/render-context.ts +13 -1
  218. package/src/retired-codegen-attrs.ts +52 -0
  219. package/src/routes-expose.ts +86 -0
  220. package/src/runner.ts +122 -11
  221. package/src/templates/callable-file.ts +60 -8
  222. package/src/templates/drizzle-schema.ts +248 -60
  223. package/src/templates/entity-constants.ts +95 -184
  224. package/src/templates/entity-file.ts +46 -4
  225. package/src/templates/entity-meta-file.ts +16 -3
  226. package/src/templates/entity-ui-descriptor.ts +305 -0
  227. package/src/templates/field-meta.ts +100 -19
  228. package/src/templates/names-decl.ts +165 -0
  229. package/src/templates/projection-decl.ts +72 -31
  230. package/src/templates/queries-file.ts +17 -2
  231. package/src/templates/requirement-test.ts +13 -2
  232. package/src/templates/routes-file-hono.ts +14 -2
  233. package/src/templates/routes-file.ts +69 -24
  234. package/src/templates/value-object-file.ts +15 -1
  235. package/src/templates/view-decl.ts +53 -3
  236. package/src/templates/zod-validators.ts +41 -5
  237. package/src/view-context.ts +88 -0
@@ -0,0 +1,305 @@
1
+ // The per-field FORM descriptor — the one derivation of "what does this field look like
2
+ // in a UI", shared by everything that answers that question.
3
+ //
4
+ // Two consumers, and the reason they must share:
5
+ //
6
+ // • `renderEntityConstants()` emits it as the `<Entity>` const in `<Entity>.meta.ts`,
7
+ // which `useEntityForm` reads at RUNTIME to render the form;
8
+ // • the `agent/ui.md` docs surface renders it as a table, which is what an agent reads
9
+ // BEFORE it writes code against that form.
10
+ //
11
+ // If those two were derived separately, the page describing the form and the descriptor
12
+ // driving it could disagree — and the disagreement would be invisible, because the page
13
+ // looks authoritative and nothing compares them. That is the same "a name resolved twice
14
+ // is a name that can disagree with itself" rule `names.ts` states for physical names, one
15
+ // tier up.
16
+ //
17
+ // #356 — THE SURFACE IS THE FORM. `view` resolves through `inferViewKind(field, "form")`,
18
+ // which selects the view named for the form context, so a field declaring several views is
19
+ // described by the one the generated `<Entity>.form.tsx` actually renders. The grid tiers
20
+ // compute their own view kind at codegen time and never read this.
21
+ //
22
+ // RULES ARE AN ORDERED LIST, NOT A KEYED OBJECT, and that is load-bearing rather than
23
+ // stylistic: the emitted `rules: { ... }` object preserves the order the field's validator
24
+ // children were declared in, so a field whose `validator.regex` precedes its
25
+ // `validator.required` emits `pattern` first. A keyed shape would impose one fixed order
26
+ // and silently rewrite every such entity's descriptor.
27
+ //
28
+ // The pattern is carried RAW. Escaping it into a `/.../` literal is a TypeScript-emission
29
+ // concern and belongs to the emitter, not to the description of the field.
30
+
31
+ import type { MetaData, MetaField, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
32
+ import {
33
+ VIEW_SUBTYPE_TEXT,
34
+ VIEW_SUBTYPE_TEXTAREA,
35
+ VIEW_SUBTYPE_NUMBER,
36
+ VIEW_SUBTYPE_CHECKBOX,
37
+ VIEW_SUBTYPE_DATE,
38
+ VIEW_SUBTYPE_PASSWORD,
39
+ VIEW_SUBTYPE_HIDDEN,
40
+ VIEW_SUBTYPE_DROPDOWN,
41
+ VIEW_SUBTYPE_RADIO,
42
+ VALIDATOR_SUBTYPE_REQUIRED,
43
+ VALIDATOR_SUBTYPE_LENGTH,
44
+ VALIDATOR_SUBTYPE_REGEX,
45
+ VALIDATOR_ATTR_MIN,
46
+ VALIDATOR_ATTR_MAX,
47
+ VALIDATOR_ATTR_PATTERN,
48
+ FIELD_ATTR_MAX_LENGTH,
49
+ FIELD_ATTR_REQUIRED,
50
+ pluralize,
51
+ toSnakeCase,
52
+ } from "@metaobjectsdev/metadata";
53
+ import { inferViewKind, currencyMetaFor, labelFor, humanize, valueObjectFor } from "./field-meta.js";
54
+ import { VIEW_CONTEXT_FORM } from "../view-context.js";
55
+ import { isProjection } from "../projection/projection-detector.js";
56
+ // `restPath` lives HERE rather than in api-surface.ts, which is where it used to sit and
57
+ // which now re-exports it. The descriptor is what emits `$path`, so the composition has to
58
+ // be reachable from this module; importing it back from `api-surface.js` would be a cycle,
59
+ // since that module imports `resourcePath` from this one. Neither of these two imports
60
+ // reaches back here, so moving the function down is the acyclic direction.
61
+ import { tphDiscriminatorBase, tphDiscriminatorPin } from "./zod-validators.js";
62
+ import { tphRouteSegment } from "./tph-discriminator.js";
63
+
64
+ /** One validation rule, in the order its source was declared. */
65
+ export type UiRule =
66
+ | { readonly kind: "required"; readonly message: string }
67
+ | { readonly kind: "minLength"; readonly value: number; readonly message: string }
68
+ | { readonly kind: "maxLength"; readonly value: number; readonly message: string }
69
+ /** `pattern` is the RAW authored regex source — never pre-escaped. */
70
+ | { readonly kind: "pattern"; readonly pattern: string; readonly message: string };
71
+
72
+ export interface UiFieldDescriptor {
73
+ readonly name: string;
74
+ readonly label: string;
75
+ /** The MetaView subtype resolved for the FORM context. */
76
+ readonly view: string;
77
+ /** Present only when the view maps to a real HTML `<input type=…>`. Derived from the
78
+ * view subtype — there is no authored override (see `htmlTypeFromView`). */
79
+ readonly htmlType?: string | undefined;
80
+ /** Empty when the field declares nothing to validate. */
81
+ readonly rules: readonly UiRule[];
82
+ /** Present only for a `field.currency`. */
83
+ readonly currency?: { readonly currency: string; readonly locale: string } | undefined;
84
+ /**
85
+ * Present when the generated FORM renders this field as a nested value-object
86
+ * sub-form rather than an input — a `field.object` whose `@objectRef` resolves (see
87
+ * `valueObjectFor`). Computed only when `buildEntityUiDescriptor` is given the loaded
88
+ * root; `renderEntityConstants` does not pass one and emits nothing for it, so the
89
+ * `<Entity>` const is byte-identical either way.
90
+ */
91
+ readonly nested?: { readonly objectRef: string; readonly isArray: boolean } | undefined;
92
+ }
93
+
94
+ export interface EntityUiDescriptor {
95
+ readonly entity: string;
96
+ /** The address the generated routes SERVE this object at — `/subscribers`, and
97
+ * `/vehicles/car` for a TPH subtype. Emitted as `$path`. See {@link restPath}. */
98
+ readonly path: string;
99
+ /** Every field, INHERITED INCLUDED (ADR-0039 resolving `fields()`). */
100
+ readonly fields: readonly UiFieldDescriptor[];
101
+ }
102
+
103
+ /**
104
+ * An object's OWN pluralized resource path — the one derivation of that spelling, and the
105
+ * input {@link restPath} composes an address from. It is NOT `$path` on its own.
106
+ *
107
+ * A projection is KEBAB-cased and an entity is SNAKE-cased, and that split is not a
108
+ * style choice: both spellings are already mounted. `renderProjectionDecl` emits
109
+ * `$path: "/order-summaries"` and `renderEntityConstants` emits `$path:
110
+ * "/order_summaries"`, and `routes-file.ts` mounts whichever const belongs to the object
111
+ * — so an address computed without the split is wrong for every multi-word projection.
112
+ * It used to live in a second function inside `projection-decl.ts`, which is exactly how
113
+ * `agent/ui.md` and `api/AGENT-API.md` both came to print `/order_summaries` for a
114
+ * projection served at `/order-summaries`.
115
+ *
116
+ * "Subscriber" → "/subscribers"
117
+ * "WorkoutEvent" → "/workout_events"
118
+ * "ProgramSummary" → "/program-summaries" (projection)
119
+ *
120
+ * A TPH SUBTYPE is not addressed by this path — it is mounted under its base — so this is
121
+ * an INPUT to {@link restPath}, not the answer. `$path` carries `restPath`; call this only
122
+ * when you specifically want an object's own pluralized name, never to build an address.
123
+ */
124
+ export function resourcePath(entity: MetaData): string {
125
+ // The two compositions differ in ORDER as well as separator, and both are load-bearing:
126
+ // pluralize-then-snake is what the projection const has always emitted, snake-then-
127
+ // pluralize is what the entity const has. Neither may be "tidied" into the other.
128
+ return isProjection(entity)
129
+ ? `/${toSnakeCase(pluralize(entity.name)).replace(/_/g, "-")}`
130
+ : `/${pluralize(toSnakeCase(entity.name))}`;
131
+ }
132
+
133
+ /**
134
+ * THE address the generated routes serve this object at — and therefore what the
135
+ * `<Entity>` const emits as `$path`.
136
+ *
137
+ * `resourcePath` answers a different question: an object's own pluralized name. The two
138
+ * agree for everything EXCEPT a TPH subtype, which emits no routes file of its own —
139
+ * `routes-file.ts` mounts the whole hierarchy from the discriminator BASE, giving the
140
+ * union read-only routes at the base path and each subtype a full CRUD set at
141
+ * `<base path>/<route segment>`.
142
+ *
143
+ * `$path` used to carry `resourcePath`, so a subtype's own `$path` named an endpoint that
144
+ * did not exist. That was not confined to prose: `agent/ui.md` printed it as fact, and the
145
+ * TanStack `grid-hook-file.ts` builds its fetch URL from `<Entity>.$path` with no TPH
146
+ * branch at all, so an opted-in per-subtype grid requested an address nothing served.
147
+ * Fixing it here fixes every consumer, because every consumer reads the const.
148
+ *
149
+ * The composition is the same one `routes-file.ts` and `hooks-file.ts` emit as CODE
150
+ * (`Base.$path + "/car"`); they reference the const rather than a computed string, so this
151
+ * is the one place it can be evaluated. The SEGMENT rule is not restated —
152
+ * `tphRouteSegment` owns it, and all three read it from there.
153
+ */
154
+ export function restPath(entity: MetaObject): string {
155
+ const pin = tphDiscriminatorPin(entity);
156
+ const base = tphDiscriminatorBase(entity);
157
+ if (pin === undefined || base === undefined) return resourcePath(entity);
158
+ return `${resourcePath(base)}/${tphRouteSegment(pin.value)}`;
159
+ }
160
+
161
+ /**
162
+ * Map a MetaView subtype to a real HTML `<input type=…>` value. Returns undefined for
163
+ * views that don't map to `<input>` at all (textarea, dropdown) — consumers render the
164
+ * right element type themselves.
165
+ *
166
+ * THE MAPPING IS THE ONLY SOURCE. An `@htmlType` override used to be read off the view
167
+ * node ahead of this switch; nothing registers that attribute, so the override was
168
+ * unreachable and this mapping was always the answer. A consumer needing a different
169
+ * `type=` edits the emitted `<Entity>.meta.ts`, whose hand edits the merge preserves.
170
+ */
171
+ function htmlTypeFromView(view: string): string | undefined {
172
+ switch (view) {
173
+ case VIEW_SUBTYPE_TEXT:
174
+ return "text";
175
+ case VIEW_SUBTYPE_NUMBER:
176
+ return "number";
177
+ case VIEW_SUBTYPE_DATE:
178
+ return "date";
179
+ case VIEW_SUBTYPE_PASSWORD:
180
+ return "password";
181
+ case VIEW_SUBTYPE_CHECKBOX:
182
+ return "checkbox";
183
+ case VIEW_SUBTYPE_HIDDEN:
184
+ return "hidden";
185
+ case VIEW_SUBTYPE_RADIO:
186
+ return "radio";
187
+ case "month":
188
+ return "month";
189
+ case "email":
190
+ return "email";
191
+ case VIEW_SUBTYPE_TEXTAREA:
192
+ case VIEW_SUBTYPE_DROPDOWN:
193
+ return undefined;
194
+ default:
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The field's validation rules, in DECLARATION ORDER — see the ordering note at the top
201
+ * of this file.
202
+ *
203
+ * The field-level `@required` / `@maxLength` attrs append only when no validator child
204
+ * already supplied that rule, so the two authoring spellings never double up.
205
+ */
206
+ function buildFieldRules(field: MetaField): UiRule[] {
207
+ const rules: UiRule[] = [];
208
+ let hasRequired = false;
209
+ let hasMaxLength = false;
210
+
211
+ // ADR-0039: resolving — a validator may inherit its config attrs (@min/@max/@pattern)
212
+ // via extends.
213
+ //
214
+ // THE MESSAGES ARE DERIVED, NOT AUTHORED. `@message` / `@minMessage` / `@maxMessage`
215
+ // used to be read here as overrides; no provider registers any of them, so the reads
216
+ // were unreachable and the defaults below were always what shipped. A consumer wanting
217
+ // different wording edits the emitted `rules` object in `<Entity>.meta.ts`, which is a
218
+ // generated file whose hand edits the three-way merge preserves. See ADR-0023.
219
+ for (const child of field.validators()) {
220
+ if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) {
221
+ rules.push({ kind: "required", message: `${humanize(field.name)} is required` });
222
+ hasRequired = true;
223
+ } else if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
224
+ const min = child.attr(VALIDATOR_ATTR_MIN);
225
+ const max = child.attr(VALIDATOR_ATTR_MAX);
226
+ if (typeof min === "number") {
227
+ rules.push({ kind: "minLength", value: min, message: `Must be at least ${min} characters` });
228
+ }
229
+ if (typeof max === "number") {
230
+ rules.push({ kind: "maxLength", value: max, message: `Must be ${max} characters or fewer` });
231
+ hasMaxLength = true;
232
+ }
233
+ } else if (child.subType === VALIDATOR_SUBTYPE_REGEX) {
234
+ const pattern = child.attr(VALIDATOR_ATTR_PATTERN);
235
+ if (typeof pattern === "string") {
236
+ rules.push({ kind: "pattern", pattern, message: "Invalid format" });
237
+ }
238
+ }
239
+ }
240
+
241
+ // Field-level @required attr (if not already covered by validator).
242
+ if (!hasRequired && field.attr(FIELD_ATTR_REQUIRED) === true) {
243
+ rules.push({ kind: "required", message: `${humanize(field.name)} is required` });
244
+ }
245
+
246
+ // Field-level @maxLength attr (if not already covered).
247
+ const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH);
248
+ if (!hasMaxLength && typeof maxLenAttr === "number") {
249
+ rules.push({
250
+ kind: "maxLength",
251
+ value: maxLenAttr,
252
+ message: `Must be ${maxLenAttr} characters or fewer`,
253
+ });
254
+ }
255
+
256
+ return rules;
257
+ }
258
+
259
+ /**
260
+ * Describe one field's form presentation.
261
+ *
262
+ * `root` is OPTIONAL and only decides whether `nested` is filled in: resolving a
263
+ * `field.object`'s `@objectRef` needs the run's other objects, and the two callers differ
264
+ * — `renderEntityConstants` has no root (it is also ejected into adopter repos with a
265
+ * fixed signature) while the docs surface does. Passing one adds a member the const
266
+ * emitter ignores; it never changes any member the const emits.
267
+ */
268
+ export function buildUiFieldDescriptor(field: MetaField, root?: MetaRoot): UiFieldDescriptor {
269
+ // `inferViewKind` IS the resolution: the view child declared for the FORM (own or
270
+ // inherited) wins, else the field subtype's default. This module used to keep a
271
+ // `resolveView` that returned the same string plus the view NODE, and the node existed
272
+ // only to read `@placeholder` / `@helpText` / `@htmlType` off it — none of which any
273
+ // provider registers, so all three reads were unreachable. With them gone the node has
274
+ // no reader and the two functions were the same function.
275
+ const view = inferViewKind(field, VIEW_CONTEXT_FORM);
276
+ const currencyMeta = currencyMetaFor(field, VIEW_CONTEXT_FORM);
277
+ const vo = root === undefined ? undefined : valueObjectFor(field, root);
278
+ return {
279
+ name: field.name,
280
+ label: labelFor(field, VIEW_CONTEXT_FORM),
281
+ view,
282
+ htmlType: htmlTypeFromView(view),
283
+ rules: buildFieldRules(field),
284
+ currency: currencyMeta === null
285
+ ? undefined
286
+ : { currency: currencyMeta.currency, locale: currencyMeta.locale },
287
+ nested: vo === undefined
288
+ ? undefined
289
+ : { objectRef: vo.name, isArray: field.resolvedIsArray() },
290
+ };
291
+ }
292
+
293
+ /** Describe one object's whole form surface. See {@link buildUiFieldDescriptor} for `root`. */
294
+ export function buildEntityUiDescriptor(obj: MetaObject, root?: MetaRoot): EntityUiDescriptor {
295
+ return {
296
+ entity: obj.name,
297
+ // `restPath`, not `resourcePath`: `$path` is the address this object is SERVED at.
298
+ // They differ only for a TPH subtype, and for that one case `resourcePath` names an
299
+ // endpoint nothing mounts (see restPath's note).
300
+ path: restPath(obj),
301
+ // ADR-0039: resolving `fields()` so inherited fields appear, exactly as the emitted
302
+ // descriptor has always done.
303
+ fields: obj.fields().map((f) => buildUiFieldDescriptor(f, root)),
304
+ };
305
+ }
@@ -4,6 +4,8 @@
4
4
  // All helpers take a MetaField node.
5
5
 
6
6
  import { MetaField } from "@metaobjectsdev/metadata";
7
+ import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
8
+ import { stripPackage } from "@metaobjectsdev/metadata";
7
9
  import {
8
10
  FIELD_SUBTYPE_STRING,
9
11
  FIELD_SUBTYPE_INT,
@@ -20,6 +22,8 @@ import {
20
22
  FIELD_SUBTYPE_UUID,
21
23
  FIELD_SUBTYPE_URI,
22
24
  FIELD_SUBTYPE_INET,
25
+ FIELD_SUBTYPE_OBJECT,
26
+ FIELD_ATTR_OBJECT_REF,
23
27
  FIELD_ATTR_STRING_FORMAT,
24
28
  FIELD_ATTR_LENIENT,
25
29
  STRING_FORMAT_EMAIL,
@@ -29,12 +33,14 @@ import {
29
33
  VIEW_SUBTYPE_NUMBER,
30
34
  VIEW_SUBTYPE_CHECKBOX,
31
35
  VIEW_SUBTYPE_CURRENCY,
36
+ VIEW_SUBTYPE_DROPDOWN,
32
37
  FIELD_ATTR_CURRENCY,
33
38
  FIELD_ATTR_CURRENCY_DEFAULT,
34
39
  VIEW_CURRENCY_ATTR_LOCALE,
35
40
  VIEW_CURRENCY_ATTR_LOCALE_DEFAULT,
36
41
  } from "@metaobjectsdev/metadata";
37
42
  import { enumValues, zodEnumExpr } from "../enum-meta.js";
43
+ import { viewForContext } from "../view-context.js";
38
44
  import { ZOD_INET_EXPR } from "./net-regex.js";
39
45
 
40
46
  // ---------------------------------------------------------------------------
@@ -42,12 +48,16 @@ import { ZOD_INET_EXPR } from "./net-regex.js";
42
48
  // ---------------------------------------------------------------------------
43
49
 
44
50
  /**
45
- * Resolve the cell-renderer key (view kind) for a field.
46
- * Explicit view child wins; field subType determines default.
51
+ * Resolve the cell-renderer key (view kind) for a field, for the surface named
52
+ * by `context`.
53
+ *
54
+ * An explicit view declared for THAT surface wins; field subType determines the
55
+ * default. #356: `context` is required rather than defaulting, because a caller
56
+ * that omits it is exactly the caller that renders somebody else's view.
47
57
  */
48
- export function inferViewKind(field: MetaField): string {
49
- // Explicit view (own or inherited via extends) has highest priority.
50
- const viewChild = field.views()[0];
58
+ export function inferViewKind(field: MetaField, context: string): string {
59
+ // Explicit view for this surface (own or inherited via extends) has highest priority.
60
+ const viewChild = viewForContext(field, context);
51
61
  if (viewChild) return viewChild.subType;
52
62
  // Field subtype → default view.
53
63
  return defaultViewForSubType(field.subType);
@@ -69,6 +79,22 @@ function defaultViewForSubType(subType: string): string {
69
79
  return VIEW_SUBTYPE_DATE;
70
80
  case FIELD_SUBTYPE_CURRENCY:
71
81
  return VIEW_SUBTYPE_CURRENCY;
82
+ case FIELD_SUBTYPE_ENUM:
83
+ // A closed member set is a CHOICE, and the generated form has always rendered it as
84
+ // one: `codegen-ts-react`'s `viewKindFor` returns `dropdown` for an enum declaring no
85
+ // view, and emits a `<select>` with an `<option>` per member.
86
+ //
87
+ // This branch was missing, so the same question had two answers. The emitted
88
+ // `<Entity>` descriptor — which `useEntityForm` reads at RUNTIME — fell through to
89
+ // `text` and told every consumer the control was a free-text input, for the one field
90
+ // subtype where free text is exactly what the model forbids. The form was right and
91
+ // the descriptor describing it was wrong.
92
+ //
93
+ // Fixed HERE rather than in the react tier because this is the shared resolver: the
94
+ // descriptor, the `agent/ui.md` page and the form must give one answer, and pinning
95
+ // the agreement in the tier that renders it would leave the two other readers still
96
+ // computing their own.
97
+ return VIEW_SUBTYPE_DROPDOWN;
72
98
  default:
73
99
  return VIEW_SUBTYPE_TEXT;
74
100
  }
@@ -152,14 +178,25 @@ export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" =
152
178
  // ---------------------------------------------------------------------------
153
179
 
154
180
  /**
155
- * Resolve currency code + locale for a currency-subtype field.
156
- * Returns null for non-currency fields.
181
+ * Resolve currency code + locale for a currency-subtype field, for the surface
182
+ * named by `context`. Returns null for non-currency fields.
157
183
  */
158
- export function currencyMetaFor(field: MetaField): { currency: string; locale: string } | null {
184
+ export function currencyMetaFor(
185
+ field: MetaField,
186
+ context: string,
187
+ ): { currency: string; locale: string } | null {
159
188
  if (field.subType !== FIELD_SUBTYPE_CURRENCY) return null;
160
189
  const currency =
161
190
  (field.attr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT;
162
- const viewChild = field.views().find((c) => c.subType === VIEW_SUBTYPE_CURRENCY);
191
+ // #356: the surface's own view carries the @locale for that surface — two
192
+ // currency views with different locales must not be resolved by position.
193
+ // When the surface's view is NOT a currency view (or there is none), keep the
194
+ // pre-#356 scan: an authored @locale anywhere on the field beats the default.
195
+ const contextView = viewForContext(field, context);
196
+ const viewChild =
197
+ contextView?.subType === VIEW_SUBTYPE_CURRENCY
198
+ ? contextView
199
+ : field.views().find((c) => c.subType === VIEW_SUBTYPE_CURRENCY);
163
200
  // ADR-0039: resolving — a view may inherit @locale via extends.
164
201
  const locale =
165
202
  (viewChild?.attr(VIEW_CURRENCY_ATTR_LOCALE) as string | undefined) ??
@@ -167,20 +204,58 @@ export function currencyMetaFor(field: MetaField): { currency: string; locale: s
167
204
  return { currency, locale };
168
205
  }
169
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // valueObjectFor
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * The `object.value` a `field.object`'s `@objectRef` names, or `undefined` when the
213
+ * field is not an object field, declares no ref, or names one that is not in the run.
214
+ *
215
+ * THIS IS THE "DOES THE FORM NEST THIS FIELD?" PREDICATE, and it lives here because two
216
+ * tiers have to give one answer: `codegen-ts-react`'s form generator recurses into the
217
+ * referenced value object as a `<fieldset>` sub-form (or a `useFieldArray` repeatable
218
+ * group when the field is an array) instead of emitting an input at all, and
219
+ * `agent/ui.md` documents what that form renders. When they answered separately the page
220
+ * printed `text` — the field's resolved `view.*` subtype, which the form never consults
221
+ * for a nested field — as if it were the control.
222
+ *
223
+ * The resolution is BARE-NAME on purpose: it is the same lookup the Zod and
224
+ * inferred-type templates perform, and changing it here would change what the generated
225
+ * form nests. A package-aware resolution is a separate decision with its own blast
226
+ * radius (ADR-0042).
227
+ */
228
+ export function valueObjectFor(field: MetaField, root: MetaRoot): MetaObject | undefined {
229
+ if (field.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
230
+ // ADR-0039: resolving — @objectRef may be inherited via extends.
231
+ const ref = field.attr(FIELD_ATTR_OBJECT_REF);
232
+ if (typeof ref !== "string" || ref.length === 0) return undefined;
233
+ const base = stripPackage(ref);
234
+ return root.objects().find((o) => o.name === base);
235
+ }
236
+
170
237
  // ---------------------------------------------------------------------------
171
238
  // labelFor
172
239
  // ---------------------------------------------------------------------------
173
240
 
174
241
  /**
175
- * Resolve the human-readable label for a field.
176
- * Uses @label attr on a view child if present; otherwise humanizes the field name.
242
+ * Resolve the human-readable label for a field, for the surface named by `context`.
243
+ * Uses the registered `@title` common attr on that surface's view first, else on the
244
+ * field itself; otherwise humanizes the field name.
245
+ *
246
+ * #353 — this read `@label`, which no provider registers on any `view.*` subtype, so the
247
+ * override branch was unreachable: authoring it fails the strict load `meta verify` runs
248
+ * (ERR_UNKNOWN_ATTR). `title` is already a registered common attr on every node and
249
+ * already means "a noun phrase", so nothing was registered for this (ADR-0037 step 0).
177
250
  */
178
- export function labelFor(field: MetaField): string {
179
- for (const child of field.views()) {
180
- // ADR-0039: resolving — a view may inherit @label via extends.
181
- const label = child.attr("label");
182
- if (typeof label === "string" && label.length > 0) return label;
183
- }
251
+ export function labelFor(field: MetaField, context: string): string {
252
+ // #356: the label reads the view for THIS surface, not whichever view was
253
+ // declared first — a grid header must not retitle the form's field.
254
+ // ADR-0039: resolving — a view may inherit @title via extends.
255
+ const title = viewForContext(field, context)?.attr("title");
256
+ if (typeof title === "string" && title.length > 0) return title;
257
+ const fieldTitle = field.attr("title");
258
+ if (typeof fieldTitle === "string" && fieldTitle.length > 0) return fieldTitle;
184
259
  return humanize(field.name);
185
260
  }
186
261
 
@@ -188,8 +263,14 @@ export function labelFor(field: MetaField): string {
188
263
  // Internal
189
264
  // ---------------------------------------------------------------------------
190
265
 
191
- /** Convert a camelCase or PascalCase field name to a human-friendly label. */
192
- function humanize(s: string): string {
266
+ /**
267
+ * Convert a camelCase or PascalCase field name to a human-friendly label.
268
+ *
269
+ * THE one copy in this package: `entity-ui-descriptor.ts` re-exports this rather than
270
+ * keeping a second body, because two spellings of "humanize" is two answers to what a
271
+ * field is CALLED — the label a form renders and the label the docs page prints.
272
+ */
273
+ export function humanize(s: string): string {
193
274
  return s
194
275
  .replace(/([a-z])([A-Z])/g, "$1 $2")
195
276
  .replace(/_/g, " ")
@@ -0,0 +1,165 @@
1
+ /**
2
+ * §A1/§A2 — `<Entity>Names`: the physical database names for one object, as constants a
3
+ * hand-written consumer references instead of a string literal.
4
+ *
5
+ * Shape copied from the FR-009 filter allowlist, which is the same problem (a per-entity
6
+ * name artifact) already solved in all five ports. Deliberately NOT folded into the entity
7
+ * descriptor: four of five ports have no descriptor to extend, and merging in TypeScript
8
+ * alone would make TS the odd port out on the axis this project protects hardest.
9
+ *
10
+ * An artifact whose object EXTENDS another one that has an artifact of its own spreads it
11
+ * rather than restating what it inherits — "extend from the parent, do not redo all the
12
+ * names". Two shapes, decided structurally:
13
+ *
14
+ * - the object declares its OWN source: spread only the super's `fields`, because the
15
+ * physical name, kind, schema and read-only-ness are this object's own. Spreading the
16
+ * whole super here would leak its `schema` onto a child that declares none.
17
+ * - the object INHERITS its source (a TPH subtype sharing its base's single table):
18
+ * spread the WHOLE super, so the table name is stated once, on the base.
19
+ */
20
+ import type { ColumnNamingStrategy, MetaObject } from "@metaobjectsdev/metadata";
21
+ import { GENERATED_HEADER } from "../constants.js";
22
+ import { PHYSICAL_NAME_ATTR_BY_KIND, primaryRdbSource } from "@metaobjectsdev/metadata";
23
+
24
+ /** The physical-name alias keys, in the metamodel's own order. */
25
+ const PHYSICAL_NAME_ALIASES = [...PHYSICAL_NAME_ATTR_BY_KIND.values()] as const;
26
+ import {
27
+ resolveObjectNames, resolveSuperFragmentNames,
28
+ type FieldNames, type KeyNames, type ObjectNames, type SourceNames,
29
+ } from "../names.js";
30
+
31
+ export interface NamesDeclOpts {
32
+ readonly strategy?: ColumnNamingStrategy | undefined;
33
+ /**
34
+ * Module specifier for the super's names module (e.g. `"./BaseEntity.names"`), when this
35
+ * artifact extends one. Computed by the CALLER, which is the only place that knows the
36
+ * project's output layout and extension style. Omitted ⇒ the flat shape: every inherited
37
+ * column restated, which is what an ejected reference template that has not been updated
38
+ * still produces. Correct, just not deduplicated.
39
+ */
40
+ readonly superSpecifier?: string | undefined;
41
+ /**
42
+ * Render the FRAGMENT form — an abstract base that a sourced object extends, carrying
43
+ * columns and no physical name. See `resolveSuperFragmentNames`.
44
+ */
45
+ readonly fragment?: boolean | undefined;
46
+ }
47
+
48
+ /** Back-compat: the second argument was the naming strategy, and an ejected copy still passes it. */
49
+ function normalize(opts?: ColumnNamingStrategy | NamesDeclOpts): NamesDeclOpts {
50
+ return typeof opts === "string" || opts === undefined ? { strategy: opts } : opts;
51
+ }
52
+
53
+ export function renderNamesDecl(
54
+ obj: MetaObject,
55
+ opts?: ColumnNamingStrategy | NamesDeclOpts,
56
+ ): string {
57
+ const o = normalize(opts);
58
+ // `fragment` says "this is an ANCESTOR render" — emit even though the walk reached this
59
+ // object by climbing rather than by matching. It does NOT say which shape to render, and
60
+ // it must not: a caller that hardcodes `true` is correct for an abstract base with
61
+ // columns and no table, and wrong for the other ancestor this walk reaches — a TPH BASE
62
+ // under `meta gen --entities <Subtype>`, which owns the shared table. Rendered as a
63
+ // fragment it emits no source at all while the subtype still spreads it.
64
+ //
65
+ // Derived HERE rather than at the call site because the call sites are plural and one of
66
+ // them is EJECTED: `src/reference/names.ts` is copied into an adopter's repo by
67
+ // `meta init` and thereafter owned by them. Deciding it in the engine makes every copy
68
+ // already on disk correct without an edit, and leaves no caller able to get it wrong.
69
+ const n: ObjectNames | undefined = o.fragment === true && primaryRdbSource(obj) === undefined
70
+ ? resolveSuperFragmentNames(obj, o.strategy)
71
+ : resolveObjectNames(obj, o.strategy);
72
+ if (n === undefined) return "";
73
+
74
+ const superSym = n.superNames === undefined || o.superSpecifier === undefined
75
+ ? undefined
76
+ : `${n.superNames.name}Names`;
77
+
78
+ const q = (v: string): string => JSON.stringify(v);
79
+ // A key is emitted bare only when it is a valid identifier. Field names always are;
80
+ // an index name is author-chosen and routinely is not (`uq_cust_email` is, `2fa-idx`
81
+ // is not), so quoting is decided per key rather than per collection — an unquoted
82
+ // non-identifier key is a file that does not parse.
83
+ const key = (k: string): string => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : q(k));
84
+
85
+ /**
86
+ * One `name`-keyed collection — `fields`, `identities`, `indexes`.
87
+ *
88
+ * With a super to spread, only what THIS object declares is emitted and the rest is
89
+ * reached through the parent's artifact, so a physical name is stated once. Without one,
90
+ * every member must be here: a consumer looks a column up by field name and an inherited
91
+ * miss falls back to a literal, which is the defect the artifact exists to remove.
92
+ *
93
+ * The collection key is always emitted, even when empty, because a child spreads
94
+ * `...Super.identities` unconditionally.
95
+ */
96
+ const collection = (
97
+ label: string,
98
+ all: Readonly<Record<string, unknown>>,
99
+ own: Readonly<Record<string, unknown>>,
100
+ render: (v: never) => string,
101
+ ): string => {
102
+ const rows = superSym === undefined ? all : own;
103
+ const body = Object.keys(rows).sort()
104
+ .map((k) => ` ${key(k)}: ${render(rows[k] as never)},`)
105
+ .join("\n");
106
+ const spread = superSym === undefined ? "" : ` ...${superSym}.${label},\n`;
107
+ if (spread === "" && body === "") return ` ${label}: {},`;
108
+ return ` ${label}: {\n${spread}${body}${body === "" ? "" : "\n"} },`;
109
+ };
110
+
111
+ const fieldsBlock = collection("fields", n.fields, n.ownFields,
112
+ (f: FieldNames) => `{ name: ${q(f.name)}, column: ${q(f.column)} }`);
113
+
114
+ // `type` and `subType` on every entry, because the artifact mirrors the metadata tree
115
+ // and because on these two collections the subType is the only thing that says whether
116
+ // an index is unique — ADR-0040 put that in the type rather than in an attribute.
117
+ const renderKey = (k: KeyNames): string =>
118
+ `{ type: ${q(k.type)}, subType: ${q(k.subType)}, name: ${q(k.name)}` +
119
+ (k.index === undefined ? " }" : `, index: ${q(k.index)} }`);
120
+ const identitiesBlock = collection("identities", n.identities, n.ownIdentities, renderKey);
121
+ const indexesBlock = collection("indexes", n.indexes, n.ownIndexes, renderKey);
122
+
123
+ const header =
124
+ `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
125
+ `// Source metadata: ${obj.name}\n` +
126
+ (superSym === undefined ? "" : `import { ${superSym} } from ${q(o.superSpecifier as string)};\n\n`);
127
+
128
+ // The object's own identity. `name` is the METAMODEL name — it held the physical name
129
+ // until 0.25.0, and that key changing meaning without changing shape is the one thing
130
+ // here a hand-written consumer adopts without a compile error.
131
+ const identity =
132
+ ` type: ${q(n.type)},\n` +
133
+ ` subType: ${q(n.subType)},\n` +
134
+ ` name: ${q(n.name)},\n`;
135
+
136
+ /** One source, under its role. The physical name sits under the alias for its `@kind`. */
137
+ const renderSource = (role: string, src: SourceNames): string => {
138
+ const parts = [`type: ${q(src.type)}`, `subType: ${q(src.subType)}`, `kind: ${q(src.kind)}`];
139
+ if (src.schema !== undefined) parts.push(`schema: ${q(src.schema)}`);
140
+ for (const alias of PHYSICAL_NAME_ALIASES) {
141
+ const v = src[alias as keyof SourceNames];
142
+ if (typeof v === "string") parts.push(`${alias}: ${q(v)}`);
143
+ }
144
+ return ` ${key(role)}: { ${parts.join(", ")} },`;
145
+ };
146
+
147
+ // A fragment declares no source and must never acquire one; a TPH subtype INHERITS its
148
+ // base's, so it spreads rather than restating — structural (the two resolve to the SAME
149
+ // node), never an equality test on the resolved strings.
150
+ const sourceRows = Object.keys(n.ownSources).sort()
151
+ .map((role) => renderSource(role, n.ownSources[role] as SourceNames)).join("\n");
152
+ const spreadSources = superSym !== undefined && n.inheritsSource ? ` ...${superSym}.sources,\n` : "";
153
+ const sourcesBlock =
154
+ spreadSources === "" && sourceRows === ""
155
+ ? " sources: {},"
156
+ : ` sources: {\n${spreadSources}${sourceRows}${sourceRows === "" ? "" : "\n"} },`;
157
+
158
+ return `${header}export const ${obj.name}Names = {
159
+ ${identity}${sourcesBlock}
160
+ ${fieldsBlock}
161
+ ${identitiesBlock}
162
+ ${indexesBlock}
163
+ } as const;
164
+ `;
165
+ }