@metaobjectsdev/runtime-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.
@@ -0,0 +1,367 @@
1
+ // Phase B (metadata-driven extract) runtime entry point — the TS keystone that turns dirty
2
+ // LLM text into a populated, typed object graph.
3
+ //
4
+ // This is the runtime bridge between the two halves of the standard:
5
+ // • the extract ENGINE (@metaobjectsdev/render: ExtractSchema / FieldSpec / extract) — a
6
+ // zero-core-dependency, descriptor-driven module that parses dirty JSON/XML into a forgiving
7
+ // record/array tree + a ExtractionReport. It knows nothing of the runtime object model.
8
+ // • the Phase A runtime OBJECT MODEL (@metaobjectsdev/metadata: MetaObject.newInstance() +
9
+ // the MetaField get/set SPI + @objectRef) — instantiates the right backing type
10
+ // (ValueObject or a registered/bound class) with the correct back-reference.
11
+ //
12
+ // Siting. render stays metadata-free (it is a published render package; coupling it to the
13
+ // metadata model would regress every render consumer). The metadata-driven bridge therefore lives
14
+ // HERE, in @metaobjectsdev/runtime-ts — the lowest package that already depends on metadata and
15
+ // that can also take a one-way dependency on render. This mirrors the JVM layering, where the
16
+ // extract engine sits in the metadata-free `render` module and the runtime extract bridge lives in
17
+ // the `om` runtime module (which depends on BOTH metadata and render). The edges are one-way:
18
+ // runtime-ts → render and runtime-ts → metadata; render depends on neither, so there is no cycle.
19
+ //
20
+ // Reflection-free. Assembly uses MetaObject.newInstance() (the ObjectClassRegistry resolves the
21
+ // bound type, else a ValueObject) and the MetaField setValue SPI — no eval / dynamic import.
22
+ //
23
+ // Never throws. Lost/malformed fields are classified in the report, never raised. Opt into
24
+ // strictness with orThrow() (re-exported from @metaobjectsdev/render), which throws a ExtractError
25
+ // iff a required field was lost.
26
+
27
+ import {
28
+ MetaObject,
29
+ MetaField,
30
+ MetaRoot,
31
+ type MetaData,
32
+ PACKAGE_SEPARATOR,
33
+ FIELD_SUBTYPE_ENUM,
34
+ FIELD_SUBTYPE_OBJECT,
35
+ FIELD_SUBTYPE_STRING,
36
+ FIELD_SUBTYPE_CLASS,
37
+ FIELD_SUBTYPE_UUID,
38
+ FIELD_SUBTYPE_DATE,
39
+ FIELD_SUBTYPE_TIME,
40
+ FIELD_SUBTYPE_TIMESTAMP,
41
+ FIELD_SUBTYPE_INT,
42
+ FIELD_SUBTYPE_SHORT,
43
+ FIELD_SUBTYPE_BYTE,
44
+ FIELD_SUBTYPE_LONG,
45
+ FIELD_SUBTYPE_CURRENCY,
46
+ FIELD_SUBTYPE_DOUBLE,
47
+ FIELD_SUBTYPE_FLOAT,
48
+ FIELD_SUBTYPE_DECIMAL,
49
+ FIELD_SUBTYPE_BOOLEAN,
50
+ FIELD_ATTR_REQUIRED,
51
+ FIELD_ATTR_VALUES,
52
+ FIELD_ATTR_ENUM_ALIAS,
53
+ FIELD_ATTR_COERCE_DEFAULT,
54
+ FIELD_ATTR_DEFAULT,
55
+ FIELD_ATTR_NORMALIZE,
56
+ FIELD_ATTR_OBJECT_REF,
57
+ NORMALIZE_DEFAULT,
58
+ type NormalizeMode,
59
+ } from "@metaobjectsdev/metadata";
60
+
61
+ import {
62
+ Format,
63
+ FieldKind,
64
+ scalar,
65
+ enumField,
66
+ enumArray,
67
+ object,
68
+ extractSchema,
69
+ extract,
70
+ type FieldSpec,
71
+ type ExtractOptions,
72
+ type ExtractSchema,
73
+ type ExtractionResult,
74
+ } from "@metaobjectsdev/render";
75
+
76
+ /**
77
+ * Maximum nested-object recursion depth. Mirrors the render OutputFormatRenderer.MAX_NEST_DEPTH
78
+ * (and FR-012) — must stay identical cross-port (Java MetaObjectExtractor.MAX_NEST_DEPTH = 8). At or
79
+ * beyond this depth a nested OBJECT field becomes an opaque STRING leaf instead of recursing.
80
+ */
81
+ export const MAX_NEST_DEPTH = 8;
82
+
83
+ // =============================================================================
84
+ // Public API
85
+ // =============================================================================
86
+
87
+ /**
88
+ * Extract `text` into a typed object graph described by `mo`.
89
+ *
90
+ * Pipeline: build a ExtractSchema from `mo` (driven entirely by metadata), run the engine to get
91
+ * a forgiving record/array tree + report, then assemble that tree into a populated object graph.
92
+ *
93
+ * @param mo the object describing the expected shape (the single source of truth)
94
+ * @param text the dirty model output
95
+ * @param format JSON (default) or XML — drives the engine's locate/parse
96
+ * @param opts bounded runtime overrides (aliases/normalizers/onField/tolerance)
97
+ * @returns the assembled object (a ValueObject unless a type is bound for the FQN) + report.
98
+ * NEVER throws.
99
+ */
100
+ export function extractObject(
101
+ mo: MetaObject,
102
+ text: string | null | undefined,
103
+ format: Format = Format.JSON,
104
+ opts?: Partial<ExtractOptions> | null,
105
+ ): ExtractionResult<object> {
106
+ const schema = extractSchemaFor(mo, format);
107
+ const outcome = extract(text, schema, opts);
108
+ const obj = assemble(mo, outcome.data);
109
+ return { data: obj, report: outcome.report };
110
+ }
111
+
112
+ // =============================================================================
113
+ // extractSchemaFor — metadata -> ExtractSchema
114
+ // =============================================================================
115
+
116
+ /**
117
+ * Build a ExtractSchema for `mo` by walking its effective fields and mapping each MetaField to a
118
+ * FieldSpec. Recurses into nested OBJECT fields via `@objectRef`, guarded against cycles/over-depth
119
+ * by an identity visited-set keyed on MetaObject + a depth counter bounded by {@link MAX_NEST_DEPTH}.
120
+ */
121
+ export function extractSchemaFor(mo: MetaObject, format: Format = Format.JSON): ExtractSchema {
122
+ return extractSchemaForInner(mo, format, new Set<MetaObject>(), 0);
123
+ }
124
+
125
+ function extractSchemaForInner(
126
+ mo: MetaObject,
127
+ format: Format,
128
+ visited: Set<MetaObject>,
129
+ depth: number,
130
+ ): ExtractSchema {
131
+ visited.add(mo);
132
+ const fields: FieldSpec[] = mo.fields().map((f) => fieldSpecFor(f, mo, format, visited, depth));
133
+ visited.delete(mo);
134
+ // rootName is the simple (short) name; the engine's XML locate uses it as the root tag.
135
+ return extractSchema(format, mo.name, fields);
136
+ }
137
+
138
+ function fieldSpecFor(
139
+ field: MetaField,
140
+ owner: MetaObject,
141
+ format: Format,
142
+ visited: Set<MetaObject>,
143
+ depth: number,
144
+ ): FieldSpec {
145
+ const name = field.name;
146
+ const required = isRequired(field);
147
+
148
+ // --- Enum (scalar or array) ----------------------------------------------
149
+ if (field.subType === FIELD_SUBTYPE_ENUM) {
150
+ const values = enumValues(field);
151
+ const aliases = enumAliases(field);
152
+ const cd = ownAttrString(field, FIELD_ATTR_COERCE_DEFAULT);
153
+ const dv = ownAttrString(field, FIELD_ATTR_DEFAULT);
154
+ const normalize = resolveNormalize(field, owner);
155
+ const build = field.isArray === true ? enumArray : enumField;
156
+ return build(name, required, values, aliases, cd, normalize, dv);
157
+ }
158
+
159
+ // --- Nested object (single or array) --------------------------------------
160
+ if (field.subType === FIELD_SUBTYPE_OBJECT || field.objectRef !== undefined) {
161
+ const ref = resolveObjectRef(field);
162
+ const cyclicOrDeep = ref === undefined || visited.has(ref) || depth + 1 >= MAX_NEST_DEPTH;
163
+ if (cyclicOrDeep) {
164
+ // Opaque leaf — never recurse into a cycle / past the depth bound.
165
+ return scalar(name, FieldKind.STRING, required);
166
+ }
167
+ const nested = extractSchemaForInner(ref, format, visited, depth + 1);
168
+ return object(name, required, field.isArray === true, nested);
169
+ }
170
+
171
+ // --- Scalar (carry generalized @default) ----------------------------------
172
+ const kind = scalarKind(field.subType);
173
+ const dv = ownAttrString(field, FIELD_ATTR_DEFAULT);
174
+ if (field.isArray === true) {
175
+ // Scalar array: the engine coerces each element; no per-element default fill.
176
+ return scalarArray(name, kind, required);
177
+ }
178
+ return scalar(name, kind, required, dv);
179
+ }
180
+
181
+ // =============================================================================
182
+ // assemble — extracted record/array tree -> object graph
183
+ // =============================================================================
184
+
185
+ /**
186
+ * Assemble a extracted `Record<string, unknown>` (the engine's forgiving tree) into a populated
187
+ * object graph described by `mo`.
188
+ *
189
+ * `mo.newInstance()` yields the bound type (or a ValueObject) with the back-ref already set. Each
190
+ * field's value is written via the MetaField setValue SPI:
191
+ * • scalar / enum / scalar-array / enum-array → setValue (engine already coerced; arrays arrive
192
+ * as a list);
193
+ * • OBJECT non-array → recursively assemble the child record, then setValue;
194
+ * • OBJECT array → recursively assemble each element record into a list, then setValue.
195
+ *
196
+ * Cycle/depth-guarded identically to extractSchemaFor. NEVER throws on lost/malformed data — those
197
+ * were classified in the report during the engine pass.
198
+ */
199
+ export function assemble(mo: MetaObject, data: Record<string, unknown> | null | undefined): object {
200
+ return assembleInner(mo, data, new Set<MetaObject>(), 0);
201
+ }
202
+
203
+ function assembleInner(
204
+ mo: MetaObject,
205
+ data: Record<string, unknown> | null | undefined,
206
+ visited: Set<MetaObject>,
207
+ depth: number,
208
+ ): object {
209
+ const o = mo.newInstance();
210
+ if (data == null) {
211
+ return o;
212
+ }
213
+ visited.add(mo);
214
+ for (const field of mo.fields()) {
215
+ const name = field.name;
216
+ const value = data[name];
217
+
218
+ const isObjectField = field.subType === FIELD_SUBTYPE_OBJECT || field.objectRef !== undefined;
219
+ // Enum fields are string-backed scalars/arrays — treat them as scalar assignment.
220
+ if (!isObjectField || field.subType === FIELD_SUBTYPE_ENUM) {
221
+ // scalar / enum / scalar-array / enum-array: the engine already coerced the value.
222
+ if (value !== undefined && value !== null) {
223
+ field.setValue(o, value);
224
+ }
225
+ continue;
226
+ }
227
+
228
+ // Nested object — guard cycles/depth (mirror the schema guard).
229
+ const ref = resolveObjectRef(field);
230
+ const cyclicOrDeep = ref === undefined || visited.has(ref) || depth + 1 >= MAX_NEST_DEPTH;
231
+
232
+ if (field.isArray === true) {
233
+ // Array-of-objects: map each element record -> assembled child.
234
+ if (Array.isArray(value)) {
235
+ const children: object[] = [];
236
+ for (const elem of value) {
237
+ if (!cyclicOrDeep && isPlainObject(elem)) {
238
+ children.push(assembleInner(ref!, elem as Record<string, unknown>, visited, depth + 1));
239
+ }
240
+ }
241
+ field.setValue(o, children);
242
+ }
243
+ // absent array stays absent (the engine reports it).
244
+ } else {
245
+ // Single object.
246
+ if (!cyclicOrDeep && isPlainObject(value)) {
247
+ const child = assembleInner(ref!, value as Record<string, unknown>, visited, depth + 1);
248
+ field.setValue(o, child);
249
+ }
250
+ // else leave unset.
251
+ }
252
+ }
253
+ visited.delete(mo);
254
+ return o;
255
+ }
256
+
257
+ // =============================================================================
258
+ // Field-spec helpers (mirror codegen-ts fr010-field-mapping + the Java reader)
259
+ // =============================================================================
260
+
261
+ /** A scalar-array FieldSpec: scalar() builds array:false, so set array:true explicitly. */
262
+ function scalarArray(name: string, kind: FieldKind, required: boolean): FieldSpec {
263
+ return { ...scalar(name, kind, required), array: true };
264
+ }
265
+
266
+ /** The engine FieldKind for a scalar field subtype. Unknown subtypes fall back to STRING. */
267
+ function scalarKind(subType: string): FieldKind {
268
+ switch (subType) {
269
+ case FIELD_SUBTYPE_STRING:
270
+ case FIELD_SUBTYPE_CLASS:
271
+ case FIELD_SUBTYPE_UUID:
272
+ case FIELD_SUBTYPE_DATE:
273
+ case FIELD_SUBTYPE_TIME:
274
+ case FIELD_SUBTYPE_TIMESTAMP:
275
+ // decimal's wire form is an exact decimal STRING (parsing it as a float would be
276
+ // lossy) — matching the codegen sibling (fr010-field-mapping) + C# runtime extract.
277
+ case FIELD_SUBTYPE_DECIMAL:
278
+ return FieldKind.STRING;
279
+ case FIELD_SUBTYPE_INT:
280
+ case FIELD_SUBTYPE_SHORT:
281
+ case FIELD_SUBTYPE_BYTE:
282
+ return FieldKind.INT;
283
+ case FIELD_SUBTYPE_LONG:
284
+ case FIELD_SUBTYPE_CURRENCY:
285
+ return FieldKind.LONG;
286
+ case FIELD_SUBTYPE_DOUBLE:
287
+ case FIELD_SUBTYPE_FLOAT:
288
+ return FieldKind.DOUBLE;
289
+ case FIELD_SUBTYPE_BOOLEAN:
290
+ return FieldKind.BOOLEAN;
291
+ default:
292
+ return FieldKind.STRING;
293
+ }
294
+ }
295
+
296
+ /** True iff `@required` is explicitly true (or the string "true"). */
297
+ function isRequired(field: MetaField): boolean {
298
+ const v = field.ownAttr(FIELD_ATTR_REQUIRED);
299
+ if (v === true) return true;
300
+ return typeof v === "string" && v.toLowerCase() === "true";
301
+ }
302
+
303
+ /** The string members of an enum field's `@values` attr (empty when absent). */
304
+ function enumValues(field: MetaField): string[] {
305
+ const v = field.ownAttr(FIELD_ATTR_VALUES);
306
+ if (Array.isArray(v)) return v.map((e) => String(e));
307
+ return [];
308
+ }
309
+
310
+ /** The `@enumAlias` map (an object literal) of an enum field, or {} when absent/empty. */
311
+ function enumAliases(field: MetaField): Record<string, string> {
312
+ const raw = field.ownAttr(FIELD_ATTR_ENUM_ALIAS);
313
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
314
+ const out: Record<string, string> = {};
315
+ for (const [k, val] of Object.entries(raw as Record<string, unknown>)) {
316
+ if (val != null) out[k] = String(val);
317
+ }
318
+ return out;
319
+ }
320
+
321
+ /**
322
+ * Resolve the enum normalization mode: field-level `@normalize`, else the owning object's
323
+ * `@normalize`, else the global NORMALIZE_DEFAULT ("strip"). Mirrors the cross-port resolution.
324
+ */
325
+ function resolveNormalize(field: MetaField, owner: MetaObject | null): NormalizeMode {
326
+ const fieldMode = normalizeAttrOf(field);
327
+ if (fieldMode != null) return fieldMode;
328
+ const objMode = owner == null ? null : normalizeAttrOf(owner);
329
+ if (objMode != null) return objMode;
330
+ return NORMALIZE_DEFAULT;
331
+ }
332
+
333
+ function normalizeAttrOf(node: MetaData): NormalizeMode | null {
334
+ const v = node.ownAttr(FIELD_ATTR_NORMALIZE);
335
+ return typeof v === "string" && v.length > 0 ? (v as NormalizeMode) : null;
336
+ }
337
+
338
+ /** The own (non-inherited) string value of an attr on a node, or null when absent/empty. */
339
+ function ownAttrString(node: MetaData, attr: string): string | null {
340
+ const v = node.ownAttr(attr);
341
+ if (typeof v === "string") return v.length > 0 ? v : null;
342
+ return v == null ? null : String(v);
343
+ }
344
+
345
+ /**
346
+ * Resolve a field's `@objectRef` FQN to its MetaObject by walking to the tree root and looking it
347
+ * up. The objectRef may be a bare name or a `pkg::Name` FQN; we try the value as-is, then the short
348
+ * name after the last package separator. Returns undefined when unresolvable (→ opaque-leaf guard).
349
+ */
350
+ function resolveObjectRef(field: MetaField): MetaObject | undefined {
351
+ const ref = ownAttrString(field, FIELD_ATTR_OBJECT_REF) ?? field.objectRef;
352
+ if (ref === undefined || ref === null) return undefined;
353
+ const root = field.root();
354
+ if (!(root instanceof MetaRoot)) return undefined;
355
+ const direct = root.findObject(ref);
356
+ if (direct !== undefined) return direct;
357
+ const sep = ref.lastIndexOf(PACKAGE_SEPARATOR);
358
+ if (sep >= 0) {
359
+ const short = ref.slice(sep + PACKAGE_SEPARATOR.length);
360
+ return root.findObject(short);
361
+ }
362
+ return undefined;
363
+ }
364
+
365
+ function isPlainObject(o: unknown): boolean {
366
+ return typeof o === "object" && o !== null && !Array.isArray(o);
367
+ }
@@ -13,9 +13,12 @@
13
13
 
