@metaobjectsdev/codegen-ts 0.6.0 → 0.7.0-rc.2

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 (99) hide show
  1. package/README.md +161 -4
  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 +73 -2
  5. package/dist/column-mapper.js.map +1 -1
  6. package/dist/generators/entity-file.d.ts +15 -0
  7. package/dist/generators/entity-file.d.ts.map +1 -1
  8. package/dist/generators/entity-file.js +2 -1
  9. package/dist/generators/entity-file.js.map +1 -1
  10. package/dist/generators/index.d.ts +2 -0
  11. package/dist/generators/index.d.ts.map +1 -1
  12. package/dist/generators/index.js +2 -0
  13. package/dist/generators/index.js.map +1 -1
  14. package/dist/generators/output-parser-file.d.ts +9 -0
  15. package/dist/generators/output-parser-file.d.ts.map +1 -0
  16. package/dist/generators/output-parser-file.js +37 -0
  17. package/dist/generators/output-parser-file.js.map +1 -0
  18. package/dist/generators/prompt-render-file.d.ts +9 -0
  19. package/dist/generators/prompt-render-file.d.ts.map +1 -0
  20. package/dist/generators/prompt-render-file.js +70 -0
  21. package/dist/generators/prompt-render-file.js.map +1 -0
  22. package/dist/generators/queries-file.d.ts +1 -1
  23. package/dist/generators/queries-file.d.ts.map +1 -1
  24. package/dist/generators/queries-file.js +11 -3
  25. package/dist/generators/queries-file.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/metaobjects-config.d.ts +3 -1
  31. package/dist/metaobjects-config.d.ts.map +1 -1
  32. package/dist/metaobjects-config.js +2 -1
  33. package/dist/metaobjects-config.js.map +1 -1
  34. package/dist/naming.d.ts +3 -12
  35. package/dist/naming.d.ts.map +1 -1
  36. package/dist/naming.js +14 -44
  37. package/dist/naming.js.map +1 -1
  38. package/dist/payload-codegen.d.ts +8 -0
  39. package/dist/payload-codegen.d.ts.map +1 -1
  40. package/dist/payload-codegen.js +33 -3
  41. package/dist/payload-codegen.js.map +1 -1
  42. package/dist/projection/extract-view-spec.d.ts +1 -1
  43. package/dist/projection/extract-view-spec.js +1 -1
  44. package/dist/source-detect.d.ts +10 -0
  45. package/dist/source-detect.d.ts.map +1 -0
  46. package/dist/source-detect.js +30 -0
  47. package/dist/source-detect.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/output-parser.d.ts +8 -0
  59. package/dist/templates/output-parser.d.ts.map +1 -0
  60. package/dist/templates/output-parser.js +129 -0
  61. package/dist/templates/output-parser.js.map +1 -0
  62. package/dist/templates/projection-decl.d.ts +1 -1
  63. package/dist/templates/projection-decl.js +1 -1
  64. package/dist/templates/queries-file.d.ts.map +1 -1
  65. package/dist/templates/queries-file.js +15 -4
  66. package/dist/templates/queries-file.js.map +1 -1
  67. package/dist/templates/queries.d.ts.map +1 -1
  68. package/dist/templates/queries.js +11 -30
  69. package/dist/templates/queries.js.map +1 -1
  70. package/dist/templates/value-object-file.d.ts +3 -0
  71. package/dist/templates/value-object-file.d.ts.map +1 -0
  72. package/dist/templates/value-object-file.js +27 -0
  73. package/dist/templates/value-object-file.js.map +1 -0
  74. package/dist/templates/zod-validators.d.ts +10 -0
  75. package/dist/templates/zod-validators.d.ts.map +1 -1
  76. package/dist/templates/zod-validators.js +108 -30
  77. package/dist/templates/zod-validators.js.map +1 -1
  78. package/package.json +4 -4
  79. package/src/column-mapper.ts +86 -1
  80. package/src/generators/entity-file.ts +17 -1
  81. package/src/generators/index.ts +2 -0
  82. package/src/generators/output-parser-file.ts +50 -0
  83. package/src/generators/prompt-render-file.ts +95 -0
  84. package/src/generators/queries-file.ts +13 -4
  85. package/src/index.ts +1 -1
  86. package/src/metaobjects-config.ts +4 -2
  87. package/src/naming.ts +22 -46
  88. package/src/payload-codegen.ts +34 -2
  89. package/src/projection/extract-view-spec.ts +1 -1
  90. package/src/source-detect.ts +28 -0
  91. package/src/templates/drizzle-schema.ts +27 -3
  92. package/src/templates/entity-file.ts +36 -5
  93. package/src/templates/inferred-types.ts +117 -3
  94. package/src/templates/output-parser.ts +143 -0
  95. package/src/templates/projection-decl.ts +1 -1
  96. package/src/templates/queries-file.ts +18 -4
  97. package/src/templates/queries.ts +11 -33
  98. package/src/templates/value-object-file.ts +30 -0
  99. package/src/templates/zod-validators.ts +121 -35
