@metaobjectsdev/codegen-ts 0.7.0-rc.1 → 0.7.0-rc.11

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 (88) hide show
  1. package/README.md +30 -0
  2. package/dist/column-mapper.d.ts +16 -0
  3. package/dist/column-mapper.d.ts.map +1 -1
  4. package/dist/column-mapper.js +71 -1
  5. package/dist/column-mapper.js.map +1 -1
  6. package/dist/generators/docs-file.d.ts +8 -0
  7. package/dist/generators/docs-file.d.ts.map +1 -0
  8. package/dist/generators/docs-file.js +52 -0
  9. package/dist/generators/docs-file.js.map +1 -0
  10. package/dist/generators/entity-file.d.ts +15 -0
  11. package/dist/generators/entity-file.d.ts.map +1 -1
  12. package/dist/generators/entity-file.js +2 -1
  13. package/dist/generators/entity-file.js.map +1 -1
  14. package/dist/generators/index.d.ts +2 -0
  15. package/dist/generators/index.d.ts.map +1 -1
  16. package/dist/generators/index.js +2 -0
  17. package/dist/generators/index.js.map +1 -1
  18. package/dist/generators/prompt-render-file.d.ts.map +1 -1
  19. package/dist/generators/prompt-render-file.js +30 -12
  20. package/dist/generators/prompt-render-file.js.map +1 -1
  21. package/dist/generators/queries-file.d.ts +1 -1
  22. package/dist/generators/queries-file.d.ts.map +1 -1
  23. package/dist/generators/queries-file.js +11 -3
  24. package/dist/generators/queries-file.js.map +1 -1
  25. package/dist/generators/routes-file-hono.d.ts +21 -0
  26. package/dist/generators/routes-file-hono.d.ts.map +1 -0
  27. package/dist/generators/routes-file-hono.js +38 -0
  28. package/dist/generators/routes-file-hono.js.map +1 -0
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/metaobjects-config.d.ts +9 -2
  34. package/dist/metaobjects-config.d.ts.map +1 -1
  35. package/dist/metaobjects-config.js.map +1 -1
  36. package/dist/payload-codegen.d.ts +8 -0
  37. package/dist/payload-codegen.d.ts.map +1 -1
  38. package/dist/payload-codegen.js +33 -3
  39. package/dist/payload-codegen.js.map +1 -1
  40. package/dist/source-detect.d.ts +10 -0
  41. package/dist/source-detect.d.ts.map +1 -0
  42. package/dist/source-detect.js +30 -0
  43. package/dist/source-detect.js.map +1 -0
  44. package/dist/templates/docs-file.d.ts +48 -0
  45. package/dist/templates/docs-file.d.ts.map +1 -0
  46. package/dist/templates/docs-file.js +451 -0
  47. package/dist/templates/docs-file.js.map +1 -0
  48. package/dist/templates/drizzle-schema.js +27 -3
  49. package/dist/templates/drizzle-schema.js.map +1 -1
  50. package/dist/templates/entity-file.d.ts +15 -1
  51. package/dist/templates/entity-file.d.ts.map +1 -1
  52. package/dist/templates/entity-file.js +15 -5
  53. package/dist/templates/entity-file.js.map +1 -1
  54. package/dist/templates/inferred-types.d.ts +9 -0
  55. package/dist/templates/inferred-types.d.ts.map +1 -1
  56. package/dist/templates/inferred-types.js +88 -2
  57. package/dist/templates/inferred-types.js.map +1 -1
  58. package/dist/templates/routes-file-hono.d.ts +4 -0
  59. package/dist/templates/routes-file-hono.d.ts.map +1 -0
  60. package/dist/templates/routes-file-hono.js +119 -0
  61. package/dist/templates/routes-file-hono.js.map +1 -0
  62. package/dist/templates/value-object-file.d.ts +3 -0
  63. package/dist/templates/value-object-file.d.ts.map +1 -0
  64. package/dist/templates/value-object-file.js +27 -0
  65. package/dist/templates/value-object-file.js.map +1 -0
  66. package/dist/templates/zod-validators.d.ts +10 -0
  67. package/dist/templates/zod-validators.d.ts.map +1 -1
  68. package/dist/templates/zod-validators.js +108 -30
  69. package/dist/templates/zod-validators.js.map +1 -1
  70. package/package.json +5 -4
  71. package/src/column-mapper.ts +84 -0
  72. package/src/generators/docs-file.ts +64 -0
  73. package/src/generators/entity-file.ts +17 -1
  74. package/src/generators/index.ts +2 -0
  75. package/src/generators/prompt-render-file.ts +38 -12
  76. package/src/generators/queries-file.ts +13 -4
  77. package/src/generators/routes-file-hono.ts +48 -0
  78. package/src/index.ts +2 -2
  79. package/src/metaobjects-config.ts +9 -2
  80. package/src/payload-codegen.ts +34 -2
  81. package/src/source-detect.ts +28 -0
  82. package/src/templates/docs-file.ts +552 -0
  83. package/src/templates/drizzle-schema.ts +27 -3
  84. package/src/templates/entity-file.ts +36 -5
  85. package/src/templates/inferred-types.ts +117 -3
  86. package/src/templates/routes-file-hono.ts +142 -0
  87. package/src/templates/value-object-file.ts +30 -0
  88. package/src/templates/zod-validators.ts +121 -35
