@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
@@ -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
 
@@ -139,10 +139,21 @@ export function renderRequirementTest(a: RequirementTestArgs): string {
139
139
  body = ` // Intended, not built. Write the assertion when this becomes live.`;
140
140
  }
141
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.
142
149
  return (
143
150
  `// ${GENERATED_HEADER}.\n` +
144
- `// The test IDENTITY is generated from the requirement; the BODY below is yours\n` +
145
- `// 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` +
146
157
  `import { test, expect } from "bun:test";\n` +
147
158
  `\n` +
148
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
 
@@ -23,6 +23,7 @@ import { renderEntityConstants } from "./entity-constants.js";
23
23
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
24
24
  import { renderFilterType } from "./filter-type.js";
25
25
  import { GENERATED_HEADER } from "../constants.js";
26
+ import { namesRef, namesConstArg } from "../names.js";
26
27
 
27
28
  export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: RenderContext): string {
28
29
  const enumAliases = renderEnumTypeAliases(obj, ctx);
@@ -34,7 +35,20 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
34
35
  // FR-017 Tier 3: a TPH subtype also emits its field-metadata constants object
35
36
  // (the `<Sub>` const), so the React form generator can render per-field
36
37
  // labels / rules / inputs the same way it does for ordinary entities.
37
- const tphConstants = tphSubtype ? renderEntityConstants(obj, apiPrefix) : null;
38
+ //
39
+ // §A6/§B2 — a TPH subtype declares no source.rdb of its own; it INHERITS the
40
+ // discriminator base's single shared table (`children()` is resolving — ADR-0039).
41
+ // `resolveObjectNames` therefore returns a DEFINED result for it (confirmed
42
+ // empirically: `name` is the BASE's physical table name, `fields` covers both the
43
+ // base's inherited columns and the subtype's own), and `namesFile()` really does emit
44
+ // a `<Sub>.names.ts` for it — this is not a case where the literal is correct and must
45
+ // stay. Same `namesRef` pair every other §A6 site builds; `ctx` is guaranteed present
46
+ // at every real call site (entity-file.ts and its reference/entity.ts mirror both
47
+ // always pass it) even though the parameter stays optional for a bare unit-test call.
48
+ const tphNames = tphSubtype && ctx !== undefined ? namesRef(obj, ctx) : undefined;
49
+ const tphConstants = tphSubtype
50
+ ? renderEntityConstants(obj, apiPrefix, namesConstArg(tphNames))
51
+ : null;
38
52
  // FR-017 Tier 3: per-subtype filter + sort allowlists, excluding the
39
53
  // discriminator (it's pinned by the per-subtype route path, so a client must
40
54
  // not filter on it). Included fields are the subtype's own + inherited base
@@ -14,6 +14,7 @@ import {
14
14
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
15
15
  import { mapColumnType } from "../column-mapper.js";
16
16
  import { zodTypeFor } from "./field-meta.js";
17
+ import { columnExpr, type ObjectNames } from "../names.js";
17
18
 
18
19
  export interface ViewDeclOpts {
19
20
  readonly dialect: "postgres" | "sqlite";
@@ -38,6 +39,31 @@ export interface ViewDeclOpts {
38
39
  * UpdateSchema, all of which already treat the PK as non-null.
39
40
  */
40
41
  readonly pkFieldNames: ReadonlySet<string>;
42
+ /**
43
+ * §A6 — the resolved names artifact supplying this declaration's COLUMN constants,
44
+ * plus the ts-poet symbol reference (`imp()`'d import of `<Object>Names`), when the
45
+ * names generator is in the run. Absent ⇒ every column stays a literal.
46
+ *
47
+ * Deliberately says nothing about the view's OWN physical name: the two answers come
48
+ * apart on a write-through entity, whose artifact holds the TABLE's name (its primary
49
+ * source) while this declaration binds the REPLICA view. Conflating them cost the
50
+ * write-through read model its column constants — it had to omit `names` entirely to
51
+ * stop the artifact's view name being emitted, and lost every
52
+ * `.fields.<f>.column` reference with it. The view's name is now the caller's to pass
53
+ * (see the `viewName` parameter), which is the only place that knows.
54
+ */
55
+ readonly names?: { readonly resolved: ObjectNames; readonly symbol: Code } | undefined;
56
+ /**
57
+ * The view's `@schema` — a bare name, or an EXPRESSION for it (a names constant).
58
+ *
59
+ * Passed in rather than derived from `names`, and for the same reason `viewName` is: a
60
+ * write-through entity's REPLICA view is a different source node from the one
61
+ * `<Entity>Names` resolves, so the artifact's `schema` there is the WRITE TABLE's and
62
+ * would be a confidently wrong answer. A caller whose artifact genuinely names this view
63
+ * passes the constant expression; the replica-view caller passes its own literal, which
64
+ * is consistent with that view's NAME already being a known literal for the same reason.
65
+ */
66
+ readonly schema?: string | Code | undefined;
41
67
  }
42
68
 
43
69
  /**
@@ -85,7 +111,10 @@ function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code {
85
111
  dollarType = code`.$type<Record<string, ${imp(`${vo.name}@${vo.module}`)}>>()`;
86
112
  }
87
113
  }
88
- return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${viewModifiers}`;
114
+ // A6 — reference the constant whenever the artifact is in the run AND carries this
115
+ // field. A lookup MISS is normal, not a divergence — see ViewDeclOpts.names.
116
+ const dbNameExpr = columnExpr(opts.names, f.name, spec.dbName);
117
+ return code` ${f.name}: ${colSym}(${dbNameExpr}${optsArg})${dollarType}${viewModifiers}`;
89
118
  }
90
119
 
91
120
  /**
@@ -95,7 +124,13 @@ function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code {
95
124
  */
96
125
  export function renderExistingViewDecl(
97
126
  fields: readonly MetaField[],
98
- viewName: string,
127
+ /**
128
+ * The view's own physical name: a plain string is emitted as a literal, a `Code` is
129
+ * emitted verbatim (a caller whose names artifact genuinely names THIS view passes
130
+ * `physicalNameExpr(names, viewName)`). See `ViewDeclOpts.names` for why this is not
131
+ * derived from that artifact here.
132
+ */
133
+ viewName: string | Code,
99
134
  viewVar: string,
100
135
  opts: ViewDeclOpts,
101
136
  ): Code {
@@ -103,11 +138,26 @@ export function renderExistingViewDecl(
103
138
  const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
104
139
  const viewSym = imp(`${viewFn}@${viewModule}`);
105
140
  const viewColumnLines = fields.map((f) => viewColumnLine(f, opts));
141
+ const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName;
142
+
143
+ // @schema — a view lands in a schema exactly as a table does, and migrate qualifies the
144
+ // `CREATE VIEW` it emits. Fixing the table binding and leaving this one would be the more
145
+ // dangerous half-job: the two would then disagree with each OTHER as well as with the
146
+ // database. Drizzle spells it with the same shape as the table case, `pgSchema(s).view(...)`
147
+ // — PgSchema carries `view` alongside `table`. sqlite is excluded for the same reason as
148
+ // there: no schema concept, and migrate refuses a non-default @schema on that dialect.
149
+ const viewSchemaExpr: Code | undefined =
150
+ opts.dialect !== "postgres" || opts.schema === undefined ? undefined
151
+ : typeof opts.schema === "string" ? code`${JSON.stringify(opts.schema)}`
152
+ : opts.schema;
153
+ const viewCall: Code = viewSchemaExpr === undefined
154
+ ? code`${viewSym}`
155
+ : code`${imp(`pgSchema@${viewModule}`)}(${viewSchemaExpr}).view`;
106
156
  return code`
107
157
  // View declaration — Drizzle uses this for typed SELECT queries.
108
158
  // The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
109
159
  // not to attempt DDL for this declaration.
110
- export const ${viewVar} = ${viewSym}(${JSON.stringify(viewName)}, {
160
+ export const ${viewVar} = ${viewCall}(${viewNameExpr}, {
111
161
  ${joinCode(viewColumnLines, { on: ",\n" })}
112
162
  }).existing();
113
163
  `;
@@ -51,19 +51,55 @@ import { isRequired } from "../column-mapper.js";
51
51
  * when the object is not a TPH subtype.
52
52
  */
53
53
  export function tphDiscriminatorPin(obj: MetaObject): { fieldName: string; value: string } | undefined {
54
- // ADR-0039: own — super-resolution walk. A subtype declares its OWN
55
- // @discriminatorValue (must not inherit one), and the base level is found by
56
- // walking superResolved reading each level's OWN @discriminator.
54
+ // ADR-0039: own — a subtype declares its OWN @discriminatorValue (must not inherit
55
+ // one); the base level is found by the super-resolution walk in `tphDiscriminatorLevel`.
57
56
  const value = obj.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE);
58
57
  if (typeof value !== "string" || value === "") return undefined;
58
+ const level = tphDiscriminatorLevel(obj);
59
+ if (level === undefined) return undefined;
60
+ return { fieldName: level.fieldName, value };
61
+ }
59
62
 
60
- // Walk the extends chain to find the root carrying @discriminator.
63
+ /**
64
+ * The discriminator BASE an object extends — the nearest ancestor declaring
65
+ * `@discriminator` — or undefined when there is none.
66
+ *
67
+ * The base is NOT the topmost ancestor: a multi-level hierarchy can extend further up
68
+ * past the level that owns the discriminator, and a route path composed against the
69
+ * wrong level names an endpoint nothing mounts. One walk, read by `tphDiscriminatorPin`
70
+ * and by `restPath` (api-surface.ts), so the pin and the address agree on which object
71
+ * is the base.
72
+ */
73
+ export function tphDiscriminatorBase(obj: MetaObject): MetaObject | undefined {
74
+ return tphDiscriminatorLevel(obj)?.base;
75
+ }
76
+
77
+ /**
78
+ * True when this object itself DECLARES `@discriminator` — the level of a TPH hierarchy
79
+ * that owns the discriminator, whether or not any concrete subtype extends it yet.
80
+ *
81
+ * ADR-0039: own — `@discriminator` identifies a hierarchy LEVEL, so it is read own: a
82
+ * subtype inheriting the attr through `extends` must not be mistaken for a base. This is
83
+ * the one predicate the form generator's filter (`hasGeneratedForm`) and the `agent/ui.md`
84
+ * page both ask, so "is this a discriminator base?" has one spelling.
85
+ */
86
+ export function declaresTphDiscriminator(obj: MetaObject): boolean {
87
+ return typeof obj.ownAttr(OBJECT_ATTR_DISCRIMINATOR) === "string";
88
+ }
89
+
90
+ /**
91
+ * The ONE super-resolution walk behind `tphDiscriminatorBase` and `tphDiscriminatorPin`:
92
+ * the nearest ancestor declaring a non-empty `@discriminator`, with the field name it
93
+ * declares. Returning both from one walk is what keeps the pin's field name and the
94
+ * base's identity from ever being read at two different levels.
95
+ */
96
+ function tphDiscriminatorLevel(obj: MetaObject): { base: MetaObject; fieldName: string } | undefined {
61
97
  let cursor = obj.superResolved;
62
98
  while (cursor !== undefined) {
63
99
  // ADR-0039: own — super-resolution walk; read each level's OWN @discriminator.
64
100
  const fieldName = cursor.ownAttr(OBJECT_ATTR_DISCRIMINATOR);
65
101
  if (typeof fieldName === "string" && fieldName !== "") {
66
- return { fieldName, value };
102
+ return { base: cursor as MetaObject, fieldName };
67
103
  }
68
104
  cursor = cursor.superResolved;
69
105
  }