package/src/index.ts CHANGED
@@ -44,4 +44,4 @@ export { emitViewDdl } from "./projection/view-ddl-emit.js";
44
44
  export type { EmitOptions as ViewDdlEmitOptions } from "./projection/view-ddl-emit.js";
45
45
  export type { JoinNode, JoinTree, SelectColumn, SelectSpec, ViewSpec } from "./projection/view-spec.js";
46
46
  // Prompt construction (FR-004): typed payload + render-handle codegen.
47
- export { generatePayloadInterfaces, generateRenderHandle } from "./payload-codegen.js";
47
+ export { generatePayloadInterfaces, generatePayloadInterfacesBatch, generateRenderHandle } from "./payload-codegen.js";
@@ -1,9 +1,11 @@
1
+ import { DEFAULT_COLUMN_NAMING_STRATEGY, type ColumnNamingStrategy } from "@metaobjectsdev/metadata";
1
2
  import type { Generator } from "./generator.js";
2
3
  import type { ExtStyle } from "./render-context.js";
3
4
  import type { OutputLayout, ResolvedTarget } from "./import-path.js";
4
5
 
5
6
  export type Dialect = "sqlite" | "postgres";
6
- export type ColumnNamingStrategy = "snake_case" | "literal" | "kebab-case";
7
+ /** Re-exported from metadata so codegen-ts consumers see one canonical type. */
8
+ export type { ColumnNamingStrategy } from "@metaobjectsdev/metadata";
7
9
  export type { ExtStyle };
8
10
  export type { OutputLayout };
9
11
  export type { ResolvedTarget };