14
14
  import type { FastifyInstance } from "fastify";
15
15
  import type { ZodTypeAny } from "zod";
16
+ import qs from "qs";
16
17
  import type { ObjectManager } from "../object-manager.js";
17
18
  import type { Row } from "../persistence-driver.js";
18
19
  import type { RouteShorthandOptions } from "fastify";
20
+ import type { SortAllowlist } from "../drizzle-fastify/filter-allowlist.js";
21
+ import { isTruthyFlag, contractErrorCode } from "../drizzle-fastify/util.js";
19
22
 
20
23
  // ---------------------------------------------------------------------------
21
24
  // Public surface
@@ -59,6 +62,14 @@ export interface CrudRoutesOptions {
59
62
  * uses PUT for updates.
60
63
  */
61
64
  updateMethod?: "patch" | "put";
65
+ /**
66
+ * Per-entity sort allowlist (`<Entity>SortAllowlist` from codegen). When set,
67
+ * a `?sort=field:dir` referencing a field NOT in the allowlist (or an invalid
68
+ * order) is rejected with HTTP 400 `{ error: "invalid_sort" }` — matching the
69
+ * cross-port REST contract and the drizzle-fastify mount. When absent, `?sort`
70
+ * is ignored (legacy back-compat: limit/offset only).
71
+ */
72
+ sortAllowlist?: SortAllowlist;
62
73
  }
