@metaobjectsdev/runtime-ts 1.0.9 → 1.0.11
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.
- package/README.md +1 -1
- package/dist/constraint-errors.d.ts.map +1 -1
- package/dist/constraint-errors.js +3 -1
- package/dist/constraint-errors.js.map +1 -1
- package/dist/drivers/drizzle-driver.d.ts +2 -1
- package/dist/drivers/drizzle-driver.d.ts.map +1 -1
- package/dist/drivers/drizzle-driver.js +41 -18
- package/dist/drivers/drizzle-driver.js.map +1 -1
- package/dist/drivers/kysely-driver.d.ts +3 -1
- package/dist/drivers/kysely-driver.d.ts.map +1 -1
- package/dist/drivers/kysely-driver.js +37 -4
- package/dist/drivers/kysely-driver.js.map +1 -1
- package/dist/drivers/mysql-support.d.ts +19 -0
- package/dist/drivers/mysql-support.d.ts.map +1 -0
- package/dist/drivers/mysql-support.js +64 -0
- package/dist/drivers/mysql-support.js.map +1 -0
- package/dist/drizzle-fastify/filter-parser.d.ts +2 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +11 -4
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +2 -1
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +23 -30
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts +2 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +9 -3
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/util.d.ts +7 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +35 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/extract-object.d.ts.map +1 -1
- package/dist/extract-object.js +13 -2
- package/dist/extract-object.js.map +1 -1
- package/dist/hono/index.d.ts +2 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +21 -30
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts +2 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +9 -3
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +2 -1
- package/dist/object-manager.js.map +1 -1
- package/dist/persistence-driver.d.ts +8 -1
- package/dist/persistence-driver.d.ts.map +1 -1
- package/dist/query-builder.d.ts +2 -0
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +5 -0
- package/dist/query-builder.js.map +1 -1
- package/dist/sql-dialect.d.ts +11 -0
- package/dist/sql-dialect.d.ts.map +1 -0
- package/dist/sql-dialect.js +16 -0
- package/dist/sql-dialect.js.map +1 -0
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +63 -4
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +102 -73
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/constraint-errors.ts +3 -1
- package/src/drivers/drizzle-driver.ts +44 -17
- package/src/drivers/kysely-driver.ts +43 -7
- package/src/drivers/mysql-support.ts +77 -0
- package/src/drizzle-fastify/filter-parser.ts +16 -7
- package/src/drizzle-fastify/index.ts +23 -26
- package/src/drizzle-fastify/mount-read-only.ts +10 -4
- package/src/drizzle-fastify/util.ts +29 -0
- package/src/extract-object.ts +18 -0
- package/src/hono/index.ts +21 -26
- package/src/hono/mount-read-only.ts +10 -4
- package/src/object-manager.ts +2 -1
- package/src/persistence-driver.ts +8 -1
- package/src/query-builder.ts +9 -0
- package/src/sql-dialect.ts +21 -0
- package/src/type-coercer.ts +55 -3
- package/src/validator-runner.ts +100 -70
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The driver IS the unit of transaction — transaction(fn) yields a tx-scoped sub-driver.
|
|
3
3
|
// Java analog: ObjectConnection.
|
|
4
4
|
|
|
5
|
-
export type Dialect = "sqlite" | "postgres" | "memory";
|
|
5
|
+
export type Dialect = "sqlite" | "postgres" | "mysql" | "memory";
|
|
6
6
|
|
|
7
7
|
export type PrimitiveValue = string | number | boolean;
|
|
8
8
|
|
|
@@ -42,12 +42,19 @@ export interface InsertSpec {
|
|
|
42
42
|
values: Row;
|
|
43
43
|
/** Columns to return — driver handles RETURNING / lastInsertId per dialect. */
|
|
44
44
|
returning: string[];
|
|
45
|
+
/**
|
|
46
|
+
* The primary-key columns. A dialect without RETURNING (MySQL) reads the inserted row
|
|
47
|
+
* back by them; the others ignore it.
|
|
48
|
+
*/
|
|
49
|
+
key?: string[];
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface InsertManySpec {
|
|
48
53
|
table: string;
|
|
49
54
|
rows: Row[];
|
|
50
55
|
returning: string[];
|
|
56
|
+
/** As {@link InsertSpec.key}. */
|
|
57
|
+
key?: string[];
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
export interface UpdateSpec {
|
package/src/query-builder.ts
CHANGED
|
@@ -232,9 +232,18 @@ export function buildInsertSpec(
|
|
|
232
232
|
table: resolveTableName(entity),
|
|
233
233
|
values,
|
|
234
234
|
returning: allFields.map((f) => resolveColumnName(getField(entity, f), strategy)),
|
|
235
|
+
key: primaryKeyColumns(entity, strategy),
|
|
235
236
|
};
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
/** The primary-key COLUMN names (a driver without RETURNING reads an insert back by them). */
|
|
240
|
+
export function primaryKeyColumns(
|
|
241
|
+
entity: MetaData,
|
|
242
|
+
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
243
|
+
): string[] {
|
|
244
|
+
return resolvePkFields(entity).map((f) => resolveColumnName(getField(entity, f), strategy));
|
|
245
|
+
}
|
|
246
|
+
|
|
238
247
|
/**
|
|
239
248
|
* A TPH discriminator scope: AND'd into the by-id where so a subtype-scoped
|
|
240
249
|
* update/delete only matches rows of that subtype (a row of a different
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL dialects the runtime mounts and drivers speak. `mysql` is a codegen + runtime dialect
|
|
3
|
+
* only: MetaObjects does not own a MySQL schema (`meta migrate` supports Postgres, SQLite and
|
|
4
|
+
* D1), so the adopter writes the DDL.
|
|
5
|
+
*/
|
|
6
|
+
export type SqlDialect = "sqlite" | "postgres" | "mysql";
|
|
7
|
+
|
|
8
|
+
/** Whether the dialect's INSERT / UPDATE / DELETE can return rows (`RETURNING`). MySQL cannot. */
|
|
9
|
+
export function supportsReturning(dialect: SqlDialect | undefined): boolean {
|
|
10
|
+
return dialect !== "mysql";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Quote an identifier for raw SQL: backticks on MySQL, double quotes elsewhere. */
|
|
14
|
+
export function quoteIdent(dialect: SqlDialect | undefined, name: string): string {
|
|
15
|
+
if (dialect === "mysql") {
|
|
16
|
+
if (name.includes("`")) throw new Error(`unsafe identifier: ${name}`);
|
|
17
|
+
return `\`${name}\``;
|
|
18
|
+
}
|
|
19
|
+
if (name.includes('"')) throw new Error(`unsafe identifier: ${name}`);
|
|
20
|
+
return `"${name}"`;
|
|
21
|
+
}
|
package/src/type-coercer.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Read/write value coercion at the persistence boundary.
|
|
2
2
|
//
|
|
3
3
|
// - SQLite has no native boolean: booleans are stored as 0/1 ints, so we map
|
|
4
|
-
// boolean↔int on the way in/out
|
|
4
|
+
// boolean↔int on the way in/out. MySQL's BOOLEAN is TINYINT(1) and mysql2 reads it
|
|
5
|
+
// back as 0/1, so it takes the same mapping.
|
|
5
6
|
// - JSONB-backed object/map fields: the driver (node-postgres) does NOT
|
|
6
7
|
// serialize a plain JS object to a jsonb column — it must arrive as a JSON
|
|
7
8
|
// string, or the write fails / writes "[object Object]". A `field.object`
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
FIELD_SUBTYPE_BOOLEAN,
|
|
20
21
|
FIELD_SUBTYPE_ENUM,
|
|
21
22
|
FIELD_SUBTYPE_OBJECT,
|
|
23
|
+
FIELD_SUBTYPE_TIMESTAMP,
|
|
22
24
|
FIELD_ATTR_STORAGE,
|
|
23
25
|
FIELD_ATTR_DB_COLUMN_TYPE,
|
|
24
26
|
FIELD_ATTR_INT_VALUE_MAP,
|
|
@@ -29,9 +31,9 @@ import {
|
|
|
29
31
|
import type { Dialect, Row } from "./persistence-driver.js";
|
|
30
32
|
|
|
31
33
|
export function coerceRowOnRead(entity: MetaData, row: Row, dialect: Dialect): Row {
|
|
32
|
-
const hydrated = deserializeJsonbObjectFields(entity, row);
|
|
34
|
+
const hydrated = parseJsonTextArrays(entity, deserializeJsonbObjectFields(entity, row));
|
|
33
35
|
const decoded = decodeIntBackedEnums(entity, hydrated);
|
|
34
|
-
if (dialect !== "sqlite") return decoded;
|
|
36
|
+
if (dialect !== "sqlite" && dialect !== "mysql") return decoded;
|
|
35
37
|
return mapBooleansFromInt(entity, decoded);
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -133,6 +135,7 @@ export function coerceRowOnWrite(entity: MetaData, row: Row, dialect: Dialect):
|
|
|
133
135
|
// (SQLite has one integer storage class), so the symbol→int encode is not gated
|
|
134
136
|
// on the dialect the way the boolean mapping below is.
|
|
135
137
|
const encoded = encodeIntBackedEnums(entity, jsonbColumned);
|
|
138
|
+
if (dialect === "mysql") return mapBooleansToInt(entity, isoTimestampsToDates(entity, encoded));
|
|
136
139
|
if (dialect !== "sqlite") return encoded;
|
|
137
140
|
return mapBooleansToInt(entity, encoded);
|
|
138
141
|
}
|
|
@@ -227,3 +230,52 @@ function mapBooleansToInt(entity: MetaData, row: Row): Row {
|
|
|
227
230
|
}
|
|
228
231
|
return out;
|
|
229
232
|
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* MySQL: a `DATETIME` rejects the ISO wire form (`…T…Z` is ERROR 1292), so a
|
|
236
|
+
* `field.timestamp` string is handed to the driver as a `Date`. mysql2 writes it in the
|
|
237
|
+
* pool's `timezone` and reads it back the same way, so the instant round-trips; create the
|
|
238
|
+
* pool with `timezone: "Z"` to store the UTC wall clock, as the generated Drizzle tier and
|
|
239
|
+
* the Java port do.
|
|
240
|
+
*/
|
|
241
|
+
function isoTimestampsToDates(entity: MetaData, row: Row): Row {
|
|
242
|
+
let out: Row | null = null;
|
|
243
|
+
// ADR-0039: resolving — a timestamp may be inherited from a base via extends.
|
|
244
|
+
for (const child of entity.children()) {
|
|
245
|
+
if (child.type !== TYPE_FIELD || child.subType !== FIELD_SUBTYPE_TIMESTAMP) continue;
|
|
246
|
+
if (!(child.name in row)) continue;
|
|
247
|
+
const v = row[child.name];
|
|
248
|
+
if (typeof v !== "string") continue;
|
|
249
|
+
const d = new Date(v);
|
|
250
|
+
if (Number.isNaN(d.getTime())) continue;
|
|
251
|
+
out ??= { ...row };
|
|
252
|
+
out[child.name] = d;
|
|
253
|
+
}
|
|
254
|
+
return out ?? row;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A scalar array (`field.string isArray`, …) read back as JSON TEXT — SQLite's JSON-in-TEXT
|
|
259
|
+
* column through a driver that does not parse it (Kysely) — becomes the array again. A
|
|
260
|
+
* value that is already an array (Postgres native arrays, mysql2's parsed JSON, Drizzle's
|
|
261
|
+
* JSON column modes) passes through.
|
|
262
|
+
*/
|
|
263
|
+
function parseJsonTextArrays(entity: MetaData, row: Row): Row {
|
|
264
|
+
let out: Row | null = null;
|
|
265
|
+
// ADR-0039: resolving — an array field may be inherited via extends.
|
|
266
|
+
for (const child of entity.children()) {
|
|
267
|
+
if (child.type !== TYPE_FIELD || child.subType === FIELD_SUBTYPE_OBJECT) continue;
|
|
268
|
+
if (!child.resolvedIsArray()) continue;
|
|
269
|
+
const v = row[child.name];
|
|
270
|
+
if (typeof v !== "string" || !v.startsWith("[")) continue;
|
|
271
|
+
try {
|
|
272
|
+
const parsed: unknown = JSON.parse(v);
|
|
273
|
+
if (!Array.isArray(parsed)) continue;
|
|
274
|
+
out ??= { ...row };
|
|
275
|
+
out[child.name] = parsed;
|
|
276
|
+
} catch {
|
|
277
|
+
// Not JSON: leave the stored text as it is.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out ?? row;
|
|
281
|
+
}
|
package/src/validator-runner.ts
CHANGED
|
@@ -108,7 +108,7 @@ export function runValidators(
|
|
|
108
108
|
if (field.subType === FIELD_SUBTYPE_OBJECT) {
|
|
109
109
|
const vo = resolveVoRef(field);
|
|
110
110
|
if (vo !== undefined) {
|
|
111
|
-
if (field.
|
|
111
|
+
if (field.resolvedIsArray() && !Array.isArray(value)) {
|
|
112
112
|
errors.push({
|
|
113
113
|
field: field.name, rule: "type",
|
|
114
114
|
message: `'${field.name}' must be an array of ${vo.name}`,
|
|
@@ -116,7 +116,7 @@ export function runValidators(
|
|
|
116
116
|
});
|
|
117
117
|
continue;
|
|
118
118
|
}
|
|
119
|
-
const elements: unknown[] = field.
|
|
119
|
+
const elements: unknown[] = field.resolvedIsArray() ? (value as unknown[]) : [value];
|
|
120
120
|
elements.forEach((el, i) => {
|
|
121
121
|
if (typeof el !== "object" || el === null || Array.isArray(el)) {
|
|
122
122
|
errors.push({
|
|
@@ -131,7 +131,7 @@ export function runValidators(
|
|
|
131
131
|
for (const e of sub.errors) {
|
|
132
132
|
errors.push({
|
|
133
133
|
...e,
|
|
134
|
-
field: field.
|
|
134
|
+
field: field.resolvedIsArray() ? `${field.name}[${i}].${e.field}` : `${field.name}.${e.field}`,
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
}
|
|
@@ -140,78 +140,26 @@ export function runValidators(
|
|
|
140
140
|
continue;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
message: typeError,
|
|
149
|
-
expected: field.subType,
|
|
150
|
-
received: typeof value,
|
|
151
|
-
});
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const maxLen = resolveMaxLength(field);
|
|
156
|
-
const minLen = resolveMinLength(field);
|
|
157
|
-
if (typeof value === "string") {
|
|
158
|
-
if (maxLen !== undefined && value.length > maxLen) {
|
|
159
|
-
errors.push({
|
|
160
|
-
field: field.name,
|
|
161
|
-
rule: "length",
|
|
162
|
-
message: `'${field.name}' must be at most ${maxLen} chars (got ${value.length})`,
|
|
163
|
-
expected: { max: maxLen },
|
|
164
|
-
received: value.length,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
// FR-036 Pin 1: a @required string is non-empty. The effective floor is
|
|
168
|
-
// max(@min, 1) so the runtime OM rejects "" for a required string exactly as
|
|
169
|
-
// the generated Zod InsertSchema (.min(1)) does — the two enforcement surfaces
|
|
170
|
-
// stay in lockstep. A non-required field keeps its authored @min.
|
|
171
|
-
const effectiveMin = Math.max(minLen ?? 0, required ? 1 : 0);
|
|
172
|
-
if (effectiveMin > 0 && value.length < effectiveMin) {
|
|
173
|
-
errors.push({
|
|
174
|
-
field: field.name,
|
|
175
|
-
rule: "length",
|
|
176
|
-
message: `'${field.name}' must be at least ${effectiveMin} chars (got ${value.length})`,
|
|
177
|
-
expected: { min: effectiveMin },
|
|
178
|
-
received: value.length,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ADR-0039: effective children — a validator may be inherited via extends.
|
|
184
|
-
for (const child of field.children()) {
|
|
185
|
-
if (child.type !== TYPE_VALIDATOR) continue;
|
|
186
|
-
if (child.subType !== VALIDATOR_SUBTYPE_REGEX) continue;
|
|
187
|
-
// ADR-0039: effective attr — @pattern may be inherited.
|
|
188
|
-
const pattern = child.attr(VALIDATOR_ATTR_PATTERN);
|
|
189
|
-
if (typeof pattern !== "string") continue;
|
|
190
|
-
if (typeof value !== "string") continue;
|
|
191
|
-
let regex: RegExp;
|
|
192
|
-
try {
|
|
193
|
-
// FR-036 Pin 2: validator.regex @pattern is FULL-MATCH — anchor as ^(?:…)$
|
|
194
|
-
// so the runtime OM matches the generated Zod schema's full-match semantic.
|
|
195
|
-
regex = new RegExp(`^(?:${pattern})$`);
|
|
196
|
-
} catch {
|
|
143
|
+
// A scalar array (`field.string isArray`, …) — stored as a native array or a JSON array.
|
|
144
|
+
// Each element is checked against the element rules; an element error names its index,
|
|
145
|
+
// as the value-object branch above does. ADR-0039: resolving `isArray`.
|
|
146
|
+
if (field.resolvedIsArray()) {
|
|
147
|
+
if (!Array.isArray(value)) {
|
|
197
148
|
errors.push({
|
|
198
|
-
field: field.name,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
expected: pattern,
|
|
149
|
+
field: field.name, rule: "type",
|
|
150
|
+
message: `'${field.name}' must be an array`,
|
|
151
|
+
expected: "array", received: typeof value,
|
|
202
152
|
});
|
|
203
153
|
continue;
|
|
204
154
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
expected: pattern,
|
|
211
|
-
received: value,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
155
|
+
value.forEach((el, i) => {
|
|
156
|
+
if (el === null || el === undefined) return;
|
|
157
|
+
errors.push(...scalarErrors(field, el, false, `${field.name}[${i}]`));
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
214
160
|
}
|
|
161
|
+
|
|
162
|
+
errors.push(...scalarErrors(field, value, required, field.name));
|
|
215
163
|
}
|
|
216
164
|
|
|
217
165
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
@@ -288,3 +236,85 @@ function checkType(subType: string, value: unknown): string | null {
|
|
|
288
236
|
}
|
|
289
237
|
return null;
|
|
290
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The type, length and regex errors for one scalar value of `field` — the whole field
|
|
242
|
+
* value, or one element of a scalar array (then `required` is false: element presence is not
|
|
243
|
+
* the field's `@required`, and `label` is `name[i]`).
|
|
244
|
+
*/
|
|
245
|
+
function scalarErrors(field: MetaData, value: unknown, required: boolean, label: string): ValidationFailure[] {
|
|
246
|
+
const errors: ValidationFailure[] = [];
|
|
247
|
+
const typeError = checkType(field.subType, value);
|
|
248
|
+
if (typeError !== null) {
|
|
249
|
+
errors.push({
|
|
250
|
+
field: label,
|
|
251
|
+
rule: "type",
|
|
252
|
+
message: typeError,
|
|
253
|
+
expected: field.subType,
|
|
254
|
+
received: typeof value,
|
|
255
|
+
});
|
|
256
|
+
return errors;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const maxLen = resolveMaxLength(field);
|
|
260
|
+
const minLen = resolveMinLength(field);
|
|
261
|
+
if (typeof value === "string") {
|
|
262
|
+
if (maxLen !== undefined && value.length > maxLen) {
|
|
263
|
+
errors.push({
|
|
264
|
+
field: label,
|
|
265
|
+
rule: "length",
|
|
266
|
+
message: `'${label}' must be at most ${maxLen} chars (got ${value.length})`,
|
|
267
|
+
expected: { max: maxLen },
|
|
268
|
+
received: value.length,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// FR-036 Pin 1: a @required string is non-empty. The effective floor is
|
|
272
|
+
// max(@min, 1) so the runtime OM rejects "" for a required string exactly as
|
|
273
|
+
// the generated Zod InsertSchema (.min(1)) does — the two enforcement surfaces
|
|
274
|
+
// stay in lockstep. A non-required field keeps its authored @min.
|
|
275
|
+
const effectiveMin = Math.max(minLen ?? 0, required ? 1 : 0);
|
|
276
|
+
if (effectiveMin > 0 && value.length < effectiveMin) {
|
|
277
|
+
errors.push({
|
|
278
|
+
field: label,
|
|
279
|
+
rule: "length",
|
|
280
|
+
message: `'${label}' must be at least ${effectiveMin} chars (got ${value.length})`,
|
|
281
|
+
expected: { min: effectiveMin },
|
|
282
|
+
received: value.length,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ADR-0039: effective children — a validator may be inherited via extends.
|
|
288
|
+
for (const child of field.children()) {
|
|
289
|
+
if (child.type !== TYPE_VALIDATOR) continue;
|
|
290
|
+
if (child.subType !== VALIDATOR_SUBTYPE_REGEX) continue;
|
|
291
|
+
// ADR-0039: effective attr — @pattern may be inherited.
|
|
292
|
+
const pattern = child.attr(VALIDATOR_ATTR_PATTERN);
|
|
293
|
+
if (typeof pattern !== "string") continue;
|
|
294
|
+
if (typeof value !== "string") continue;
|
|
295
|
+
let regex: RegExp;
|
|
296
|
+
try {
|
|
297
|
+
// FR-036 Pin 2: validator.regex @pattern is FULL-MATCH — anchor as ^(?:…)$
|
|
298
|
+
// so the runtime OM matches the generated Zod schema's full-match semantic.
|
|
299
|
+
regex = new RegExp(`^(?:${pattern})$`);
|
|
300
|
+
} catch {
|
|
301
|
+
errors.push({
|
|
302
|
+
field: label,
|
|
303
|
+
rule: "regex",
|
|
304
|
+
message: `'${label}' has an invalid validator pattern: ${pattern}`,
|
|
305
|
+
expected: pattern,
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!regex.test(value)) {
|
|
310
|
+
errors.push({
|
|
311
|
+
field: label,
|
|
312
|
+
rule: "regex",
|
|
313
|
+
message: `'${label}' does not match required pattern`,
|
|
314
|
+
expected: pattern,
|
|
315
|
+
received: value,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return errors;
|
|
320
|
+
}
|