@@ -87,7 +89,7 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record<string, Res
87
89
  export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig {
88
90
  return {
89
91
  ...config,
90
- columnNamingStrategy: config.columnNamingStrategy ?? "snake_case",
92
+ columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
91
93
  apiPrefix: config.apiPrefix ?? "",
92
94
  outputLayout: config.outputLayout ?? "flat",
93
95
  targets: resolveTargets(config),
package/src/naming.ts CHANGED
@@ -1,32 +1,19 @@
1
1
  // Naming helpers — case conversion + pluralization for codegen output.
2
- // All functions are pure.
2
+ // All functions are pure. The strategy primitives (toSnakeCase, toKebabCase,
3
+ // applyColumnNamingStrategy, pluralize, DEFAULT_COLUMN_NAMING_STRATEGY) are
4
+ // re-exported from @metaobjectsdev/metadata so codegen + runtime + migrate
5
+ // share a single source of truth for how field/table names lower to columns.
3
6
 
4
- import type { ColumnNamingStrategy } from "./metaobjects-config.js";
7
+ import {
8
+ applyColumnNamingStrategy,
9
+ DEFAULT_COLUMN_NAMING_STRATEGY,
10
+ pluralize,
11
+ toKebabCase,
12
+ toSnakeCase,
13
+ type ColumnNamingStrategy,
14
+ } from "@metaobjectsdev/metadata";
5
15
 
6
- /**
7
- * Convert PascalCase or camelCase to snake_case.
8
- * Treats consecutive capitals (e.g., "APIKey") as a single word: "api_key".
9
- */
10
- export function toSnakeCase(s: string): string {
11
- return s
12
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
13
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
14
- .toLowerCase();
15
- }
16
-
17
- /** Convert PascalCase or camelCase to kebab-case. */
18
- function toKebabCase(s: string): string {
19
- return toSnakeCase(s).replace(/_/g, "-");
20
- }
21
-
22
- /** Apply a ColumnNamingStrategy to a name. */
23
- function applyStrategy(name: string, strategy: ColumnNamingStrategy): string {
24
- switch (strategy) {
25
- case "snake_case": return toSnakeCase(name);
26
- case "literal": return name;
27
- case "kebab-case": return toKebabCase(name);
28
- }
29
- }
16
+ export { pluralize, toSnakeCase } from "@metaobjectsdev/metadata";
30
17
 
31
18
  /**
32
19
  * Convert snake_case to camelCase. Preserves already-camelCase input.
@@ -42,31 +29,20 @@ export function toPascalCase(s: string): string {
42
29
  return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
43
30
  }
44
31
 
45
- /**
46
- * Simple English pluralization. Documented imperfection per design §13 #1:
47
- * irregular plurals (Person → Persons, not People) are not handled.
48
- * Users override via source[dbTable]@name in metadata.
49
- */
50
- export function pluralize(s: string): string {
51
- if (/(s|x|z|ch|sh)$/i.test(s)) return s + "es";
52
- if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
53
- return s + "s";
54
- }
55
-
56
32
  /** PascalCase entity → strategy-applied plural for DB table name. */
57
33
  export function tableNameFromEntity(
58
34
  entityName: string,
59
- strategy: ColumnNamingStrategy = "snake_case",
35
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
60
36
  ): string {
61
- return applyStrategy(pluralize(entityName), strategy);
37
+ return applyColumnNamingStrategy(pluralize(entityName), strategy);
62
38
  }
63
39
 
64
40
  /** camelCase or PascalCase field → strategy-applied DB column name. */
65
41
  export function columnNameFromField(
66
42
  fieldName: string,
67
- strategy: ColumnNamingStrategy = "snake_case",
43
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
68
44
  ): string {
69
- return applyStrategy(fieldName, strategy);
45
+ return applyColumnNamingStrategy(fieldName, strategy);
70
46
  }
71
47
 
72
48
  /**
@@ -78,14 +54,14 @@ export function viewNameFromProjection(
78
54
  projectionName: string,
79
55
  strategy: ColumnNamingStrategy,
80
56
  ): string {
81
- switch (strategy) {
82
- case "snake_case": return "v_" + toSnakeCase(projectionName);
83
- case "literal": return "v_" + projectionName;
84
- case "kebab-case": return "v-" + toKebabCase(projectionName);
85
- }
57
+ const sep = strategy === "kebab-case" ? "-" : "_";
58
+ return "v" + sep + applyColumnNamingStrategy(projectionName, strategy);
86
59
  }
87
60
 
88
61
  /** PascalCase entity → camelCase plural for the Drizzle table variable. */
89
62
  export function variableNameFromEntity(entityName: string): string {
90
63
  return pluralize(toCamelCase(entityName.charAt(0).toLowerCase() + entityName.slice(1)));
91
64
  }
65
+
66
+ // Re-exported here for callers that import from codegen-ts's naming module.
67
+ export { toKebabCase };
@@ -17,6 +17,7 @@ import {
17
17
  TYPE_TEMPLATE,
18
18
  FIELD_SUBTYPE_OBJECT,
19
19
  FIELD_ATTR_OBJECT_REF,
20
+ FIELD_ATTR_REQUIRED,
20
21
  TEMPLATE_ATTR_PAYLOAD_REF,
21
22
  TEMPLATE_ATTR_TEXT_REF,
22
23
  TEMPLATE_ATTR_FORMAT,
@@ -55,7 +56,13 @@ function fieldTsType(field: MetaData): { type: string; refVo?: string } {
55
56
  if (typeof ref === "string") result.refVo = ref;
56
57
  return result;
57
58
  }
58
- return { type: SCALAR_TS[field.subType] ?? "unknown" };
59
+ const scalar = SCALAR_TS[field.subType] ?? "unknown";
60
+ return { type: field.isArray ? `${scalar}[]` : scalar };
61
+ }
62
+
63
+ /** True iff the field's @required is explicitly set to true. */
64
+ function isFieldRequired(field: MetaData): boolean {
65
+ return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
59
66
  }
60
67
 
61
68
  function emitInterface(root: MetaData, voName: string, emitted: Set<string>, out: string[]): void {
@@ -67,7 +74,15 @@ function emitInterface(root: MetaData, voName: string, emitted: Set<string>, out
67
74
  const refs: string[] = [];
68
75
  for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) {
69
76
  const { type, refVo } = fieldTsType(f);
70
- lines.push(` ${f.name}: ${type};`);
77
+ // Required fields: `name: T;`
78
+ // Optional fields: `name?: T | null;` — the `| null` lets values from
79
+ // Drizzle entity rows (which return `null` for nullable columns) flow
80
+ // straight in. Without it, TS treats undefined-vs-null as a hard error
81
+ // at the entity → payload boundary.
82
+ const isRequired = isFieldRequired(f);
83
+ const tsType = isRequired ? type : `${type} | null`;
84
+ const optional = isRequired ? "" : "?";
85
+ lines.push(` ${f.name}${optional}: ${tsType};`);
71
86
  if (refVo) refs.push(refVo);
72
87
  }
73
88
  lines.push("}");
@@ -82,6 +97,23 @@ export function generatePayloadInterfaces(root: MetaData, voName: string): strin
82
97
  return out.join("\n\n") + "\n";
83
98
  }
84
99
 
100
+ /**
101
+ * Emit interfaces for several payloads at once, using a single shared dedupe
102
+ * set so nested types (e.g. lens projections referenced by multiple payloads)
103
+ * appear exactly once in the combined output.
104
+ *
105
+ * Returns the empty string when `voNames` is empty.
106
+ */
107
+ export function generatePayloadInterfacesBatch(root: MetaData, voNames: readonly string[]): string {
108
+ if (voNames.length === 0) return "";
109
+ const out: string[] = [];
110
+ const emitted = new Set<string>();
111
+ for (const name of voNames) {
112
+ emitInterface(root, name, emitted, out);
113
+ }
114
+ return out.length === 0 ? "" : out.join("\n\n") + "\n";
115
+ }
116
+
85
117
  function pascal(s: string): string {
86
118
  return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
87
119
  }
@@ -348,7 +348,7 @@ function buildGroupBy(spec: SelectSpec): string[] {
348
348
  * and extends a writable entity).
349
349
  * @param root The loader's MetaRoot — all top-level objects are
350
350
  * direct children of root (returned by `MetaDataLoader.load()`
351
- * / `FileMetaDataLoader.loadFiles()` as `result.root`).
351
+ * or `MetaDataLoader.fromDirectory()` as `result.root`).
352
352
  * @param ctx Column naming strategy for SQL identifiers.
353
353
  */
354
354
  export function extractViewSpec(
@@ -0,0 +1,28 @@
1
+ // Source-detect helpers — discriminate table-backed entities from value-only
2
+ // objects (and other in-memory / transit shapes) by inspecting source.* children.
3
+ //
4
+ // Used by the entity-file composer to pick a streamlined "value-only" emission
5
+ // path for metaobjects that declare no writable relational source. Pure
6
+ // metadata-driven, not a typeId discriminator: any object subtype can opt out
7
+ // of Drizzle table emission simply by omitting source.rdb.
8
+
9
+ import { MetaSource } from "@metaobjectsdev/metadata";
10
+ import { TYPE_SOURCE, SOURCE_SUBTYPE_RDB } from "@metaobjectsdev/metadata";
11
+ import type { MetaObject } from "@metaobjectsdev/metadata";
12
+
13
+ /**
14
+ * True when the entity declares at least one writable source.rdb child.
15
+ * Discriminates table-backed entities (full Drizzle file: table + Insert/Update
16
+ * schemas + filter allowlists + constants) from value-only objects (TS
17
+ * interface + Zod schema only). Absence of source.rdb means in-memory /
18
+ * transit data — no migration, no ORM table to point at.
19
+ */
20
+ export function hasWritableRdbSource(entity: MetaObject): boolean {
21
+ for (const child of entity.ownChildren()) {
22
+ if (child.type !== TYPE_SOURCE) continue;
23
+ if (child.subType !== SOURCE_SUBTYPE_RDB) continue;
24
+ if (!(child instanceof MetaSource)) continue;
25
+ if (child.isWritable()) return true;
26
+ }
27
+ return false;
28
+ }
@@ -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
 
@@ -1,9 +1,34 @@
1
1
  // Inferred types template — emits Drizzle's InferSelectModel / InferInsertModel type aliases,
2
2
  // plus named union types for field.enum fields.
3
+ //
4
+ // Also emits the structural TS interface for value-only objects (metaobjects
5
+ // with no writable source.rdb). That path side-steps Drizzle entirely: the
6
+ // interface is computed directly from the field tree, with `name?: T` for
7
+ // optional fields (matching Zod's `.optional()` inference — `T | undefined` —
8
+ // without the superfluous `| null` that Drizzle nullable columns introduce).
3
9
 
4
- import { code, imp, type Code } from "ts-poet";
5
- import type { MetaObject } from "@metaobjectsdev/metadata";
6
- import { FIELD_SUBTYPE_ENUM } from "@metaobjectsdev/metadata";
10
+ import { code, imp, joinCode, type Code } from "ts-poet";
11
+ import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
12
+ import {
13
+ FIELD_SUBTYPE_ENUM,
14
+ FIELD_SUBTYPE_OBJECT,
15
+ FIELD_SUBTYPE_STRING,
16
+ FIELD_SUBTYPE_INT,
17
+ FIELD_SUBTYPE_SHORT,
18
+ FIELD_SUBTYPE_BYTE,
19
+ FIELD_SUBTYPE_LONG,
20
+ FIELD_SUBTYPE_DOUBLE,
21
+ FIELD_SUBTYPE_FLOAT,
22
+ FIELD_SUBTYPE_DECIMAL,
23
+ FIELD_SUBTYPE_CURRENCY,
24
+ FIELD_SUBTYPE_BOOLEAN,
25
+ FIELD_SUBTYPE_DATE,
26
+ FIELD_SUBTYPE_TIME,
27
+ FIELD_SUBTYPE_TIMESTAMP,
28
+ FIELD_SUBTYPE_CLASS,
29
+ FIELD_ATTR_REQUIRED,
30
+ FIELD_ATTR_OBJECT_REF,
31
+ } from "@metaobjectsdev/metadata";
7
32
  import { variableNameFromEntity, toPascalCase } from "../naming.js";
8
33
  import { enumValues } from "../enum-meta.js";
9
34
  import { renderDocsFor } from "./jsdoc.js";
@@ -53,3 +78,92 @@ export function renderEnumTypeAliases(entity: MetaObject): Code | null {
53
78
 
54
79
  return lines.length > 0 ? code`${lines.join("\n")}` : null;
55
80
  }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Value-object interface emitter
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
87
+ [FIELD_SUBTYPE_STRING]: "string",
88
+ [FIELD_SUBTYPE_CLASS]: "string",
89
+ [FIELD_SUBTYPE_INT]: "number",
90
+ [FIELD_SUBTYPE_SHORT]: "number",
91
+ [FIELD_SUBTYPE_BYTE]: "number",
92
+ [FIELD_SUBTYPE_LONG]: "number",
93
+ [FIELD_SUBTYPE_DOUBLE]: "number",
94
+ [FIELD_SUBTYPE_FLOAT]: "number",
95
+ [FIELD_SUBTYPE_DECIMAL]: "number",
96
+ [FIELD_SUBTYPE_CURRENCY]: "number",
97
+ [FIELD_SUBTYPE_BOOLEAN]: "boolean",
98
+ [FIELD_SUBTYPE_DATE]: "string",
99
+ [FIELD_SUBTYPE_TIME]: "string",
100
+ [FIELD_SUBTYPE_TIMESTAMP]: "string",
101
+ };
102
+
103
+ /** Type-alias name for a field.enum, mirroring renderEnumTypeAliases. */
104
+ function enumTypeAliasName(entity: MetaObject, field: MetaField): string {
105
+ const superField = field.resolveSuper();
106
+ return superField !== undefined
107
+ ? toPascalCase(superField.name)
108
+ : `${entity.name}${toPascalCase(field.name)}`;
109
+ }
110
+
111
+ /**
112
+ * One-line TS type expression for a field on a value-only object.
113
+ * Returns a `Code` so cross-module `field.object` refs can be hoisted via
114
+ * ts-poet `imp(...)` — matching how the Zod emitter hoists `<Ref>InsertSchema`.
115
+ */
116
+ function valueObjectFieldType(entity: MetaObject, field: MetaField): Code {
117
+ // field.object: import the referenced TS interface from its sibling module
118
+ // so ts-poet hoists the import. Mirrors zod-validators.ts's `<Ref>InsertSchema`
119
+ // import strategy, just for the type alias instead of the schema constant.
120
+ if (field.subType === FIELD_SUBTYPE_OBJECT) {
121
+ const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
122
+ if (typeof ref === "string" && ref.length > 0) {
123
+ const refImp = imp(`${ref}@./${ref}.js`);
124
+ return field.isArray ? code`${refImp}[]` : code`${refImp}`;
125
+ }
126
+ return field.isArray ? code`unknown[]` : code`unknown`;
127
+ }
128
+
129
+ // field.enum: use the same type-alias name as renderEnumTypeAliases emits.
130
+ if (field.subType === FIELD_SUBTYPE_ENUM) {
131
+ const values = enumValues(field);
132
+ if (values !== undefined) {
133
+ const alias = enumTypeAliasName(entity, field);
134
+ return field.isArray ? code`${alias}[]` : code`${alias}`;
135
+ }
136
+ return field.isArray ? code`string[]` : code`string`;
137
+ }
138
+
139
+ const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown";
140
+ return field.isArray ? code`${scalar}[]` : code`${scalar}`;
141
+ }
142
+
143
+ /**
144
+ * Emit a structural `interface <Name> { ... }` for a value-only object.
145
+ *
146
+ * Optional fields use `name?: T` (matching the Zod `.optional()` inference
147
+ * `T | undefined`) instead of `name?: T | null`. Value objects never round-
148
+ * trip through Drizzle nullable columns, so the null-bridge is unnecessary
149
+ * here — and forces consumers into a residual cast at the call site.
150
+ */
151
+ export function renderValueObjectInterface(entity: MetaObject): Code {
152
+ const docs = renderDocsFor(entity);
153
+ const docsPrefix = docs ? `${docs}\n` : "";
154
+
155
+ const lines: Code[] = [];
156
+ for (const field of entity.fields()) {
157
+ const required = field.ownAttr(FIELD_ATTR_REQUIRED) === true;
158
+ const optional = required ? "" : "?";
159
+ const tsType = valueObjectFieldType(entity, field);
160
+ lines.push(code` ${field.name}${optional}: ${tsType};`);
161
+ }
162
+
163
+ // joinCode with "\n" interpolates each Code segment on its own line and
164
+ // keeps the imp() registrations intact so ts-poet hoists the imports.
165
+ return code`${docsPrefix}export interface ${entity.name} {
166
+ ${joinCode(lines, { on: "\n" })}
167
+ }
168
+ `;
169
+ }
@@ -0,0 +1,143 @@
1
+ // server/typescript/packages/codegen-ts/src/templates/output-parser.ts
2
+ //
3
+ // Per-template renderer for template.output codegen. Walks the @payloadRef's
4
+ // value-object into a Zod schema and emits a dual-API parser (parse + safeParse)
5
+ // alongside the schema. The emitted file is self-contained: it derives a
6
+ // local data type via `z.infer<typeof Schema>` and exports it as
7
+ // `<TemplateName>Data`. Consumers wiring `promptRender()` get a structurally
8
+ // identical payload-VO interface in `prompts.ts`; either type can be used
9
+ // interchangeably with parse results.
10
+
11
+ import {
12
+ type MetaData,
13
+ TYPE_OBJECT,
14
+ TYPE_FIELD,
15
+ TYPE_TEMPLATE,
16
+ TEMPLATE_SUBTYPE_OUTPUT,
17
+ FIELD_SUBTYPE_OBJECT,
18
+ FIELD_ATTR_OBJECT_REF,
19
+ TEMPLATE_ATTR_PAYLOAD_REF,
20
+ } from "@metaobjectsdev/metadata";
21
+
22
+ const SCALAR_ZOD: Record<string, string> = {
23
+ string: "z.string()",
24
+ class: "z.string()",
25
+ int: "z.number().int()",
26
+ short: "z.number().int()",
27
+ byte: "z.number().int()",
28
+ long: "z.number().int()",
29
+ double: "z.number()",
30
+ float: "z.number()",
31
+ boolean: "z.boolean()",
32
+ };
33
+
34
+ function findObject(root: MetaData, name: string): MetaData | undefined {
35
+ return root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
36
+ }
37
+
38
+ function findTemplate(root: MetaData, name: string): MetaData | undefined {
39
+ return root.ownChildren().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
40
+ }
41
+
42
+ /** Render the Zod expression for a single field; recurses on @objectRef. */
43
+ function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet<string>, depth: number): string {
44
+ // isArray is a native (reserved) property on MetaData, not an attr.
45
+ const isArray = field.isArray === true;
46
+ let base: string;
47
+ if (field.subType === FIELD_SUBTYPE_OBJECT) {
48
+ const refName = field.ownAttr(FIELD_ATTR_OBJECT_REF);
49
+ if (typeof refName !== "string") {
50
+ base = "z.unknown()";
51
+ } else if (seen.has(refName)) {
52
+ // Cycle guard — emit unknown for self-references (rare; lazy schemas not in scope for v1).
53
+ base = "z.unknown()";
54
+ } else {
55
+ const inner = findObject(root, refName);
56
+ base = inner ? renderObjectSchema(inner, root, new Set(seen).add(refName), depth + 1) : "z.unknown()";
57
+ }
58
+ } else {
59
+ base = SCALAR_ZOD[field.subType] ?? "z.unknown()";
60
+ }
61
+ return isArray ? `z.array(${base})` : base;
62
+ }
63
+
64
+ /** Render a `z.object({ ... })` for an object.value node.
65
+ * At depth 0 the schema starts at column 0 (consumer's `const Foo = z.object({`),
66
+ * so fields sit at 2 spaces and the closing `})` at 0 spaces — matching the
67
+ * surrounding `const NameSchema = ...` statement's indent. Nested schemas
68
+ * step in two spaces per depth level. */
69
+ function renderObjectSchema(vo: MetaData, root: MetaData, seen: ReadonlySet<string>, depth: number): string {
70
+ const fields = vo.children().filter((c) => c.type === TYPE_FIELD);
71
+ const fieldIndent = " ".repeat(depth + 1);
72
+ const closeIndent = " ".repeat(depth);
73
+ const lines = fields.map((f) => `${fieldIndent}${f.name}: ${fieldZod(f, root, seen, depth)},`);
74
+ return `z.object({\n${lines.join("\n")}\n${closeIndent}})`;
75
+ }
76
+
77
+ /**
78
+ * Render the full output-parser file for one `template.output` node.
79
+ * Throws if the template isn't found, isn't a template.output, or its
80
+ * @payloadRef doesn't resolve to an object.value.
81
+ */
82
+ export function renderOutputParser(root: MetaData, templateName: string): string {
83
+ const tmpl = findTemplate(root, templateName);
84
+ if (!tmpl) {
85
+ throw new Error(`template "${templateName}" not found in metadata root`);
86
+ }
87
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
88
+ throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
89
+ }
90
+ const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
91
+ if (typeof payloadRef !== "string") {
92
+ throw new Error(`template "${templateName}" missing @payloadRef`);
93
+ }
94
+ const vo = findObject(root, payloadRef);
95
+ if (!vo) {
96
+ throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
97
+ }
98
+
99
+ const schema = renderObjectSchema(vo, root, new Set([payloadRef]), 0);
100
+ const schemaName = `${templateName}Schema`;
101
+ const dataName = `${templateName}Data`;
102
+ const errorName = `${templateName}ValidationError`;
103
+ const parseName = `parse${templateName}`;
104
+ const safeParseName = `safeParse${templateName}`;
105
+
106
+ return `import { z } from "zod";
107
+
108
+ const ${schemaName} = ${schema};
109
+
110
+ export type ${dataName} = z.infer<typeof ${schemaName}>;
111
+ export type ${errorName} = z.ZodError;
112
+
113
+ /**
114
+ * Parse an LLM response into a typed ${dataName}.
115
+ * @throws ZodError on validation failure.
116
+ */
117
+ export function ${parseName}(text: string): ${dataName} {
118
+ return ${schemaName}.parse(JSON.parse(text));
119
+ }
120
+
121
+ /**
122
+ * Parse an LLM response with explicit error handling (Result-style).
123
+ * Does not throw on validation failure.
124
+ */
125
+ export function ${safeParseName}(
126
+ text: string,
127
+ ): { success: true; data: ${dataName} } | { success: false; error: ${errorName} } {
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(text);
131
+ } catch (err) {
132
+ return {
133
+ success: false,
134
+ error: new z.ZodError([{ code: "custom", path: [], message: \`invalid JSON: \${(err as Error).message}\` }]),
135
+ };
136
+ }
137
+ const result = ${schemaName}.safeParse(parsed);
138
+ return result.success
139
+ ? { success: true, data: result.data }
140
+ : { success: false, error: result.error };
141
+ }
142
+ `;
143
+ }
@@ -56,7 +56,7 @@ function pathFromProjectionName(name: string): string {
56
56
  *
57
57
  * @param projection The projection entity (has a source[dbView] child).
58
58
  * @param root The loader's root (all top-level objects as direct children,
59
- * from `MetaDataLoader.load()` / `FileMetaDataLoader.loadFiles()` as `result.root`).
59
+ * from `MetaDataLoader.load()` or `MetaDataLoader.fromDirectory()` as `result.root`).
60
60
  * @param opts Column naming strategy + dialect.
