@metaobjectsdev/codegen-ts 0.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +101 -0
  3. package/dist/column-mapper.d.ts +38 -0
  4. package/dist/column-mapper.d.ts.map +1 -0
  5. package/dist/column-mapper.js +205 -0
  6. package/dist/column-mapper.js.map +1 -0
  7. package/dist/constants.d.ts +7 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +8 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/errors.d.ts +7 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +11 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/format.d.ts +2 -0
  16. package/dist/format.d.ts.map +1 -0
  17. package/dist/format.js +47 -0
  18. package/dist/format.js.map +1 -0
  19. package/dist/generator.d.ts +44 -0
  20. package/dist/generator.d.ts.map +1 -0
  21. package/dist/generator.js +17 -0
  22. package/dist/generator.js.map +1 -0
  23. package/dist/generators/barrel.d.ts +6 -0
  24. package/dist/generators/barrel.d.ts.map +1 -0
  25. package/dist/generators/barrel.js +17 -0
  26. package/dist/generators/barrel.js.map +1 -0
  27. package/dist/generators/entity-file.d.ts +8 -0
  28. package/dist/generators/entity-file.d.ts.map +1 -0
  29. package/dist/generators/entity-file.js +27 -0
  30. package/dist/generators/entity-file.js.map +1 -0
  31. package/dist/generators/index.d.ts +5 -0
  32. package/dist/generators/index.d.ts.map +1 -0
  33. package/dist/generators/index.js +5 -0
  34. package/dist/generators/index.js.map +1 -0
  35. package/dist/generators/queries-file.d.ts +8 -0
  36. package/dist/generators/queries-file.d.ts.map +1 -0
  37. package/dist/generators/queries-file.js +26 -0
  38. package/dist/generators/queries-file.js.map +1 -0
  39. package/dist/generators/routes-file.d.ts +12 -0
  40. package/dist/generators/routes-file.d.ts.map +1 -0
  41. package/dist/generators/routes-file.js +30 -0
  42. package/dist/generators/routes-file.js.map +1 -0
  43. package/dist/import-path.d.ts +41 -0
  44. package/dist/import-path.d.ts.map +1 -0
  45. package/dist/import-path.js +95 -0
  46. package/dist/import-path.js.map +1 -0
  47. package/dist/index.d.ts +29 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +21 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/metaobjects-config.d.ts +56 -0
  52. package/dist/metaobjects-config.d.ts.map +1 -0
  53. package/dist/metaobjects-config.js +42 -0
  54. package/dist/metaobjects-config.js.map +1 -0
  55. package/dist/naming.d.ts +29 -0
  56. package/dist/naming.d.ts.map +1 -0
  57. package/dist/naming.js +67 -0
  58. package/dist/naming.js.map +1 -0
  59. package/dist/overwrite-policy.d.ts +8 -0
  60. package/dist/overwrite-policy.d.ts.map +1 -0
  61. package/dist/overwrite-policy.js +23 -0
  62. package/dist/overwrite-policy.js.map +1 -0
  63. package/dist/pk-resolver.d.ts +18 -0
  64. package/dist/pk-resolver.d.ts.map +1 -0
  65. package/dist/pk-resolver.js +36 -0
  66. package/dist/pk-resolver.js.map +1 -0
  67. package/dist/projection/extract-view-spec.d.ts +18 -0
  68. package/dist/projection/extract-view-spec.d.ts.map +1 -0
  69. package/dist/projection/extract-view-spec.js +272 -0
  70. package/dist/projection/extract-view-spec.js.map +1 -0
  71. package/dist/projection/index.d.ts +5 -0
  72. package/dist/projection/index.d.ts.map +1 -0
  73. package/dist/projection/index.js +5 -0
  74. package/dist/projection/index.js.map +1 -0
  75. package/dist/projection/projection-detector.d.ts +4 -0
  76. package/dist/projection/projection-detector.d.ts.map +1 -0
  77. package/dist/projection/projection-detector.js +13 -0
  78. package/dist/projection/projection-detector.js.map +1 -0
  79. package/dist/projection/view-ddl-emit.d.ts +10 -0
  80. package/dist/projection/view-ddl-emit.d.ts.map +1 -0
  81. package/dist/projection/view-ddl-emit.js +47 -0
  82. package/dist/projection/view-ddl-emit.js.map +1 -0
  83. package/dist/projection/view-spec.d.ts +56 -0
  84. package/dist/projection/view-spec.d.ts.map +1 -0
  85. package/dist/projection/view-spec.js +2 -0
  86. package/dist/projection/view-spec.js.map +1 -0
  87. package/dist/relation-resolver.d.ts +21 -0
  88. package/dist/relation-resolver.d.ts.map +1 -0
  89. package/dist/relation-resolver.js +62 -0
  90. package/dist/relation-resolver.js.map +1 -0
  91. package/dist/render-context.d.ts +65 -0
  92. package/dist/render-context.d.ts.map +1 -0
  93. package/dist/render-context.js +28 -0
  94. package/dist/render-context.js.map +1 -0
  95. package/dist/runner.d.ts +17 -0
  96. package/dist/runner.d.ts.map +1 -0
  97. package/dist/runner.js +135 -0
  98. package/dist/runner.js.map +1 -0
  99. package/dist/templates/barrel.d.ts +8 -0
  100. package/dist/templates/barrel.d.ts.map +1 -0
  101. package/dist/templates/barrel.js +12 -0
  102. package/dist/templates/barrel.js.map +1 -0
  103. package/dist/templates/drizzle-schema.d.ts +13 -0
  104. package/dist/templates/drizzle-schema.d.ts.map +1 -0
  105. package/dist/templates/drizzle-schema.js +251 -0
  106. package/dist/templates/drizzle-schema.js.map +1 -0
  107. package/dist/templates/entity-constants.d.ts +4 -0
  108. package/dist/templates/entity-constants.d.ts.map +1 -0
  109. package/dist/templates/entity-constants.js +215 -0
  110. package/dist/templates/entity-constants.js.map +1 -0
  111. package/dist/templates/entity-file.d.ts +4 -0
  112. package/dist/templates/entity-file.d.ts.map +1 -0
  113. package/dist/templates/entity-file.js +45 -0
  114. package/dist/templates/entity-file.js.map +1 -0
  115. package/dist/templates/field-meta.d.ts +24 -0
  116. package/dist/templates/field-meta.d.ts.map +1 -0
  117. package/dist/templates/field-meta.js +117 -0
  118. package/dist/templates/field-meta.js.map +1 -0
  119. package/dist/templates/filter-allowlist.d.ts +5 -0
  120. package/dist/templates/filter-allowlist.d.ts.map +1 -0
  121. package/dist/templates/filter-allowlist.js +86 -0
  122. package/dist/templates/filter-allowlist.js.map +1 -0
  123. package/dist/templates/filter-shared.d.ts +15 -0
  124. package/dist/templates/filter-shared.d.ts.map +1 -0
  125. package/dist/templates/filter-shared.js +30 -0
  126. package/dist/templates/filter-shared.js.map +1 -0
  127. package/dist/templates/filter-type.d.ts +4 -0
  128. package/dist/templates/filter-type.d.ts.map +1 -0
  129. package/dist/templates/filter-type.js +78 -0
  130. package/dist/templates/filter-type.js.map +1 -0
  131. package/dist/templates/inferred-types.d.ts +4 -0
  132. package/dist/templates/inferred-types.d.ts.map +1 -0
  133. package/dist/templates/inferred-types.js +14 -0
  134. package/dist/templates/inferred-types.js.map +1 -0
  135. package/dist/templates/projection-decl.d.ts +21 -0
  136. package/dist/templates/projection-decl.d.ts.map +1 -0
  137. package/dist/templates/projection-decl.js +116 -0
  138. package/dist/templates/projection-decl.js.map +1 -0
  139. package/dist/templates/queries-file.d.ts +4 -0
  140. package/dist/templates/queries-file.d.ts.map +1 -0
  141. package/dist/templates/queries-file.js +39 -0
  142. package/dist/templates/queries-file.js.map +1 -0
  143. package/dist/templates/queries.d.ts +9 -0
  144. package/dist/templates/queries.d.ts.map +1 -0
  145. package/dist/templates/queries.js +115 -0
  146. package/dist/templates/queries.js.map +1 -0
  147. package/dist/templates/relations-block.d.ts +9 -0
  148. package/dist/templates/relations-block.d.ts.map +1 -0
  149. package/dist/templates/relations-block.js +45 -0
  150. package/dist/templates/relations-block.js.map +1 -0
  151. package/dist/templates/routes-file.d.ts +4 -0
  152. package/dist/templates/routes-file.d.ts.map +1 -0
  153. package/dist/templates/routes-file.js +158 -0
  154. package/dist/templates/routes-file.js.map +1 -0
  155. package/dist/templates/zod-validators.d.ts +4 -0
  156. package/dist/templates/zod-validators.d.ts.map +1 -0
  157. package/dist/templates/zod-validators.js +129 -0
  158. package/dist/templates/zod-validators.js.map +1 -0
  159. package/package.json +59 -0
  160. package/src/column-mapper.ts +266 -0
  161. package/src/constants.ts +10 -0
  162. package/src/errors.ts +10 -0
  163. package/src/format.ts +50 -0
  164. package/src/generator.ts +73 -0
  165. package/src/generators/barrel.ts +28 -0
  166. package/src/generators/entity-file.ts +33 -0
  167. package/src/generators/index.ts +4 -0
  168. package/src/generators/queries-file.ts +32 -0
  169. package/src/generators/routes-file.ts +36 -0
  170. package/src/import-path.ts +153 -0
  171. package/src/index.ts +45 -0
  172. package/src/metaobjects-config.ts +95 -0
  173. package/src/naming.ts +84 -0
  174. package/src/overwrite-policy.ts +39 -0
  175. package/src/pk-resolver.ts +47 -0
  176. package/src/projection/extract-view-spec.ts +372 -0
  177. package/src/projection/index.ts +4 -0
  178. package/src/projection/projection-detector.ts +26 -0
  179. package/src/projection/view-ddl-emit.ts +66 -0
  180. package/src/projection/view-spec.ts +62 -0
  181. package/src/relation-resolver.ts +87 -0
  182. package/src/render-context.ts +93 -0
  183. package/src/runner.ts +178 -0
  184. package/src/templates/barrel.ts +23 -0
  185. package/src/templates/drizzle-schema.ts +286 -0
  186. package/src/templates/entity-constants.ts +248 -0
  187. package/src/templates/entity-file.ts +51 -0
  188. package/src/templates/field-meta.ts +150 -0
  189. package/src/templates/filter-allowlist.ts +104 -0
  190. package/src/templates/filter-shared.ts +30 -0
  191. package/src/templates/filter-type.ts +93 -0
  192. package/src/templates/inferred-types.ts +16 -0
  193. package/src/templates/projection-decl.ts +146 -0
  194. package/src/templates/queries-file.ts +56 -0
  195. package/src/templates/queries.ts +132 -0
  196. package/src/templates/relations-block.ts +65 -0
  197. package/src/templates/routes-file.ts +179 -0
  198. package/src/templates/zod-validators.ts +140 -0