@@ -0,0 +1,552 @@
1
+ // Docs-file template — emits a Markdown documentation page for one entity.
2
+ //
3
+ // One `<Entity>.md` file per object.entity / object.value. Documents:
4
+ // - Description / type / source / package preamble
5
+ // - Storage table (writable source.rdb only): per-field TypeScript type, SQL
6
+ // column expression, and constraints
7
+ // - Identity (primary, secondary, reference)
8
+ // - Relationships (own composition / aggregation / association children, plus
9
+ // incoming reference identities)
10
+ // - Validation entry points (Zod schemas exported from <Entity>.ts)
11
+ // - "Used by" — any template.* node whose @payloadRef points at this entity
12
+ // - Generated-code surface (which sibling files codegen produces)
13
+ //
14
+ // All output is plain Markdown; no ts-poet involvement. Byte-stable: identical
15
+ // metadata input always produces identical output (the conformance runner
16
+ // asserts byte equality against `expected/<Entity>.md`).
17
+ //
18
+ // Skipped sections on object.value: Storage / Identity / Relationships.
19
+
20
+ import {
21
+ type MetaObject,
22
+ type MetaField,
23
+ type MetaIdentity,
24
+ type MetaRoot,
25
+ TYPE_TEMPLATE,
26
+ TEMPLATE_ATTR_PAYLOAD_REF,
27
+ OBJECT_SUBTYPE_VALUE,
28
+ IDENTITY_SUBTYPE_PRIMARY,
29
+ IDENTITY_SUBTYPE_SECONDARY,
30
+ IDENTITY_SUBTYPE_REFERENCE,
31
+ IDENTITY_ATTR_GENERATION,
32
+ RELATIONSHIP_ATTR_CARDINALITY,
33
+ RELATIONSHIP_ATTR_OBJECT_REF,
34
+ RELATIONSHIP_SUBTYPE_COMPOSITION,
35
+ RELATIONSHIP_SUBTYPE_AGGREGATION,
36
+ RELATIONSHIP_SUBTYPE_ASSOCIATION,
37
+ FIELD_SUBTYPE_ENUM,
38
+ FIELD_SUBTYPE_OBJECT,
39
+ FIELD_SUBTYPE_STRING,
40
+ FIELD_SUBTYPE_CLASS,
41
+ FIELD_SUBTYPE_INT,
42
+ FIELD_SUBTYPE_SHORT,
43
+ FIELD_SUBTYPE_BYTE,
44
+ FIELD_SUBTYPE_LONG,
45
+ FIELD_SUBTYPE_DOUBLE,
46
+ FIELD_SUBTYPE_FLOAT,
47
+ FIELD_SUBTYPE_DECIMAL,
48
+ FIELD_SUBTYPE_CURRENCY,
49
+ FIELD_SUBTYPE_BOOLEAN,
50
+ FIELD_SUBTYPE_DATE,
51
+ FIELD_SUBTYPE_TIME,
52
+ FIELD_SUBTYPE_TIMESTAMP,
53
+ FIELD_ATTR_REQUIRED,
54
+ FIELD_ATTR_UNIQUE,
55
+ FIELD_ATTR_OBJECT_REF,
56
+ FIELD_ATTR_MAX_LENGTH,
57
+ FIELD_ATTR_DEFAULT,
58
+ VALIDATOR_SUBTYPE_LENGTH,
59
+ VALIDATOR_SUBTYPE_REGEX,
60
+ VALIDATOR_SUBTYPE_NUMERIC,
61
+ VALIDATOR_SUBTYPE_REQUIRED,
62
+ VALIDATOR_ATTR_PATTERN,
63
+ VALIDATOR_ATTR_MIN,
64
+ VALIDATOR_ATTR_MAX,
65
+ DOC_ATTR_DESCRIPTION,
66
+ stripPackage,
67
+ } from "@metaobjectsdev/metadata";
68
+ import { mapColumnType, type Dialect } from "../column-mapper.js";
69
+ import type { ColumnNamingStrategy } from "../metaobjects-config.js";
70
+ import { toPascalCase } from "../naming.js";
71
+ import { enumValues } from "../enum-meta.js";
72
+ import { hasWritableRdbSource } from "../source-detect.js";
73
+ import { GENERATED_HEADER } from "../constants.js";
74
+
75
+ /**
76
+ * Options for the markdown emitter. Mirrors the bits of `RenderContext` it
77
+ * actually needs (dialect + column-naming-strategy + cross-entity loader).
78
+ * Held as a separate type so unit tests can call `renderDocsFile()` without
79
+ * constructing a full `RenderContext`.
80
+ */
81
+ export interface DocsRenderOpts {
82
+ dialect: Dialect;
83
+ columnNamingStrategy?: ColumnNamingStrategy;
84
+ loadedRoot: MetaRoot;
85
+ /** Names of generators present in the pipeline — drives the "Generated code"
86
+ * section. Always includes "entity-file" implicitly. Recognized names:
87
+ * "queries-file", "routes-file", "routes-file-hono". */
88
+ generatorNames?: ReadonlySet<string>;
89
+ }
90
+
91
+ /** Scalar field.subType → TypeScript scalar mapping. Mirrors inferred-types.ts. */
92
+ const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
93
+ [FIELD_SUBTYPE_STRING]: "string",
94
+ [FIELD_SUBTYPE_CLASS]: "string",
95
+ [FIELD_SUBTYPE_INT]: "number",
96
+ [FIELD_SUBTYPE_SHORT]: "number",
97
+ [FIELD_SUBTYPE_BYTE]: "number",
98
+ [FIELD_SUBTYPE_LONG]: "number",
99
+ [FIELD_SUBTYPE_DOUBLE]: "number",
100
+ [FIELD_SUBTYPE_FLOAT]: "number",
101
+ [FIELD_SUBTYPE_DECIMAL]: "number",
102
+ [FIELD_SUBTYPE_CURRENCY]: "number",
103
+ [FIELD_SUBTYPE_BOOLEAN]: "boolean",
104
+ [FIELD_SUBTYPE_DATE]: "string",
105
+ [FIELD_SUBTYPE_TIME]: "string",
106
+ [FIELD_SUBTYPE_TIMESTAMP]: "string",
107
+ };
108
+
109
+ /** Mirror inferred-types.ts's enumTypeAliasName. */
110
+ function enumTypeAliasName(entity: MetaObject, field: MetaField): string {
111
+ const superField = field.resolveSuper();
112
+ return superField !== undefined
113
+ ? toPascalCase(superField.name)
114
+ : `${entity.name}${toPascalCase(field.name)}`;
115
+ }
116
+
117
+ /**
118
+ * The TS type column shown in the Storage table — equivalent to what
119
+ * `InferSelectModel<typeof <table>>` produces. For required (or PK) → bare;
120
+ * for optional → `T | null` (Drizzle nullable columns surface as `null`, not
121
+ * `undefined`, on select).
122
+ */
123
+ function tsTypeForStorage(
124
+ entity: MetaObject,
125
+ field: MetaField,
126
+ pkFieldNames: ReadonlySet<string>,
127
+ ): string {
128
+ let base: string;
129
+
130
+ if (field.subType === FIELD_SUBTYPE_ENUM) {
131
+ const values = enumValues(field);
132
+ if (values !== undefined && values.length > 0) {
133
+ // For storage we show the literal union directly (matches what Drizzle
134
+ // infers via `text(..., { enum: [...] as const })` on a single-column).
135
+ // For arrays we use the alias name to keep the table tidy.
136
+ if (field.isArray) {
137
+ base = `${enumTypeAliasName(entity, field)}[]`;
138
+ } else {
139
+ base = values.map((v) => JSON.stringify(v)).join(" | ");
140
+ }
141
+ } else {
142
+ base = field.isArray ? "string[]" : "string";
143
+ }
144
+ } else if (field.subType === FIELD_SUBTYPE_OBJECT) {
145
+ const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
146
+ const refName = typeof ref === "string" && ref.length > 0 ? ref : "unknown";
147
+ base = field.isArray ? `${refName}[]` : refName;
148
+ } else {
149
+ const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown";
150
+ base = field.isArray ? `${scalar}[]` : scalar;
151
+ }
152
+
153
+ // Primary-key columns are always NOT NULL on the select side regardless of
154
+ // an explicit @required attr; Drizzle's `.primaryKey()` implies notNull.
155
+ // Required fields likewise come through as bare T. Optional → T | null.
156
+ const required = pkFieldNames.has(field.name) || isFieldRequired(field);
157
+ return required ? base : `${base} | null`;
158
+ }
159
+
160
+ /** True iff the field is required (own @required or validator.required, effective). */
161
+ function isFieldRequired(field: MetaField): boolean {
162
+ if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
163
+ return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED);
164
+ }
165
+
166
+ /**
167
+ * The SQL column column — the literal Drizzle call (function + args).
168
+ * Mirrors what `mapColumnType()` + `renderColumn()` emit, minus the chain
169
+ * modifiers (those land in Constraints).
170
+ */
171
+ function sqlColumnExpr(spec: ReturnType<typeof mapColumnType>): string {
172
+ const dbName = JSON.stringify(spec.dbName);
173
+ if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) {
174
+ const parts: string[] = [];
175
+ for (const [k, v] of Object.entries(spec.fnOptions)) {
176
+ const lit = JSON.stringify(v);
177
+ if (Array.isArray(v)) {
178
+ parts.push(`${k}: ${lit} as const`);
179
+ } else {
180
+ parts.push(`${k}: ${lit}`);
181
+ }
182
+ }
183
+ return `${spec.fnName}(${dbName}, { ${parts.join(", ")} })`;
184
+ }
185
+ return `${spec.fnName}(${dbName})`;
186
+ }
187
+
188
+ /**
189
+ * Build the Constraints cell text for one field. Pure description — not
190
+ * exhaustive of every Drizzle modifier emitted, but covers the contract-level
191
+ * shape (required / optional, PK, JSON, CHECK, regex, length, numeric bounds,
192
+ * FK references, extends:).
193
+ */
194
+ function constraintsCell(
195
+ entity: MetaObject,
196
+ field: MetaField,
197
+ pkFieldNames: Set<string>,
198
+ fkMap: Map<string, { targetEntity: string; targetField: string }>,
199
+ ): string {
200
+ const parts: string[] = [];
201
+
202
+ if (pkFieldNames.has(field.name)) {
203
+ parts.push("primary key");
204
+ const primary = entity.primaryIdentity();
205
+ const gen = primary?.ownAttr(IDENTITY_ATTR_GENERATION);
206
+ if (typeof gen === "string") {
207
+ parts.push(`generation: \`${gen}\``);
208
+ }
209
+ } else if (isFieldRequired(field)) {
210
+ parts.push("required");
211
+ } else {
212
+ parts.push("optional");
213
+ }
214
+
215
+ if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) {
216
+ parts.push("unique");
217
+ }
218
+
219
+ if (field.isArray) {
220
+ parts.push("JSON column");
221
+ }
222
+
223
+ // Enum CHECK predicate.
224
+ if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
225
+ const values = enumValues(field);
226
+ if (values !== undefined && values.length > 0) {
227
+ const list = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
228
+ // Use the strategy-mapped column name to mirror the emitted CHECK constraint.
229
+ parts.push(`CHECK \`${field.column ?? field.name} IN (${list})\``);
230
+ }
231
+ }
232
+
233
+ // Regex pattern.
234
+ for (const v of field.validators()) {
235
+ if (v.subType === VALIDATOR_SUBTYPE_REGEX) {
236
+ const pattern = v.ownAttr(VALIDATOR_ATTR_PATTERN);
237
+ if (typeof pattern === "string" && pattern.length > 0) {
238
+ parts.push(`pattern \`${pattern}\``);
239
+ }
240
+ }
241
+ }
242
+
243
+ // Length bounds (validator.length / @maxLength).
244
+ const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
245
+ if (typeof maxLenAttr === "number") {
246
+ parts.push(`maxLength: ${maxLenAttr}`);
247
+ }
248
+ for (const v of field.validators()) {
249
+ if (v.subType === VALIDATOR_SUBTYPE_LENGTH) {
250
+ const min = v.ownAttr(VALIDATOR_ATTR_MIN);
251
+ const max = v.ownAttr(VALIDATOR_ATTR_MAX);
252
+ if (typeof min === "number") parts.push(`minLength: ${min}`);
253
+ if (typeof max === "number" && typeof maxLenAttr !== "number") parts.push(`maxLength: ${max}`);
254
+ }
255
+ }
256
+
257
+ // Numeric bounds.
258
+ for (const v of field.validators()) {
259
+ if (v.subType === VALIDATOR_SUBTYPE_NUMERIC) {
260
+ const min = v.ownAttr(VALIDATOR_ATTR_MIN);
261
+ const max = v.ownAttr(VALIDATOR_ATTR_MAX);
262
+ if (typeof min === "number") parts.push(`min: ${min}`);
263
+ if (typeof max === "number") parts.push(`max: ${max}`);
264
+ }
265
+ }
266
+
267
+ // Foreign key (incoming side via reference identity).
268
+ const fk = fkMap.get(field.name);
269
+ if (fk !== undefined) {
270
+ parts.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
271
+ }
272
+
273
+ // Default expression — the actual literal/sql shape is implementation detail;
274
+ // surface the raw declared default so the reader can see "what's the default."
275
+ const def = field.ownAttr(FIELD_ATTR_DEFAULT);
276
+ if (def !== undefined) {
277
+ parts.push(`default: \`${String(def)}\``);
278
+ }
279
+
280
+ // Extends: surface the abstract super field name for traceability.
281
+ const sup = field.resolveSuper();
282
+ if (sup !== undefined) {
283
+ parts.push(`extends \`${sup.name}\``);
284
+ }
285
+
286
+ return parts.join(", ");
287
+ }
288
+
289
+ /** Build the FK lookup for the entity's own reference identities. */
290
+ function buildFkMap(
291
+ entity: MetaObject,
292
+ root: MetaRoot,
293
+ ): Map<string, { targetEntity: string; targetField: string }> {
294
+ const out = new Map<string, { targetEntity: string; targetField: string }>();
295
+ for (const ref of entity.referenceIdentities()) {
296
+ const fkField = ref.fields[0];
297
+ const targetEntity = ref.targetEntity;
298
+ if (fkField === undefined || targetEntity === undefined) continue;
299
+ const targetField = ref.resolvedTargetPkField(root) ?? "id";
300
+ out.set(fkField, { targetEntity: stripPackage(targetEntity), targetField });
301
+ }
302
+ return out;
303
+ }
304
+
305
+ /** Resolve provenance from the node's source envelope — best-effort. */
306
+ function sourceLine(entity: MetaObject): string | undefined {
307
+ const src = entity.source;
308
+ if (!src) return undefined;
309
+ if ("files" in src && src.files.length > 0) {
310
+ // Mention the first file. Use the path verbatim — node IDs from
311
+ // InMemoryStringSource (e.g. "meta.blog.json") or file system paths
312
+ // (e.g. "metaobjects/meta-author.yaml") both read naturally here.
313
+ return src.files[0];
314
+ }
315
+ if (src.format === "code") {
316
+ return src.caller !== undefined ? `(code) ${src.caller}` : "(code)";
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ /** Description from the entity's @description doc-attr (effective). */
322
+ function entityDescription(entity: MetaObject): string | undefined {
323
+ const v = entity.attr(DOC_ATTR_DESCRIPTION);
324
+ return typeof v === "string" && v.length > 0 ? v : undefined;
325
+ }
326
+
327
+ /** Render the Storage section (a markdown table). */
328
+ function renderStorageSection(
329
+ entity: MetaObject,
330
+ opts: DocsRenderOpts,
331
+ ): string {
332
+ const strategy = opts.columnNamingStrategy ?? "snake_case";
333
+ const primary = entity.primaryIdentity();
334
+ const pkFields = primary?.fields ?? [];
335
+ const pkFieldNames = new Set<string>(pkFields);
336
+ const fkMap = buildFkMap(entity, opts.loadedRoot);
337
+
338
+ const rows: string[] = [];
339
+ rows.push("| Field | TypeScript type | SQL column | Constraints |");
340
+ rows.push("|---|---|---|---|");
341
+ for (const field of entity.fields()) {
342
+ const spec = mapColumnType(field, opts.dialect, strategy);
343
+ const tsType = tsTypeForStorage(entity, field, pkFieldNames);
344
+ const sqlExpr = sqlColumnExpr(spec);
345
+ const cons = constraintsCell(entity, field, pkFieldNames, fkMap);
346
+ // Wrap each cell in backticks where appropriate to keep table alignment
347
+ // readable. The pipe character is escaped so Drizzle's `text(..., { enum: [...] })`
348
+ // and TS literal unions ("a" | "b") render cleanly inside one cell.
349
+ const tsTypeCell = tsType.split("|").map((s) => s.trim()).join(" \\| ");
350
+ const sqlCell = `\`${sqlExpr}\``;
351
+ rows.push(`| \`${field.name}\` | \`${tsTypeCell}\` | ${sqlCell} | ${cons} |`);
352
+ }
353
+
354
+ return `## Storage\n\n${rows.join("\n")}`;
355
+ }
356
+
357
+ /** Render the Identity section as bullets. */
358
+ function renderIdentitySection(entity: MetaObject): string | undefined {
359
+ const ids = entity.identities();
360
+ if (ids.length === 0) return undefined;
361
+
362
+ const bullets: string[] = [];
363
+ for (const id of ids) {
364
+ bullets.push(`- ${describeIdentity(id)}`);
365
+ }
366
+ return `## Identity\n\n${bullets.join("\n")}`;
367
+ }
368
+
369
+ function describeIdentity(id: MetaIdentity): string {
370
+ const fields = id.fields;
371
+ const fieldList = fields.length === 1
372
+ ? `\`${fields[0]}\``
373
+ : `(${fields.map((f) => `\`${f}\``).join(", ")})`;
374
+
375
+ if (id.subType === IDENTITY_SUBTYPE_PRIMARY) {
376
+ const gen = id.ownAttr(IDENTITY_ATTR_GENERATION);
377
+ const genSuffix = typeof gen === "string" ? ` — generation: \`${gen}\`` : "";
378
+ return `**Primary key:** ${fieldList}${genSuffix}`;
379
+ }
380
+ if (id.subType === IDENTITY_SUBTYPE_SECONDARY) {
381
+ const uniqueText = id.unique ? "unique" : "non-unique";
382
+ return `**Secondary index:** ${fieldList} — ${uniqueText}`;
383
+ }
384
+ if (id.subType === IDENTITY_SUBTYPE_REFERENCE) {
385
+ // Reference identity carries @references = "TargetEntity[.field]"
386
+ const refIdent = id as unknown as { referencesRaw?: string };
387
+ const raw = refIdent.referencesRaw;
388
+ if (typeof raw === "string" && raw.length > 0) {
389
+ return `**Reference:** ${fieldList} → \`${raw}\``;
390
+ }
391
+ return `**Reference:** ${fieldList}`;
392
+ }
393
+ return `**Identity (${id.subType}):** ${fieldList}`;
394
+ }
395
+
396
+ /** Render the Relationships section as bullets. */
397
+ function renderRelationshipsSection(entity: MetaObject): string | undefined {
398
+ const rels = entity.relationships();
399
+ if (rels.length === 0) return undefined;
400
+
401
+ const bullets: string[] = [];
402
+ for (const r of rels) {
403
+ const cardinality = r.ownAttr(RELATIONSHIP_ATTR_CARDINALITY);
404
+ const card = typeof cardinality === "string" ? cardinality : "?";
405
+ const targetRaw = r.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF);
406
+ const target = typeof targetRaw === "string" ? stripPackage(targetRaw) : "?";
407
+ const subtype = r.subType;
408
+ let label: string;
409
+ switch (subtype) {
410
+ case RELATIONSHIP_SUBTYPE_COMPOSITION:
411
+ label = "composition";
412
+ break;
413
+ case RELATIONSHIP_SUBTYPE_AGGREGATION:
414
+ label = "aggregation";
415
+ break;
416
+ case RELATIONSHIP_SUBTYPE_ASSOCIATION:
417
+ label = "association";
418
+ break;
419
+ default:
420
+ label = subtype;
421
+ }
422
+ bullets.push(`- \`${r.name}\` — ${card} → \`${target}\` (${label})`);
423
+ }
424
+ return `## Relationships\n\n${bullets.join("\n")}`;
425
+ }
426
+
427
+ /** Render the Validation section — pointers at the Zod schemas. */
428
+ function renderValidationSection(entity: MetaObject): string {
429
+ const lower = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
430
+ const lines = [
431
+ `- \`${entity.name}InsertSchema\` (Zod) — for creating new ${lower}s.`,
432
+ `- \`${entity.name}UpdateSchema\` (Zod) — for partial updates.`,
433
+ `- See \`${entity.name}.ts\` for the exported schemas.`,
434
+ ];
435
+ return `## Validation\n\n${lines.join("\n")}`;
436
+ }
437
+
438
+ /** Render the "Used by" section — templates that declare @payloadRef → this entity. */
439
+ function renderUsedBySection(entity: MetaObject, root: MetaRoot): string | undefined {
440
+ const matches: string[] = [];
441
+ for (const child of root.ownChildren()) {
442
+ if (child.type !== TYPE_TEMPLATE) continue;
443
+ const ref = child.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
444
+ if (typeof ref !== "string") continue;
445
+ if (stripPackage(ref) !== entity.name) continue;
446
+ matches.push(`- \`template.${child.subType} ${child.name}\` — uses \`${entity.name}\` as \`@payloadRef\``);
447
+ }
448
+ if (matches.length === 0) return undefined;
449
+ return `## Used by\n\n${matches.join("\n")}`;
450
+ }
451
+
452
+ /** Render the Generated-code section — sibling files this entity produces. */
453
+ function renderGeneratedCodeSection(entity: MetaObject, opts: DocsRenderOpts): string {
454
+ const gens = opts.generatorNames ?? new Set<string>();
455
+ const isValue = entity.subType === OBJECT_SUBTYPE_VALUE;
456
+ const lower = entity.name.charAt(0).toLowerCase() + entity.name.slice(1);
457
+ const lines: string[] = [];
458
+ lines.push(`- \`${entity.name}.ts\` — Drizzle table, Zod schemas, type aliases, enum literal unions.`);
459
+ // Queries are only emitted for non-value entities (queriesFile() unconditionally
460
+ // skips object.value).
461
+ if (gens.has("queries-file") && !isValue) {
462
+ lines.push(`- \`${entity.name}.queries.ts\` — typed CRUD helpers (find / list / create / update / delete; takes \`db\` as first param per ADR-0008).`);
463
+ }
464
+ if (gens.has("routes-file") && !isValue) {
465
+ lines.push(`- \`${entity.name}.routes.ts\` — Fastify CRUD-5 route registration (\`register${entity.name}Routes\`).`);
466
+ }
467
+ if (gens.has("routes-file-hono") && !isValue) {
468
+ lines.push(`- \`${entity.name}.routes.hono.ts\` — Hono CRUD-5 route registration (\`register${entity.name}Routes\`).`);
469
+ }
470
+ // Silence the unused `lower` warning — kept for future expansion.
471
+ void lower;
472
+ return `## Generated code\n\n${lines.join("\n")}`;
473
+ }
474
+
475
+ /**
476
+ * Top-level: render the full markdown page for one entity / value object.
477
+ *
478
+ * Sections (object.entity with source.rdb):
479
+ * # <Name>
480
+ * > <description>?
481
+ * **Type:** ...
482
+ * **Source:** ...
483
+ * **Package:** ...
484
+ *
485
+ * ## Storage
486
+ * ## Identity
487
+ * ## Relationships (when present)
488
+ * ## Validation
489
+ * ## Used by (when present)
490
+ * ## Generated code
491
+ *
492
+ * Sections (object.value, or any object lacking source.rdb):
493
+ * # <Name>
494
+ * > <description>?
495
+ * **Type:** ...
496
+ * **Source:** ...
497
+ * **Package:** ...
498
+ *
499
+ * ## Validation
500
+ * ## Used by (when present)
501
+ * ## Generated code
502
+ */
503
+ export function renderDocsFile(entity: MetaObject, opts: DocsRenderOpts): string {
504
+ const parts: string[] = [];
505
+
506
+ // HTML comment header carrying the @generated marker — drives the
507
+ // overwrite-policy so subsequent `meta gen` runs can refresh the file
508
+ // without confirmation. Hidden in rendered markdown (HTML comments don't
509
+ // surface in GitHub / VS Code / mdBook output) but visible in raw source.
510
+ parts.push(`<!-- ${GENERATED_HEADER} — DO NOT EDIT. -->`);
511
+
512
+ parts.push(`# ${entity.name}`);
513
+
514
+ const desc = entityDescription(entity);
515
+ if (desc !== undefined) {
516
+ // Each non-empty line of the description becomes a quote line — keeps
517
+ // multi-paragraph descriptions readable.
518
+ const lines = desc.split("\n");
519
+ parts.push(lines.map((l) => `> ${l}`.trimEnd()).join("\n"));
520
+ }
521
+
522
+ const headerLines: string[] = [];
523
+ headerLines.push(`**Type:** \`${entity.type}.${entity.subType}\``);
524
+ const src = sourceLine(entity);
525
+ if (src !== undefined) {
526
+ headerLines.push(`**Source:** \`${src}\``);
527
+ }
528
+ if (entity.package !== undefined && entity.package !== "") {
529
+ headerLines.push(`**Package:** \`${entity.package}\``);
530
+ }
531
+ parts.push(headerLines.join("\n"));
532
+
533
+ const hasStorage = entity.subType !== OBJECT_SUBTYPE_VALUE && hasWritableRdbSource(entity);
534
+
535
+ if (hasStorage) {
536
+ parts.push(renderStorageSection(entity, opts));
537
+ const id = renderIdentitySection(entity);
538
+ if (id !== undefined) parts.push(id);
539
+ const rel = renderRelationshipsSection(entity);
540
+ if (rel !== undefined) parts.push(rel);
541
+ }
542
+
543
+ parts.push(renderValidationSection(entity));
544
+
545
+ const usedBy = renderUsedBySection(entity, opts.loadedRoot);
546
+ if (usedBy !== undefined) parts.push(usedBy);
547
+
548
+ parts.push(renderGeneratedCodeSection(entity, opts));
549
+
550
+ // Join sections with a blank line, single trailing newline.
551
+ return parts.map((s) => s.trimEnd()).join("\n\n") + "\n";
552
+ }
@@ -187,9 +187,17 @@ function buildCompositeKeyCallback(
187
187
  return code`${primaryKeySym}({ columns: [${columnRefs}] })`;
188
188
  }