61
61
  */
62
62
  export function renderProjectionDecl(
@@ -4,7 +4,7 @@
4
4
  import { code, joinCode, type Code } from "ts-poet";
5
5
  import { MetaObject } from "@metaobjectsdev/metadata";
6
6
  import { type RenderContext } from "../render-context.js";
7
- import { entityModuleSpecifier, relativeModuleSpecifier } from "../import-path.js";
7
+ import { entityModuleSpecifier } from "../import-path.js";
8
8
  import {
9
9
  renderFindByIdFn,
10
10
  renderListFn,
@@ -26,13 +26,27 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
26
26
  entityName,
27
27
  ctx.extStyle,
28
28
  );
29
- const dbImportSpec = relativeModuleSpecifier(ctx.outputLayout, obj.package, ctx.dbImport);
30
29
  const varName = variableNameFromEntity(entityName);
31
30
 
32
- // Literal imports (db + entity types) live in a code block so they sort
31
+ // The persistence-context `db` is parameter-passed into every generated CRUD
32
+ // helper (ADR-0008). Emit the dialect-correct Drizzle type alias so the
33
+ // signatures `findXxx(db: Db, ...)` typecheck without the consumer importing
34
+ // anything to construct one. Consumers pass any compatible Drizzle instance.
35
+ const dbTypeImport =
36
+ ctx.dialect === "postgres"
37
+ ? `import type { NodePgDatabase } from "drizzle-orm/node-postgres";`
38
+ : `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
39
+ const dbTypeAlias =
40
+ ctx.dialect === "postgres"
41
+ ? `type Db = NodePgDatabase<Record<string, never>>;`
42
+ : `type Db = BaseSQLiteDatabase<"async", Record<string, never>>;`;
43
+
44
+ // Literal imports (Db type + entity types) live in a code block so they sort
33
45
  // alongside ts-poet's hoisted imp() imports at the top of the body.
34
46
  const literalImports = code`
35
- import { db } from ${JSON.stringify(dbImportSpec)};
47
+ ${dbTypeImport}
48
+ ${dbTypeAlias}
49
+
36
50
  import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
37
51
  `;
38
52