@metaobjectsdev/codegen-ts 0.9.0 → 0.10.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 (323) hide show
  1. package/README.md +1 -1
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +24 -8
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/constants.d.ts +8 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +16 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/docs-paths.d.ts +58 -0
  10. package/dist/docs-paths.d.ts.map +1 -0
  11. package/dist/docs-paths.js +89 -0
  12. package/dist/docs-paths.js.map +1 -0
  13. package/dist/enum-import.d.ts +14 -0
  14. package/dist/enum-import.d.ts.map +1 -0
  15. package/dist/enum-import.js +35 -0
  16. package/dist/enum-import.js.map +1 -0
  17. package/dist/enum-shared.d.ts +32 -0
  18. package/dist/enum-shared.d.ts.map +1 -0
  19. package/dist/enum-shared.js +83 -0
  20. package/dist/enum-shared.js.map +1 -0
  21. package/dist/generator-registry.d.ts +22 -0
  22. package/dist/generator-registry.d.ts.map +1 -0
  23. package/dist/generator-registry.js +161 -0
  24. package/dist/generator-registry.js.map +1 -0
  25. package/dist/generator.d.ts +6 -0
  26. package/dist/generator.d.ts.map +1 -1
  27. package/dist/generator.js.map +1 -1
  28. package/dist/generators/api-doc-render.d.ts +17 -0
  29. package/dist/generators/api-doc-render.d.ts.map +1 -0
  30. package/dist/generators/api-doc-render.js +431 -0
  31. package/dist/generators/api-doc-render.js.map +1 -0
  32. package/dist/generators/api-docs-file.d.ts +21 -0
  33. package/dist/generators/api-docs-file.d.ts.map +1 -0
  34. package/dist/generators/api-docs-file.js +112 -0
  35. package/dist/generators/api-docs-file.js.map +1 -0
  36. package/dist/generators/api-field-shape.d.ts +39 -0
  37. package/dist/generators/api-field-shape.d.ts.map +1 -0
  38. package/dist/generators/api-field-shape.js +92 -0
  39. package/dist/generators/api-field-shape.js.map +1 -0
  40. package/dist/generators/api-label.d.ts +3 -0
  41. package/dist/generators/api-label.d.ts.map +1 -0
  42. package/dist/generators/api-label.js +8 -0
  43. package/dist/generators/api-label.js.map +1 -0
  44. package/dist/generators/api-model.d.ts +122 -0
  45. package/dist/generators/api-model.d.ts.map +1 -0
  46. package/dist/generators/api-model.js +809 -0
  47. package/dist/generators/api-model.js.map +1 -0
  48. package/dist/generators/docs-data-builder.d.ts +26 -4
  49. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  50. package/dist/generators/docs-data-builder.js +436 -164
  51. package/dist/generators/docs-data-builder.js.map +1 -1
  52. package/dist/generators/docs-data.d.ts +136 -27
  53. package/dist/generators/docs-data.d.ts.map +1 -1
  54. package/dist/generators/docs-data.js +1 -1
  55. package/dist/generators/docs-data.js.map +1 -1
  56. package/dist/generators/docs-file.d.ts +19 -0
  57. package/dist/generators/docs-file.d.ts.map +1 -1
  58. package/dist/generators/docs-file.js +154 -27
  59. package/dist/generators/docs-file.js.map +1 -1
  60. package/dist/generators/entity-file.d.ts.map +1 -1
  61. package/dist/generators/entity-file.js +29 -14
  62. package/dist/generators/entity-file.js.map +1 -1
  63. package/dist/generators/extractor-file.d.ts.map +1 -1
  64. package/dist/generators/extractor-file.js +2 -1
  65. package/dist/generators/extractor-file.js.map +1 -1
  66. package/dist/generators/field-anchor.d.ts +7 -0
  67. package/dist/generators/field-anchor.d.ts.map +1 -0
  68. package/dist/generators/field-anchor.js +23 -0
  69. package/dist/generators/field-anchor.js.map +1 -0
  70. package/dist/generators/index.d.ts +8 -1
  71. package/dist/generators/index.d.ts.map +1 -1
  72. package/dist/generators/index.js +6 -0
  73. package/dist/generators/index.js.map +1 -1
  74. package/dist/generators/mermaid-er.d.ts +14 -0
  75. package/dist/generators/mermaid-er.d.ts.map +1 -1
  76. package/dist/generators/mermaid-er.js +14 -0
  77. package/dist/generators/mermaid-er.js.map +1 -1
  78. package/dist/generators/output-parser-file.d.ts.map +1 -1
  79. package/dist/generators/output-parser-file.js +3 -4
  80. package/dist/generators/output-parser-file.js.map +1 -1
  81. package/dist/generators/output-prompt-file.d.ts.map +1 -1
  82. package/dist/generators/output-prompt-file.js +2 -2
  83. package/dist/generators/output-prompt-file.js.map +1 -1
  84. package/dist/generators/prompt-render-file.d.ts.map +1 -1
  85. package/dist/generators/prompt-render-file.js +3 -4
  86. package/dist/generators/prompt-render-file.js.map +1 -1
  87. package/dist/generators/queries-file.d.ts.map +1 -1
  88. package/dist/generators/queries-file.js +8 -3
  89. package/dist/generators/queries-file.js.map +1 -1
  90. package/dist/generators/render-helper-file.d.ts.map +1 -1
  91. package/dist/generators/render-helper-file.js +2 -2
  92. package/dist/generators/render-helper-file.js.map +1 -1
  93. package/dist/generators/routes-file-hono.d.ts.map +1 -1
  94. package/dist/generators/routes-file-hono.js +5 -1
  95. package/dist/generators/routes-file-hono.js.map +1 -1
  96. package/dist/generators/routes-file.d.ts +3 -0
  97. package/dist/generators/routes-file.d.ts.map +1 -1
  98. package/dist/generators/routes-file.js +6 -1
  99. package/dist/generators/routes-file.js.map +1 -1
  100. package/dist/generators/template-doc-builder.d.ts +19 -0
  101. package/dist/generators/template-doc-builder.d.ts.map +1 -0
  102. package/dist/generators/template-doc-builder.js +220 -0
  103. package/dist/generators/template-doc-builder.js.map +1 -0
  104. package/dist/generators/template-doc-data.d.ts +62 -0
  105. package/dist/generators/template-doc-data.d.ts.map +1 -0
  106. package/dist/generators/template-doc-data.js +16 -0
  107. package/dist/generators/template-doc-data.js.map +1 -0
  108. package/dist/generators/template-payload-tree.d.ts +15 -0
  109. package/dist/generators/template-payload-tree.d.ts.map +1 -0
  110. package/dist/generators/template-payload-tree.js +61 -0
  111. package/dist/generators/template-payload-tree.js.map +1 -0
  112. package/dist/generators/template-source-annotate.d.ts +74 -0
  113. package/dist/generators/template-source-annotate.d.ts.map +1 -0
  114. package/dist/generators/template-source-annotate.js +184 -0
  115. package/dist/generators/template-source-annotate.js.map +1 -0
  116. package/dist/generators/template-source-render.d.ts +24 -0
  117. package/dist/generators/template-source-render.d.ts.map +1 -0
  118. package/dist/generators/template-source-render.js +175 -0
  119. package/dist/generators/template-source-render.js.map +1 -0
  120. package/dist/generators/trace-helper-file.d.ts +9 -0
  121. package/dist/generators/trace-helper-file.d.ts.map +1 -0
  122. package/dist/generators/trace-helper-file.js +196 -0
  123. package/dist/generators/trace-helper-file.js.map +1 -0
  124. package/dist/index.d.ts +29 -4
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +28 -2
  127. package/dist/index.js.map +1 -1
  128. package/dist/metaobjects-config.d.ts +75 -2
  129. package/dist/metaobjects-config.d.ts.map +1 -1
  130. package/dist/metaobjects-config.js +43 -0
  131. package/dist/metaobjects-config.js.map +1 -1
  132. package/dist/naming.d.ts +19 -0
  133. package/dist/naming.d.ts.map +1 -1
  134. package/dist/naming.js +41 -0
  135. package/dist/naming.js.map +1 -1
  136. package/dist/payload-codegen.d.ts.map +1 -1
  137. package/dist/payload-codegen.js +12 -4
  138. package/dist/payload-codegen.js.map +1 -1
  139. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  140. package/dist/projection/extract-view-spec.js +51 -25
  141. package/dist/projection/extract-view-spec.js.map +1 -1
  142. package/dist/relation-resolver.d.ts +16 -0
  143. package/dist/relation-resolver.d.ts.map +1 -1
  144. package/dist/relation-resolver.js +82 -1
  145. package/dist/relation-resolver.js.map +1 -1
  146. package/dist/render-context.d.ts +4 -0
  147. package/dist/render-context.d.ts.map +1 -1
  148. package/dist/render-context.js.map +1 -1
  149. package/dist/render-engine/embedded-templates.generated.d.ts +2 -0
  150. package/dist/render-engine/embedded-templates.generated.d.ts.map +1 -0
  151. package/dist/render-engine/embedded-templates.generated.js +15 -0
  152. package/dist/render-engine/embedded-templates.generated.js.map +1 -0
  153. package/dist/render-engine/framework-provider.d.ts.map +1 -1
  154. package/dist/render-engine/framework-provider.js +26 -13
  155. package/dist/render-engine/framework-provider.js.map +1 -1
  156. package/dist/runner.d.ts.map +1 -1
  157. package/dist/runner.js +17 -0
  158. package/dist/runner.js.map +1 -1
  159. package/dist/templates/docs-file.d.ts +2 -6
  160. package/dist/templates/docs-file.d.ts.map +1 -1
  161. package/dist/templates/docs-file.js +2 -5
  162. package/dist/templates/docs-file.js.map +1 -1
  163. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  164. package/dist/templates/drizzle-schema.js +30 -2
  165. package/dist/templates/drizzle-schema.js.map +1 -1
  166. package/dist/templates/entity-constants.d.ts +7 -0
  167. package/dist/templates/entity-constants.d.ts.map +1 -1
  168. package/dist/templates/entity-constants.js +1 -1
  169. package/dist/templates/entity-constants.js.map +1 -1
  170. package/dist/templates/entity-file.d.ts.map +1 -1
  171. package/dist/templates/entity-file.js +16 -5
  172. package/dist/templates/entity-file.js.map +1 -1
  173. package/dist/templates/enums-file.d.ts +11 -0
  174. package/dist/templates/enums-file.d.ts.map +1 -0
  175. package/dist/templates/enums-file.js +44 -0
  176. package/dist/templates/enums-file.js.map +1 -0
  177. package/dist/templates/extract-delegate-emitter.d.ts.map +1 -1
  178. package/dist/templates/extract-delegate-emitter.js +5 -7
  179. package/dist/templates/extract-delegate-emitter.js.map +1 -1
  180. package/dist/templates/extract-schema-emitter.d.ts.map +1 -1
  181. package/dist/templates/extract-schema-emitter.js +5 -1
  182. package/dist/templates/extract-schema-emitter.js.map +1 -1
  183. package/dist/templates/extractor.d.ts.map +1 -1
  184. package/dist/templates/extractor.js +56 -39
  185. package/dist/templates/extractor.js.map +1 -1
  186. package/dist/templates/field-meta.d.ts.map +1 -1
  187. package/dist/templates/field-meta.js +1 -5
  188. package/dist/templates/field-meta.js.map +1 -1
  189. package/dist/templates/filter-allowlist.d.ts +7 -2
  190. package/dist/templates/filter-allowlist.d.ts.map +1 -1
  191. package/dist/templates/filter-allowlist.js +17 -9
  192. package/dist/templates/filter-allowlist.js.map +1 -1
  193. package/dist/templates/filter-type.d.ts +7 -1
  194. package/dist/templates/filter-type.d.ts.map +1 -1
  195. package/dist/templates/filter-type.js +9 -5
  196. package/dist/templates/filter-type.js.map +1 -1
  197. package/dist/templates/find-templates.d.ts +4 -0
  198. package/dist/templates/find-templates.d.ts.map +1 -0
  199. package/dist/templates/find-templates.js +15 -0
  200. package/dist/templates/find-templates.js.map +1 -0
  201. package/dist/templates/fr010-field-mapping.d.ts +2 -0
  202. package/dist/templates/fr010-field-mapping.d.ts.map +1 -1
  203. package/dist/templates/fr010-field-mapping.js +10 -6
  204. package/dist/templates/fr010-field-mapping.js.map +1 -1
  205. package/dist/templates/inferred-types.d.ts +44 -7
  206. package/dist/templates/inferred-types.d.ts.map +1 -1
  207. package/dist/templates/inferred-types.js +107 -16
  208. package/dist/templates/inferred-types.js.map +1 -1
  209. package/dist/templates/mermaid-er.d.ts +35 -2
  210. package/dist/templates/mermaid-er.d.ts.map +1 -1
  211. package/dist/templates/mermaid-er.js +174 -7
  212. package/dist/templates/mermaid-er.js.map +1 -1
  213. package/dist/templates/output-parser.d.ts.map +1 -1
  214. package/dist/templates/output-parser.js +30 -79
  215. package/dist/templates/output-parser.js.map +1 -1
  216. package/dist/templates/output-prompt.d.ts.map +1 -1
  217. package/dist/templates/output-prompt.js +2 -2
  218. package/dist/templates/output-prompt.js.map +1 -1
  219. package/dist/templates/queries-file.d.ts.map +1 -1
  220. package/dist/templates/queries-file.js +112 -4
  221. package/dist/templates/queries-file.js.map +1 -1
  222. package/dist/templates/queries.d.ts +5 -0
  223. package/dist/templates/queries.d.ts.map +1 -1
  224. package/dist/templates/queries.js +7 -7
  225. package/dist/templates/queries.js.map +1 -1
  226. package/dist/templates/recover-schema-emitter.d.ts +8 -0
  227. package/dist/templates/recover-schema-emitter.d.ts.map +1 -0
  228. package/dist/templates/recover-schema-emitter.js +64 -0
  229. package/dist/templates/recover-schema-emitter.js.map +1 -0
  230. package/dist/templates/relations-block.js +10 -0
  231. package/dist/templates/relations-block.js.map +1 -1
  232. package/dist/templates/render-helper.d.ts.map +1 -1
  233. package/dist/templates/render-helper.js +4 -4
  234. package/dist/templates/render-helper.js.map +1 -1
  235. package/dist/templates/routes-file.d.ts.map +1 -1
  236. package/dist/templates/routes-file.js +183 -6
  237. package/dist/templates/routes-file.js.map +1 -1
  238. package/dist/templates/tph-discriminator.d.ts +56 -0
  239. package/dist/templates/tph-discriminator.d.ts.map +1 -0
  240. package/dist/templates/tph-discriminator.js +180 -0
  241. package/dist/templates/tph-discriminator.js.map +1 -0
  242. package/dist/templates/value-object-file.d.ts +2 -1
  243. package/dist/templates/value-object-file.d.ts.map +1 -1
  244. package/dist/templates/value-object-file.js +32 -4
  245. package/dist/templates/value-object-file.js.map +1 -1
  246. package/dist/templates/zod-validators.d.ts +64 -1
  247. package/dist/templates/zod-validators.d.ts.map +1 -1
  248. package/dist/templates/zod-validators.js +181 -8
  249. package/dist/templates/zod-validators.js.map +1 -1
  250. package/package.json +103 -34
  251. package/src/column-mapper.ts +25 -8
  252. package/src/constants.ts +18 -0
  253. package/src/docs-paths.ts +128 -0
  254. package/src/enum-import.ts +43 -0
  255. package/src/enum-shared.ts +95 -0
  256. package/src/generator-registry.ts +204 -0
  257. package/src/generator.ts +6 -0
  258. package/src/generators/api-doc-render.ts +572 -0
  259. package/src/generators/api-docs-file.ts +146 -0
  260. package/src/generators/api-field-shape.ts +114 -0
  261. package/src/generators/api-label.ts +7 -0
  262. package/src/generators/api-model.ts +1067 -0
  263. package/src/generators/docs-data-builder.ts +479 -185
  264. package/src/generators/docs-data.ts +139 -28
  265. package/src/generators/docs-file.ts +205 -39
  266. package/src/generators/entity-file.ts +31 -15
  267. package/src/generators/extractor-file.ts +2 -1
  268. package/src/generators/field-anchor.ts +24 -0
  269. package/src/generators/index.ts +8 -1
  270. package/src/generators/mermaid-er.ts +14 -0
  271. package/src/generators/output-parser-file.ts +3 -4
  272. package/src/generators/output-prompt-file.ts +2 -1
  273. package/src/generators/prompt-render-file.ts +3 -4
  274. package/src/generators/queries-file.ts +9 -3
  275. package/src/generators/render-helper-file.ts +2 -1
  276. package/src/generators/routes-file-hono.ts +5 -1
  277. package/src/generators/routes-file.ts +7 -1
  278. package/src/generators/template-doc-builder.ts +306 -0
  279. package/src/generators/template-doc-data.ts +85 -0
  280. package/src/generators/template-payload-tree.ts +71 -0
  281. package/src/generators/template-source-annotate.ts +290 -0
  282. package/src/generators/template-source-render.ts +203 -0
  283. package/src/generators/trace-helper-file.ts +301 -0
  284. package/src/index.ts +55 -4
  285. package/src/metaobjects-config.ts +117 -2
  286. package/src/naming.ts +48 -0
  287. package/src/payload-codegen.ts +14 -3
  288. package/src/projection/extract-view-spec.ts +49 -30
  289. package/src/relation-resolver.ts +103 -1
  290. package/src/render-context.ts +4 -0
  291. package/src/render-engine/embedded-templates.generated.ts +14 -0
  292. package/src/render-engine/framework-provider.ts +25 -11
  293. package/src/runner.ts +21 -0
  294. package/src/templates/docs-file.ts +2 -9
  295. package/src/templates/drizzle-schema.ts +31 -1
  296. package/src/templates/entity-constants.ts +1 -1
  297. package/src/templates/entity-file.ts +16 -5
  298. package/src/templates/enums-file.ts +50 -0
  299. package/src/templates/extract-delegate-emitter.ts +5 -6
  300. package/src/templates/extractor.ts +68 -38
  301. package/src/templates/field-meta.ts +0 -6
  302. package/src/templates/filter-allowlist.ts +17 -10
  303. package/src/templates/filter-type.ts +8 -6
  304. package/src/templates/find-templates.ts +15 -0
  305. package/src/templates/fr010-field-mapping.ts +10 -8
  306. package/src/templates/inferred-types.ts +108 -18
  307. package/src/templates/mermaid-er.ts +176 -8
  308. package/src/templates/output-parser.ts +30 -79
  309. package/src/templates/output-prompt.ts +2 -1
  310. package/src/templates/queries-file.ts +132 -3
  311. package/src/templates/queries.ts +15 -7
  312. package/src/templates/relations-block.ts +17 -0
  313. package/src/templates/render-helper.ts +4 -3
  314. package/src/templates/routes-file.ts +233 -6
  315. package/src/templates/tph-discriminator.ts +232 -0
  316. package/src/templates/value-object-file.ts +38 -4
  317. package/src/templates/zod-validators.ts +204 -7
  318. package/templates/api/agent-api.md.mustache +30 -0
  319. package/templates/api/entity-api.md.mustache +69 -0
  320. package/templates/api/index.md.mustache +21 -0
  321. package/templates/docs/entity-page.md.mustache +33 -21
  322. package/templates/docs/template-page.md.mustache +56 -0
  323. package/src/templates/extract-schema-emitter.ts +0 -111
