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