@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
@@ -0,0 +1,333 @@
1
+ // server/typescript/packages/codegen-ts/src/templates/extractor.ts
2
+ //
3
+ // The strict `extract` tier — a generated `<Name>.extractor.ts` that sits OVER the existing tolerant
4
+ // extractLenient<Name> and turns dirty LLM text into the STRICT typed payload graph (nested objects +
5
+ // arrays-of-objects populated) in one call.
6
+ //
7
+ // Cross-port parity: this mirrors the Java ExtractorCodeGenerator (FOC Task 6). The Java port's
8
+ // extract(loader, text) / extractLenient(loader, text) both take the loaded MetaDataLoader and delegate
9
+ // to the Phase-B runtime extract (MetaObjectExtractor) so the WHOLE nested graph is assembled; the
10
+ // returned flavored object IS the strict type there (the binding provider makes newInstance()
11
+ // return it). TS has no flavored object-class — extractLenient returns an all-nullable `<Name>Extracted`
12
+ // mirror and the strict payload is a separate `interface`, so the TS port adds the recursive
13
+ // mirror→strict mapper (toStrict<Type>) that the Java/Kotlin ports get for free from the runtime.
14
+ //
15
+ // Why extract takes the MetaRoot: the SELF-CONTAINED extractLenient<Name>(text) leaves nested objects
16
+ // null (the historical FR-010 gap). The nested-capable path is extractLenient<Name>WithLoader(root, text),
17
+ // which delegates to the runtime extract. So the extract tier — like the Java port — is loader
18
+ // (MetaRoot)-driven. extractLenient<Name>WithLoader is re-exposed here under the public name extractLenient<Name>.
19
+ //
20
+ // NO registry / binding provider / factory; codegen walks the whole type graph statically (the
21
+ // same MetaObject walk the extract-schema / payload emitters use).
22
+
23
+ import {
24
+ type MetaData,
25
+ type MetaField,
26
+ TYPE_OBJECT,
27
+ TYPE_TEMPLATE,
28
+ TEMPLATE_SUBTYPE_OUTPUT,
29
+ FIELD_SUBTYPE_OBJECT,
30
+ FIELD_SUBTYPE_ENUM,
31
+ FIELD_ATTR_OBJECT_REF,
32
+ FIELD_ATTR_REQUIRED,
33
+ TEMPLATE_ATTR_PAYLOAD_REF,
34
+ TEMPLATE_ATTR_FORMAT,
35
+ PACKAGE_SEPARATOR,
36
+ } from "@metaobjectsdev/metadata";
37
+ import { fields, isArray } from "./fr010-field-mapping.js";
38
+ import { mirrorName } from "./extract-delegate-emitter.js";
39
+ import { enumUnionAliasName } from "./inferred-types.js";
40
+ import { enumValues } from "../enum-meta.js";
41
+
42
+ function findObject(root: MetaData, name: string): MetaData | undefined {
43
+ return root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
44
+ }
45
+
46
+ function findTemplate(root: MetaData, name: string): MetaData | undefined {
47
+ return root.ownChildren().find((c) => c.type === TYPE_TEMPLATE && c.name === name);
48
+ }
49
+
50
+ /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */
51
+ function refVo(field: MetaData, root: MetaData): MetaData | undefined {
52
+ const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
53
+ if (typeof ref !== "string") return undefined;
54
+ const direct = findObject(root, ref);
55
+ if (direct !== undefined) return direct;
56
+ const sep = ref.lastIndexOf(PACKAGE_SEPARATOR);
57
+ if (sep >= 0) return findObject(root, ref.slice(sep + PACKAGE_SEPARATOR.length));
58
+ return undefined;
59
+ }
60
+
61
+ function isObjectField(field: MetaData): boolean {
62
+ return field.subType === FIELD_SUBTYPE_OBJECT;
63
+ }
64
+
65
+ /**
66
+ * The union-alias type name for a `field.enum` with effective `@values`, or undefined when the
67
+ * field is not a value-constrained enum. Reuses `enumUnionAliasName` — the SAME naming the payload
68
+ * emitter (`payload-codegen.ts`) types the field as — so the cast target resolves to the exact
69
+ * alias exported from `payloads.ts`. `ownerName` is the owning value-object's interface name.
70
+ */
71
+ function enumAlias(field: MetaData, ownerName: string): string | undefined {
72
+ if (field.subType !== FIELD_SUBTYPE_ENUM) return undefined;
73
+ const values = enumValues(field as MetaField);
74
+ if (values === undefined) return undefined;
75
+ return enumUnionAliasName(ownerName, field as MetaField);
76
+ }
77
+
78
+ /**
79
+ * True iff the field is required IN THE STRICT PAYLOAD TYPE. This MUST match
80
+ * payload-codegen.ts's `isFieldRequired` predicate EXACTLY (boolean `true` only) — the payload
81
+ * interface decides `T` vs `T | null` by that predicate, and the mapper's optionality assumption
82
+ * (`m.f!` vs `m.f ?? null`) has to agree with the type it is constructing. A `@required:"true"`
83
+ * string field is therefore `T | null` in the payload AND optional here (no skew).
84
+ */
85
+ function isFieldRequired(field: MetaData): boolean {
86
+ return field.ownAttr(FIELD_ATTR_REQUIRED) === true;
87
+ }
88
+
89
+ /** The mirror→strict mapper name for a value-object (`toStrict<Name>`). */
90
+ function mapperName(vo: MetaData): string {
91
+ return `toStrict${vo.name}`;
92
+ }
93
+
94
+ /**
95
+ * The mapper-body initializer expression for one field, reading mirror member `m.<name>` and
96
+ * mapping it onto the strict payload's exact optionality (required → `m.f!`; optional → `m.f ?? null`).
97
+ * Nested single/array objects recurse into their toStrict<Type> mapper, guarding null when optional.
98
+ */
99
+ function strictArg(field: MetaData, root: MetaData, ownerName: string): string {
100
+ const name = field.name;
101
+ const required = isFieldRequired(field);
102
+
103
+ if (isObjectField(field)) {
104
+ const target = refVo(field, root);
105
+ if (target === undefined) {
106
+ // Unresolved @objectRef — the payload type would be `unknown`; pass through as-is.
107
+ return required ? `m.${name}!` : `m.${name} ?? null`;
108
+ }
109
+ const fn = mapperName(target);
110
+ if (isArray(field)) {
111
+ // Required array-of-objects: each element mapped; element nulls dropped at the type level
112
+ // via the non-null assertion (extract never yields null elements for a present array).
113
+ if (required) return `m.${name}!.map((e) => ${fn}(e!))`;
114
+ return `m.${name} ? m.${name}!.map((e) => ${fn}(e!)) : null`;
115
+ }
116
+ // Single nested object.
117
+ if (required) return `${fn}(m.${name}!)`;
118
+ return `m.${name} ? ${fn}(m.${name}) : null`;
119
+ }
120
+
121
+ // Scalar ARRAY (e.g. `field.string` with isArray): the mirror types it `(T | null)[] | null`
122
+ // but the strict payload types it `T[]` (required) / `T[] | null` (optional). A bare `m.f!`
123
+ // would leave the element type `T | null`, a `tsc --strict` TS2322 error. Filter out null
124
+ // elements so the element type narrows to non-null (consistent with the lost-element DROP policy
125
+ // already used for required arrays-of-objects above).
126
+ //
127
+ // ENUM arrays: the mirror element is a plain `string`, but the strict payload types it as the
128
+ // closed `<Alias>[]` union. The null-filter alone narrows to `string[]`, not `<Alias>[]` — a
129
+ // `tsc --strict` TS2322 error. So the null-filtered result is CAST to `<Alias>[]`. The cast is
130
+ // sound: the engine validated each present element is a member of the closed set (else the field
131
+ // is lost/MALFORMED and extract throws), so the runtime string IS a valid union member.
132
+ const alias = enumAlias(field, ownerName);
133
+ if (isArray(field)) {
134
+ if (required) {
135
+ const filtered = `(m.${name} ?? []).filter((x): x is NonNullable<typeof x> => x != null)`;
136
+ return alias !== undefined ? `(${filtered}) as ${alias}[]` : filtered;
137
+ }
138
+ const filtered = `m.${name}.filter((x): x is NonNullable<typeof x> => x != null)`;
139
+ const guarded = `m.${name} == null ? null : ${filtered}`;
140
+ return alias !== undefined
141
+ ? `m.${name} == null ? null : (${filtered}) as ${alias}[]`
142
+ : guarded;
143
+ }
144
+
145
+ // Scalar / enum (single): the strict payload's optionality decides the shape.
146
+ // Required → non-null assertion; optional → `?? null` (matches the payload's `f?: T | null`).
147
+ //
148
+ // ENUM scalar: the mirror member is a plain `string`, but the strict payload types it as the
149
+ // closed `<Alias>` union — assigning `string` into `<Alias>` is a `tsc --strict` TS2322 error.
150
+ // So the value is CAST to `<Alias>`. Sound for the same reason as enum arrays above: the engine
151
+ // already validated membership (or extract throws on a lost required field).
152
+ if (alias !== undefined) {
153
+ return required ? `m.${name}! as ${alias}` : `(m.${name} ?? null) as ${alias} | null`;
154
+ }
155
+ return required ? `m.${name}!` : `m.${name} ?? null`;
156
+ }
157
+
158
+ /**
159
+ * Emit one `toStrict<VO>(m)` mapper per value-object reachable from `vo` (payload + nested,
160
+ * deduped, cycle-safe). Each maps the all-nullable `<VO>Extracted` mirror onto the strict `<VO>`
161
+ * payload interface. The ROOT mapper reads the canonically-named root mirror (`<Template>Extracted`)
162
+ * since the template name may differ from the payload VO name.
163
+ */
164
+ function emitMappers(payloadVo: MetaData, root: MetaData, rootMirror: string): string {
165
+ const out: string[] = [];
166
+ const seen = new Set<string>();
167
+ emitMapper(payloadVo, root, seen, out, rootMirror);
168
+ return out.join("\n\n");
169
+ }
170
+
171
+ function emitMapper(
172
+ vo: MetaData,
173
+ root: MetaData,
174
+ seen: Set<string>,
175
+ out: string[],
176
+ mirrorOverride?: string,
177
+ ): void {
178
+ if (seen.has(vo.name)) return;
179
+ seen.add(vo.name);
180
+
181
+ const fn = mapperName(vo);
182
+ const strict = vo.name;
183
+ const mir = mirrorOverride ?? mirrorName(vo);
184
+ const assigns = fields(vo).map((f) => ` ${f.name}: ${strictArg(f, root, vo.name)},`);
185
+ out.push(
186
+ [
187
+ `/** Map the all-nullable \`${mir}\` mirror onto the strict \`${strict}\` payload. Generated. */`,
188
+ `function ${fn}(m: ${mir}): ${strict} {`,
189
+ ` return {`,
190
+ ...assigns,
191
+ ` };`,
192
+ `}`,
193
+ ].join("\n"),
194
+ );
195
+
196
+ for (const f of fields(vo)) {
197
+ if (isObjectField(f)) {
198
+ const target = refVo(f, root);
199
+ if (target !== undefined) emitMapper(target, root, seen, out);
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Collect the strict payload-interface names reachable from `vo` (for the type-only import),
206
+ * PLUS every enum union-alias reachable from those VOs. Both are exported from `payloads.ts`
207
+ * (the alias is hoisted above the interface there), so the extractor's `as <Alias>` casts need
208
+ * the alias names imported alongside the interface names. Deduped, in discovery order.
209
+ */
210
+ function reachablePayloadTypes(vo: MetaData, root: MetaData): string[] {
211
+ const order: string[] = [];
212
+ const seen = new Set<string>();
213
+ const visit = (cur: MetaData) => {
214
+ if (seen.has(cur.name)) return;
215
+ seen.add(cur.name);
216
+ order.push(cur.name);
217
+ for (const f of fields(cur)) {
218
+ const alias = enumAlias(f, cur.name);
219
+ if (alias !== undefined && !seen.has(alias)) {
220
+ seen.add(alias);
221
+ order.push(alias);
222
+ }
223
+ if (isObjectField(f)) {
224
+ const target = refVo(f, root);
225
+ if (target !== undefined) visit(target);
226
+ }
227
+ }
228
+ };
229
+ visit(vo);
230
+ return order;
231
+ }
232
+
233
+ /** Collect the mirror-interface names reachable from `vo` (root mirror + nested VO mirrors). */
234
+ function reachableMirrorTypes(vo: MetaData, root: MetaData, rootMirror: string): string[] {
235
+ const out: string[] = [rootMirror];
236
+ const seen = new Set<string>([vo.name]);
237
+ const visit = (cur: MetaData) => {
238
+ for (const f of fields(cur)) {
239
+ if (isObjectField(f)) {
240
+ const target = refVo(f, root);
241
+ if (target !== undefined && !seen.has(target.name)) {
242
+ seen.add(target.name);
243
+ out.push(mirrorName(target));
244
+ visit(target);
245
+ }
246
+ }
247
+ }
248
+ };
249
+ visit(vo);
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Render the full `<TemplateName>.extractor.ts` for one `template.output` node.
255
+ * Throws if the template isn't found / isn't a template.output / its @payloadRef doesn't resolve,
256
+ * or if the target format is not json/xml (the extract tier requires the extract<Name> API, which
257
+ * only the json/xml output-parsers emit).
258
+ */
259
+ export function renderExtractor(root: MetaData, templateName: string): string {
260
+ const tmpl = findTemplate(root, templateName);
261
+ if (!tmpl) {
262
+ throw new Error(`template "${templateName}" not found in metadata root`);
263
+ }
264
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
265
+ throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
266
+ }
267
+ const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
268
+ if (typeof payloadRef !== "string") {
269
+ throw new Error(`template "${templateName}" missing @payloadRef`);
270
+ }
271
+ const vo = findObject(root, payloadRef);
272
+ if (!vo) {
273
+ throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
274
+ }
275
+ const format = ((tmpl.ownAttr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
276
+ if (format !== "json" && format !== "xml") {
277
+ throw new Error(
278
+ `template "${templateName}" @format "${format}" has no extract API to extract over (json/xml only)`,
279
+ );
280
+ }
281
+
282
+ const strictType = vo.name; // the payload VO's interface name (payload-codegen emits the bare VO name)
283
+ const rootMirror = `${templateName}Extracted`;
284
+ const extractLenientWithName = `extractLenient${templateName}WithLoader`; // the nested-capable lenient extract (output-parser)
285
+ const extractLenientPublic = `extractLenient${templateName}`; // re-exposed never-throws lenient tier name
286
+ const extractName = `extract${templateName}`;
287
+ const rootMapper = mapperName(vo);
288
+
289
+ const payloadTypes = reachablePayloadTypes(vo, root);
290
+ const mirrorTypes = reachableMirrorTypes(vo, root, rootMirror);
291
+ const mappers = emitMappers(vo, root, rootMirror);
292
+
293
+ const lostMsg =
294
+ `${extractName}: lost required field(s): `;
295
+
296
+ return (
297
+ `// GENERATED — extractor for "${templateName}".\n` +
298
+ `//\n` +
299
+ `// Turns dirty LLM text into a fully-typed \`${strictType}\` graph (nested objects +\n` +
300
+ `// arrays-of-objects populated) in one call, by delegating to the nested-capable extract and\n` +
301
+ `// mapping the all-nullable mirror onto the strict payload. No registry / binding / factory.\n` +
302
+ `\n` +
303
+ `import {\n ${extractLenientWithName},\n type ${mirrorTypes.join(",\n type ")},\n} from "./${templateName}.output.js";\n` +
304
+ `import type { ${payloadTypes.join(", ")} } from "./payloads.js";\n` +
305
+ `import type { MetaRoot } from "@metaobjectsdev/metadata";\n` +
306
+ `import type { ExtractionResult } from "@metaobjectsdev/render";\n` +
307
+ `\n` +
308
+ `/**\n` +
309
+ ` * Extract a fully-typed \`${strictType}\` from dirty \`text\` using the loaded \`root\` (which must\n` +
310
+ ` * declare the "${payloadRef}" payload value-object). Runs the tolerant extract, then maps the\n` +
311
+ ` * extracted mirror onto the strict payload.\n` +
312
+ ` *\n` +
313
+ ` * @throws Error iff a \`@required\` field was lost (the strict opt-in gate).\n` +
314
+ ` */\n` +
315
+ `export function ${extractName}(root: MetaRoot, text: string): ${strictType} {\n` +
316
+ ` const r = ${extractLenientWithName}(root, text);\n` +
317
+ ` if (r.report.hasLostRequired()) {\n` +
318
+ ` throw new Error(${JSON.stringify(lostMsg)} + r.report.lostRequired().join(", "));\n` +
319
+ ` }\n` +
320
+ ` return ${rootMapper}(r.data!);\n` +
321
+ `}\n` +
322
+ `\n` +
323
+ `/**\n` +
324
+ ` * Extract a \`${strictType}\` from dirty \`text\` using the loaded \`root\`, never throwing.\n` +
325
+ ` * Re-exposes the nested-capable extract; inspect \`report\` for lost/defaulted fields.\n` +
326
+ ` */\n` +
327
+ `export function ${extractLenientPublic}(root: MetaRoot, text: string): ExtractionResult<${rootMirror}> {\n` +
328
+ ` return ${extractLenientWithName}(root, text);\n` +
329
+ `}\n` +
330
+ `\n` +
331
+ `${mappers}\n`
332
+ );
333
+ }
@@ -19,6 +19,7 @@ import {
19
19
  FIELD_SUBTYPE_TIMESTAMP,
20
20
  FIELD_SUBTYPE_CURRENCY,
21
21
  FIELD_SUBTYPE_ENUM,
22
+ FIELD_SUBTYPE_UUID,
22
23
  VIEW_SUBTYPE_TEXT,
23
24
  VIEW_SUBTYPE_DATE,
24
25
  VIEW_SUBTYPE_NUMBER,
@@ -80,6 +81,7 @@ function defaultViewForSubType(subType: string): string {
80
81
  export function zodTypeFor(field: MetaField): string {
81
82
  switch (field.subType) {
82
83
  case FIELD_SUBTYPE_STRING:
84
+ case FIELD_SUBTYPE_UUID:
83
85
  return "z.string()";
84
86
  case FIELD_SUBTYPE_BOOLEAN:
85
87
  return "z.boolean()";
@@ -13,25 +13,27 @@ import {
13
13
  FIELD_SUBTYPE_LONG,
14
14
  FIELD_SUBTYPE_DOUBLE,
15
15
  FIELD_SUBTYPE_FLOAT,
16
- FIELD_SUBTYPE_DECIMAL,
17
16
  opsForSubType,
18
17
  } from "@metaobjectsdev/metadata";
19
18
  import { isSortableField } from "./filter-shared.js";
20
19
 
21
- const NUMBER_SUBTYPES = new Set<string>([
20
+ // VALUE-type classification only (distinct from the OPERATOR band, which comes from
21
+ // opsForSubType). decimal is deliberately NOT here: its operator band stays NUMERIC
22
+ // (eq/ne/gt/gte/lt/lte/in/isNull, see OPS_BY_SUBTYPE) but its VALUE type is `string`,
23
+ // matching the entity field representation (exact decimal string, not lossy number).
24
+ const NUMBER_VALUE_SUBTYPES = new Set<string>([
22
25
  FIELD_SUBTYPE_INT,
23
26
  FIELD_SUBTYPE_SHORT,
24
27
  FIELD_SUBTYPE_BYTE,
25
28
  FIELD_SUBTYPE_LONG,
26
29
  FIELD_SUBTYPE_DOUBLE,
27
30
  FIELD_SUBTYPE_FLOAT,
28
- FIELD_SUBTYPE_DECIMAL,
29
31
  ]);
30
32
 
31
- /** Maps a field subtype to its TS value type name for operator union codegen. */
33
+ /** Maps a field subtype to its TS VALUE type name for operator union codegen. */
32
34
  function tsNameFor(fieldSubType: string): string {
33
35
  if (fieldSubType === FIELD_SUBTYPE_BOOLEAN) return "boolean";
34
- if (NUMBER_SUBTYPES.has(fieldSubType)) return "number";
36
+ if (NUMBER_VALUE_SUBTYPES.has(fieldSubType)) return "number";
35
37
  return "string";
36
38
  }
37
39
 
@@ -1,9 +1,9 @@
1
1
  // server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts
2
2
  //
3
- // Shared field-kind mapping for the FR-010 codegen emitters (recover-schema-emitter +
3
+ // Shared field-kind mapping for the FR-010 codegen emitters (extract-schema-emitter +
4
4
  // output-format-spec-emitter). Maps a metadata field subtype onto the render engine's
5
- // FieldKind member, the idiomatic nullable TS type used by the recover mirror interface,
6
- // and the RecoverMap accessor that reads it from the forgiving outcome map.
5
+ // FieldKind member, the idiomatic nullable TS type used by the extract mirror interface,
6
+ // and the ExtractMap accessor that reads it from the forgiving outcome map.
7
7
  //
8
8
  // Mirrors the C# Fr010FieldMapping (adapted to TS syntax + the `| null` nullable mirror).
9
9
  // Bounded scope (parity with Java/Kotlin/C#): scalar / enum / scalar-array. Nested object +
@@ -14,6 +14,7 @@ import {
14
14
  TYPE_FIELD,
15
15
  FIELD_SUBTYPE_STRING,
16
16
  FIELD_SUBTYPE_CLASS,
17
+ FIELD_SUBTYPE_UUID,
17
18
  FIELD_SUBTYPE_DATE,
18
19
  FIELD_SUBTYPE_TIME,
19
20
  FIELD_SUBTYPE_TIMESTAMP,
@@ -30,6 +31,11 @@ import {
30
31
  FIELD_SUBTYPE_OBJECT,
31
32
  FIELD_ATTR_REQUIRED,
32
33
  FIELD_ATTR_VALUES,
34
+ FIELD_ATTR_COERCE_DEFAULT,
35
+ FIELD_ATTR_DEFAULT,
36
+ FIELD_ATTR_NORMALIZE,
37
+ NORMALIZE_DEFAULT,
38
+ type NormalizeMode,
33
39
  } from "@metaobjectsdev/metadata";
34
40
 
35
41
  /** The render-engine FieldKind member name for a scalar field subtype, or null if non-scalar. */
@@ -37,9 +43,14 @@ export function scalarKind(subType: string): string | null {
37
43
  switch (subType) {
38
44
  case FIELD_SUBTYPE_STRING:
39
45
  case FIELD_SUBTYPE_CLASS:
46
+ case FIELD_SUBTYPE_UUID:
40
47
  case FIELD_SUBTYPE_DATE:
41
48
  case FIELD_SUBTYPE_TIME:
42
49
  case FIELD_SUBTYPE_TIMESTAMP:
50
+ // field.decimal is a precision-exact decimal STRING on the wire (not a
51
+ // float64): extract/output map it as a string scalar so digits survive a
52
+ // round-trip — matching the generated TS `string` representation.
53
+ case FIELD_SUBTYPE_DECIMAL:
43
54
  return "STRING";
44
55
  case FIELD_SUBTYPE_INT:
45
56
  case FIELD_SUBTYPE_SHORT:
@@ -50,7 +61,6 @@ export function scalarKind(subType: string): string | null {
50
61
  return "LONG";
51
62
  case FIELD_SUBTYPE_DOUBLE:
52
63
  case FIELD_SUBTYPE_FLOAT:
53
- case FIELD_SUBTYPE_DECIMAL:
54
64
  return "DOUBLE";
55
65
  case FIELD_SUBTYPE_BOOLEAN:
56
66
  return "BOOLEAN";
@@ -83,12 +93,52 @@ export function enumValues(field: MetaData): string[] {
83
93
  return [];
84
94
  }
85
95
 
86
- /** The nullable TS type for a field in the recover mirror interface. */
96
+ /**
97
+ * FR-011: the field's `@coerceDefault` member symbol (present-but-uncoercible enum fallback),
98
+ * or null when absent. Read own-attr only — `@coerceDefault` is concrete, never inherited.
99
+ */
100
+ export function coerceDefault(field: MetaData): string | null {
101
+ const v = field.ownAttr(FIELD_ATTR_COERCE_DEFAULT);
102
+ return typeof v === "string" && v.length > 0 ? v : null;
103
+ }
104
+
105
+ /**
106
+ * FR-011: the field's `@default` member symbol (absent-fill enum value), or null when absent.
107
+ */
108
+ export function defaultValue(field: MetaData): string | null {
109
+ const v = field.ownAttr(FIELD_ATTR_DEFAULT);
110
+ return typeof v === "string" && v.length > 0 ? v : null;
111
+ }
112
+
113
+ /**
114
+ * FR-011: resolve the enum normalization mode for a field — field-level `@normalize`,
115
+ * else the owning `object.value`'s `@normalize` (the per-object default), else the global
116
+ * `NORMALIZE_DEFAULT` ("strip"). `ownerObject` may be null (resolution then skips the
117
+ * object tier). Mirrors the cross-port field → object → global resolution.
118
+ */
119
+ export function resolveNormalize(field: MetaData, ownerObject: MetaData | null): NormalizeMode {
120
+ const fieldMode = normalizeAttrOf(field);
121
+ if (fieldMode != null) return fieldMode;
122
+ const objMode = ownerObject == null ? null : normalizeAttrOf(ownerObject);
123
+ if (objMode != null) return objMode;
124
+ return NORMALIZE_DEFAULT;
125
+ }
126
+
127
+ /** The `@normalize` attr of a node as a NormalizeMode, or null when absent. */
128
+ function normalizeAttrOf(node: MetaData): NormalizeMode | null {
129
+ const v = node.ownAttr(FIELD_ATTR_NORMALIZE);
130
+ return typeof v === "string" && v.length > 0 ? (v as NormalizeMode) : null;
131
+ }
132
+
133
+ /** The nullable TS type for a field in the extract mirror interface. */
87
134
  export function mirrorType(field: MetaData): string {
88
- // Matches asStringList's `(string | null)[] | null` return a recovered array
135
+ // Nested object (single or array): the self-contained path defers to null (typed unknown);
136
+ // the runtime-delegating path overrides this with the nested mirror type. Checked BEFORE the
137
+ // generic isArray branch so an array-of-objects is NOT mistyped as a string array.
138
+ if (field.subType === FIELD_SUBTYPE_OBJECT) return "unknown"; // nested deferred
139
+ // Matches asStringList's `(string | null)[] | null` return — a extracted array
89
140
  // can contain null elements where individual items were lost.
90
141
  if (isArray(field)) return "(string | null)[] | null";
91
- if (field.subType === FIELD_SUBTYPE_OBJECT) return "unknown"; // nested deferred
92
142
  if (field.subType === FIELD_SUBTYPE_ENUM) return "string | null"; // enum is string-backed
93
143
  switch (scalarKind(field.subType)) {
94
144
  case "INT":
@@ -103,13 +153,16 @@ export function mirrorType(field: MetaData): string {
103
153
  }
104
154
 
105
155
  /**
106
- * The RecoverMap.as* helper name that reads this field from the forgiving map, or null
156
+ * The ExtractMap.as* helper name that reads this field from the forgiving map, or null
107
157
  * for a nested object (which emits a null literal, not a helper call). Single source of
108
- * truth for the per-field dispatch — both recoverMapCall and recoverMapHelpersUsed use it.
158
+ * truth for the per-field dispatch — both extractMapCall and extractMapHelpersUsed use it.
109
159
  */
110
- function recoverMapHelper(field: MetaData): string | null {
160
+ function extractMapHelper(field: MetaData): string | null {
161
+ // Nested object (single or array) → null literal in the self-contained path (no helper).
162
+ // Checked BEFORE isArray so an array-of-objects is NOT read via asStringList (which would not
163
+ // type-check against the nested mirror's `(NestedExtracted | null)[]`). Mirrors the Java fix.
164
+ if (field.subType === FIELD_SUBTYPE_OBJECT) return null;
111
165
  if (isArray(field)) return "asStringList";
112
- if (field.subType === FIELD_SUBTYPE_OBJECT) return null; // null literal, no helper
113
166
  if (field.subType === FIELD_SUBTYPE_ENUM) return "asString";
114
167
  switch (scalarKind(field.subType)) {
115
168
  case "INT":
@@ -125,18 +178,18 @@ function recoverMapHelper(field: MetaData): string | null {
125
178
  }
126
179
  }
127
180
 
128
- /** The RecoverMap.as* helper name + call that reads this field from the forgiving map `d`. */
129
- export function recoverMapCall(field: MetaData): string {
130
- const helper = recoverMapHelper(field);
131
- if (helper === null) return "null /* FR-010: nested recover deferred */";
181
+ /** The ExtractMap.as* helper name + call that reads this field from the forgiving map `d`. */
182
+ export function extractMapCall(field: MetaData): string {
183
+ const helper = extractMapHelper(field);
184
+ if (helper === null) return "null /* FR-010: nested extract deferred */";
132
185
  return `${helper}(d, ${jsonStringLiteral(field.name)})`;
133
186
  }
134
187
 
135
- /** Distinct RecoverMap helper names used across a value-object's fields (for the import). */
136
- export function recoverMapHelpersUsed(vo: MetaData): string[] {
188
+ /** Distinct ExtractMap helper names used across a value-object's fields (for the import). */
189
+ export function extractMapHelpersUsed(vo: MetaData): string[] {
137
190
  const used = new Set<string>();
138
191
  for (const f of fields(vo)) {
139
- const helper = recoverMapHelper(f);
192
+ const helper = extractMapHelper(f);
140
193
  if (helper !== null) used.add(helper);
141
194
  }
142
195
  return [...used].sort();
@@ -26,6 +26,7 @@ import {
26
26
  FIELD_SUBTYPE_TIME,
27
27
  FIELD_SUBTYPE_TIMESTAMP,
28
28
  FIELD_SUBTYPE_CLASS,
29
+ FIELD_SUBTYPE_UUID,
29
30
  FIELD_ATTR_REQUIRED,
30
31
  FIELD_ATTR_OBJECT_REF,
31
32
  } from "@metaobjectsdev/metadata";
@@ -46,6 +47,29 @@ export type ${entity.name}Update = Partial<${entity.name}Insert>;
46
47
  `;
47
48
  }
48
49
 
50
+ /**
51
+ * The string-literal-union type-alias name for a `field.enum` field — the SINGLE
52
+ * source of truth for enum-union naming (reused by the entity inferred-types
53
+ * emitter AND the payload-VO emitter so both agree byte-for-byte).
54
+ *
55
+ * - If the field extends an abstract field.enum (super), use the super field's
56
+ * PascalCase name (so multiple fields sharing one abstract enum collapse to a
57
+ * single alias).
58
+ * - Otherwise use `<Owner><FieldPascal>` for inline enums, where `<Owner>` is the
59
+ * owning object's name (entity OR payload value-object).
60
+ */
61
+ export function enumUnionAliasName(ownerName: string, field: MetaField): string {
62
+ const superField = field.resolveSuper();
63
+ return superField !== undefined
64
+ ? toPascalCase(superField.name)
65
+ : `${ownerName}${toPascalCase(field.name)}`;
66
+ }
67
+
68
+ /** The `"A" | "B"` union string for a set of enum member values. */
69
+ export function enumUnionString(values: string[]): string {
70
+ return values.map((v) => JSON.stringify(v)).join(" | ");
71
+ }
72
+
49
73
  /**
50
74
  * Emit one `export type <Name> = "A" | "B";` line per field.enum field on the entity.
51
75
  * - If the field extends an abstract field.enum (super), use the super field's PascalCase name.
@@ -63,17 +87,11 @@ export function renderEnumTypeAliases(entity: MetaObject): Code | null {
63
87
  const values = enumValues(field);
64
88
  if (values === undefined) continue;
65
89
 
66
- // Derive the type-alias name.
67
- const superField = field.resolveSuper();
68
- const typeName = superField !== undefined
69
- ? toPascalCase(superField.name)
70
- : `${entity.name}${toPascalCase(field.name)}`;
71
-
90
+ const typeName = enumUnionAliasName(entity.name, field);
72
91
  if (seen.has(typeName)) continue;
73
92
  seen.add(typeName);
74
93
 
75
- const union = values.map((v) => JSON.stringify(v)).join(" | ");
76
- lines.push(`export type ${typeName} = ${union};`);
94
+ lines.push(`export type ${typeName} = ${enumUnionString(values)};`);
77
95
  }
78
96
 
79
97
  return lines.length > 0 ? code`${lines.join("\n")}` : null;
@@ -86,13 +104,17 @@ export function renderEnumTypeAliases(entity: MetaObject): Code | null {
86
104
  const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
87
105
  [FIELD_SUBTYPE_STRING]: "string",
88
106
  [FIELD_SUBTYPE_CLASS]: "string",
107
+ [FIELD_SUBTYPE_UUID]: "string",
89
108
  [FIELD_SUBTYPE_INT]: "number",
90
109
  [FIELD_SUBTYPE_SHORT]: "number",
91
110
  [FIELD_SUBTYPE_BYTE]: "number",
92
111
  [FIELD_SUBTYPE_LONG]: "number",
93
112
  [FIELD_SUBTYPE_DOUBLE]: "number",
94
113
  [FIELD_SUBTYPE_FLOAT]: "number",
95
- [FIELD_SUBTYPE_DECIMAL]: "number",
114
+ // field.decimal is precision-exact: Drizzle's pg `numeric` column infers as
115
+ // `string` (the driver returns NUMERIC as a string to avoid float rounding),
116
+ // so the value-object/structural-interface scalar mapping must match.
117
+ [FIELD_SUBTYPE_DECIMAL]: "string",
96
118
  [FIELD_SUBTYPE_CURRENCY]: "number",
97
119
  [FIELD_SUBTYPE_BOOLEAN]: "boolean",
98
120
  [FIELD_SUBTYPE_DATE]: "string",
@@ -100,14 +122,6 @@ const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
100
122
  [FIELD_SUBTYPE_TIMESTAMP]: "string",
101
123
  };
102
124
 
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
125
  /**
112
126
  * One-line TS type expression for a field on a value-only object.
113
127
  * Returns a `Code` so cross-module `field.object` refs can be hoisted via
@@ -130,7 +144,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField): Code {
130
144
  if (field.subType === FIELD_SUBTYPE_ENUM) {
131
145
  const values = enumValues(field);
132
146
  if (values !== undefined) {
133
- const alias = enumTypeAliasName(entity, field);
147
+ const alias = enumUnionAliasName(entity.name, field);
134
148
  return field.isArray ? code`${alias}[]` : code`${alias}`;
135
149
  }
136
150
  return field.isArray ? code`string[]` : code`string`;