@metaobjectsdev/codegen-ts 0.24.4 → 0.25.0

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 (265) 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/client-directive.d.ts +18 -0
  7. package/dist/client-directive.d.ts.map +1 -0
  8. package/dist/client-directive.js +37 -0
  9. package/dist/client-directive.js.map +1 -0
  10. package/dist/column-mapper.d.ts +12 -0
  11. package/dist/column-mapper.d.ts.map +1 -1
  12. package/dist/column-mapper.js +1 -0
  13. package/dist/column-mapper.js.map +1 -1
  14. package/dist/constants.d.ts +24 -11
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +54 -19
  17. package/dist/constants.js.map +1 -1
  18. package/dist/db-emitting.d.ts +6 -0
  19. package/dist/db-emitting.d.ts.map +1 -0
  20. package/dist/db-emitting.js +26 -0
  21. package/dist/db-emitting.js.map +1 -0
  22. package/dist/generator-registry.d.ts.map +1 -1
  23. package/dist/generator-registry.js +7 -1
  24. package/dist/generator-registry.js.map +1 -1
  25. package/dist/generator.d.ts +5 -0
  26. package/dist/generator.d.ts.map +1 -1
  27. package/dist/generator.js.map +1 -1
  28. package/dist/generators/agent-docs-file.d.ts +19 -0
  29. package/dist/generators/agent-docs-file.d.ts.map +1 -0
  30. package/dist/generators/agent-docs-file.js +257 -0
  31. package/dist/generators/agent-docs-file.js.map +1 -0
  32. package/dist/generators/agent-requirements-page.d.ts +22 -0
  33. package/dist/generators/agent-requirements-page.d.ts.map +1 -0
  34. package/dist/generators/agent-requirements-page.js +175 -0
  35. package/dist/generators/agent-requirements-page.js.map +1 -0
  36. package/dist/generators/agent-schema-input.d.ts +69 -0
  37. package/dist/generators/agent-schema-input.d.ts.map +1 -0
  38. package/dist/generators/agent-schema-input.js +23 -0
  39. package/dist/generators/agent-schema-input.js.map +1 -0
  40. package/dist/generators/agent-schema-page.d.ts +21 -0
  41. package/dist/generators/agent-schema-page.d.ts.map +1 -0
  42. package/dist/generators/agent-schema-page.js +241 -0
  43. package/dist/generators/agent-schema-page.js.map +1 -0
  44. package/dist/generators/agent-ui-page.d.ts +22 -0
  45. package/dist/generators/agent-ui-page.d.ts.map +1 -0
  46. package/dist/generators/agent-ui-page.js +253 -0
  47. package/dist/generators/agent-ui-page.js.map +1 -0
  48. package/dist/generators/api-docs-file.d.ts.map +1 -1
  49. package/dist/generators/api-docs-file.js +13 -6
  50. package/dist/generators/api-docs-file.js.map +1 -1
  51. package/dist/generators/api-model.d.ts +6 -0
  52. package/dist/generators/api-model.d.ts.map +1 -1
  53. package/dist/generators/api-model.js +54 -35
  54. package/dist/generators/api-model.js.map +1 -1
  55. package/dist/generators/callable-file.js +1 -1
  56. package/dist/generators/callable-file.js.map +1 -1
  57. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  58. package/dist/generators/docs-data-builder.js +47 -2
  59. package/dist/generators/docs-data-builder.js.map +1 -1
  60. package/dist/generators/docs-data.d.ts +6 -2
  61. package/dist/generators/docs-data.d.ts.map +1 -1
  62. package/dist/generators/index.d.ts +3 -0
  63. package/dist/generators/index.d.ts.map +1 -1
  64. package/dist/generators/index.js +4 -0
  65. package/dist/generators/index.js.map +1 -1
  66. package/dist/generators/names-file.d.ts +17 -0
  67. package/dist/generators/names-file.d.ts.map +1 -0
  68. package/dist/generators/names-file.js +95 -0
  69. package/dist/generators/names-file.js.map +1 -0
  70. package/dist/generators/requirements-markdown.d.ts +18 -1
  71. package/dist/generators/requirements-markdown.d.ts.map +1 -1
  72. package/dist/generators/requirements-markdown.js +11 -9
  73. package/dist/generators/requirements-markdown.js.map +1 -1
  74. package/dist/generators/routes-file-hono.d.ts +17 -2
  75. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  76. package/dist/generators/routes-file-hono.js +15 -12
  77. package/dist/generators/routes-file-hono.js.map +1 -1
  78. package/dist/generators/routes-file.d.ts +17 -2
  79. package/dist/generators/routes-file.d.ts.map +1 -1
  80. package/dist/generators/routes-file.js +10 -8
  81. package/dist/generators/routes-file.js.map +1 -1
  82. package/dist/generators/template-generator.d.ts.map +1 -1
  83. package/dist/generators/template-generator.js +20 -6
  84. package/dist/generators/template-generator.js.map +1 -1
  85. package/dist/index.d.ts +21 -5
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +40 -4
  88. package/dist/index.js.map +1 -1
  89. package/dist/metaobjects-config.d.ts +67 -1
  90. package/dist/metaobjects-config.d.ts.map +1 -1
  91. package/dist/metaobjects-config.js +10 -1
  92. package/dist/metaobjects-config.js.map +1 -1
  93. package/dist/names.d.ts +275 -0
  94. package/dist/names.d.ts.map +1 -0
  95. package/dist/names.js +434 -0
  96. package/dist/names.js.map +1 -0
  97. package/dist/naming.d.ts +17 -0
  98. package/dist/naming.d.ts.map +1 -1
  99. package/dist/naming.js +21 -0
  100. package/dist/naming.js.map +1 -1
  101. package/dist/overwrite-policy.d.ts.map +1 -1
  102. package/dist/overwrite-policy.js +15 -3
  103. package/dist/overwrite-policy.js.map +1 -1
  104. package/dist/payload-codegen.d.ts.map +1 -1
  105. package/dist/payload-codegen.js +4 -3
  106. package/dist/payload-codegen.js.map +1 -1
  107. package/dist/projection/extract-view-spec.d.ts +25 -1
  108. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  109. package/dist/projection/extract-view-spec.js +79 -6
  110. package/dist/projection/extract-view-spec.js.map +1 -1
  111. package/dist/prompt-generator-gate.d.ts +27 -0
  112. package/dist/prompt-generator-gate.d.ts.map +1 -0
  113. package/dist/prompt-generator-gate.js +62 -0
  114. package/dist/prompt-generator-gate.js.map +1 -0
  115. package/dist/reference-templates.d.ts +22 -5
  116. package/dist/reference-templates.d.ts.map +1 -1
  117. package/dist/reference-templates.js +55 -21
  118. package/dist/reference-templates.js.map +1 -1
  119. package/dist/render-context.d.ts +20 -1
  120. package/dist/render-context.d.ts.map +1 -1
  121. package/dist/render-context.js +2 -0
  122. package/dist/render-context.js.map +1 -1
  123. package/dist/retired-codegen-attrs.d.ts +32 -0
  124. package/dist/retired-codegen-attrs.d.ts.map +1 -0
  125. package/dist/retired-codegen-attrs.js +47 -0
  126. package/dist/retired-codegen-attrs.js.map +1 -0
  127. package/dist/routes-expose.d.ts +63 -0
  128. package/dist/routes-expose.d.ts.map +1 -0
  129. package/dist/routes-expose.js +57 -0
  130. package/dist/routes-expose.js.map +1 -0
  131. package/dist/runner.d.ts +18 -0
  132. package/dist/runner.d.ts.map +1 -1
  133. package/dist/runner.js +115 -7
  134. package/dist/runner.js.map +1 -1
  135. package/dist/templates/callable-file.d.ts +7 -2
  136. package/dist/templates/callable-file.d.ts.map +1 -1
  137. package/dist/templates/callable-file.js +54 -9
  138. package/dist/templates/callable-file.js.map +1 -1
  139. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  140. package/dist/templates/drizzle-schema.js +236 -65
  141. package/dist/templates/drizzle-schema.js.map +1 -1
  142. package/dist/templates/entity-constants.d.ts +15 -6
  143. package/dist/templates/entity-constants.d.ts.map +1 -1
  144. package/dist/templates/entity-constants.js +85 -161
  145. package/dist/templates/entity-constants.js.map +1 -1
  146. package/dist/templates/entity-file.d.ts.map +1 -1
  147. package/dist/templates/entity-file.js +38 -4
  148. package/dist/templates/entity-file.js.map +1 -1
  149. package/dist/templates/entity-meta-file.d.ts +15 -1
  150. package/dist/templates/entity-meta-file.d.ts.map +1 -1
  151. package/dist/templates/entity-meta-file.js +12 -2
  152. package/dist/templates/entity-meta-file.js.map +1 -1
  153. package/dist/templates/entity-ui-descriptor.d.ts +112 -0
  154. package/dist/templates/entity-ui-descriptor.d.ts.map +1 -0
  155. package/dist/templates/entity-ui-descriptor.js +238 -0
  156. package/dist/templates/entity-ui-descriptor.js.map +1 -0
  157. package/dist/templates/field-meta.d.ts +46 -9
  158. package/dist/templates/field-meta.d.ts.map +1 -1
  159. package/dist/templates/field-meta.js +95 -21
  160. package/dist/templates/field-meta.js.map +1 -1
  161. package/dist/templates/names-decl.d.ts +38 -0
  162. package/dist/templates/names-decl.d.ts.map +1 -0
  163. package/dist/templates/names-decl.js +104 -0
  164. package/dist/templates/names-decl.js.map +1 -0
  165. package/dist/templates/output-parser.d.ts.map +1 -1
  166. package/dist/templates/output-parser.js +54 -8
  167. package/dist/templates/output-parser.js.map +1 -1
  168. package/dist/templates/projection-decl.d.ts +21 -0
  169. package/dist/templates/projection-decl.d.ts.map +1 -1
  170. package/dist/templates/projection-decl.js +53 -29
  171. package/dist/templates/projection-decl.js.map +1 -1
  172. package/dist/templates/queries-file.d.ts.map +1 -1
  173. package/dist/templates/queries-file.js +17 -2
  174. package/dist/templates/queries-file.js.map +1 -1
  175. package/dist/templates/render-helper.d.ts.map +1 -1
  176. package/dist/templates/render-helper.js +3 -1
  177. package/dist/templates/render-helper.js.map +1 -1
  178. package/dist/templates/requirement-test.d.ts.map +1 -1
  179. package/dist/templates/requirement-test.js +48 -21
  180. package/dist/templates/requirement-test.js.map +1 -1
  181. package/dist/templates/routes-file-hono.d.ts +2 -1
  182. package/dist/templates/routes-file-hono.d.ts.map +1 -1
  183. package/dist/templates/routes-file-hono.js +11 -2
  184. package/dist/templates/routes-file-hono.js.map +1 -1
  185. package/dist/templates/routes-file.d.ts +2 -1
  186. package/dist/templates/routes-file.d.ts.map +1 -1
  187. package/dist/templates/routes-file.js +54 -21
  188. package/dist/templates/routes-file.js.map +1 -1
  189. package/dist/templates/value-object-file.d.ts.map +1 -1
  190. package/dist/templates/value-object-file.js +15 -1
  191. package/dist/templates/value-object-file.js.map +1 -1
  192. package/dist/templates/view-decl.d.ts +37 -1
  193. package/dist/templates/view-decl.d.ts.map +1 -1
  194. package/dist/templates/view-decl.js +27 -3
  195. package/dist/templates/view-decl.js.map +1 -1
  196. package/dist/templates/zod-validators.d.ts +21 -0
  197. package/dist/templates/zod-validators.d.ts.map +1 -1
  198. package/dist/templates/zod-validators.js +40 -5
  199. package/dist/templates/zod-validators.js.map +1 -1
  200. package/dist/view-context.d.ts +24 -0
  201. package/dist/view-context.d.ts.map +1 -0
  202. package/dist/view-context.js +81 -0
  203. package/dist/view-context.js.map +1 -0
  204. package/package.json +6 -6
  205. package/src/api-surface.ts +53 -0
  206. package/src/client-directive.ts +36 -0
  207. package/src/column-mapper.ts +13 -0
  208. package/src/constants.ts +91 -16
  209. package/src/db-emitting.ts +31 -0
  210. package/src/generator-registry.ts +7 -0
  211. package/src/generator.ts +5 -0
  212. package/src/generators/agent-docs-file.ts +297 -0
  213. package/src/generators/agent-requirements-page.ts +199 -0
  214. package/src/generators/agent-schema-input.ts +88 -0
  215. package/src/generators/agent-schema-page.ts +297 -0
  216. package/src/generators/agent-ui-page.ts +290 -0
  217. package/src/generators/api-docs-file.ts +13 -6
  218. package/src/generators/api-model.ts +63 -34
  219. package/src/generators/callable-file.ts +1 -1
  220. package/src/generators/docs-data-builder.ts +46 -2
  221. package/src/generators/docs-data.ts +6 -2
  222. package/src/generators/index.ts +10 -0
  223. package/src/generators/names-file.ts +98 -0
  224. package/src/generators/requirements-markdown.ts +36 -2
  225. package/src/generators/routes-file-hono.ts +26 -15
  226. package/src/generators/routes-file.ts +20 -7
  227. package/src/generators/template-generator.ts +20 -6
  228. package/src/index.ts +63 -5
  229. package/src/metaobjects-config.ts +78 -2
  230. package/src/names.ts +630 -0
  231. package/src/naming.ts +23 -0
  232. package/src/overwrite-policy.ts +15 -3
  233. package/src/payload-codegen.ts +4 -3
  234. package/src/projection/extract-view-spec.ts +85 -6
  235. package/src/prompt-generator-gate.ts +73 -0
  236. package/src/reference/barrel.ts +3 -0
  237. package/src/reference/entity.ts +28 -2
  238. package/src/reference/names.ts +125 -0
  239. package/src/reference/queries.ts +14 -2
  240. package/src/reference/routes-hono.ts +113 -0
  241. package/src/reference/routes.ts +30 -9
  242. package/src/reference-templates.ts +61 -21
  243. package/src/render-context.ts +22 -1
  244. package/src/retired-codegen-attrs.ts +52 -0
  245. package/src/routes-expose.ts +86 -0
  246. package/src/runner.ts +129 -11
  247. package/src/templates/callable-file.ts +60 -8
  248. package/src/templates/drizzle-schema.ts +248 -60
  249. package/src/templates/entity-constants.ts +95 -184
  250. package/src/templates/entity-file.ts +46 -4
  251. package/src/templates/entity-meta-file.ts +16 -3
  252. package/src/templates/entity-ui-descriptor.ts +305 -0
  253. package/src/templates/field-meta.ts +100 -19
  254. package/src/templates/names-decl.ts +165 -0
  255. package/src/templates/output-parser.ts +54 -7
  256. package/src/templates/projection-decl.ts +72 -31
  257. package/src/templates/queries-file.ts +17 -2
  258. package/src/templates/render-helper.ts +3 -1
  259. package/src/templates/requirement-test.ts +49 -18
  260. package/src/templates/routes-file-hono.ts +14 -2
  261. package/src/templates/routes-file.ts +69 -24
  262. package/src/templates/value-object-file.ts +15 -1
  263. package/src/templates/view-decl.ts +53 -3
  264. package/src/templates/zod-validators.ts +41 -5
  265. package/src/view-context.ts +88 -0