@@ -0,0 +1,266 @@
1
+ // Field-type → Drizzle column type mapping. Per design §6.
2
+ // Uses the typed MetaField.validators() accessor (effective — includes inherited) for all validator checks.
3
+
4
+ import type { MetaField } from "@metaobjectsdev/metadata";
5
+ import {
6
+ FIELD_SUBTYPE_STRING,
7
+ FIELD_SUBTYPE_INT,
8
+ FIELD_SUBTYPE_LONG,
9
+ FIELD_SUBTYPE_CURRENCY,
10
+ FIELD_SUBTYPE_DOUBLE,
11
+ FIELD_SUBTYPE_FLOAT,
12
+ FIELD_SUBTYPE_DECIMAL,
13
+ FIELD_SUBTYPE_BOOLEAN,
14
+ FIELD_SUBTYPE_DATE,
15
+ FIELD_SUBTYPE_TIME,
16
+ FIELD_SUBTYPE_TIMESTAMP,
17
+ FIELD_SUBTYPE_OBJECT,
18
+ FIELD_SUBTYPE_CLASS,
19
+ VALIDATOR_SUBTYPE_REQUIRED,
20
+ VALIDATOR_SUBTYPE_LENGTH,
21
+ FIELD_ATTR_MAX_LENGTH,
22
+ FIELD_ATTR_REQUIRED,
23
+ FIELD_ATTR_DB_COLUMN,
24
+ FIELD_ATTR_UNIQUE,
25
+ FIELD_ATTR_DEFAULT,
26
+ VALIDATOR_ATTR_MAX,
27
+ } from "@metaobjectsdev/metadata";
28
+ import { columnNameFromField } from "./naming.js";
29
+ import type { Dialect, ColumnNamingStrategy } from "./metaobjects-config.js";
30
+
31
+ export type { Dialect };
32
+
33
+ /**
34
+ * Discriminated union describing how a column default should be emitted.
35
+ * - { kind: "now" } — dialect-aware: sql`CURRENT_TIMESTAMP` (sqlite) or .defaultNow() (postgres)
36
+ * - { kind: "sqlExpr"; raw } — raw SQL expression wrapped in sql`...` (CURRENT_DATE, CURRENT_TIME, function calls)
37
+ * - { kind: "literal"; value } — .default(JSON.stringify(value))
38
+ */
39
+ export type DefaultExpr =
40
+ | { kind: "now" }
41
+ | { kind: "sqlExpr"; raw: string }
42
+ | { kind: "literal"; value: unknown };
43
+
44
+ /**
45
+ * Patterns recognized as SQL expressions in a default value. Anything matching
46
+ * these is treated as a SQL expression, not a string literal. Mirrors
47
+ * migrate-ts/src/expected-schema.ts's EXPR_DEFAULT_PATTERNS so both sides
48
+ * agree on what's an expression.
49
+ */
50
+ const SQL_EXPR_PATTERNS: RegExp[] = [
51
+ /^now$/i,
52
+ /^now\(\)$/i,
53
+ /^current_timestamp$/i,
54
+ /^current_date$/i,
55
+ /^current_time$/i,
56
+ /\(\)$/, // anything function-like
57
+ ];
58
+
59
+ /** True iff the value should be emitted as a SQL expression. */
60
+ function isSqlExprDefault(value: string): boolean {
61
+ return SQL_EXPR_PATTERNS.some((re) => re.test(value));
62
+ }
63
+
64
+ /** Map a recognized SQL expression to its canonical raw form (uppercase keywords). */
65
+ function canonicalizeSqlExpr(value: string): string {
66
+ const lower = value.toLowerCase();
67
+ if (lower === "now" || lower === "now()" || lower === "current_timestamp") {
68
+ return "CURRENT_TIMESTAMP";
69
+ }
70
+ if (lower === "current_date") return "CURRENT_DATE";
71
+ if (lower === "current_time") return "CURRENT_TIME";
72
+ return value; // unrecognized — pass through (function calls etc.)
73
+ }
74
+
75
+ export interface ColumnSpec {
76
+ /** Drizzle function name, e.g., "text", "integer", "varchar". */
77
+ fnName: string;
78
+ /** DB column name (snake_case from field name, or @dbColumn override). */
79
+ dbName: string;
80
+ /** Positional args after dbName (currently always empty; reserved). */
81
+ fnArgs: unknown[];
82
+ /** Object passed as second arg if non-empty (e.g., { length: 200 }, { mode: 'boolean' }). */
83
+ fnOptions?: Record<string, unknown>;
84
+ /** Method chain modifiers, e.g., [".notNull()", ".unique()"]. */
85
+ modifiers: string[];
86
+ /** Default expression for the column — dialect-specific emission handled by the template. */
87
+ defaultExpr?: DefaultExpr;
88
+ /** Drizzle import module: "drizzle-orm/sqlite-core" or "drizzle-orm/pg-core". */
89
+ importModule: string;
90
+ /** Optional leading line-comment for the generated column (e.g., type-fallback notice). */
91
+ leadingComment?: string;
92
+ }
93
+
94
+ /** Resolve max length from validator.length child or @maxLength attr.
95
+ * Uses field.validators() (effective) so inherited validators are seen. */
96
+ function getMaxLength(field: MetaField): number | undefined {
97
+ const lenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
98
+ if (typeof lenAttr === "number") return lenAttr;
99
+ for (const child of field.validators()) {
100
+ if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
101
+ const max = child.ownAttr(VALIDATOR_ATTR_MAX);
102
+ if (typeof max === "number") return max;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /** Check for validator.required child OR @required attr.
109
+ * Uses field.validators() (effective) so inherited validators are seen. */
110
+ function isRequired(field: MetaField): boolean {
111
+ if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
112
+ return field.validators().some((child) => child.subType === VALIDATOR_SUBTYPE_REQUIRED);
113
+ }
114
+
115
+ export function mapColumnType(
116
+ field: MetaField,
117
+ dialect: Dialect,
118
+ strategy: ColumnNamingStrategy = "snake_case",
119
+ ): ColumnSpec {
120
+ const dbName = (field.ownAttr(FIELD_ATTR_DB_COLUMN) as string | undefined) ?? columnNameFromField(field.name, strategy);
121
+ const importModule = dialect === "sqlite" ? "drizzle-orm/sqlite-core" : "drizzle-orm/pg-core";
122
+ const subType = field.subType;
123
+ const isArray = field.isArray;
124
+
125
+ let fnName: string;
126
+ let fnOptions: Record<string, unknown> | undefined;
127
+
128
+ let leadingComment: string | undefined;
129
+ if (dialect === "sqlite") {
130
+ if (isArray) {
131
+ // SQLite has no native array type; serialize as JSON in a text column.
132
+ fnName = "text";
133
+ fnOptions = { mode: "json" };
134
+ } else {
135
+ switch (subType) {
136
+ case FIELD_SUBTYPE_BOOLEAN:
137
+ fnName = "integer";
138
+ fnOptions = { mode: "boolean" };
139
+ break;
140
+ case FIELD_SUBTYPE_INT:
141
+ case FIELD_SUBTYPE_CURRENCY:
142
+ case FIELD_SUBTYPE_LONG:
143
+ fnName = "integer";
144
+ break;
145
+ case FIELD_SUBTYPE_DOUBLE:
146
+ case FIELD_SUBTYPE_FLOAT:
147
+ fnName = "real";
148
+ break;
149
+ case FIELD_SUBTYPE_DECIMAL:
150
+ fnName = "text";
151
+ // SQLite has no decimal type; the user must do precision math at the app
152
+ // layer or migrate to Postgres. Surface this in the generated file so it
153
+ // isn't a silent rounding hazard.
154
+ leadingComment = "TODO: SQLite has no decimal type; stored as text. Convert at the application boundary or migrate to Postgres for native numeric.";
155
+ break;
156
+ case FIELD_SUBTYPE_DATE:
157
+ case FIELD_SUBTYPE_TIME:
158
+ case FIELD_SUBTYPE_TIMESTAMP:
159
+ case FIELD_SUBTYPE_STRING:
160
+ case FIELD_SUBTYPE_CLASS:
161
+ case FIELD_SUBTYPE_OBJECT:
162
+ default:
163
+ fnName = "text";
164
+ break;
165
+ }
166
+ }
167
+ } else {
168
+ switch (subType) {
169
+ case FIELD_SUBTYPE_BOOLEAN:
170
+ fnName = "boolean";
171
+ break;
172
+ case FIELD_SUBTYPE_INT:
173
+ fnName = "integer";
174
+ break;
175
+ case FIELD_SUBTYPE_CURRENCY:
176
+ case FIELD_SUBTYPE_LONG:
177
+ fnName = "bigint";
178
+ fnOptions = { mode: "number" };
179
+ break;
180
+ case FIELD_SUBTYPE_DOUBLE:
181
+ fnName = "doublePrecision";
182
+ break;
183
+ case FIELD_SUBTYPE_FLOAT:
184
+ fnName = "real";
185
+ break;
186
+ case FIELD_SUBTYPE_DATE:
187
+ fnName = "date";
188
+ break;
189
+ case FIELD_SUBTYPE_TIME:
190
+ fnName = "time";
191
+ break;
192
+ case FIELD_SUBTYPE_TIMESTAMP:
193
+ fnName = "timestamp";
194
+ break;
195
+ case FIELD_SUBTYPE_DECIMAL:
196
+ fnName = "numeric";
197
+ fnOptions = { precision: 19, scale: 4 }; // sane default; @precision/@scale attrs override
198
+ break;
199
+ case FIELD_SUBTYPE_STRING: {
200
+ const maxLen = getMaxLength(field);
201
+ if (maxLen !== undefined) {
202
+ fnName = "varchar";
203
+ fnOptions = { length: maxLen };
204
+ } else {
205
+ fnName = "text";
206
+ }
207
+ break;
208
+ }
209
+ case FIELD_SUBTYPE_CLASS:
210
+ case FIELD_SUBTYPE_OBJECT:
211
+ default:
212
+ fnName = "text";
213
+ break;
214
+ }
215
+ }
216
+
217
+ const modifiers: string[] = [];
218
+
219
+ if (dialect === "postgres" && isArray) {
220
+ modifiers.push(".array()");
221
+ }
222
+
223
+ if (isRequired(field)) {
224
+ modifiers.push(".notNull()");
225
+ }
226
+
227
+ if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) {
228
+ modifiers.push(".unique()");
229
+ }
230
+
231
+ let defaultExpr: DefaultExpr | undefined;
232
+ const defaultAttr = field.ownAttr(FIELD_ATTR_DEFAULT);
233
+ if (defaultAttr !== undefined) {
234
+ // SQL-expression detection runs on the raw string value — a string like
235
+ // "CURRENT_TIMESTAMP" or "now" must be emitted as sql`...`, not a literal.
236
+ if (typeof defaultAttr === "string" && isSqlExprDefault(defaultAttr)) {
237
+ const canonical = canonicalizeSqlExpr(defaultAttr);
238
+ // "now"/"CURRENT_TIMESTAMP" gets the dialect-aware emit path (defaultNow for postgres);
239
+ // other SQL keywords go through the generic sqlExpr emit.
240
+ if (canonical === "CURRENT_TIMESTAMP") {
241
+ defaultExpr = { kind: "now" };
242
+ } else {
243
+ defaultExpr = { kind: "sqlExpr", raw: canonical };
244
+ }
245
+ } else {
246
+ // Literal branch: use the field-type-converted value so booleans/numbers
247
+ // are real JS booleans/numbers (not strings). field.defaultValue() applies
248
+ // convertToDataType(field.dataType, raw) — Java parity with getDefaultValue().
249
+ // JSON.stringify(false) → "false", JSON.stringify(0) → "0" (unquoted) in templates.
250
+ const typedValue = field.defaultValue() ?? defaultAttr;
251
+ defaultExpr = { kind: "literal", value: typedValue };
252
+ }
253
+ }
254
+
255
+ const result: ColumnSpec = {
256
+ fnName,
257
+ dbName,
258
+ fnArgs: [],
259
+ modifiers,
260
+ importModule,
261
+ };
262
+ if (fnOptions !== undefined) result.fnOptions = fnOptions;
263
+ if (defaultExpr !== undefined) result.defaultExpr = defaultExpr;
264
+ if (leadingComment !== undefined) result.leadingComment = leadingComment;
265
+ return result;
266
+ }
@@ -0,0 +1,10 @@
1
+ // Local constants for codegen-ts.
2
+
3
+ /** The marker that says "codegen owns this file" — drives the overwrite policy. */
4
+ export const GENERATED_HEADER = "@generated by @metaobjectsdev/codegen-ts";
5
+
6
+ /** Suffix for sibling user extension files (codegen never touches these). */
7
+ export const EXTRA_SUFFIX = ".extra";
8
+
9
+ /** Default outDir used by tests + as a sane default for generate(). */
10
+ export const DEFAULT_OUT_DIR = "./src/db/entities";
package/src/errors.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Typed errors for codegen-ts.
2
+
3
+ export class CodegenError extends Error {
4
+ readonly file?: string;
5
+ constructor(message: string, opts?: { file?: string }) {
6
+ super(message);
7
+ this.name = "CodegenError";
8
+ if (opts?.file !== undefined) this.file = opts.file;
9
+ }
10
+ }
package/src/format.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Biome formatter integration. Used by generate() to format rendered TS before write.
2
+ // Per design §11: format errors are warnings, not throws.
3
+
4
+ import { Biome, Distribution } from "@biomejs/js-api";
5
+
6
+ // Cache the Promise (not the resolved instance) so concurrent first calls
7
+ // share the same in-flight Biome.create() rather than racing to spawn two.
8
+ let _biomePromise: Promise<Biome> | undefined;
9
+
10
+ function getBiome(): Promise<Biome> {
11
+ if (!_biomePromise) {
12
+ _biomePromise = Biome.create({ distribution: Distribution.NODE }).then((biome) => {
13
+ biome.applyConfiguration({
14
+ formatter: { enabled: true, indentStyle: "space", indentWidth: 2 },
15
+ javascript: { formatter: { quoteStyle: "double", semicolons: "always" } },
16
+ });
17
+ return biome;
18
+ });
19
+ }
20
+ return _biomePromise;
21
+ }
22
+
23
+ /**
24
+ * Summarize Biome diagnostics into a single human-readable line, without
25
+ * dumping the raw diagnostic objects (which produce "[Object ...]" /
26
+ * "source: null" noise when stringified by console).
27
+ */
28
+ function summarizeDiagnostics(diagnostics: ReadonlyArray<unknown>): string {
29
+ const count = diagnostics.length;
30
+ const first = diagnostics[0] as { description?: string; message?: string } | undefined;
31
+ const firstMsg = first?.description ?? first?.message ?? "(no description)";
32
+ return count === 1
33
+ ? `1 diagnostic: ${firstMsg}`
34
+ : `${count} diagnostics; first: ${firstMsg}`;
35
+ }
36
+
37
+ export async function formatTs(content: string): Promise<string> {
38
+ try {
39
+ const biome = await getBiome();
40
+ const result = biome.formatContent(content, { filePath: "in-memory.ts" });
41
+ if (result.diagnostics.length > 0) {
42
+ console.warn(`[codegen-ts] Biome formatter: ${summarizeDiagnostics(result.diagnostics)}`);
43
+ return content;
44
+ }
45
+ return result.content;
46
+ } catch (err) {
47
+ console.warn(`[codegen-ts] Biome formatter error: ${(err as Error).message}`);
48
+ return content;
49
+ }
50
+ }
@@ -0,0 +1,73 @@
1
+ import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
2
+ import type { RenderContext } from "./render-context.js";
3
+ import type { ResolvedGenConfig } from "./metaobjects-config.js";
4
+
5
+ export interface EmittedFile {
6
+ /** Path relative to ResolvedGenConfig.outDir. */
7
+ path: string;
8
+ /** Final TypeScript source (formatted by the generator itself). */
9
+ content: string;
10
+ /** Set by the runner from generator.name — generators should not set this. */
11
+ generatedBy?: string;
12
+ }
13
+
14
+ export interface GenContext {
15
+ entities: MetaObject[];
16
+ loadedRoot: MetaRoot;
17
+ /** Pre-composed by the runner from generator.filter (returns true when no
18
+ * filter is set). Always call this from helpers; do not call generator.filter
19
+ * directly. */
20
+ matches: (entity: MetaObject) => boolean;
21
+ config: ResolvedGenConfig;
22
+ /** Pre-built by the runner for built-in generators that wrap existing
23
+ * templates. Third-party generators typically don't need this. Always
24
+ * present at run time when invoked via runGen(); optional in the type
25
+ * so tests and custom callers don't need a placeholder. */
26
+ renderContext?: RenderContext;
27
+ warn: (msg: string) => void;
28
+ }
29
+
30
+ export interface Generator {
31
+ /** kebab-case identifier; surfaces in diagnostics + drift logs. */
32
+ name: string;
33
+ /** Optional per-entity filter applied via ctx.matches inside generate(). */
34
+ filter?: (entity: MetaObject) => boolean;
35
+ generate: (ctx: GenContext) => EmittedFile[] | Promise<EmittedFile[]>;
36
+ /** Named output target (registry key). Defaults to "default". */
37
+ target?: string;
38
+ /** Marks the generator that produces entity modules — the runner uses its
39
+ * target as the entity-module target for cross-target import resolution. */
40
+ emitsEntityModule?: boolean;
41
+ }
42
+
43
+ export type GeneratorFactory<TOpts = void> = TOpts extends void
44
+ ? () => Generator
45
+ : (opts?: TOpts) => Generator;
46
+
47
+ /** One-file-per-entity convenience. Async-safe. */
48
+ export function perEntity(
49
+ fn: (entity: MetaObject, ctx: GenContext) =>
50
+ | EmittedFile
51
+ | EmittedFile[]
52
+ | Promise<EmittedFile | EmittedFile[]>,
53
+ ): (ctx: GenContext) => Promise<EmittedFile[]> {
54
+ return async (ctx) => {
55
+ const matched = ctx.entities.filter(ctx.matches);
56
+ const results = await Promise.all(matched.map((e) => fn(e, ctx)));
57
+ return results.flatMap((r) => (Array.isArray(r) ? r : [r]));
58
+ };
59
+ }
60
+
61
+ /** Called once with all matching entities. Use for barrels and cross-entity files. */
62
+ export function oncePerRun(
63
+ fn: (entities: MetaObject[], ctx: GenContext) =>
64
+ | EmittedFile
65
+ | EmittedFile[]
66
+ | Promise<EmittedFile | EmittedFile[]>,
67
+ ): (ctx: GenContext) => Promise<EmittedFile[]> {
68
+ return async (ctx) => {
69
+ const matched = ctx.entities.filter(ctx.matches);
70
+ const result = await fn(matched, ctx);
71
+ return Array.isArray(result) ? result : [result];
72
+ };
73
+ }
@@ -0,0 +1,28 @@
1
+ import { oncePerRun, type Generator, type GeneratorFactory } from "../generator.js";
2
+ import { renderBarrel } from "../templates/barrel.js";
3
+ import { formatTs } from "../format.js";
4
+
5
+ export interface BarrelOpts {
6
+ target?: string;
7
+ }
8
+
9
+ export const barrel = function barrel(opts?: BarrelOpts): Generator {
10
+ const generator: Generator = {
11
+ name: "barrel",
12
+ generate: oncePerRun(async (entities, ctx) => ({
13
+ path: "index.ts",
14
+ content: await formatTs(
15
+ renderBarrel(
16
+ entities.map((e) => ({ name: e.name, package: e.package })),
17
+ ctx.renderContext!.extStyle,
18
+ ctx.renderContext!.selfTarget,
19
+ ctx.renderContext!.entityModuleTarget,
20
+ ),
21
+ ),
22
+ })),
23
+ };
24
+ if (opts?.target) {
25
+ generator.target = opts.target;
26
+ }
27
+ return generator;
28
+ } as GeneratorFactory<BarrelOpts>;
@@ -0,0 +1,33 @@
1
+ import type { MetaObject } from "@metaobjectsdev/metadata";
2
+ import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
+ import { renderEntityFile } from "../templates/entity-file.js";
4
+ import { formatTs } from "../format.js";
5
+ import { entityOutputPath } from "../import-path.js";
6
+
7
+ export interface EntityFileOpts {
8
+ filter?: (entity: MetaObject) => boolean;
9
+ target?: string;
10
+ }
11
+
12
+ export const entityFile = function entityFile(opts?: EntityFileOpts): Generator {
13
+ const generator: Generator = {
14
+ name: "entity-file",
15
+ emitsEntityModule: true,
16
+ generate: perEntity(async (entity, ctx) => {
17
+ if (!ctx.renderContext) {
18
+ throw new Error("entity-file: renderContext is required (provided by runGen)");
19
+ }
20
+ return {
21
+ path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.ts`),
22
+ content: await formatTs(renderEntityFile(entity, ctx.renderContext)),
23
+ };
24
+ }),
25
+ };
26
+ if (opts?.filter) {
27
+ generator.filter = opts.filter;
28
+ }
29
+ if (opts?.target) {
30
+ generator.target = opts.target;
31
+ }
32
+ return generator;
33
+ } as GeneratorFactory<EntityFileOpts>;
@@ -0,0 +1,4 @@
1
+ export { entityFile, type EntityFileOpts } from "./entity-file.js";
2
+ export { queriesFile, type QueriesFileOpts } from "./queries-file.js";
3
+ export { routesFile, type RoutesFileOpts } from "./routes-file.js";
4
+ export { barrel, type BarrelOpts } from "./barrel.js";
@@ -0,0 +1,32 @@
1
+ import type { MetaObject } from "@metaobjectsdev/metadata";
2
+ import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
+ import { renderQueriesFile } from "../templates/queries-file.js";
4
+ import { formatTs } from "../format.js";
5
+ import { entityOutputPath } from "../import-path.js";
6
+
7
+ export interface QueriesFileOpts {
8
+ filter?: (entity: MetaObject) => boolean;
9
+ target?: string;
10
+ }
11
+
12
+ export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
13
+ const generator: Generator = {
14
+ name: "queries-file",
15
+ generate: perEntity(async (entity, ctx) => {
16
+ if (!ctx.renderContext) {
17
+ throw new Error("queries-file: renderContext is required (provided by runGen)");
18
+ }
19
+ return {
20
+ path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.queries.ts`),
21
+ content: await formatTs(renderQueriesFile(entity, ctx.renderContext)),
22
+ };
23
+ }),
24
+ };
25
+ if (opts?.filter) {
26
+ generator.filter = opts.filter;
27
+ }
28
+ if (opts?.target) {
29
+ generator.target = opts.target;
30
+ }
31
+ return generator;
32
+ } as GeneratorFactory<QueriesFileOpts>;
@@ -0,0 +1,36 @@
1
+ import type { MetaObject } from "@metaobjectsdev/metadata";
2
+ import { perEntity, type Generator, type GeneratorFactory } from "../generator.js";
3
+ import { renderRoutesFile } from "../templates/routes-file.js";
4
+ import { formatTs } from "../format.js";
5
+ import { entityOutputPath } from "../import-path.js";
6
+
7
+ export interface RoutesFileOpts {
8
+ filter?: (entity: MetaObject) => boolean;
9
+ target?: string;
10
+ }
11
+
12
+ /**
13
+ * Per-entity opt-out via `@emitRoutes: false` is honored. If the user supplies
14
+ * their own filter, both must pass (AND).
15
+ */
16
+ export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
17
+ const userFilter = opts?.filter ?? (() => true);
18
+ const generator: Generator = {
19
+ name: "routes-file",
20
+ // Always set: AND-composes metadata opt-out with optional user filter.
21
+ filter: (e: MetaObject) => e.ownAttr("emitRoutes") !== false && userFilter(e),
22
+ generate: perEntity(async (entity, ctx) => {
23
+ if (!ctx.renderContext) {
24
+ throw new Error("routes-file: renderContext is required (provided by runGen)");
25
+ }
26
+ return {
27
+ path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.routes.ts`),
28
+ content: await formatTs(renderRoutesFile(entity, ctx.renderContext)),
29
+ };
30
+ }),
31
+ };
32
+ if (opts?.target) {
33
+ generator.target = opts.target;
34
+ }
35
+ return generator;
36
+ } as GeneratorFactory<RoutesFileOpts>;