@@ -18,26 +18,14 @@ import {
18
18
  IDENTITY_ATTR_GENERATION,
19
19
  RELATIONSHIP_ATTR_CARDINALITY,
20
20
  RELATIONSHIP_ATTR_OBJECT_REF,
21
+ RELATIONSHIP_ATTR_THROUGH,
22
+ RELATIONSHIP_ATTR_SOURCE_REF_FIELD,
23
+ RELATIONSHIP_ATTR_SYMMETRIC,
21
24
  RELATIONSHIP_SUBTYPE_COMPOSITION,
22
25
  RELATIONSHIP_SUBTYPE_AGGREGATION,
23
26
  RELATIONSHIP_SUBTYPE_ASSOCIATION,
24
27
  FIELD_SUBTYPE_ENUM,
25
28
  FIELD_SUBTYPE_OBJECT,
26
- FIELD_SUBTYPE_STRING,
27
- FIELD_SUBTYPE_CLASS,
28
- FIELD_SUBTYPE_UUID,
29
- FIELD_SUBTYPE_INT,
30
- FIELD_SUBTYPE_SHORT,
31
- FIELD_SUBTYPE_BYTE,
32
- FIELD_SUBTYPE_LONG,
33
- FIELD_SUBTYPE_DOUBLE,
34
- FIELD_SUBTYPE_FLOAT,
35
- FIELD_SUBTYPE_DECIMAL,
36
- FIELD_SUBTYPE_CURRENCY,
37
- FIELD_SUBTYPE_BOOLEAN,
38
- FIELD_SUBTYPE_DATE,
39
- FIELD_SUBTYPE_TIME,
40
- FIELD_SUBTYPE_TIMESTAMP,
41
29
  FIELD_ATTR_REQUIRED,
42
30
  FIELD_ATTR_UNIQUE,
43
31
  FIELD_ATTR_OBJECT_REF,
@@ -51,196 +39,424 @@ import {
51
39
  VALIDATOR_ATTR_MIN,
52
40
  VALIDATOR_ATTR_MAX,
53
41
  DOC_ATTR_DESCRIPTION,
42
+ DOC_ATTR_SUMMARY,
43
+ FIELD_ATTR_DB_COLUMN_TYPE,
54
44
  stripPackage,
55
45
  } from "@metaobjectsdev/metadata";
56
- import { mapColumnType, type Dialect } from "../column-mapper.js";
46
+ import type { Dialect } from "../column-mapper.js";
57
47
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
58
- import { toPascalCase } from "../naming.js";
48
+ import type { OutputLayout } from "../import-path.js";
49
+ import { docPageHref, docPageNode } from "../docs-paths.js";
50
+ import { fieldAnchorHtml } from "./field-anchor.js";
59
51
  import { enumValues } from "../enum-meta.js";
60
52
  import { hasWritableRdbSource } from "../source-detect.js";
61
53
  import { GENERATED_HEADER } from "../constants.js";
54
+ import { renderEntityNeighborhoodErBlock } from "../templates/mermaid-er.js";
62
55
  import type {
63
56
  EntityDocData,
64
57
  StorageFieldDoc,
65
58
  IdentityDoc,
66
59
  RelationshipDoc,
67
60
  UsedByDoc,
68
- GeneratedFileDoc,
61
+ ConstraintRow,
62
+ FieldDoc,
63
+ FieldDetailDoc,
69
64
  } from "./docs-data.js";
70
65
 
71
66
  export interface BuildDocDataOpts {
72
67
  dialect: Dialect;
73
68
  columnNamingStrategy?: ColumnNamingStrategy;
74
69
  loadedRoot: MetaRoot;
75
- /** Set of generator names present in the pipeline; drives "Generated code". */
76
- generatorNames?: ReadonlySet<string>;
70
+ /** Page-placement layout. Defaults to "flat" (back-compat: same-dir links). */
71
+ layout?: OutputLayout;
72
+ /** Cross-links to this entity's GENERATED-SDK api pages, one per api surface
73
+ * (per language). Computed by the caller (docsFile) via the shared
74
+ * `apiSurfaceHref` so each resolves in BOTH layouts. ABSENT for model-only
75
+ * runs → default output byte-identical. */
76
+ apiRefs?: Array<{ label: string; href: string }>;
77
77
  }
78
78
 
79
- const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
80
- [FIELD_SUBTYPE_STRING]: "string",
81
- [FIELD_SUBTYPE_CLASS]: "string",
82
- [FIELD_SUBTYPE_UUID]: "string",
83
- [FIELD_SUBTYPE_INT]: "number",
84
- [FIELD_SUBTYPE_SHORT]: "number",
85
- [FIELD_SUBTYPE_BYTE]: "number",
86
- [FIELD_SUBTYPE_LONG]: "number",
87
- [FIELD_SUBTYPE_DOUBLE]: "number",
88
- [FIELD_SUBTYPE_FLOAT]: "number",
89
- // field.decimal is precision-exact: surfaced as a TS `string` (Drizzle pg
90
- // `numeric` infers `string`). Keep the docs scalar mapping in lockstep.
91
- [FIELD_SUBTYPE_DECIMAL]: "string",
92
- [FIELD_SUBTYPE_CURRENCY]: "number",
93
- [FIELD_SUBTYPE_BOOLEAN]: "boolean",
94
- [FIELD_SUBTYPE_DATE]: "string",
95
- [FIELD_SUBTYPE_TIME]: "string",
96
- [FIELD_SUBTYPE_TIMESTAMP]: "string",
97
- };
98
-
99
- function enumTypeAliasName(entity: MetaObject, field: MetaField): string {
100
- const superField = field.resolveSuper();
101
- return superField !== undefined
102
- ? toPascalCase(superField.name)
103
- : `${entity.name}${toPascalCase(field.name)}`;
104
- }
105
-
106
- function isFieldRequired(field: MetaField): boolean {
79
+ /** Whether a field is required — `@required` true OR a `validator.required`
80
+ * child. The SINGLE source of truth for required-ness across the Constraints
81
+ * table, the Storage nullable rule, and (via the api-docs field-shape builder)
82
+ * the documented model-field optionality. Exported so the field-shape builder
83
+ * reuses the EXACT same rule rather than re-deriving it. */
84
+ export function isFieldRequired(field: MetaField): boolean {
107
85
  if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
108
86
  return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED);
109
87
  }