@@ -15,11 +15,15 @@ import {
15
15
  TYPE_TEMPLATE,
16
16
  TEMPLATE_SUBTYPE_PROMPT,
17
17
  FIELD_SUBTYPE_OBJECT,
18
+ FIELD_SUBTYPE_ENUM,
18
19
  FIELD_ATTR_OBJECT_REF,
19
20
  TEMPLATE_ATTR_RESPONSE_REF,
20
21
  RESPONSE_FORMAT_XML,
21
22
  resolveObjectRef,
22
23
  } from "@metaobjectsdev/metadata";
24
+ import type { MetaField } from "@metaobjectsdev/metadata";
25
+ import { enumValues, zodEnumExpr } from "../enum-meta.js";
26
+ import { templateSymbolBase } from "../naming.js";
23
27
  import { responseShape } from "./find-inbound.js";
24
28
  import { isRequired } from "./fr010-field-mapping.js";
25
29
  import {
@@ -32,6 +36,29 @@ import {
32
36
  } from "./extract-delegate-emitter.js";
33
37
  import type { RenderContext } from "../render-context.js";
34
38
 
39
+ /**
40
+ * Wire-shape validators per field subtype, for a JSON reply from a model.
41
+ *
42
+ * This map used to hold only the nine entries above `uuid`, so EVERY other subtype fell
43
+ * through the `?? "z.unknown()"` below — enum, uuid, date, time, timestamp, decimal,
44
+ * currency, uri and inet. `z.unknown()` accepts anything, including `null` on a `@required`
45
+ * field, so a declared response payload of eleven fields validated three of them. It
46
+ * mapped `class`, `short` and `byte` — the three subtypes this project CUT as
47
+ * non-functional registration-only stubs — while missing `currency` and `uuid`, which are
48
+ * exactly where a hallucinated value costs money or corrupts an identifier.
49
+ *
50
+ * The inversion is what makes it serious: validation was strongest on the payload we
51
+ * control and absent on the reply we do not, in the tier whose entire name is
52
+ * parser-on-receipt. The tolerant extractor in this same generated file reads the live
53
+ * metadata and DOES reject a non-member; so did Python's `FieldSpec.enum_field`. Only this
54
+ * strict path — the one called `parse<Name>`, documented `@throws on validation failure`,
55
+ * and the first one an adopter reaches for — threw the domain away.
56
+ *
57
+ * Temporal and numeric subtypes stay STRING-shaped on the wire deliberately: these
58
+ * validate raw JSON, where a timestamp is an ISO string and a decimal is a string to
59
+ * avoid float loss (the same reasoning as `zod-validators.ts`'s `z.coerce.date()` note).
60
+ * Coercion into native types is the caller's job, after the shape is known good.
61
+ */
35
62
  const SCALAR_ZOD: Record<string, string> = {
36
63
  string: "z.string()",
37
64
  class: "z.string()",
@@ -42,6 +69,17 @@ const SCALAR_ZOD: Record<string, string> = {
42
69
  double: "z.number()",
43
70
  float: "z.number()",
44
71
  boolean: "z.boolean()",
72
+ uuid: "z.string().uuid()",
73
+ date: "z.string()",
74
+ time: "z.string()",
75
+ timestamp: "z.string()",
76
+ // A decimal crosses the wire as a string precisely so it does not become a float;
77
+ // accepting a number here would re-admit the loss the subtype exists to prevent.
78
+ decimal: "z.string()",
79
+ // Integer minor units — the wire contract every port shares. Never a float.
80
+ currency: "z.number().int()",
81
+ uri: "z.string()",
82
+ inet: "z.string()",
45
83
  };
46
84
 
47
85
  // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
@@ -84,6 +122,12 @@ function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet<string>, de
84
122
  const inner = findObject(root, refName, field.parent?.package ?? field.parent?.fileDefaultPackage ?? "");
85
123
  base = inner ? renderObjectSchema(inner, root, new Set(seen).add(refName), depth + 1) : "z.unknown()";
86
124
  }
125
+ } else if (field.subType === FIELD_SUBTYPE_ENUM) {
126
+ // The members are declared, resolving (so an `extends`-inherited @values is seen),
127
+ // and already reachable — the send-side type in prompts.ts is built from them. The
128
+ // one closed domain in an untrusted reply is the one worth checking hardest.
129
+ const values = enumValues(field as MetaField);
130
+ base = values !== undefined && values.length > 0 ? zodEnumExpr(values) : "z.unknown()";
87
131
  } else {
88
132
  base = SCALAR_ZOD[field.subType] ?? "z.unknown()";
89
133
  }
