@metaobjectsdev/codegen-ts 0.8.1-rc.1 → 0.9.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 (97) hide show
  1. package/dist/column-mapper.d.ts.map +1 -1
  2. package/dist/column-mapper.js +123 -46
  3. package/dist/column-mapper.js.map +1 -1
  4. package/dist/generators/callable-file.d.ts +8 -0
  5. package/dist/generators/callable-file.d.ts.map +1 -0
  6. package/dist/generators/callable-file.js +32 -0
  7. package/dist/generators/callable-file.js.map +1 -0
  8. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  9. package/dist/generators/docs-data-builder.js +5 -2
  10. package/dist/generators/docs-data-builder.js.map +1 -1
  11. package/dist/generators/extractor-file.d.ts +9 -0
  12. package/dist/generators/extractor-file.d.ts.map +1 -0
  13. package/dist/generators/extractor-file.js +45 -0
  14. package/dist/generators/extractor-file.js.map +1 -0
  15. package/dist/generators/index.d.ts +3 -0
  16. package/dist/generators/index.d.ts.map +1 -1
  17. package/dist/generators/index.js +3 -0
  18. package/dist/generators/index.js.map +1 -1
  19. package/dist/generators/render-helper-file.d.ts +9 -0
  20. package/dist/generators/render-helper-file.d.ts.map +1 -0
  21. package/dist/generators/render-helper-file.js +58 -0
  22. package/dist/generators/render-helper-file.js.map +1 -0
  23. package/dist/payload-codegen.d.ts.map +1 -1
  24. package/dist/payload-codegen.js +42 -8
  25. package/dist/payload-codegen.js.map +1 -1
  26. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  27. package/dist/projection/extract-view-spec.js +11 -3
  28. package/dist/projection/extract-view-spec.js.map +1 -1
  29. package/dist/render-engine/framework-provider.d.ts +6 -5
  30. package/dist/render-engine/framework-provider.d.ts.map +1 -1
  31. package/dist/render-engine/framework-provider.js +53 -11
  32. package/dist/render-engine/framework-provider.js.map +1 -1
  33. package/dist/templates/callable-file.d.ts +8 -0
  34. package/dist/templates/callable-file.d.ts.map +1 -0
  35. package/dist/templates/callable-file.js +98 -0
  36. package/dist/templates/callable-file.js.map +1 -0
  37. package/dist/templates/extract-delegate-emitter.d.ts +42 -0
  38. package/dist/templates/extract-delegate-emitter.d.ts.map +1 -0
  39. package/dist/templates/extract-delegate-emitter.js +339 -0
  40. package/dist/templates/extract-delegate-emitter.js.map +1 -0
  41. package/dist/templates/{recover-schema-emitter.d.ts → extract-schema-emitter.d.ts} +2 -2
  42. package/dist/templates/extract-schema-emitter.d.ts.map +1 -0
  43. package/dist/templates/{recover-schema-emitter.js → extract-schema-emitter.js} +37 -20
  44. package/dist/templates/extract-schema-emitter.js.map +1 -0
  45. package/dist/templates/extractor.d.ts +9 -0
  46. package/dist/templates/extractor.d.ts.map +1 -0
  47. package/dist/templates/extractor.js +296 -0
  48. package/dist/templates/extractor.js.map +1 -0
  49. package/dist/templates/field-meta.d.ts.map +1 -1
  50. package/dist/templates/field-meta.js +2 -1
  51. package/dist/templates/field-meta.js.map +1 -1
  52. package/dist/templates/filter-type.d.ts.map +1 -1
  53. package/dist/templates/filter-type.js +8 -5
  54. package/dist/templates/filter-type.js.map +1 -1
  55. package/dist/templates/fr010-field-mapping.d.ts +22 -6
  56. package/dist/templates/fr010-field-mapping.d.ts.map +1 -1
  57. package/dist/templates/fr010-field-mapping.js +66 -21
  58. package/dist/templates/fr010-field-mapping.js.map +1 -1
  59. package/dist/templates/inferred-types.d.ts +15 -1
  60. package/dist/templates/inferred-types.d.ts.map +1 -1
  61. package/dist/templates/inferred-types.js +30 -17
  62. package/dist/templates/inferred-types.js.map +1 -1
  63. package/dist/templates/output-parser.d.ts.map +1 -1
  64. package/dist/templates/output-parser.js +98 -34
  65. package/dist/templates/output-parser.js.map +1 -1
  66. package/dist/templates/output-prompt.js +2 -2
  67. package/dist/templates/render-helper.d.ts +14 -0
  68. package/dist/templates/render-helper.d.ts.map +1 -0
  69. package/dist/templates/render-helper.js +180 -0
  70. package/dist/templates/render-helper.js.map +1 -0
  71. package/dist/templates/zod-validators.d.ts.map +1 -1
  72. package/dist/templates/zod-validators.js +59 -3
  73. package/dist/templates/zod-validators.js.map +1 -1
  74. package/package.json +10 -4
  75. package/src/column-mapper.ts +128 -45
  76. package/src/generators/callable-file.ts +44 -0
  77. package/src/generators/docs-data-builder.ts +5 -1
  78. package/src/generators/extractor-file.ts +57 -0
  79. package/src/generators/index.ts +3 -0
  80. package/src/generators/render-helper-file.ts +74 -0
  81. package/src/payload-codegen.ts +52 -7
  82. package/src/projection/extract-view-spec.ts +11 -3
  83. package/src/render-engine/framework-provider.ts +53 -16
  84. package/src/templates/callable-file.ts +122 -0
  85. package/src/templates/extract-delegate-emitter.ts +370 -0
  86. package/src/templates/{recover-schema-emitter.ts → extract-schema-emitter.ts} +39 -19
  87. package/src/templates/extractor.ts +333 -0
  88. package/src/templates/field-meta.ts +2 -0
  89. package/src/templates/filter-type.ts +7 -5
  90. package/src/templates/fr010-field-mapping.ts +71 -18
  91. package/src/templates/inferred-types.ts +32 -18
  92. package/src/templates/output-parser.ts +108 -35
  93. package/src/templates/output-prompt.ts +2 -2
  94. package/src/templates/render-helper.ts +244 -0
  95. package/src/templates/zod-validators.ts +51 -4
  96. package/dist/templates/recover-schema-emitter.d.ts.map +0 -1
  97. package/dist/templates/recover-schema-emitter.js.map +0 -1