110
88
 
111
- function tsTypeForStorage(
112
- entity: MetaObject,
113
- field: MetaField,
114
- pkFieldNames: ReadonlySet<string>,
115
- ): string {
116
- let base: string;
89
+ /** The raw validator/limit facts for the Constraints table. Walks the field's
90
+ * validators ONCE, bucketed by subtype, plus the `@maxLength` attr.
91
+ * `buildConstraintRow()` consumes these — the SINGLE source of truth for the
92
+ * validator emission. The emission ORDER and exact strings come from here:
93
+ * regex pattern → maxLength-from-@maxLength → length-validator (min/max)
94
+ * numeric-validator (min/max). */
95
+ interface ValidatorParts {
96
+ /** `@maxLength` attr value if a finite number, else undefined. */
97
+ maxLenAttr: number | undefined;
98
+ /** "pattern `...`" entries from regex validators. */
99
+ regexParts: string[];
100
+ /** "minLength: N" / "maxLength: N" entries from length validators. */
101
+ lengthParts: string[];
102
+ /** "min: N" / "max: N" entries from numeric validators. */
103
+ numericParts: string[];
104
+ }
117
105
 
118
- if (field.subType === FIELD_SUBTYPE_ENUM) {
119
- const values = enumValues(field);
120
- if (values !== undefined && values.length > 0) {
121
- if (field.isArray) {
122
- base = `${enumTypeAliasName(entity, field)}[]`;
123
- } else {
124
- base = values.map((v) => JSON.stringify(v)).join(" | ");
106
+ function collectValidatorParts(field: MetaField): ValidatorParts {
107
+ const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
108
+ const regexParts: string[] = [];
109
+ const lengthParts: string[] = [];
110
+ const numericParts: string[] = [];
111
+ for (const v of field.validators()) {
112
+ if (v.subType === VALIDATOR_SUBTYPE_REGEX) {
113
+ const pattern = v.ownAttr(VALIDATOR_ATTR_PATTERN);
114
+ if (typeof pattern === "string" && pattern.length > 0) {
115
+ regexParts.push(`pattern \`${pattern}\``);
125
116
  }
126
- } else {
127
- base = field.isArray ? "string[]" : "string";
117
+ } else if (v.subType === VALIDATOR_SUBTYPE_LENGTH) {
118
+ const min = v.ownAttr(VALIDATOR_ATTR_MIN);
119
+ const max = v.ownAttr(VALIDATOR_ATTR_MAX);
120
+ if (typeof min === "number") lengthParts.push(`minLength: ${min}`);
121
+ if (typeof max === "number" && typeof maxLenAttr !== "number") lengthParts.push(`maxLength: ${max}`);
122
+ } else if (v.subType === VALIDATOR_SUBTYPE_NUMERIC) {
123
+ const min = v.ownAttr(VALIDATOR_ATTR_MIN);
124
+ const max = v.ownAttr(VALIDATOR_ATTR_MAX);
125
+ if (typeof min === "number") numericParts.push(`min: ${min}`);
126
+ if (typeof max === "number") numericParts.push(`max: ${max}`);
128
127
  }
129
- } else if (field.subType === FIELD_SUBTYPE_OBJECT) {
128
+ }
129
+ return {
130
+ maxLenAttr: typeof maxLenAttr === "number" ? maxLenAttr : undefined,
131
+ regexParts,
132
+ lengthParts,
133
+ numericParts,
134
+ };
135
+ }
136
+
137
+ /** The NEUTRAL logical type string (no backticks): the field's logical
138
+ * subtype (e.g. `string`, `enum`, `decimal`), suffixed `[]` for arrays, and
139
+ * the referenced object name for `field.object`. Language-agnostic — built
140
+ * from declared metadata, never re-derived into ANSI/ORM SQL. Shared by the
141
+ * Constraints table (`neutralTypeCell`) and the Storage table's physical-type
142
+ * fallback (`storageTypeCell`). */
143
+ export function neutralTypeStr(field: MetaField): string {
144
+ let base: string;
145
+ if (field.subType === FIELD_SUBTYPE_OBJECT) {
130
146
  const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
131
- const refName = typeof ref === "string" && ref.length > 0 ? ref : "unknown";
132
- base = field.isArray ? `${refName}[]` : refName;
147
+ base = typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : "object";
133
148
  } else {
134
- const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown";
135
- base = field.isArray ? `${scalar}[]` : scalar;
149
+ base = field.subType;
136
150
  }
151
+ if (field.isArray) base = `${base}[]`;
152
+ return base;
153
+ }
137
154
 
138
- const required = pkFieldNames.has(field.name) || isFieldRequired(field);
139
- return required ? base : `${base} | null`;
155
+ /** Neutral logical type cell for the Constraints table — `neutralTypeStr`
156
+ * wrapped in backticks. */
157
+ function neutralTypeCell(field: MetaField): string {
158
+ return `\`${neutralTypeStr(field)}\``;
140
159
  }
141
160
 
142
- function sqlColumnExpr(spec: ReturnType<typeof mapColumnType>): string {
143
- const dbName = JSON.stringify(spec.dbName);
144
- if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) {
145
- const parts: string[] = [];
146
- for (const [k, v] of Object.entries(spec.fnOptions)) {
147
- const lit = JSON.stringify(v);
148
- if (Array.isArray(v)) {
149
- parts.push(`${k}: ${lit} as const`);
150
- } else {
151
- parts.push(`${k}: ${lit}`);
152
- }
153
- }
154
- return `${spec.fnName}(${dbName}, { ${parts.join(", ")} })`;
161
+ /** Neutral PHYSICAL type cell for the Storage table. Metadata-driven, no DDL
162
+ * re-derivation (ADR-0020): if the field declares a `@dbColumnType` physical
163
+ * override (e.g. `uuid`, `jsonb`, `timestamp_with_tz`) show it UPPERCASED;
164
+ * otherwise fall back to the same neutral LOGICAL type the Constraints table
165
+ * uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
166
+ * migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
167
+ function storageTypeCell(field: MetaField): string {
168
+ const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
169
+ if (typeof dbColumnType === "string" && dbColumnType.length > 0) {
170
+ return `\`${dbColumnType.toUpperCase()}\``;
155
171
  }
156
- return `${spec.fnName}(${dbName})`;
172
+ return `\`${neutralTypeStr(field)}\``;
157
173
  }
158
174
 
159
- function constraintsCell(
175
+ /** Build one neutral Constraints-table row for a field. Reuses the same
176
+ * per-field constraint logic as `constraintsCell()` (required-ness, maxLength,
177
+ * enum CHECK-sets, validators, default, unique, references), but splits the
178
+ * facts across the Required / Limits / Rules columns instead of one cell.
179
+ * Renders for every field, with or without storage. */
180
+ function buildConstraintRow(
160
181
  entity: MetaObject,
161
182
  field: MetaField,
162
183
  pkFieldNames: Set<string>,
163
184
  fkMap: Map<string, { targetEntity: string; targetField: string }>,
164
- ): string {
165
- const parts: string[] = [];
185
+ ): ConstraintRow {
186
+ const isPk = pkFieldNames.has(field.name);
187
+ const required = isPk || isFieldRequired(field);
166
188
 
167
- if (pkFieldNames.has(field.name)) {
168
- parts.push("primary key");
169
- const primary = entity.primaryIdentity();
170
- const gen = primary?.ownAttr(IDENTITY_ATTR_GENERATION);
171
- if (typeof gen === "string") {
172
- parts.push(`generation: \`${gen}\``);
189
+ const limits: string[] = [];
190
+ const rules: string[] = [];
191
+
192
+ if (isPk) rules.push("primary key");
193
+ if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
194
+
195
+ if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
196
+ const values = enumValues(field);
197
+ if (values !== undefined && values.length > 0) {
198
+ const list = values.map((v) => `\`${v}\``).join(", ");
199
+ rules.push(`one of ${list}`);
173
200
  }
174
- } else if (isFieldRequired(field)) {
175
- parts.push("required");
176
- } else {
177
- parts.push("optional");
178
201
  }
179
202
 
180
- if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) {
181
- parts.push("unique");
203
+ // Same validator facts as constraintsCell() (shared walk), arranged across
204
+ // the Limits / Rules columns instead of one cell.
205
+ const { maxLenAttr, regexParts, lengthParts, numericParts } = collectValidatorParts(field);
206
+ rules.push(...regexParts);
207
+ if (maxLenAttr !== undefined) limits.push(`maxLength: ${maxLenAttr}`);
208
+ limits.push(...lengthParts, ...numericParts);
209
+
210
+ const fk = fkMap.get(field.name);
211
+ if (fk !== undefined) {
212
+ rules.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
213
+ }
214
+
215
+ const def = field.ownAttr(FIELD_ATTR_DEFAULT);
216
+ if (def !== undefined) rules.push(`default: \`${String(def)}\``);
217
+
218
+ const sup = field.resolveSuper();
219
+ if (sup !== undefined) rules.push(`extends \`${sup.name}\``);
220
+
221
+ return {
222
+ // The Field cell carries a stable HTML anchor (`<a id="field-<name>">`)
223
+ // before the backticked name, so the template-source annotator's
224
+ // `#field-<name>` links resolve. Slug = `fieldAnchorSlug(name)` — the SINGLE
225
+ // source shared with the annotator so anchor and link can't drift. The
226
+ // anchor is a language-independent HTML id, so the page stays neutral.
227
+ field: `${fieldAnchorHtml(field.name)}\`${field.name}\``,
228
+ required: required ? "yes" : "",
229
+ type: neutralTypeCell(field),
230
+ limits: limits.join(", "),
231
+ rules: rules.join(", "),
232
+ };
233
+ }
234
+
235
+ /** Build one row of the unified Fields table — collapses the per-field facts
236
+ * the old Storage + Constraints tables split between into a single Markdown
237
+ * row. PK/FK key role becomes a glyph prefix on the Field cell, the FK
238
+ * target becomes a `→ \`Target\`` suffix on the Type cell, the @column
239
+ * override (only when interesting) lands in the Storage cell, everything
240
+ * else (validators, defaults, enum CHECK-sets, references, unique, extends)
241
+ * goes into the Rules cell joined by " · ".
242
+ *
243
+ * Identity bullets remain a separate section above — they describe the
244
+ * *identity declarations* (composite keys, generation strategy, reference
245
+ * topology), not the per-field facts. */
246
+ function buildFieldRow(
247
+ entity: MetaObject,
248
+ field: MetaField,
249
+ pkFieldNames: Set<string>,
250
+ fkMap: Map<string, { targetEntity: string; targetField: string }>,
251
+ ): FieldDoc {
252
+ const isPk = pkFieldNames.has(field.name);
253
+ const fk = fkMap.get(field.name);
254
+ const required = isPk || isFieldRequired(field);
255
+
256
+ // Field cell — anchor + glyph + name.
257
+ let glyph = "";
258
+ if (isPk) glyph = "🔑 ";
259
+ else if (fk !== undefined) glyph = "🔗 ";
260
+ const fieldCell = `${fieldAnchorHtml(field.name)}${glyph}\`${field.name}\``;
261
+
262
+ // Type cell — neutral logical type; for FK, append the target as a link.
263
+ let typeCell = neutralTypeCell(field);
264
+ if (fk !== undefined) {
265
+ typeCell = `${typeCell} → \`${fk.targetEntity}\``;
182
266
  }
183
267
 
184
- if (field.isArray) {
185
- parts.push("JSON column");
268
+ // Storage cell — only populated when interesting:
269
+ // - @column override that differs from the field name, OR
270
+ // - @dbColumnType physical override set
271
+ // Otherwise empty. Keeps the column noise-free for the 90% case where
272
+ // field name and column name agree.
273
+ const columnName = field.column;
274
+ const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
275
+ const columnDiffers = typeof columnName === "string" && columnName !== field.name;
276
+ const hasPhysicalOverride = typeof dbColumnType === "string" && dbColumnType.length > 0;
277
+ let storageCell = "";
278
+ if (columnDiffers && hasPhysicalOverride) {
279
+ storageCell = `\`${columnName}\` \`${dbColumnType!.toUpperCase()}\``;
280
+ } else if (columnDiffers) {
281
+ storageCell = `\`${columnName}\``;
282
+ } else if (hasPhysicalOverride) {
283
+ storageCell = `\`${dbColumnType!.toUpperCase()}\``;
186
284
  }
187
285
 
286
+ // Rules cell — joined facts. Same logic as buildConstraintRow's Rules
287
+ // column, plus the maxLength/length/numeric limits that used to live in
288
+ // the separate Limits cell (collapsed in to keep the table to 5 columns).
289
+ const rules: string[] = [];
290
+ if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique");
291
+
188
292
  if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
189
293
  const values = enumValues(field);
190
294
  if (values !== undefined && values.length > 0) {
191
- const list = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
192
- parts.push(`CHECK \`${field.column ?? field.name} IN (${list})\``);
295
+ const list = values.map((v) => `\`${v}\``).join(", ");
296
+ rules.push(`one of ${list}`);
193
297
  }
194
298
  }
195
299
 
196
- // Walk validators once, bucket by subtype. We re-emit in the original
197
- // emission order to preserve byte-identity with the docs-file-basic
198
- // conformance fixture: regex pattern → maxLength-from-@maxLength
199
- // length-validator (min/max) → numeric-validator (min/max).
300
+ const { maxLenAttr, regexParts, lengthParts, numericParts } = collectValidatorParts(field);
301
+ rules.push(...regexParts);
302
+ if (maxLenAttr !== undefined) rules.push(`maxLength: ${maxLenAttr}`);
303
+ rules.push(...lengthParts, ...numericParts);
304
+
305
+ // The FK reference is already encoded in typeCell — don't repeat it in rules.
306
+ const def = field.ownAttr(FIELD_ATTR_DEFAULT);
307
+ if (def !== undefined) rules.push(`default: \`${String(def)}\``);
308
+
309
+ const sup = field.resolveSuper();
310
+ if (sup !== undefined) rules.push(`extends \`${sup.name}\``);
311
+
312
+ return {
313
+ field: field.name,
314
+ fieldCell,
315
+ typeCell,
316
+ requiredCell: required ? "yes" : "",
317
+ storageCell,
318
+ rulesCell: rules.join(" · "),
319
+ };
320
+ }
321
+
322
+ /** Build an expanded per-field detail block — `### \`name\`` heading, italic
323
+ * @summary lead-in, @description paragraph, then a bullet list of every
324
+ * notable rule (validators, default, FK, extends-enum, column override).
325
+ * Returns `undefined` when the field has nothing extra to say (just type +
326
+ * required) — the caller filters these out so the section stays tight.
327
+ *
328
+ * The validator list is the most important value-add: the Fields table
329
+ * collapses `pattern \`X\` · maxLength: 200 · minLength: 3` into a single
330
+ * Rules cell; this section breaks them out as individual bullets so the
331
+ * reader can scan each rule on its own line. */
332
+ function buildFieldDetail(
333
+ field: MetaField,
334
+ pkFieldNames: Set<string>,
335
+ fkMap: Map<string, { targetEntity: string; targetField: string }>,
336
+ ): FieldDetailDoc | undefined {
337
+ const desc = field.attr(DOC_ATTR_DESCRIPTION);
338
+ const summary = field.attr(DOC_ATTR_SUMMARY);
339
+ const hasDesc = typeof desc === "string" && desc.length > 0;
340
+ const hasSummary = typeof summary === "string" && summary.length > 0;
341
+ const sup = field.resolveSuper();
342
+ const fk = fkMap.get(field.name);
343
+ const def = field.ownAttr(FIELD_ATTR_DEFAULT);
344
+ const columnName = field.column;
345
+ const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
346
+ const isUnique = field.ownAttr(FIELD_ATTR_UNIQUE) === true;
347
+ const isEnum = field.subType === FIELD_SUBTYPE_ENUM && !field.isArray;
348
+ const enumVals = isEnum ? enumValues(field) : undefined;
349
+ const validators = field.validators();
350
+ const hasValidatorChildren = validators.some(
351
+ v => v.subType === VALIDATOR_SUBTYPE_LENGTH
352
+ || v.subType === VALIDATOR_SUBTYPE_REGEX
353
+ || v.subType === VALIDATOR_SUBTYPE_NUMERIC,
354
+ );
200
355
  const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
201
- const regexParts: string[] = [];
202
- const lengthParts: string[] = [];
203
- const numericParts: string[] = [];
204
- for (const v of field.validators()) {
356
+
357
+ // "Interesting enough to render a detail block" predicate. Plain typed
358
+ // fields with no authored annotations get skipped — the at-a-glance Fields
359
+ // table covered them already.
360
+ //
361
+ // Deliberately NOT counted as "interesting":
362
+ // - PK / required-ness (already a column in the table)
363
+ // - mechanical @column overrides (adopters typically set
364
+ // @column: PascalCase(name) wholesale; surfacing every field for
365
+ // that alone would defeat the section's purpose)
366
+ // The detail section's value is surfacing AUTHORED docs + validators +
367
+ // business rules, not physical column mapping.
368
+ const isInteresting =
369
+ hasDesc
370
+ || hasSummary
371
+ || sup !== undefined
372
+ || fk !== undefined
373
+ || def !== undefined
374
+ || hasValidatorChildren
375
+ || typeof maxLenAttr === "number"
376
+ || (enumVals !== undefined && enumVals.length > 0)
377
+ || isUnique
378
+ || (typeof dbColumnType === "string" && dbColumnType.length > 0);
379
+ if (!isInteresting) return undefined;
380
+
381
+ const parts: string[] = [`### \`${field.name}\``];
382
+
383
+ if (hasSummary) {
384
+ parts.push("");
385
+ parts.push(`*${summary as string}*`);
386
+ }
387
+ if (hasDesc) {
388
+ parts.push("");
389
+ parts.push(String(desc).trim());
390
+ }
391
+
392
+ // Bullet list — one fact per line. Order: type → FK → required/PK → column
393
+ // → default → unique → extends → enum values → validators.
394
+ const bullets: string[] = [];
395
+ bullets.push(`**Type:** ${neutralTypeCell(field)}`);
396
+ if (fk !== undefined) {
397
+ bullets.push(`**References:** [\`${fk.targetEntity}.${fk.targetField}\`](${fk.targetEntity}.md)`);
398
+ }
399
+ if (pkFieldNames.has(field.name)) {
400
+ bullets.push("**Primary key**");
401
+ } else if (isFieldRequired(field)) {
402
+ bullets.push("**Required**");
403
+ }
404
+ if (typeof columnName === "string" && columnName !== field.name) {
405
+ bullets.push(`**Column:** \`${columnName}\``);
406
+ }
407
+ if (typeof dbColumnType === "string" && dbColumnType.length > 0) {
408
+ bullets.push(`**Physical type:** \`${dbColumnType.toUpperCase()}\``);
409
+ }
410
+ if (def !== undefined) {
411
+ bullets.push(`**Default:** \`${String(def)}\``);
412
+ }
413
+ if (isUnique) bullets.push("**Unique**");
414
+ if (sup !== undefined) {
415
+ // The postprocess script rewrites `extends \`Name\`` → enum anchor link.
416
+ bullets.push(`**Extends:** \`${sup.name}\``);
417
+ }
418
+ if (enumVals !== undefined && enumVals.length > 0) {
419
+ const vals = enumVals.map((v) => `\`${v}\``).join(" · ");
420
+ bullets.push(`**Enum values:** ${vals}`);
421
+ }
422
+
423
+ // Validators — one bullet per validator subtype (regex / length / numeric),
424
+ // rendered in declaration order so authors can rely on the order they
425
+ // wrote.
426
+ for (const v of validators) {
205
427
  if (v.subType === VALIDATOR_SUBTYPE_REGEX) {
206
428
  const pattern = v.ownAttr(VALIDATOR_ATTR_PATTERN);
207
429
  if (typeof pattern === "string" && pattern.length > 0) {
208
- regexParts.push(`pattern \`${pattern}\``);
430
+ bullets.push(`**Validator (regex):** pattern \`${pattern}\``);
209
431
  }
210
432
  } else if (v.subType === VALIDATOR_SUBTYPE_LENGTH) {
211
433
  const min = v.ownAttr(VALIDATOR_ATTR_MIN);
212
434
  const max = v.ownAttr(VALIDATOR_ATTR_MAX);
213
- if (typeof min === "number") lengthParts.push(`minLength: ${min}`);
214
- if (typeof max === "number" && typeof maxLenAttr !== "number") lengthParts.push(`maxLength: ${max}`);
435
+ const fragments: string[] = [];
436
+ if (typeof min === "number") fragments.push(`min ${min}`);
437
+ if (typeof max === "number") fragments.push(`max ${max}`);
438
+ if (fragments.length > 0) bullets.push(`**Validator (length):** ${fragments.join(", ")}`);
215
439
  } else if (v.subType === VALIDATOR_SUBTYPE_NUMERIC) {
216
440
  const min = v.ownAttr(VALIDATOR_ATTR_MIN);
217
441
  const max = v.ownAttr(VALIDATOR_ATTR_MAX);
218
- if (typeof min === "number") numericParts.push(`min: ${min}`);
219
- if (typeof max === "number") numericParts.push(`max: ${max}`);
442
+ const fragments: string[] = [];
443
+ if (typeof min === "number") fragments.push(`min ${min}`);
444
+ if (typeof max === "number") fragments.push(`max ${max}`);
445
+ if (fragments.length > 0) bullets.push(`**Validator (numeric):** ${fragments.join(", ")}`);
220
446
  }
221
447
  }
222
- parts.push(...regexParts);
448
+ // @maxLength is the shorthand; render alongside validators for consistency.
223
449
  if (typeof maxLenAttr === "number") {
224
- parts.push(`maxLength: ${maxLenAttr}`);
450
+ bullets.push(`**Max length:** ${maxLenAttr}`);
225
451
  }
226
- parts.push(...lengthParts, ...numericParts);
227
452
 
228
- const fk = fkMap.get(field.name);
229
- if (fk !== undefined) {
230
- parts.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
231
- }
232
-
233
- const def = field.ownAttr(FIELD_ATTR_DEFAULT);
234
- if (def !== undefined) {
235
- parts.push(`default: \`${String(def)}\``);
236
- }
453
+ parts.push("");
454
+ for (const b of bullets) parts.push(`- ${b}`);
237
455
 
238
- const sup = field.resolveSuper();
239
- if (sup !== undefined) {
240
- parts.push(`extends \`${sup.name}\``);
241
- }
242
-
243
- return parts.join(", ");
456
+ return {
457
+ field: field.name,
458
+ block: parts.join("\n"),
459
+ };
244
460
  }
245
461
 
246
462
  function buildFkMap(
@@ -275,6 +491,11 @@ function entityDescription(entity: MetaObject): string | undefined {
275
491
  return typeof v === "string" && v.length > 0 ? v : undefined;
276
492
  }
277
493
 
494
+ function entitySummary(entity: MetaObject): string | undefined {
495
+ const v = entity.attr(DOC_ATTR_SUMMARY);
496
+ return typeof v === "string" && v.length > 0 ? v : undefined;
497
+ }
498
+
278
499
  function describeIdentity(id: MetaIdentity): string {
279
500
  const fields = id.fields;
280
501
  const fieldList = fields.length === 1
@@ -315,6 +536,27 @@ function relationshipBullet(r: ReturnType<MetaObject["relationships"]>[number]):
315
536
  case RELATIONSHIP_SUBTYPE_ASSOCIATION: label = "association"; break;
316
537
  default: label = subtype;
317
538
  }
539
+
540
+ // M:N (FR-018): the relationship traverses a junction (`@through`). Describe
541
+ // the edge as related-target THROUGH junction, and mark the self-join shape:
542
+ // symmetric (undirected) → "symmetric self-join"
543
+ // @sourceRefField set (directed) → "directed self-join via `<field>`"
544
+ // The junction/disambiguator are DECLARED facts (ADR-0020 — no re-derivation).
545
+ const throughRaw = r.ownAttr(RELATIONSHIP_ATTR_THROUGH);
546
+ if (typeof throughRaw === "string" && throughRaw.length > 0) {
547
+ const through = stripPackage(throughRaw);
548
+ const noteParts = [`${label}, through \`${through}\``];
549
+ if (r.ownAttr(RELATIONSHIP_ATTR_SYMMETRIC) === true) {
550
+ noteParts.push("symmetric self-join");
551
+ } else {
552
+ const srcRef = r.ownAttr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
553
+ if (typeof srcRef === "string" && srcRef.length > 0) {
554
+ noteParts.push(`directed self-join via \`${srcRef}\``);
555
+ }
556
+ }
557
+ return `\`${r.name}\` — ${card} → \`${target}\` (${noteParts.join(", ")})`;
558
+ }
559
+
318
560
  return `\`${r.name}\` — ${card} → \`${target}\` (${label})`;
319
561
  }
320
562
 
@@ -325,28 +567,47 @@ export function buildEntityDocData(
325
567
  entity: MetaObject,
326
568
  opts: BuildDocDataOpts,
327
569
  ): EntityDocData {
328
- const strategy = opts.columnNamingStrategy ?? "snake_case";
329
570
  const root = opts.loadedRoot;
571
+ const layout = opts.layout ?? "flat";
330
572
  const primary = entity.primaryIdentity();
331
573
  const pkFields = primary?.fields ?? [];
332
574
  const pkFieldNames = new Set<string>(pkFields);
333
575
  const fkMap = buildFkMap(entity, root);
334
576
 
335
- // ---- Storage rows
577
+ // ---- Storage rows — NEUTRAL physical persistence MAPPING (ADR-0020): the
578
+ // physical column name, a neutral physical type (declared `@dbColumnType`
579
+ // override else the logical type), nullability, and the key role. NO
580
+ // TypeScript type, NO ORM DDL, NO ANSI re-derivation — declared metadata
581
+ // facts only. The value-add over the Constraints table is the field→column
582
+ // mapping + physical-type override + key role.
336
583
  const storageRows: StorageFieldDoc[] = entity.fields().map((field) => {
337
- const spec = mapColumnType(field, opts.dialect, strategy);
338
- const tsType = tsTypeForStorage(entity, field, pkFieldNames);
339
- const tsTypeCell = tsType.split("|").map((s) => s.trim()).join(" \\| ");
340
- const sqlExpr = sqlColumnExpr(spec);
341
- const cons = constraintsCell(entity, field, pkFieldNames, fkMap);
342
- const tsTypeCellStr = `\`${tsTypeCell}\``;
343
- const sqlExprCellStr = `\`${sqlExpr}\``;
584
+ const isPk = pkFieldNames.has(field.name);
585
+ // Physical column name: the field's `@column` override if set, else the
586
+ // field name. (The Storage section shows the RAW declared mapping; column
587
+ // naming-strategy folding stays a codegen concern, not a docs fact.)
588
+ const columnName = field.column ?? field.name;
589
+ const columnCell = `\`${columnName}\``;
590
+ const typeCell = storageTypeCell(field);
591
+ // Nullable iff not required and not the PK (matches the Constraints table's
592
+ // required-ness rule).
593
+ const nullable = !(isPk || isFieldRequired(field));
594
+ const nullableCell = nullable ? "yes" : "no";
595
+
596
+ let keyCell = "";
597
+ if (isPk) {
598
+ keyCell = "primary key";
599
+ } else {
600
+ const fk = fkMap.get(field.name);
601
+ if (fk !== undefined) keyCell = `foreign key → \`${fk.targetEntity}\``;
602
+ }
603
+
344
604
  return {
345
605
  name: field.name,
346
- tsTypeCell: tsTypeCellStr,
347
- sqlExprCell: sqlExprCellStr,
348
- constraintsCell: cons,
349
- rowLine: `| \`${field.name}\` | ${tsTypeCellStr} | ${sqlExprCellStr} | ${cons} |`,
606
+ columnCell,
607
+ typeCell,
608
+ nullableCell,
609
+ keyCell,
610
+ rowLine: `| ${columnCell} | ${typeCell} | ${nullableCell} | ${keyCell} |`,
350
611
  };
351
612
  });
352
613
 
@@ -365,13 +626,40 @@ export function buildEntityDocData(
365
626
  ? rels.map((r) => ({ bullet: relationshipBullet(r) }))
366
627
  : undefined;
367
628
 
368
- // ---- Validation
369
- const lower = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
370
- const validation = {
371
- insertSchema: `${entity.name}InsertSchema`,
372
- updateSchema: `${entity.name}UpdateSchema`,
373
- entityFile: `${entity.name}.ts`,
374
- lower,
629
+ // ---- Constraints (NEUTRAL — built from the object's OWN field metadata, so
630
+ // it renders for every object including value objects with no storage).
631
+ // KEPT FOR BACK-COMPAT — new templates render `fields` instead.
632
+ const constraintRows: ConstraintRow[] = entity
633
+ .fields()
634
+ .map((field) => buildConstraintRow(entity, field, pkFieldNames, fkMap));
635
+ const constraints = {
636
+ hasConstraints: constraintRows.length > 0,
637
+ rows: constraintRows,
638
+ };
639
+
640
+ // ---- Fields (merged Storage + Constraints) — the single per-field table
641
+ // the new entity-page template renders. Same source of truth as the two
642
+ // legacy tables, just folded into one row.
643
+ const fieldRows: FieldDoc[] = entity
644
+ .fields()
645
+ .map((field) => buildFieldRow(entity, field, pkFieldNames, fkMap));
646
+ const fields = {
647
+ hasFields: fieldRows.length > 0,
648
+ rows: fieldRows,
649
+ };
650
+
651
+ // ---- Field details — expanded per-field section, skipping plain fields
652
+ // that the at-a-glance table already covered. The deeper "field details
653
+ // below the table" pattern adopted by Stripe / FHIR / GraphQL — keep the
654
+ // table tight, surface authoring + validation depth below.
655
+ const fieldDetailRows: FieldDetailDoc[] = [];
656
+ for (const field of entity.fields()) {
657
+ const detail = buildFieldDetail(field, pkFieldNames, fkMap);
658
+ if (detail !== undefined) fieldDetailRows.push(detail);
659
+ }
660
+ const fieldDetails = {
661
+ hasDetails: fieldDetailRows.length > 0,
662
+ rows: fieldDetailRows,
375
663
  };
376
664
 
377
665
  // ---- UsedBy
@@ -381,39 +669,17 @@ export function buildEntityDocData(
381
669
  const ref = child.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
382
670
  if (typeof ref !== "string") continue;
383
671
  if (stripPackage(ref) !== entity.name) continue;
672
+ // Link to the template's own doc page. The href is derived from the SAME
673
+ // page-placement function used to write the template page, so it resolves
674
+ // in BOTH layouts (flat → `./<Tmpl>.md`; package → a correct relative path
675
+ // like `../comms/OrderEmail.md`).
676
+ const href = docPageHref(layout, docPageNode(entity), docPageNode(child));
384
677
  usedByMatches.push({
385
- bullet: `\`template.${child.subType} ${child.name}\` — uses \`${entity.name}\` as \`@payloadRef\``,
678
+ bullet: `[\`template.${child.subType} ${child.name}\`](${href}) — uses \`${entity.name}\` as \`@payloadRef\``,
386
679
  });
387
680
  }
388
681
  const usedBy = usedByMatches.length > 0 ? usedByMatches : undefined;
389
682
 
390
- // ---- Generated
391
- const gens = opts.generatorNames ?? new Set<string>();
392
- const generated: GeneratedFileDoc[] = [];
393
- generated.push({
394
- filename: `${entity.name}.ts`,
395
- description: "Drizzle table, Zod schemas, type aliases, enum literal unions.",
396
- });
397
- if (gens.has("queries-file") && !isValue) {
398
- generated.push({
399
- filename: `${entity.name}.queries.ts`,
400
- description:
401
- "typed CRUD helpers (find / list / create / update / delete; takes `db` as first param per ADR-0008).",
402
- });
403
- }
404
- if (gens.has("routes-file") && !isValue) {
405
- generated.push({
406
- filename: `${entity.name}.routes.ts`,
407
- description: `Fastify CRUD-5 route registration (\`register${entity.name}Routes\`).`,
408
- });
409
- }
410
- if (gens.has("routes-file-hono") && !isValue) {
411
- generated.push({
412
- filename: `${entity.name}.routes.hono.ts`,
413
- description: `Hono CRUD-5 route registration (\`register${entity.name}Routes\`).`,
414
- });
415
- }
416
-
417
683
  // Preamble header — built up exactly as the legacy emitter did.
418
684
  const preambleLines: string[] = [];
419
685
  const typeStr = `${entity.type}.${entity.subType}`;
@@ -432,6 +698,11 @@ export function buildEntityDocData(
432
698
  descriptionQuote = desc.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
433
699
  }
434
700
 
701
+ // Summary — short single-line tagline. Rendered as italic lead-in just under
702
+ // the H1, ABOVE @description. Distinct enough that an entity can carry both
703
+ // (description = paragraph; summary = headline).
704
+ const summary = entitySummary(entity);
705
+
435
706
  const data: EntityDocData = {
436
707
  generatedMarker: `<!-- ${GENERATED_HEADER} — DO NOT EDIT. -->`,
437
708
  entity: {
@@ -439,12 +710,29 @@ export function buildEntityDocData(
439
710
  type: typeStr,
440
711
  },
441
712
  preambleHeader,
442
- validation,
443
- generated,
713
+ fields,
714
+ fieldDetails,
715
+ constraints,
444
716
  };
445
717
 
446
718
  if (desc !== undefined) data.entity.description = desc;
719
+ if (summary !== undefined) {
720
+ data.entity.summary = summary;
721
+ data.summaryLead = `*${summary}*`;
722
+ }
447
723
  if (descriptionQuote !== undefined) data.descriptionQuote = descriptionQuote;
724
+
725
+ // 1-hop neighborhood diagram — every entity it FKs into + every entity that
726
+ // FKs into it. Rendered just above the Relationships section in the entity
727
+ // page template. Skipped when the entity has zero neighbors (no orphan
728
+ // empty diagram block).
729
+ const neighborhoodErBlock = hasStorage
730
+ ? renderEntityNeighborhoodErBlock(entity, root)
731
+ : undefined;
732
+ if (neighborhoodErBlock !== undefined) {
733
+ data.neighborhoodErBlock = neighborhoodErBlock;
734
+ data.hasNeighborhoodEr = true;
735
+ }
448
736
  if (src !== undefined) data.entity.source = src;
449
737
  if (entity.package !== undefined && entity.package !== "") {
450
738
  data.entity.package = entity.package;
@@ -452,7 +740,7 @@ export function buildEntityDocData(
452
740
 
453
741
  if (hasStorage) {
454
742
  data.storage = {
455
- tableHeader: "| Field | TypeScript type | SQL column | Constraints |\n|---|---|---|---|",
743
+ tableHeader: "| Column | Type | Nullable | Key |\n|---|---|---|---|",
456
744
  rows: storageRows,
457
745
  };
458
746
  data.hasStorage = true;
@@ -469,6 +757,12 @@ export function buildEntityDocData(
469
757
  data.usedBy = usedBy;
470
758
  data.hasUsedBy = true;
471
759
  }
760
+ // Cross-link to the api surfaces — present ONLY when the caller computed the
761
+ // hrefs (api surfaces emitted alongside model); model-only runs stay identical.
762
+ // `last` flags the final ref so the template renders an inline ` · ` separator.
763
+ if (opts.apiRefs !== undefined) {
764
+ data.apiRefs = opts.apiRefs.map((r, i, arr) => ({ ...r, last: i === arr.length - 1 }));
765
+ }
472
766
 
473
767
  return data;
474
768
  }