@@ -137,11 +181,14 @@ export function renderOutputParser(root: MetaData, templateName: string, ctx?: R
137
181
  const { vo, ref: payloadRef } = shape;
138
182
 
139
183
  const schema = renderObjectSchema(vo, root, new Set([payloadRef]), 0);
140
- const schemaName = `${templateName}Schema`;
141
- const dataName = `${templateName}Data`;
142
- const errorName = `${templateName}ValidationError`;
143
- const parseName = `parse${templateName}`;
144
- const safeParseName = `safeParse${templateName}`;
184
+ // One base for every symbol in this file, shared with promptRender()'s handle — the
185
+ // three template emitters used to spell the same node three ways (see naming.ts).
186
+ const base = templateSymbolBase(templateName);
187
+ const schemaName = `${base}Schema`;
188
+ const dataName = `${base}Data`;
189
+ const errorName = `${base}ValidationError`;
190
+ const parseName = `parse${base}`;
191
+ const safeParseName = `safeParse${base}`;
145
192
 
146
193
  // FR-010: emit the tolerant extract() API alongside the strict Zod parser.
147
194
  //
@@ -209,8 +256,8 @@ export function ${safeParseName}(
209
256
  // Unconditional since ADR-0052: a declared @responseRef IS the request for the
210
257
  // tolerant path, and @responseFormat is a closed json|xml set, so there is no
211
258
  // longer a third case to gate on.
212
- const extractedName = `${templateName}Extracted`;
213
- const extractLenientWithName = `extractLenient${templateName}WithLoader`;
259
+ const extractedName = `${templateSymbolBase(templateName)}Extracted`;
260
+ const extractLenientWithName = `extractLenient${templateSymbolBase(templateName)}WithLoader`;
214
261
  const payloadFqnConst = `${templateName.toUpperCase()}_PAYLOAD_NAME`;
215
262
  const formatEnum = format === RESPONSE_FORMAT_XML ? "Format.XML" : "Format.JSON";
216
263
 
@@ -10,17 +10,17 @@
10
10
  import { code, imp, joinCode, type Code } from "ts-poet";
11
11
  import {
12
12
  MetaField, MetaObject, type MetaRoot, isMetaObject,
13
- FIELD_ATTR_OBJECT_REF, stripPackage,
13
+ FIELD_ATTR_OBJECT_REF, stripPackage, resolveColumnName, resolveTableSchema,
14
14
  } from "@metaobjectsdev/metadata";
15
15
  import { projectionViewName } from "../projection/extract-view-spec.js";
16
- import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js";
17
16
  import { GENERATED_HEADER } from "../constants.js";
18
17
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
19
18
  import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
20
19
  import { valueObjectModuleSpecifier } from "../import-path.js";
20
+ import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "../names.js";
21
21
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
22
22
  import { renderFilterType } from "./filter-type.js";
23
- import { inferViewKind, currencyMetaFor, labelFor } from "./field-meta.js";
23
+ import { buildUiFieldDescriptor, resourcePath } from "./entity-ui-descriptor.js";
24
24
  import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
25
25
  import { primaryIdentityFieldNames } from "./zod-validators.js";
26
26
 
@@ -31,6 +31,11 @@ import { primaryIdentityFieldNames } from "./zod-validators.js";
31
31
  export interface ProjectionDeclOpts {
32
32
  readonly columnNamingStrategy: ColumnNamingStrategy;
33
33
  readonly dialect: "postgres" | "sqlite";
34
+ /**
35
+ * @deprecated Accepted and IGNORED — a projection's descriptor no longer carries the
36
+ * API base URL, same as an entity's. Kept on the options object because removing a
37
+ * field makes every caller still passing it fail TypeScript's excess-property check.
38
+ */
34
39
  readonly apiPrefix?: string;
35
40
  /** Drives the timestamp column TS type (Date vs string) in the view declaration. */
36
41
  readonly timestampMode?: "date" | "string";
@@ -53,24 +58,23 @@ export interface ProjectionDeclOpts {
53
58
  * e.g. a shared types package consumed by a web client that has no Drizzle.
54
59
  */
55
60
  readonly includeViewDecl?: boolean;
61
+ /**
62
+ * §A6 — the resolved names artifact for this projection plus the ts-poet symbol
63
+ * reference (`imp()`'d import of `<Projection>Names`), when the names generator is
64
+ * in the run. Built by the caller via `resolveObjectNames` + `imp(...)` — the same
65
+ * pair `drizzle-schema.ts` builds — so this file never resolves names itself; it
66
+ * only decides, per field, whether to reference the constant or fall back to the
67
+ * literal (a lookup MISS, never a divergence — `primaryRdbSource` already refused any
68
+ * object whose `@role: primary` sources disagree on a physical name). Threaded into `renderExistingViewDecl`
69
+ * too, since a projection's primary source IS the view its `sources.primary.view` names.
70
+ */
71
+ readonly names?: { readonly resolved: ObjectNames; readonly symbol: Code } | undefined;
56
72
  }
57
73
 
58
74
  // ---------------------------------------------------------------------------
59
75
  // Helpers
60
76
  // ---------------------------------------------------------------------------
61
77
 
62
- /**
63
- * Convert a PascalCase projection name to a kebab-pluralized URL path.
64
- * "ProgramSummary" → "/program-summaries"
65
- * "CustomerSummary" → "/customer-summaries"
66
- * "Box" → "/boxes"
67
- * "Wish" → "/wishes"
68
- */
69
- function pathFromProjectionName(name: string): string {
70
- const kebab = toSnakeCase(pluralize(name)).replace(/_/g, "-");
71
- return `/${kebab}`;
72
- }
73
-
74
78
  // ---------------------------------------------------------------------------
75
79
  // Public API
76
80
  // ---------------------------------------------------------------------------
@@ -92,7 +96,7 @@ export function renderProjectionDecl(
92
96
  root: MetaRoot,
93
97
  opts: ProjectionDeclOpts,
94
98
  ): string {
95
- const { dialect, columnNamingStrategy, apiPrefix = "", timestampMode = "string", allowlists = true, ctx, includeViewDecl = true } = opts;
99
+ const { dialect, columnNamingStrategy, timestampMode = "string", allowlists = true, ctx, includeViewDecl = true, names } = opts;
96
100
 
97
101
  // ADR-0044/#228 — resolve a projection field's `@objectRef` to the value object's
98
102
  // EMITTED name + module TOGETHER (lock-step): bare when unique in the run,
@@ -137,29 +141,67 @@ export function renderProjectionDecl(
137
141
  // inherited fields are not duplicated.
138
142
  for (const f of projection.ownFields()) allFields.push(f);
139
143
 
140
- const constFieldLines: string[] = allFields.map((f) => {
141
- const dbCol = columnNameFromField(f.name, columnNamingStrategy);
142
- const view = inferViewKind(f);
143
- const label = labelFor(f);
144
- const baseEntry = `name: ${JSON.stringify(f.name)}, label: ${JSON.stringify(label)}, view: ${JSON.stringify(view)}, dbCol: ${JSON.stringify(dbCol)}`;
145
- const currencyMeta = currencyMetaFor(f);
146
- if (currencyMeta !== null) {
147
- return ` ${f.name}: { ${baseEntry}, currency: ${JSON.stringify(currencyMeta.currency)}, locale: ${JSON.stringify(currencyMeta.locale)} },`;
144
+ const constFieldLines: Code[] = allFields.map((f) => {
145
+ // §A4: resolveColumnName, NOT columnNameFromField — the latter takes a string and so
146
+ // cannot read @column, silently substituting the naming strategy's answer for a
147
+ // declared or inherited physical name. ADR-0039: resolving accessor, so a projection
148
+ // field inheriting @column through `extends` resolves it.
149
+ const dbCol = resolveColumnName(f, columnNamingStrategy);
150
+ // A6 reference the constant whenever the artifact is in the run AND carries this
151
+ // field. A lookup MISS (a TPH subtype field the base's names artifact never saw) is
152
+ // normal, not a divergence — fall back to the literal, same as every other §A6 site.
153
+ const namesEntry = names?.resolved.fields[f.name];
154
+ const dbColExpr: Code =
155
+ names !== undefined && namesEntry !== undefined
156
+ ? code`${names.symbol}.fields.${f.name}.column`
157
+ : code`${JSON.stringify(dbCol)}`;
158
+ // #356 — a projection's descriptor is the same shape the entity descriptor emits,
159
+ // and now literally the SAME derivation: `buildUiFieldDescriptor` is what
160
+ // `renderEntityConstants` reads, so the view kind, the label and the currency meta
161
+ // cannot come out differently here. A projection is read-only, so the members that
162
+ // describe a FORM (htmlType / placeholder / helpText / rules) are not emitted — a
163
+ // subset of one descriptor rather than a second answer — and `dbCol`, which only a
164
+ // view-backed const carries, is added.
165
+ const ui = buildUiFieldDescriptor(f);
166
+ const baseEntry = code`name: ${JSON.stringify(ui.name)}, label: ${JSON.stringify(ui.label)}, view: ${JSON.stringify(ui.view)}, dbCol: ${dbColExpr}`;
167
+ if (ui.currency !== undefined) {
168
+ return code` ${f.name}: { ${baseEntry}, currency: ${JSON.stringify(ui.currency.currency)}, locale: ${JSON.stringify(ui.currency.locale)} },`;
148
169
  }
149
- return ` ${f.name}: { ${baseEntry} },`;
170
+ return code` ${f.name}: { ${baseEntry} },`;
150
171
  });
151
172
 
152
173
  const projName = projection.name;
153
174
  const camelName = projName.charAt(0).toLowerCase() + projName.slice(1);
154
- const path = pathFromProjectionName(projName);
175
+ // ONE derivation of `$path`, shared with the entity const and with every page that
176
+ // documents an endpoint — `resourcePath` owns the projection/entity split. The
177
+ // kebab-pluralized spelling this module used to compute for itself is what it still
178
+ // returns for a projection; the second copy is what let `agent/ui.md` and
179
+ // `api/AGENT-API.md` both print the snake-cased entity spelling for a view.
180
+ const path = resourcePath(projection);
181
+
182
+ // A6 Task 1 fix round — the descriptor's $view is a SECOND, independent embedding
183
+ // of the view's physical name (the Drizzle .existing() decl in view-decl.ts is the
184
+ // first, already converted). Same "is the artifact in this run" condition, no
185
+ // equality guard: `names.resolved.name` is always the PRIMARY source's physical
186
+ // name, while `viewName` is derived by `viewName()` in extract-view-spec.ts, which
187
+ // for a projection declaring a role:replica read-only source BEFORE its role:primary
188
+ // one used to return a different, declaration-order-dependent string. That is why
189
+ // both spellings here must come from the constant rather than from two resolvers.
190
+ const viewLine: Code = code`$view: ${physicalNameExpr(names, viewName, projection)}`;
191
+ // @schema for the view binding below. postgres-only downstream (view-decl gates on dialect).
192
+ const projectionSchema = resolveTableSchema(projection);
155
193
 
156
194
  // The projection's primary-identity fields type non-null in the view decl +
157
195
  // read schema even without @required (a PK is never NULL; see ViewDeclOpts).
158
196
  const pkFieldNames: ReadonlySet<string> = new Set(primaryIdentityFieldNames(projection));
159
197
  const sections: Code[] = [
160
198
  ...(includeViewDecl
161
- ? [renderExistingViewDecl(allFields, viewName, `${camelName}View`, {
162
- dialect, columnNamingStrategy, timestampMode, voRef, pkFieldNames,
199
+ ? [renderExistingViewDecl(allFields, physicalNameExpr(names, viewName, projection), `${camelName}View`, {
200
+ dialect, columnNamingStrategy, timestampMode, voRef, pkFieldNames, names,
201
+ // A projection's own artifact names its own view, so the constant IS this view's
202
+ // schema — unlike the write-through replica case in entity-file.ts.
203
+ schema: projectionSchema === undefined ? undefined
204
+ : sourceSchemaExpr(names) ?? projectionSchema,
163
205
  })]
164
206
  : []),
165
207
  code`
@@ -173,10 +215,9 @@ export type ${projName} = ${z}.infer<typeof ${projName}Schema>;
173
215
  code`
174
216
  export const ${projName} = {
175
217
  $entity: ${JSON.stringify(projName)},
176
- $view: ${JSON.stringify(viewName)},
218
+ ${viewLine},
177
219
  $path: ${JSON.stringify(path)},
178
- $apiPrefix: ${JSON.stringify(apiPrefix)},
179
- ${constFieldLines.join("\n")}
220
+ ${joinCode(constFieldLines, { on: "\n" })}
180
221
  } as const;
181
222
  `,
182
223
  ...(allowlists
@@ -35,16 +35,31 @@ import { hasAutoSetFields } from "./zod-validators.js";
35
35
  * postgres.js, Neon, Vercel, pglite) — accepts whichever driver the consumer chose.
36
36
  * - SQLite: `BaseSQLiteDatabase<"sync" | "async", …>` accepts BOTH sync (better-sqlite3)
37
37
  * and async (libsql/Turso/D1) drivers; the generated queries `await` results, valid on either.
38
+ *
39
+ * EVERY type argument here must stay as OPEN as Drizzle's own constraint allows — a `Db`
40
+ * this alias cannot name is a helper the consumer cannot call, and generated code that
41
+ * does not compile is indistinguishable from generated code nobody imports. Two axes have
42
+ * now been re-pinned and re-widened in turn, so neither may be narrowed again:
43
+ * - DRIVER: pinning `NodePgDatabase` / `<"async">` rejected postgres.js and
44
+ * better-sqlite3. Fixed by dropping to the base classes above.
45
+ * - SCHEMA (`TFullSchema`): the idiomatic setup is `drizzle(client, { schema })`, which
46
+ * yields `PgDatabase<…, typeof schema>` / `BaseSQLiteDatabase<…, typeof schema>`.
47
+ * Leaving this parameter at Drizzle's `Record<string, never>` DEFAULT — spelled out on
48
+ * postgres, silently inherited on sqlite by supplying only two arguments — rejected
49
+ * every such db with TS2345 ("Type '\"work_item\"' is not assignable to type 'never'").
50
+ * `Record<string, unknown>` is verbatim Drizzle's own declared bound
51
+ * (`TFullSchema extends Record<string, unknown>`), so it admits both a schema-carrying
52
+ * and a schema-less db without reaching for `any`.
38
53
  */
39
54
  function dbTypeBlock(dialect: "postgres" | "sqlite"): { import: string; alias: string } {
40
55
  return dialect === "postgres"
41
56
  ? {
42
57
  import: `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`,
43
- alias: `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`,
58
+ alias: `type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;`,
44
59
  }
45
60
  : {
46
61
  import: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`,
47
- alias: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`,
62
+ alias: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown, Record<string, unknown>>;`,
48
63
  };
49
64
  }
50
65
 
@@ -52,6 +52,7 @@ import {
52
52
  type PayloadField,
53
53
  type VerifyError,
54
54
  } from "@metaobjectsdev/render";
55
+ import { templateSymbolBase } from "../naming.js";
55
56
 
56
57
  // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
57
58
  // ADR-0042: package-local — resolveObjectRef binds a bare @objectRef in `referrerPkg` first (else root-level), an FQN exactly.
@@ -175,7 +176,8 @@ export function renderRenderHelper(
175
176
 
176
177
  const fields = derivePayloadFieldTree(root, payloadRef, tmplPkg);
177
178
  const ft = fieldTreeLiteral(fields);
178
- const fnName = `render${templateName}`;
179
+ // Same base as promptRender()'s handle and outputParser()'s symbols (naming.ts).
180
+ const fnName = `render${templateSymbolBase(templateName)}`;
179
181
  // @payloadRef may arrive package-qualified (FQN) once resolved — a bare
180
182
  // `::`-free name is required everywhere it's emitted as a TS identifier /
181
183
  // import specifier below (mirrors the same stripPackage() call every other
@@ -10,6 +10,11 @@
10
10
  // claims the capability works, so an empty green test asserts the opposite of the
11
11
  // claim — the original defect recreated in a new place.
12
12
 
13
+ import {
14
+ REQUIREMENT_STATUSES,
15
+ REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES,
16
+ REQUIREMENT_STATUS_RETIRED,
17
+ } from "@metaobjectsdev/metadata";
13
18
  import { GENERATED_HEADER } from "../constants.js";
14
19
  import type { RequirementView, ResolvedClaim } from "../requirement-walk.js";
15
20
 
@@ -27,21 +32,22 @@ export interface RequirementTestArgs {
27
32
  * Statuses whose stub is SKIPPED rather than failing.
28
33
  *
29
34
  * The rule is "does this entry claim the capability works right now?" — only `live`
30
- * and `partial` do. `planned` is intended-not-built; `abandoned` and `superseded`
31
- * describe a capability deliberately retired, whose `@implementedBy` is SUPPOSED to
32
- * dangle. Emitting a failing stub for any of the three reddens an application's suite
33
- * forever for something nobody intends to build, which is the noise an app silences
34
- * wholesale — taking the `live` stubs with it.
35
+ * and `partial` do. `planned` is intended-not-built; `retired` is built-then-
36
+ * deliberately-removed. Emitting a failing stub for either reddens an application's
37
+ * suite forever for something nobody intends to build, which is the noise an app
38
+ * silences wholesale taking the `live` stubs with it.
35
39
  *
36
- * (FR-038 §4 proposes retiring `abandoned`/`superseded` from the vocabulary entirely.
37
- * Until that breaking cut lands they are legal `@status` values, so the renderer has
38
- * to handle them.)
40
+ * This set is derived from the LOADER's enum, not restated from it. It used to be a
41
+ * literal naming `abandoned` and `superseded`, and when 0.24.0 retired both and
42
+ * 0.24.2 put `retired` in their place, the set was left behind: it skipped two
43
+ * statuses the loader had begun REFUSING and failed on the one that replaced them,
44
+ * so every retired entry emitted a permanently red stub — the exact noise this set
45
+ * exists to prevent. Deriving it means the next status move cannot leave it behind:
46
+ * a status that is neither live nor partial is skipped by construction.
39
47
  */
40
- const SKIPPED_STATUSES: ReadonlySet<string> = new Set([
41
- "planned",
42
- "abandoned",
43
- "superseded",
44
- ]);
48
+ const SKIPPED_STATUSES: ReadonlySet<string> = new Set(
49
+ REQUIREMENT_STATUSES.filter((s) => !REQUIREMENT_STATUSES_REQUIRING_LIVE_NODES.includes(s)),
50
+ );
45
51
 
46
52
  /**
47
53
  * Escape an author-supplied value for a double-quoted TS string literal.
@@ -112,17 +118,42 @@ export function renderRequirementTest(a: RequirementTestArgs): string {
112
118
  // A `live` or `partial` stub asserts FAILURE until someone writes the real
113
119
  // assertion over it. `expect.unreachable` names the requirement in the failure
114
120
  // message, so a red run says which claim is unproven rather than just "failed".
115
- const body = skipped
116
- ? ` // Intended, not built. Write the assertion when this becomes live.`
117
- : ` expect.unreachable(\n` +
121
+ // The two skipped statuses mean OPPOSITE things, so they must not share a body.
122
+ // `planned` is intended-not-built and the instruction is to write the assertion when
123
+ // it becomes live. `retired` is built-then-deliberately-removed, and telling a reader
124
+ // to write it "when this becomes live" instructs them to revive the capability —
125
+ // inverting the one guardrail 0.24.2 restored `retired` for. The repo's own harness
126
+ // generator already branches here; the shipped renderer now does too.
127
+ let body: string;
128
+ if (!skipped) {
129
+ body =
130
+ ` expect.unreachable(\n` +
118
131
  ` "unimplemented requirement stub: ${testName} — " +\n` +
119
132
  ` "replace this with an assertion that fails when: ${forStringLiteral(a.counterexample)}",\n` +
120
133
  ` );`;
134
+ } else if (a.view.status === REQUIREMENT_STATUS_RETIRED) {
135
+ body =
136
+ ` // Retired: this capability was deliberately removed and must not be rebuilt.\n` +
137
+ ` // If you assert anything here, assert that it STAYS removed.`;
138
+ } else {
139
+ body = ` // Intended, not built. Write the assertion when this becomes live.`;
140
+ }
121
141
 
142
+ // The header states the CONDITION on the survival promise, not just the promise.
143
+ // It used to say the body "survives regeneration" flat, which is true of the body and
144
+ // false of the run: a stub is worthless until hand-edited, and the moment it is edited
145
+ // and pushed, every clone that did not generate it refuses the file and exits 1 —
146
+ // demonstrable by deleting the gitignored `.gen-state` bodies and re-running. Stating
147
+ // the machine-local half here is what keeps a reader from reading a red CI run as a
148
+ // broken promise. The remedy itself lives on the refusal, not in every stub.
122
149
  return (
123
150
  `// ${GENERATED_HEADER}.\n` +
124
- `// The test IDENTITY is generated from the requirement; the BODY below is yours\n` +
125
- `// and survives regeneration. Do not rename the test — the name is the link.\n` +
151
+ `// The test IDENTITY is generated from the requirement; the BODY below is yours.\n` +
152
+ `// Do not rename the test — the name is the link.\n` +
153
+ `// Your body is never overwritten: MERGED where .metaobjects/.gen-state/ holds this\n` +
154
+ `// file's snapshot body, REFUSED (run exits 1, body kept) where it does not. Those\n` +
155
+ `// bodies are gitignored, so a fresh clone or CI is always the second case — see\n` +
156
+ `// docs/features/own-your-codegen.md for the recovery.\n` +
126
157
  `import { test, expect } from "bun:test";\n` +
127
158
  `\n` +
128
159
  `/**\n` +
@@ -30,8 +30,19 @@ import { type RenderContext } from "../render-context.js";
30
30
  import { entityModuleSpecifier } from "../import-path.js";
31
31
  import { GENERATED_HEADER } from "../constants.js";
32
32
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
33
+ import { type CrudVerb, exposeLine } from "../routes-expose.js";
33
34
 
34
- export function renderRoutesFileHono(entity: MetaObject, ctx: RenderContext): string {
35
+ export function renderRoutesFileHono(
36
+ entity: MetaObject,
37
+ ctx: RenderContext,
38
+ // #348 — resolved by the generator from its `expose` option. undefined = all five verbs,
39
+ // emitting no `expose` key at all, so output stays byte-identical without it.
40
+ //
41
+ // Applies to the CRUD mount only. The projection branch above uses
42
+ // mountReadOnlyCrudRoutes, a different helper that is read-only by construction and
43
+ // takes no `expose` — there is no writable surface there to narrow.
44
+ expose?: readonly CrudVerb[],
45
+ ): string {
35
46
  const entityName = entity.name;
36
47
  const handlerName = `register${entityName}Routes`;
37
48
 
@@ -107,6 +118,7 @@ export function ${handlerName}(app: ${HonoSym}<any, any, any>, deps: { db: unkno
107
118
  const writeThrough = isWriteThrough(entity);
108
119
  const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
109
120
  const readViewLine = writeThrough ? `\n readView: ${camelName}View,` : "";
121
+ const exposeLineHono = exposeLine(expose, " ");
110
122
 
111
123
  const HonoSym = imp("t:Hono@hono");
112
124
  const mountCrudRoutesSym = imp("mountCrudRoutes@@metaobjectsdev/runtime-ts/hono");
@@ -142,7 +154,7 @@ export function ${handlerName}(app: ${HonoSym}<any, any, any>, deps: { db: unkno
142
154
  updateSchema: ${entityName}UpdateSchema,
143
155
  filterAllowlist: ${entityName}FilterAllowlist,
144
156
  sortAllowlist: ${entityName}SortAllowlist,
145
- dialect: ${JSON.stringify(ctx.dialect)},
157
+ dialect: ${JSON.stringify(ctx.dialect)},${exposeLineHono}
146
158
  });
147
159
  }
148
160
  `;
@@ -23,19 +23,28 @@ import {
23
23
  } from "@metaobjectsdev/metadata";
24
24
  import { type RenderContext } from "../render-context.js";
25
25
  import { crossEntitySpecifier, entityModuleSpecifier, relativeModuleSpecifier } from "../import-path.js";
26
+ import { namesRef, columnExpr } from "../names.js";
26
27
  import { GENERATED_HEADER } from "../constants.js";
27
28
  import { routesHandlerName } from "../naming.js";
28
29
  import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
29
30
  import type { RelationEntry } from "../relation-resolver.js";
30
31
  import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js";
32
+ import { type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js";
31
33
 
32
- export function renderRoutesFile(entity: MetaObject, ctx: RenderContext): string {
34
+ export function renderRoutesFile(
35
+ entity: MetaObject,
36
+ ctx: RenderContext,
37
+ // #348 — resolved by the generator from its `expose` option. undefined = all five verbs,
38
+ // which emits no `expose` key at all and so keeps output byte-identical for projects
39
+ // that do not use it.
40
+ expose?: readonly CrudVerb[],
41
+ ): string {
33
42
  // FR-017 Tier 2 — a TPH discriminator base mounts polymorphic list/get at the
34
43
  // base path plus a full per-subtype CRUD route set scoped to each
35
44
  // discriminator value. (Subtype entities are filtered out of the routes
36
45
  // generator entirely — their routes live here.)
37
46
  if (isTphDiscriminatorBase(entity, ctx.loadedRoot)) {
38
- return renderTphRoutesFile(entity, ctx);
47
+ return renderTphRoutesFile(entity, ctx, expose);
39
48
  }
40
49
 
41
50
  const entityName = entity.name;
@@ -134,6 +143,8 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
134
143
  const viewImportLine = writeThrough ? `\n ${camelName}View,` : "";
135
144
  const readViewLinePrefixed = writeThrough ? `\n readView: ${camelName}View,` : "";
136
145
  const readViewLineFlat = writeThrough ? `\n readView: ${camelName}View,` : "";
146
+ const exposeLinePrefixed = exposeLine(expose, " ");
147
+ const exposeLineFlat = exposeLine(expose, " ");
137
148
 
138
149
  const FastifyInstanceSym = imp("t:FastifyInstance@fastify");
139
150
  const mountCrudRoutesSym = imp("mountCrudRoutes@@metaobjectsdev/runtime-ts/drizzle-fastify");
@@ -184,7 +195,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
184
195
  updateSchema: ${entityName}UpdateSchema,
185
196
  filterAllowlist: ${entityName}FilterAllowlist,
186
197
  sortAllowlist: ${entityName}SortAllowlist,
187
- dialect: ${JSON.stringify(ctx.dialect)},
198
+ dialect: ${JSON.stringify(ctx.dialect)},${exposeLinePrefixed}
188
199
  });
189
200
  ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} });
190
201
  }
@@ -208,7 +219,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) {
208
219
  updateSchema: ${entityName}UpdateSchema,
209
220
  filterAllowlist: ${entityName}FilterAllowlist,
210
221
  sortAllowlist: ${entityName}SortAllowlist,
211
- dialect: ${JSON.stringify(ctx.dialect)},
222
+ dialect: ${JSON.stringify(ctx.dialect)},${exposeLineFlat}
212
223
  });
213
224
  ${m2mMountsFlat}}
214
225
  `;
@@ -270,15 +281,17 @@ function renderM2mMount(
270
281
  const mountM2mRouteSym = imp("mountM2mRoute@@metaobjectsdev/runtime-ts/drizzle-fastify");
271
282
  const junction = ctx.loadedRoot.findObject(entry.junctionEntity);
272
283
  const target = ctx.loadedRoot.findObject(entry.targetEntity);
273
- const sourceColumn = junction
274
- ? resolveJunctionColumn(junction, entry.sourceJoinField!, ctx)
275
- : entry.sourceJoinField!;
276
- const targetColumn = junction
277
- ? resolveJunctionColumn(junction, entry.targetJoinField!, ctx)
278
- : entry.targetJoinField!;
279
- const targetPkColumn = target
280
- ? resolveJunctionColumn(target, ctx.pkMap.get(entry.targetEntity)?.fieldName ?? "id", ctx)
281
- : "id";
284
+ // fromPackage = source.package: this routes file is SOURCE's own module, never the
285
+ // junction's or the target's — see resolveJunctionColumn's doc comment (B1).
286
+ const sourceColumn: Code = junction
287
+ ? resolveJunctionColumn(junction, entry.sourceJoinField!, ctx, source.package)
288
+ : code`${JSON.stringify(entry.sourceJoinField!)}`;
289
+ const targetColumn: Code = junction
290
+ ? resolveJunctionColumn(junction, entry.targetJoinField!, ctx, source.package)
291
+ : code`${JSON.stringify(entry.targetJoinField!)}`;
292
+ const targetPkColumn: Code = target
293
+ ? resolveJunctionColumn(target, ctx.pkMap.get(entry.targetEntity)?.fieldName ?? "id", ctx, source.package)
294
+ : code`${JSON.stringify("id")}`;
282
295
 
283
296
  return code` ${mountM2mRouteSym}({
284
297
  fastify: ${fastifyVar},
@@ -287,19 +300,44 @@ function renderM2mMount(
287
300
  db,
288
301
  junctionTable: ${junctionVarSym},
289
302
  targetTable: ${targetVarSym},
290
- sourceColumn: ${JSON.stringify(sourceColumn)},
291
- targetColumn: ${JSON.stringify(targetColumn)},
292
- targetPkColumn: ${JSON.stringify(targetPkColumn)},
303
+ sourceColumn: ${sourceColumn},
304
+ targetColumn: ${targetColumn},
305
+ targetPkColumn: ${targetPkColumn},
293
306
  symmetric: ${entry.symmetric ? "true" : "false"},
294
307
  });`;
295
308
  }
296
309
 
297
- /** Resolve a field's physical column name on an entity (defaults if missing). */
298
- function resolveJunctionColumn(entity: MetaObject, fieldName: string, ctx: RenderContext): string {
310
+ /**
311
+ * Resolve a field's physical column name on an entity (defaults if missing), as a
312
+ * ts-poet Code fragment.
313
+ *
314
+ * §A6/§B2 — references `<Entity>Names.fields.<field>.column` whenever the names
315
+ * artifact is in this run AND carries the field (the same two-condition shape as every
316
+ * other §A6 site: is the artifact in the run, is the field in it — both PRESENCE
317
+ * guards). A literal otherwise.
318
+ *
319
+ * B1 — `entity` here is the JUNCTION or TARGET object, never the routes file's own
320
+ * SOURCE entity (see renderM2mMount) — so `fromPackage` MUST be the source entity's
321
+ * package, not `entity`'s own. A same-package caller cannot tell the two apart (they're
322
+ * equal), which is exactly how this went unnoticed until a package-layout M:N golden
323
+ * existed: a plain sibling specifier (assuming fromPackage === entity.package, the
324
+ * shape `namesRef`'s default deliberately does NOT special-case) silently produced a
325
+ * same-directory import for a names artifact that lives in a DIFFERENT directory,
326
+ * because the object whose file we're building and the object whose names we're
327
+ * importing are two different objects.
328
+ */
329
+ function resolveJunctionColumn(
330
+ entity: MetaObject,
331
+ fieldName: string,
332
+ ctx: RenderContext,
333
+ fromPackage: string | undefined,
334
+ ): Code {
299
335
  // ADR-0039: resolving — a junction FK field may be inherited via extends.
300
336
  const field = entity.children().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
301
- if (!field) return fieldName;
302
- return resolveColumnName(field, ctx.columnNamingStrategy);
337
+ if (!field) return code`${JSON.stringify(fieldName)}`;
338
+ const dbCol = resolveColumnName(field, ctx.columnNamingStrategy);
339
+ const names = namesRef(entity, ctx, fromPackage);
340
+ return columnExpr(names, field.name, dbCol);
303
341
  }
304
342
 
305
343
  /**
@@ -318,7 +356,11 @@ function resolveJunctionColumn(entity: MetaObject, fieldName: string, ctx: Rende
318
356
  * FR-017 design's `/auths/bridge` examples. Fastify resolves the static
319
357
  * `/auths/bridge` ahead of the parametric `/auths/:id`, so the two coexist.
320
358
  */
321
- function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
359
+ function renderTphRoutesFile(
360
+ base: MetaObject,
361
+ ctx: RenderContext,
362
+ expose?: readonly CrudVerb[],
363
+ ): string {
322
364
  const baseName = base.name;
323
365
  const handlerName = routesHandlerName(baseName);
324
366
  // Single source of truth for the discriminator field + subtypes + route segments.
@@ -344,6 +386,10 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
344
386
  const fastifyRef = ctx.apiPrefix ? "instance" : "fastify";
345
387
  const dialectLit = JSON.stringify(ctx.dialect);
346
388
 
389
+ // The polymorphic mount is read-only BY CONSTRUCTION — the discriminated union has no
390
+ // single writable shape — so an author-supplied `expose` intersects with that fixed set
391
+ // rather than replacing it: it may narrow to just `list`, never widen to `create`.
392
+ const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " ");
347
393
  const polymorphic = code`
348
394
  ${mountCrudRoutesSym}({
349
395
  fastify: ${fastifyRef},
@@ -354,8 +400,7 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
354
400
  updateSchema: ${baseUpdateSym},
355
401
  filterAllowlist: ${baseFilterSym},
356
402
  sortAllowlist: ${baseSortSym},
357
- dialect: ${dialectLit},
358
- expose: ["list", "get"],
403
+ dialect: ${dialectLit},${polymorphicExposeLine}
359
404
  });`;
360
405
 
361
406
  const subtypeMounts: Code[] = plan.subtypes.map(({ entity: sub, value, routeSegment: segment }) => {
@@ -386,7 +431,7 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
386
431
  filterAllowlist: ${subFilterSym},
387
432
  sortAllowlist: ${subSortSym},
388
433
  dialect: ${dialectLit},
389
- discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },
434
+ discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")}
390
435
  });`;
391
436
  });
392
437