@@ -21,10 +21,17 @@ import {
21
21
  } from "@metaobjectsdev/metadata";
22
22
  import {
23
23
  schemaLiteral,
24
- mirrorInterface,
25
24
  mirrorInitializer,
26
- } from "./recover-schema-emitter.js";
27
- import { recoverMapHelpersUsed } from "./fr010-field-mapping.js";
25
+ } from "./extract-schema-emitter.js";
26
+ import { extractMapHelpersUsed } from "./fr010-field-mapping.js";
27
+ import {
28
+ nestedMirrorInterfaces,
29
+ nestedMappers,
30
+ rootMapperName,
31
+ delegateHelpers,
32
+ usedHelpers,
33
+ hasNested,
34
+ } from "./extract-delegate-emitter.js";
28
35
 
29
36
  const SCALAR_ZOD: Record<string, string> = {
30
37
  string: "z.string()",
@@ -110,12 +117,12 @@ export function renderOutputParser(root: MetaData, templateName: string): string
110
117
  const parseName = `parse${templateName}`;
111
118
  const safeParseName = `safeParse${templateName}`;
112
119
 
113
- // FR-010: emit the tolerant recover() API alongside the strict Zod parser when the
120
+ // FR-010: emit the tolerant extract() API alongside the strict Zod parser when the
114
121
  // template targets json/xml. The @payloadRef already resolved to a value-object above,
115
- // so a RecoverSchema can always be baked. text-format outputs get no recover.
122
+ // so a ExtractSchema can always be baked. text-format outputs get no extract.
116
123
  const format = (tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text";
117
124
  const lc = format.toLowerCase();
118
- const emitRecover = lc === "json" || lc === "xml";
125
+ const emitExtractLenient = lc === "json" || lc === "xml";
119
126
 
120
127
  const strictBody = `const ${schemaName} = ${schema};
121
128
 
@@ -153,66 +160,132 @@ export function ${safeParseName}(
153
160
  }
154
161
  `;
155
162
 
156
- if (!emitRecover) {
163
+ if (!emitExtractLenient) {
157
164
  return `import { z } from "zod";\n\n${strictBody}`;
158
165
  }
159
166
 
160
- // ---- FR-010 tolerant recover block (json/xml only) ----
161
- const recoveredName = `${templateName}Recovered`;
162
- const recoverFnName = `recover${templateName}`;
163
- const tryRecoverName = `tryRecover${templateName}`;
164
- const schemaConstName = `${templateName}RecoverSchema`;
167
+ // ---- FR-010 tolerant extract block (json/xml only) ----
168
+ const extractedName = `${templateName}Extracted`;
169
+ const extractLenientFnName = `extractLenient${templateName}`;
170
+ const tryExtractLenientName = `tryExtractLenient${templateName}`;
171
+ const extractLenientWithName = `extractLenient${templateName}WithLoader`;
172
+ const schemaConstName = `${templateName}ExtractSchema`;
173
+ const payloadFqnConst = `${templateName.toUpperCase()}_PAYLOAD_NAME`;
174
+ const formatEnum = format.toLowerCase() === "xml" ? "Format.XML" : "Format.JSON";
165
175
  const schemaLit = schemaLiteral(vo, format, payloadRef);
166
- const mirrorDecl = mirrorInterface(vo, recoveredName);
167
176
  const initializer = mirrorInitializer(vo);
168
- const mapHelpers = recoverMapHelpersUsed(vo);
177
+ const mapHelpers = extractMapHelpersUsed(vo);
178
+
179
+ // The nullable mirror is shared by BOTH extract paths. Use the nested-aware emitter so the
180
+ // payload mirror's nested-object / array-of-object components are typed (not `unknown`), and
181
+ // so a mirror interface is emitted for every reachable nested value-object. The payload mirror
182
+ // keeps the canonical `<Template>Extracted` name (instead of `<PayloadVO>Extracted`) so the
183
+ // existing self-contained extract<Name>() initializer continues to satisfy it.
184
+ const mirrorDecls = nestedMirrorInterfaces(vo, root, extractedName);
185
+ const payloadHasNested = hasNested(vo, root);
169
186
 
170
- // Render-package imports the recover block needs. Only pull in the names the emitted
187
+ // Render-package imports the extract block needs. Only pull in the names the emitted
171
188
  // source actually references, so the file has no unused imports (tsc noUnusedLocals-safe).
172
- const renderImports = ["recover", "recoverSchema", "Format"];
189
+ const renderImports = ["extract", "extractSchema", "Format"];
173
190
  if (schemaLit.includes("scalar(")) renderImports.push("scalar");
174
191
  if (schemaLit.includes("enumField(")) renderImports.push("enumField");
175
192
  if (schemaLit.includes("FieldKind.")) renderImports.push("FieldKind");
176
- renderImports.push("type RecoverSchema", "type RecoverOptions", "type RecoveryResult");
193
+ renderImports.push("type ExtractSchema", "type ExtractOptions", "type ExtractionResult");
177
194
  renderImports.push(...mapHelpers);
178
195
 
179
- const recoverBody = `/** Baked recover descriptor for the ${templateName} output. */
180
- const ${schemaConstName}: RecoverSchema = ${schemaLit};
196
+ const selfContained = `/** Baked extract descriptor for the ${templateName} output. */
197
+ const ${schemaConstName}: ExtractSchema = ${schemaLit};
181
198
 
182
- ${mirrorDecl}
199
+ ${mirrorDecls}
183
200
 
184
201
  /**
185
- * Tolerant best-effort recovery of a dirty LLM response; never throws. Returns a
186
- * nullable mirror (\`${recoveredName}\`) with fields null where lost/malformed,
187
- * plus the per-field recovery report.
202
+ * Self-contained tolerant best-effort extraction of a dirty LLM response; never throws.
203
+ * Returns a nullable mirror (\`${extractedName}\`) with fields null where lost/malformed,
204
+ * plus the per-field extraction report. Does NOT populate nested-object / array-of-object
205
+ * components (those stay null — the historical FR-010 gap). For full nested extraction, use
206
+ * \`${extractLenientWithName}(root, text)\`, which delegates to the runtime extract.
188
207
  */
189
- export function ${recoverFnName}(
208
+ export function ${extractLenientFnName}(
190
209
  text: string,
191
- opts?: RecoverOptions,
192
- ): RecoveryResult<${recoveredName}> {
193
- const outcome = recover(text, ${schemaConstName}, opts);
210
+ opts?: ExtractOptions,
211
+ ): ExtractionResult<${extractedName}> {
212
+ const outcome = extract(text, ${schemaConstName}, opts);
194
213
  const d = outcome.data;
195
- const data: ${recoveredName} = ${initializer};
214
+ const data: ${extractedName} = ${initializer};
196
215
  return { data, report: outcome.report };
197
216
  }
198
217
 
199
218
  /**
200
- * Recovery as a bool gate: \`true\` when the response was non-empty and no required
201
- * field was lost. On success, \`result\` carries the recovered mirror + report.
219
+ * Extraction as a bool gate: \`true\` when the response was non-empty and no required
220
+ * field was lost. On success, \`result\` carries the extracted mirror + report.
202
221
  */
203
- export function ${tryRecoverName}(
222
+ export function ${tryExtractLenientName}(
204
223
  text: string,
205
- ): { ok: boolean; result: RecoveryResult<${recoveredName}> } {
206
- const result = ${recoverFnName}(text);
224
+ ): { ok: boolean; result: ExtractionResult<${extractedName}> } {
225
+ const result = ${extractLenientFnName}(text);
207
226
  const ok = !result.report.isEmpty() && !result.report.hasLostRequired();
208
227
  return { ok, result };
209
228
  }
210
229
  `;
211
230
 
231
+ // ---- Runtime-delegating extract (closes the nested gap) ----
232
+ // Resolves this payload's MetaObject from a loaded MetaRoot by its baked simple name and
233
+ // delegates to extractObject() in @metaobjectsdev/runtime-ts, which assembles the FULL nested
234
+ // object graph reflection-free. The assembled ValueObject graph is then mapped into the typed
235
+ // nullable mirror graph by the generated from<VO>Extracted mappers. Codegen-wrapping-runtime
236
+ // (a generated DAO calling the dynamic-metadata runtime) — mirrors the Java/Kotlin pilots.
237
+ //
238
+ // The baked PAYLOAD_NAME is the resolved payload VO's SIMPLE name (root.findObject matches on
239
+ // the object's `name`, not its FQN). The root mapper is named for the TEMPLATE (so it returns
240
+ // the canonically-named `<Template>Extracted` mirror); nested mappers use their VO names.
241
+ const payloadName = vo.name;
242
+ const rootMapper = rootMapperName(templateName);
243
+ const delegating = `
244
+ /** Payload value-object name this parser extracts — resolved against a loaded MetaRoot at runtime. */
245
+ export const ${payloadFqnConst} = ${JSON.stringify(payloadName)};
246
+
247
+ ${nestedMappers(vo, root, rootMapper, extractedName)}
248
+
249
+ ${delegateHelpers(usedHelpers(vo, root))}
250
+
251
+ /**
252
+ * Runtime-delegating tolerant extraction; never throws. Unlike \`${extractLenientFnName}(text)\`, this FULLY
253
+ * populates nested-object and array-of-object components by delegating to the metadata-driven
254
+ * runtime \`extractObject\` (which assembles the whole graph reflection-free via the Phase A object
255
+ * model), then maps the assembled graph into the typed \`${extractedName}\` mirror.
256
+ *
257
+ * @param root a loaded MetaRoot (e.g. \`(await new MetaDataLoader().load(...)).root\`) that declares
258
+ * the \`${payloadRef}\` value-object.
259
+ */
260
+ export function ${extractLenientWithName}(
261
+ root: MetaRoot,
262
+ text: string,
263
+ opts?: Partial<ExtractOptions> | null,
264
+ ): ExtractionResult<${extractedName}> {
265
+ const mo = root.findObject(${payloadFqnConst});
266
+ if (mo === undefined) {
267
+ throw new Error(\`${extractLenientWithName}: payload "\${${payloadFqnConst}}" not found in the supplied MetaRoot\`);
268
+ }
269
+ const outcome = extractObject(mo, text, ${formatEnum}, opts);
270
+ return { data: ${rootMapper}(outcome.data), report: outcome.report };
271
+ }
272
+ `;
273
+
274
+ // The delegating overload needs runtime-ts (extractObject) + the MetaRoot type from metadata.
275
+ // It is always emitted (the gap-closing path), regardless of whether THIS payload has nested
276
+ // fields — a flat payload still benefits from the loader-driven path and keeps the API uniform.
277
+ void payloadHasNested;
278
+ const metadataImport = `import type { MetaRoot } from "@metaobjectsdev/metadata";\n`;
279
+ const runtimeImport = `import { extractObject } from "@metaobjectsdev/runtime-ts";\n`;
280
+
212
281
  return (
213
282
  `import { z } from "zod";\n` +
214
- `import {\n ${renderImports.join(",\n ")},\n} from "@metaobjectsdev/render";\n\n` +
283
+ `import {\n ${renderImports.join(",\n ")},\n} from "@metaobjectsdev/render";\n` +
284
+ metadataImport +
285
+ runtimeImport +
286
+ `\n` +
215
287
  `${strictBody}\n` +
216
- `${recoverBody}`
288
+ `${selfContained}\n` +
289
+ `${delegating}`
217
290
  );
218
291
  }
@@ -5,7 +5,7 @@
5
5
  // @payloadRef resolves to a value-object, emits a `<TemplateName>.prompt.ts` file
6
6
  // exporting `render<TemplateName>Format(overrides?)` backed by the render engine's
7
7
  // renderOutputFormat(). The baked OutputFormatSpec's rootName is the payload name,
8
- // so the prompt fragment and the recover() codegen agree on the root name.
8
+ // so the prompt fragment and the extract() codegen agree on the root name.
9
9
  //
10
10
  // Mirrors the C# OutputPromptGenerator + OutputFormatSpecEmitter (split into a
11
11
  // generator factory + this pure renderer, matching the TS output-parser shape).
@@ -59,7 +59,7 @@ export function renderOutputPrompt(root: MetaData, templateName: string): string
59
59
  throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
60
60
  }
61
61
 
62
- // rootName == payload name so the prompt fragment and recover() agree.
62
+ // rootName == payload name so the prompt fragment and extract() agree.
63
63
  const spec = specLiteral(vo, tmpl, payloadRef);
64
64
  const specName = `${templateName}FormatSpec`;
65
65
  const fnName = `render${templateName}Format`;
@@ -0,0 +1,244 @@
1
+ // server/typescript/packages/codegen-ts/src/templates/render-helper.ts
2
+ //
3
+ // Per-template.output render helper: emits a typed `render<Name>(payload, provider)`
4
+ // that wraps the EXISTING render() engine, AND enforces the mustache↔payload-VO
5
+ // drift check (the EXISTING verify() engine) at BUILD time.
6
+ //
7
+ // Two shapes, keyed off @kind:
8
+ // • document (default) → renders @textRef in @format → one string.
9
+ // • email → renders @subjectRef + @htmlBodyRef (+ optional
10
+ // @textBodyRef) → a structured EmailDocument.
11
+ //
12
+ // The headline is the BUILD-TIME drift gate: BEFORE emitting, every referenced
13
+ // mustache is resolved via the codegen-time provider and verify()'d against the
14
+ // payload field tree. If a referenced text is unresolvable OR carries any
15
+ // NON-warning verify error (e.g. `{{field}}` not on the payload VO), this THROWS
16
+ // and FAILS codegen — naming the template, the ref, the error code, and the
17
+ // offending field. This is what makes the build fail when a mustache references
18
+ // a field the payload VO doesn't declare.
19
+ //
20
+ // Reuse, not reimplementation:
21
+ // • render() — the runtime helper delegates to it (emitted call).
22
+ // • verify() — run here at build time for the drift gate.
23
+ // • derivePayloadFieldTree (replicated minimally below — codegen-ts must NOT
24
+ // depend on the cli package; the walk is the same as cli's payload-field-tree).
25
+ // • PayloadField — the verify() field-tree shape; serialized into the emitted
26
+ // `verify:` literal so render()'s runtime drift check runs too.
27
+
28
+ import {
29
+ type MetaData,
30
+ TYPE_OBJECT,
31
+ TYPE_FIELD,
32
+ TYPE_TEMPLATE,
33
+ FIELD_SUBTYPE_OBJECT,
34
+ FIELD_ATTR_OBJECT_REF,
35
+ TEMPLATE_SUBTYPE_OUTPUT,
36
+ TEMPLATE_ATTR_PAYLOAD_REF,
37
+ TEMPLATE_ATTR_TEXT_REF,
38
+ TEMPLATE_ATTR_FORMAT,
39
+ TEMPLATE_ATTR_MAX_CHARS,
40
+ TEMPLATE_ATTR_KIND,
41
+ TEMPLATE_KIND_EMAIL,
42
+ TEMPLATE_KIND_DEFAULT,
43
+ TEMPLATE_ATTR_SUBJECT_REF,
44
+ TEMPLATE_ATTR_HTML_BODY_REF,
45
+ TEMPLATE_ATTR_TEXT_BODY_REF,
46
+ } from "@metaobjectsdev/metadata";
47
+ import {
48
+ verify,
49
+ ERR_REQUIRED_SLOT_UNUSED,
50
+ type Provider,
51
+ type PayloadField,
52
+ type VerifyError,
53
+ } from "@metaobjectsdev/render";
54
+
55
+ function findObject(root: MetaData, name: string): MetaData | undefined {
56
+ return root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
57
+ }
58
+
59
+ function findTemplate(root: MetaData, name: string): MetaData | undefined {
60
+ return root.ownChildren().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
61
+ }
62
+
63
+ /**
64
+ * Walk an `object.value` view-object into a render `PayloadField[]`. Object-ref
65
+ * fields recurse into their referenced view-object; a `seen` set guards a
66
+ * (pathological) reference cycle. Replicates cli's `derivePayloadFieldTree` —
67
+ * codegen-ts must not depend on the cli package (wrong layer / would cycle), so
68
+ * the same small walk lives here against the metadata constants codegen-ts
69
+ * already imports.
70
+ */
71
+ function derivePayloadFieldTree(
72
+ root: MetaData,
73
+ voName: string,
74
+ seen: ReadonlySet<string> = new Set(),
75
+ ): PayloadField[] {
76
+ if (seen.has(voName)) return [];
77
+ const vo = findObject(root, voName);
78
+ if (!vo) return [];
79
+ const nextSeen = new Set(seen).add(voName);
80
+ const fields: PayloadField[] = [];
81
+ for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) {
82
+ if (f.subType === FIELD_SUBTYPE_OBJECT) {
83
+ const ref = f.ownAttr(FIELD_ATTR_OBJECT_REF);
84
+ if (typeof ref === "string") {
85
+ fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, nextSeen) });
86
+ continue;
87
+ }
88
+ }
89
+ fields.push({ name: f.name });
90
+ }
91
+ return fields;
92
+ }
93
+
94
+ /** Serialize a PayloadField[] as a stable, deterministic TS array literal so the
95
+ * emitted render() call runs the same runtime drift check the build gate ran. */
96
+ function fieldTreeLiteral(fields: PayloadField[]): string {
97
+ // JSON.stringify is deterministic for this shape (no functions/cycles by
98
+ // construction) and produces valid TS object/array literal syntax.
99
+ return JSON.stringify(fields);
100
+ }
101
+
102
+ /** Run the build-time drift gate for one referenced mustache. Throws (fails
103
+ * codegen) when the ref is unresolvable OR verify() reports a non-warning error.
104
+ * Warnings (ERR_REQUIRED_SLOT_UNUSED) are tolerated. */
105
+ function gateRef(
106
+ templateName: string,
107
+ ref: string,
108
+ provider: Provider,
109
+ fields: PayloadField[],
110
+ ): void {
111
+ const text = provider.resolve(ref);
112
+ if (text === undefined) {
113
+ throw new Error(
114
+ `render-helper drift: template "${templateName}" ref "${ref}" — unresolved (provider returned no text)`,
115
+ );
116
+ }
117
+ const errors: VerifyError[] = verify(text, fields, { provider }).filter(
118
+ (e) => e.code !== ERR_REQUIRED_SLOT_UNUSED,
119
+ );
120
+ if (errors.length > 0) {
121
+ const e = errors[0]!;
122
+ throw new Error(
123
+ `render-helper drift: template "${templateName}" ref "${ref}" — ${e.code}: {{${e.path}}} not on payload VO`,
124
+ );
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Render the per-template render-helper source for one `template.output`, AND
130
+ * run the build-time drift gate first. Throws if: the template isn't found /
131
+ * isn't a template.output; @payloadRef is missing or doesn't resolve to an
132
+ * object.value; a required ref for the kind is missing; or ANY referenced
133
+ * mustache drifts from the payload VO (the headline build gate).
134
+ *
135
+ * @param provider the codegen-time provider (e.g. projectProvider(projectRoot))
136
+ * used to resolve + verify each referenced mustache at build time.
137
+ */
138
+ export function renderRenderHelper(
139
+ root: MetaData,
140
+ templateName: string,
141
+ provider: Provider,
142
+ ): string {
143
+ const tmpl = findTemplate(root, templateName);
144
+ if (!tmpl) {
145
+ throw new Error(`template "${templateName}" not found in metadata root`);
146
+ }
147
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
148
+ throw new Error(
149
+ `template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`,
150
+ );
151
+ }
152
+ const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
153
+ if (typeof payloadRef !== "string") {
154
+ throw new Error(`template "${templateName}" missing @payloadRef`);
155
+ }
156
+ const vo = findObject(root, payloadRef);
157
+ if (!vo) {
158
+ throw new Error(
159
+ `template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`,
160
+ );
161
+ }
162
+
163
+ const fields = derivePayloadFieldTree(root, payloadRef);
164
+ const ft = fieldTreeLiteral(fields);
165
+ const fnName = `render${templateName}`;
166
+
167
+ const kind = ((tmpl.ownAttr(TEMPLATE_ATTR_KIND) as string | undefined) ?? TEMPLATE_KIND_DEFAULT)
168
+ .toLowerCase();
169
+
170
+ if (kind === TEMPLATE_KIND_EMAIL) {
171
+ const subjectRef = tmpl.ownAttr(TEMPLATE_ATTR_SUBJECT_REF);
172
+ const htmlBodyRef = tmpl.ownAttr(TEMPLATE_ATTR_HTML_BODY_REF);
173
+ const textBodyRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_BODY_REF);
174
+ if (typeof subjectRef !== "string") {
175
+ throw new Error(`template "${templateName}" (email) missing @subjectRef`);
176
+ }
177
+ if (typeof htmlBodyRef !== "string") {
178
+ throw new Error(`template "${templateName}" (email) missing @htmlBodyRef`);
179
+ }
180
+
181
+ // BUILD-TIME drift gate — every email part-ref is resolved + verified.
182
+ gateRef(templateName, subjectRef, provider, fields);
183
+ gateRef(templateName, htmlBodyRef, provider, fields);
184
+ if (typeof textBodyRef === "string") {
185
+ gateRef(templateName, textBodyRef, provider, fields);
186
+ }
187
+
188
+ const textBodyLine =
189
+ typeof textBodyRef === "string"
190
+ ? `\n textBody: render({ ref: ${JSON.stringify(textBodyRef)}, payload, format: "text", provider, verify: ${ft} }),`
191
+ : "";
192
+
193
+ return `import { render } from "@metaobjectsdev/render";
194
+ import type { Provider, EmailDocument } from "@metaobjectsdev/render";
195
+ import type { ${payloadRef} } from "./payloads.js";
196
+
197
+ /**
198
+ * Render the ${templateName} email (subject + html body${typeof textBodyRef === "string" ? " + text body" : ""}) from a
199
+ * typed ${payloadRef} payload. Wraps the render() engine; the payload field tree is
200
+ * baked in so render()'s runtime drift check matches the build-time gate.
201
+ */
202
+ export function ${fnName}(payload: ${payloadRef}, provider: Provider): EmailDocument {
203
+ return {
204
+ subject: render({ ref: ${JSON.stringify(subjectRef)}, payload, format: "text", provider, verify: ${ft} }),
205
+ htmlBody: render({ ref: ${JSON.stringify(htmlBodyRef)}, payload, format: "html", provider, verify: ${ft} }),${textBodyLine}
206
+ };
207
+ }
208
+ `;
209
+ }
210
+
211
+ // --- document kind ---
212
+ const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
213
+ if (typeof textRef !== "string") {
214
+ throw new Error(`template "${templateName}" (document) missing @textRef`);
215
+ }
216
+ const format = ((tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
217
+ const maxCharsAttr = tmpl.ownAttr(TEMPLATE_ATTR_MAX_CHARS);
218
+ const maxChars =
219
+ typeof maxCharsAttr === "number"
220
+ ? maxCharsAttr
221
+ : typeof maxCharsAttr === "string" && maxCharsAttr.trim() !== ""
222
+ ? Number(maxCharsAttr)
223
+ : undefined;
224
+
225
+ // BUILD-TIME drift gate.
226
+ gateRef(templateName, textRef, provider, fields);
227
+
228
+ const maxCharsArg =
229
+ maxChars !== undefined && Number.isFinite(maxChars) ? `, maxChars: ${maxChars}` : "";
230
+
231
+ return `import { render } from "@metaobjectsdev/render";
232
+ import type { Provider } from "@metaobjectsdev/render";
233
+ import type { ${payloadRef} } from "./payloads.js";
234
+
235
+ /**
236
+ * Render the ${templateName} document from a typed ${payloadRef} payload. Wraps the
237
+ * render() engine; the payload field tree is baked in so render()'s runtime drift
238
+ * check matches the build-time gate enforced when this file was generated.
239
+ */
240
+ export function ${fnName}(payload: ${payloadRef}, provider: Provider): string {
241
+ return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider, verify: ${ft}${maxCharsArg} });
242
+ }
243
+ `;
244
+ }
@@ -15,11 +15,13 @@ import {
15
15
  FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
16
16
  FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
17
17
  FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
18
- FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT,
18
+ FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_UUID,
19
19
  VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
20
+ VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY,
20
21
  IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
21
22
  FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT,
22
- FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
23
+ FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_READ_ONLY,
24
+ AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE,
23
25
  VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN,
24
26
  GENERATION_INCREMENT, GENERATION_UUID,
25
27
  } from "@metaobjectsdev/metadata";
@@ -57,6 +59,10 @@ export function renderInsertSchemaOnly(obj: MetaObject): Code {
57
59
  const insertFieldLines: Code[] = [];
58
60
  for (const child of obj.fields()) {
59
61
  if (autoGenPkFields.has(child.name)) continue;
62
+ // FR-013: @readOnly fields are populated by DB / replication / external
63
+ // owner; the application has no path to write them. Exclude from the
64
+ // create-shape schema entirely.
65
+ if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue;
60
66
 
61
67
  const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
62
68
 
@@ -88,6 +94,11 @@ export function renderZodValidators(obj: MetaObject): Code {
88
94
  const updateFieldLines: Code[] = [];
89
95
  for (const child of obj.fields()) {
90
96
  if (autoGenPkFields.has(child.name)) continue;
97
+ // FR-013: @readOnly fields appear in neither InsertSchema nor UpdateSchema.
98
+ // The DB / trigger / replication owns the write path; the app must not
99
+ // pass these values in POST/PATCH bodies (routesFile enforces the same
100
+ // contract at the boundary with a 400 response).
101
+ if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue;
91
102
 
92
103
  const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET);
93
104
 
@@ -180,6 +191,7 @@ function zodFieldExpr(field: MetaField): Code {
180
191
  break;
181
192
  }
182
193
  case FIELD_SUBTYPE_STRING:
194
+ case FIELD_SUBTYPE_UUID:
183
195
  default:
184
196
  baseStr = "z.string()";
185
197
  break;
@@ -200,12 +212,28 @@ function fieldWillBeOptional(field: MetaField): boolean {
200
212
  return !isRequired || hasDefault;
201
213
  }
202
214
 
203
- /** Append .min/.max/.regex/.optional() based on field-level validators + required state. */
215
+ /** Numeric field subtypes whose Zod base is `z.number()` value bounds apply. */
216
+ const NUMERIC_FIELD_SUBTYPES = new Set<string>([
217
+ FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
218
+ FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
219
+ ]);
220
+
221
+ /** Append .min/.max/.regex/.optional() based on field-level validators + required state.
222
+ *
223
+ * Bound semantics by field shape:
224
+ * - string (scalar) → .min/.max = character count (validator.length + @maxLength)
225
+ * - numeric (scalar) → .min/.max = numeric value (validator.numeric)
226
+ * - array (any element) → .min/.max = element count (validator.array)
227
+ */
204
228
  function appendValidatorChain(base: Code, field: MetaField): Code {
205
229
  let isRequired = field.ownAttr(FIELD_ATTR_REQUIRED) === true;
206
230
  let maxLen: number | undefined = field.ownAttr(FIELD_ATTR_MAX_LENGTH) as number | undefined;
207
231
  let minLen: number | undefined;
208
232
  let pattern: string | undefined;
233
+ let numMin: number | undefined;
234
+ let numMax: number | undefined;
235
+ let arrMin: number | undefined;
236
+ let arrMax: number | undefined;
209
237
  for (const child of field.validators()) {
210
238
  if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) isRequired = true;
211
239
  if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
@@ -218,14 +246,33 @@ function appendValidatorChain(base: Code, field: MetaField): Code {
218
246
  const p = child.ownAttr(VALIDATOR_ATTR_PATTERN);
219
247
  if (typeof p === "string") pattern = p;
220
248
  }
249
+ if (child.subType === VALIDATOR_SUBTYPE_NUMERIC) {
250
+ const max = child.ownAttr(VALIDATOR_ATTR_MAX);
251
+ const min = child.ownAttr(VALIDATOR_ATTR_MIN);
252
+ if (typeof max === "number") numMax = max;
253
+ if (typeof min === "number") numMin = min;
254
+ }
255
+ if (child.subType === VALIDATOR_SUBTYPE_ARRAY) {
256
+ const max = child.ownAttr(VALIDATOR_ATTR_MAX);
257
+ const min = child.ownAttr(VALIDATOR_ATTR_MIN);
258
+ if (typeof max === "number") arrMax = max;
259
+ if (typeof min === "number") arrMin = min;
260
+ }
221
261
  }
222
262
 
223
263
  let chain: Code = base;
224
- if (field.subType === FIELD_SUBTYPE_STRING && !field.isArray) {
264
+ // Array element-count bounds apply to the z.array(...) wrapper regardless of element type.
265
+ if (field.isArray) {
266
+ if (arrMin !== undefined) chain = code`${chain}.min(${arrMin})`;
267
+ if (arrMax !== undefined) chain = code`${chain}.max(${arrMax})`;
268
+ } else if (field.subType === FIELD_SUBTYPE_STRING) {
225
269
  if (minLen !== undefined) chain = code`${chain}.min(${minLen})`;
226
270
  else if (isRequired) chain = code`${chain}.min(1)`;
227
271
  if (maxLen !== undefined) chain = code`${chain}.max(${maxLen})`;
228
272
  if (pattern !== undefined) chain = code`${chain}.regex(new RegExp(${JSON.stringify(pattern)}))`;
273
+ } else if (NUMERIC_FIELD_SUBTYPES.has(field.subType)) {
274
+ if (numMin !== undefined) chain = code`${chain}.min(${numMin})`;
275
+ if (numMax !== undefined) chain = code`${chain}.max(${numMax})`;
229
276
  }
230
277
 
231
278
  // Fields with DB-level defaults are optional in the InsertSchema: the caller
@@ -1 +0,0 @@
1
- {"version":3,"file":"recover-schema-emitter.d.ts","sourceRoot":"","sources":["../../src/templates/recover-schema-emitter.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,KAAK,QAAQ,EAId,MAAM,0BAA0B,CAAC;AAclC,yDAAyD;AACzD,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAIpF;AA+BD,0DAA0D;AAC1D,wBAAgB,eAAe,CAAC,EAAE,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAc3E;AAED,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,QAAQ,GAAG,MAAM,CAGtD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"recover-schema-emitter.js","sourceRoot":"","sources":["../../src/templates/recover-schema-emitter.ts"],"names":[],"mappings":"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"}