189
189
 
190
- /** Build a JS-style object literal string (not JSON.stringify which uses quoted keys). */
190
+ /** Build a JS-style object literal string (not JSON.stringify which uses quoted keys).
191
+ * Array values get `as const` appended so Drizzle's text(...,{ enum: [...] })
192
+ * narrows the inferred column type to a literal union instead of bare `string`. */
191
193
  function inlineObjectLiteral(obj: Record<string, unknown>): string {
192
- const entries = Object.entries(obj).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
194
+ const entries = Object.entries(obj).map(([k, v]) => {
195
+ const lit = JSON.stringify(v);
196
+ if (Array.isArray(v)) {
197
+ return `${k}: ${lit} as const`;
198
+ }
199
+ return `${k}: ${lit}`;
200
+ });
193
201
  return `{ ${entries.join(", ")} }`;
194
202
  }
195
203
 
@@ -300,7 +308,23 @@ function renderColumn(
300
308
  ? `.$defaultFn(() => new Date().toISOString())`
301
309
  : "";
302
310
 
303
- const columnLine = code` ${field.name}: ${baseCall}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`;
311
+ // $type<E[]>() chain emitted as Code (not a string modifier) so ts-poet can
312
+ // hoist the cross-module type import for objectRef variants. Positioned
313
+ // immediately after the baseCall so the chain reads `.text(...).$type<...>().notNull()...`
314
+ // which Drizzle accepts in any order but is conventional for "type narrowing
315
+ // first."
316
+ let dollarTypeSegment: Code | string = "";
317
+ if (spec.dollarTypeRef !== undefined) {
318
+ const ref = spec.dollarTypeRef;
319
+ if (ref.kind === "scalar") {
320
+ dollarTypeSegment = `.$type<${ref.tsType}[]>()`;
321
+ } else {
322
+ const refSym = imp(`${ref.name}@${ref.module}`);
323
+ dollarTypeSegment = code`.$type<${refSym}[]>()`;
324
+ }
325
+ }
326
+
327
+ const columnLine = code` ${field.name}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`;
304
328
  return spec.leadingComment !== undefined
305
329
  ? code` // ${spec.leadingComment}\n${columnLine}`
306
330
  : columnLine;
@@ -2,8 +2,9 @@
2
2
  // into one file with the @generated header. ts-poet deduplicates imports.
3
3
  //
4
4
  // Dispatch:
5
- // isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections)
6
- // vanilla / write-through entity Drizzle table path
5
+ // isProjection(entity) → renderProjectionDecl (read-only: view declaration + Zod + filter sections)
6
+ // !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema)
7
+ // vanilla / write-through entity → Drizzle table path
7
8
 
8
9
  import { joinCode, type Code } from "ts-poet";
9
10
  import type { MetaObject } from "@metaobjectsdev/metadata";
@@ -17,8 +18,31 @@ import { renderFilterType } from "./filter-type.js";
17
18
  import { GENERATED_HEADER } from "../constants.js";
18
19
  import { isProjection } from "../projection/projection-detector.js";
19
20
  import { renderProjectionDecl } from "./projection-decl.js";
21
+ import { hasWritableRdbSource } from "../source-detect.js";
22
+ import { renderValueObjectFile } from "./value-object-file.js";
23
+
24
+ /**
25
+ * Render-time options for the entity-file composer.
26
+ *
27
+ * `allowlists` (default `true`) controls whether the Fastify-flavored
28
+ * `<Entity>FilterAllowlist` + `<Entity>SortAllowlist` blocks (plus their
29
+ * `runtime-ts/drizzle-fastify` type-only imports) are emitted. Workers/Lambda
30
+ * consumers that don't mount Fastify-style server routes can pass `false` and
31
+ * drop `@metaobjectsdev/runtime-ts` from their deps entirely. The client-side
32
+ * `<Entity>Filter` type is always emitted — consumers still want it for typed
33
+ * client calls regardless of how the server is wired.
34
+ */
35
+ export interface RenderEntityFileOpts {
36
+ readonly allowlists?: boolean;
37
+ }
38
+
39
+ export function renderEntityFile(
40
+ entity: MetaObject,
41
+ ctx: RenderContext,
42
+ opts?: RenderEntityFileOpts,
43
+ ): string {
44
+ const allowlists = opts?.allowlists ?? true;
20
45
 
21
- export function renderEntityFile(entity: MetaObject, ctx: RenderContext): string {
22
46
  // --- Projection path (read-only: view-backed entity with no table source) ---
23
47
  // Projections intentionally get the z.enum() validator but NOT a named enum
24
48
  // type alias — emitting aliases here is a deliberate v1 scope decision.
@@ -30,6 +54,14 @@ export function renderEntityFile(entity: MetaObject, ctx: RenderContext): string
30
54
  });
31
55
  }
32
56
 
57
+ // --- Value-only path (no writable source.rdb: in-memory / transit shape) ---
58
+ // No Drizzle table, no migration footprint. Consumers that need to validate
59
+ // the shape (LLM tool_use input_schema, REST body parsing) use the Zod
60
+ // schema; consumers that need the type use the interface.
61
+ if (!hasWritableRdbSource(entity)) {
62
+ return renderValueObjectFile(entity);
63
+ }
64
+
33
65
  // --- Vanilla / write-through entity path ---
34
66
  const enumAliases = renderEnumTypeAliases(entity);
35
67
  const sections: Code[] = [
@@ -38,8 +70,7 @@ export function renderEntityFile(entity: MetaObject, ctx: RenderContext): string
38
70
  ...(enumAliases !== null ? [enumAliases] : []),
39
71
  renderZodValidators(entity),
40
72
  renderEntityConstants(entity, ctx.apiPrefix),
41
- renderFilterAllowlist(entity),
42
- renderSortAllowlist(entity),
73
+ ...(allowlists ? [renderFilterAllowlist(entity), renderSortAllowlist(entity)] : []),
43
74
  renderFilterType(entity),
44
75
  ];
45
76