63
74
 
64
75
  const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
@@ -98,15 +109,56 @@ function routeOpts(opts: SingleVerbOptions): RouteShorthandOptions {
98
109
  }
99
110
 
100
111
  export function mountListRoute(opts: SingleVerbOptions): void {
101
- opts.fastify.get(opts.path, routeOpts(opts), async (req) => {
112
+ opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
113
+ // Re-parse the raw URL with qs so the top-level withCount flag and the
114
+ // sort=field:dir param are available regardless of Fastify's query parser.
115
+ const rawSearch = req.raw.url?.includes("?")
116
+ ? req.raw.url.slice(req.raw.url.indexOf("?") + 1)
117
+ : "";
118
+ const parsed = qs.parse(rawSearch) as Record<string, unknown>;
119
+ const withCount = isTruthyFlag(parsed["withCount"]);
120
+
121
+ const readOpts: { limit?: number; offset?: number; orderBy?: [string, "asc" | "desc"] } = {};
102
122
  const { limit, offset } = req.query as { limit?: string; offset?: string };
103
- const readOpts: { limit?: number; offset?: number } = {};
104
123
  if (limit !== undefined) readOpts.limit = Number(limit);
105
124
  if (offset !== undefined) readOpts.offset = Number(offset);
106
- return (await opts.om()).findMany(opts.entity, undefined, readOpts);
125
+
126
+ // Sort allowlist gate (cross-port REST contract). Only enforced when a
127
+ // sortAllowlist is configured; absent → ?sort is ignored (back-compat).
128
+ if (opts.sortAllowlist && typeof parsed["sort"] === "string") {
129
+ const sortParse = parseSort(parsed["sort"], opts.sortAllowlist);
130
+ if (sortParse.error) {
131
+ return reply.code(400).send({ error: contractErrorCode(sortParse.error) });
132
+ }
133
+ if (sortParse.orderBy) readOpts.orderBy = sortParse.orderBy;
134
+ }
135
+
136
+ const om = await opts.om();
137
+ const rows = await om.findMany(opts.entity, undefined, readOpts);
138
+ if (!withCount) return rows;
139
+ // total is the UNPAGINATED count (no limit/offset).
140
+ const total = await om.count(opts.entity);
141
+ return { rows, total };
107
142
  });
108
143
  }
