@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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/dist/constraint-errors.d.ts.map +1 -1
  3. package/dist/constraint-errors.js +3 -1
  4. package/dist/constraint-errors.js.map +1 -1
  5. package/dist/drivers/drizzle-driver.d.ts +2 -1
  6. package/dist/drivers/drizzle-driver.d.ts.map +1 -1
  7. package/dist/drivers/drizzle-driver.js +41 -18
  8. package/dist/drivers/drizzle-driver.js.map +1 -1
  9. package/dist/drivers/kysely-driver.d.ts +3 -1
  10. package/dist/drivers/kysely-driver.d.ts.map +1 -1
  11. package/dist/drivers/kysely-driver.js +37 -4
  12. package/dist/drivers/kysely-driver.js.map +1 -1
  13. package/dist/drivers/mysql-support.d.ts +19 -0
  14. package/dist/drivers/mysql-support.d.ts.map +1 -0
  15. package/dist/drivers/mysql-support.js +64 -0
  16. package/dist/drivers/mysql-support.js.map +1 -0
  17. package/dist/drizzle-fastify/filter-parser.d.ts +2 -1
  18. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
  19. package/dist/drizzle-fastify/filter-parser.js +11 -4
  20. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  21. package/dist/drizzle-fastify/index.d.ts +2 -1
  22. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  23. package/dist/drizzle-fastify/index.js +23 -30
  24. package/dist/drizzle-fastify/index.js.map +1 -1
  25. package/dist/drizzle-fastify/mount-read-only.d.ts +2 -1
  26. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  27. package/dist/drizzle-fastify/mount-read-only.js +9 -3
  28. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  29. package/dist/drizzle-fastify/util.d.ts +7 -0
  30. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  31. package/dist/drizzle-fastify/util.js +35 -0
  32. package/dist/drizzle-fastify/util.js.map +1 -1
  33. package/dist/extract-object.d.ts.map +1 -1
  34. package/dist/extract-object.js +13 -2
  35. package/dist/extract-object.js.map +1 -1
  36. package/dist/hono/index.d.ts +2 -1
  37. package/dist/hono/index.d.ts.map +1 -1
  38. package/dist/hono/index.js +21 -30
  39. package/dist/hono/index.js.map +1 -1
  40. package/dist/hono/mount-read-only.d.ts +2 -1
  41. package/dist/hono/mount-read-only.d.ts.map +1 -1
  42. package/dist/hono/mount-read-only.js +9 -3
  43. package/dist/hono/mount-read-only.js.map +1 -1
  44. package/dist/object-manager.d.ts.map +1 -1
  45. package/dist/object-manager.js +2 -1
  46. package/dist/object-manager.js.map +1 -1
  47. package/dist/persistence-driver.d.ts +8 -1
  48. package/dist/persistence-driver.d.ts.map +1 -1
  49. package/dist/query-builder.d.ts +2 -0
  50. package/dist/query-builder.d.ts.map +1 -1
  51. package/dist/query-builder.js +5 -0
  52. package/dist/query-builder.js.map +1 -1
  53. package/dist/sql-dialect.d.ts +11 -0
  54. package/dist/sql-dialect.d.ts.map +1 -0
  55. package/dist/sql-dialect.js +16 -0
  56. package/dist/sql-dialect.js.map +1 -0
  57. package/dist/type-coercer.d.ts.map +1 -1
  58. package/dist/type-coercer.js +63 -4
  59. package/dist/type-coercer.js.map +1 -1
  60. package/dist/validator-runner.d.ts.map +1 -1
  61. package/dist/validator-runner.js +102 -73
  62. package/dist/validator-runner.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/constraint-errors.ts +3 -1
  65. package/src/drivers/drizzle-driver.ts +44 -17
  66. package/src/drivers/kysely-driver.ts +43 -7
  67. package/src/drivers/mysql-support.ts +77 -0
  68. package/src/drizzle-fastify/filter-parser.ts +16 -7
  69. package/src/drizzle-fastify/index.ts +23 -26
  70. package/src/drizzle-fastify/mount-read-only.ts +10 -4
  71. package/src/drizzle-fastify/util.ts +29 -0
  72. package/src/extract-object.ts +18 -0
  73. package/src/hono/index.ts +21 -26
  74. package/src/hono/mount-read-only.ts +10 -4
  75. package/src/object-manager.ts +2 -1
  76. package/src/persistence-driver.ts +8 -1
  77. package/src/query-builder.ts +9 -0
  78. package/src/sql-dialect.ts +21 -0
  79. package/src/type-coercer.ts +55 -3
  80. 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 {
@@ -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
+ }
@@ -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 for that dialect only.
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
+ }
@@ -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.isArray && !Array.isArray(value)) {
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.isArray ? (value as unknown[]) : [value];
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.isArray ? `${field.name}[${i}].${e.field}` : `${field.name}.${e.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
- const typeError = checkType(field.subType, value);
144
- if (typeError !== null) {
145
- errors.push({
146
- field: field.name,
147
- rule: "type",
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
- rule: "regex",
200
- message: `'${field.name}' has an invalid validator pattern: ${pattern}`,
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
- if (!regex.test(value)) {
206
- errors.push({
207
- field: field.name,
208
- rule: "regex",
209
- message: `'${field.name}' does not match required pattern`,
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
+ }