109
144
 
145
+ /**
146
+ * Validate a `sort=field:dir` spec against the allowlist. Mirrors the
147
+ * drizzle-fastify filter-parser's sort semantics + internal error codes
148
+ * (`sort.unknown_field` / `sort.invalid_order`) so both mounts emit the same
149
+ * `invalid_sort` envelope at the HTTP boundary.
150
+ */
151
+ function parseSort(
152
+ spec: string,
153
+ sortAllowlist: SortAllowlist,
154
+ ): { orderBy?: [string, "asc" | "desc"]; error?: string } {
155
+ const [field, orderRaw] = spec.split(":");
156
+ if (!field || !sortAllowlist[field]) return { error: "sort.unknown_field" };
157
+ const order = (orderRaw ?? "asc").toLowerCase();
158
+ if (order !== "asc" && order !== "desc") return { error: "sort.invalid_order" };
159
+ return { orderBy: [field, order] };
160
+ }
161
+
110
162
  export function mountGetRoute(opts: SingleVerbOptions): void {
111
163
  opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
112
164
  const { id } = req.params as { id: string };
package/src/index.ts CHANGED
@@ -22,3 +22,13 @@ export {
22
22
  MetadataError,
23
23
  UnsafeNameError,
24
24
  } from "./errors.js";
25
+
26
+ // FR-010 Phase B — metadata-driven runtime extract. The descriptor-driven extract ENGINE lives in
27
+ // the metadata-free @metaobjectsdev/render package; this bridge wires it to the Phase A runtime
28
+ // object model (MetaObject.newInstance() + the MetaField SPI), mirroring the JVM `om` siting.
29
+ export {
30
+ extractObject,
31
+ extractSchemaFor,
32
+ assemble,
33
+ MAX_NEST_DEPTH,
34
+ } from "./extract-object.js";
@@ -7,7 +7,7 @@ import {
7
7
  VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
8
8
  FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG,
9
9
  FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
10
- FIELD_SUBTYPE_BOOLEAN,
10
+ FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_UUID,
11
11
  FIELD_ATTR_REQUIRED, FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_DEFAULT,
12
12
  VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_PATTERN,
13
13
  } from "@metaobjectsdev/metadata";
@@ -157,7 +157,7 @@ function resolveMinLength(field: MetaData): number | undefined {
157
157
  }
158
158
 
159
159
  function checkType(subType: string, value: unknown): string | null {
160
- if (subType === FIELD_SUBTYPE_STRING) {
160
+ if (subType === FIELD_SUBTYPE_STRING || subType === FIELD_SUBTYPE_UUID) {
161
161
  if (typeof value !== "string") return `expected string`;
162
162
  } else if (NUMERIC_FIELD_SUBTYPES.has(subType)) {
163
163
  if (typeof value !== "number") return `expected number`;