@metaobjectsdev/runtime-ts 1.0.9 → 1.0.10

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 +44 -0
  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 +6 -0
  30. package/dist/drizzle-fastify/util.d.ts.map +1 -1
  31. package/dist/drizzle-fastify/util.js +28 -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 +47 -4
  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 +23 -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
@@ -4,6 +4,7 @@ import type {
4
4
  UpdateSpec, UpdateManySpec, DeleteSpec, DeleteManySpec, WhereClause, Row,
5
5
  } from "../persistence-driver.js";
6
6
  import { ConstraintViolationError } from "../errors.js";
7
+ import { arraysAsJsonText, insertedKey, mysqlConstraintKind } from "./mysql-support.js";
7
8
 
8
9
  // Kysely's fluent builder is heavily generic on the database schema, but this driver
9
10
  // is metadata-driven and accepts any table name at runtime. Confine the schema-agnostic
@@ -17,7 +18,9 @@ type AnyQuery = SelectQueryBuilder<any, any, any>;
17
18
 
18
19
  export interface KyselyDriverOptions {
19
20
  db: Kysely<Record<string, Row>>;
20
- dialect: "sqlite" | "postgres";
21
+ /** `mysql`: build the Kysely instance with `MysqlDialect` over a mysql2 pool created with
22
+ * `timezone: "Z"` (DATETIME then holds the UTC wall clock). */
23
+ dialect: "sqlite" | "postgres" | "mysql";
21
24
  }
22
25
 
23
26
  export interface KyselyDriverPublic extends PersistenceDriver {
@@ -31,8 +34,24 @@ export function kyselyDriver(opts: KyselyDriverOptions): KyselyDriverPublic {
31
34
 
32
35
  function makeKyselyDriver(
33
36
  db: Kysely<Record<string, Row>>,
34
- dialect: "sqlite" | "postgres",
37
+ dialect: "sqlite" | "postgres" | "mysql",
35
38
  ): KyselyDriverPublic {
39
+ // Neither SQLite nor MySQL has an array type: a scalar array is bound as JSON text.
40
+ const bindable = (values: Row): Row => (dialect === "postgres" ? values : arraysAsJsonText(values));
41
+
42
+ // MySQL has no RETURNING: insert, then read the row back by its key.
43
+ async function insertAndReadBack(table: string, values: Row, returning: string[], key: string[] | undefined): Promise<Row> {
44
+ const result = await (db as RawKysely).insertInto(table).values(bindable(values)).executeTakeFirst() as
45
+ { insertId?: bigint | number } | undefined;
46
+ const keyValues = key !== undefined && key.length > 0 ? insertedKey(key, values, result?.insertId) : undefined;
47
+ if (keyValues === undefined) {
48
+ throw new Error(`kyselyDriver.insert: cannot read back the row inserted into '${table}' (no primary-key value)`);
49
+ }
50
+ let q = (db as RawKysely).selectFrom(table).select(returning);
51
+ for (const [col, v] of Object.entries(keyValues)) q = q.where(col, "=", v);
52
+ return (await q.executeTakeFirstOrThrow()) as Row;
53
+ }
54
+
36
55
  return {
37
56
  db,
38
57
  dialect,
@@ -62,9 +81,10 @@ function makeKyselyDriver(
62
81
 
63
82
  async insert(spec: InsertSpec): Promise<Row> {
64
83
  try {
84
+ if (dialect === "mysql") return await insertAndReadBack(spec.table, spec.values, spec.returning, spec.key);
65
85
  const result = await (db as RawKysely)
66
86
  .insertInto(spec.table)
67
- .values(spec.values)
87
+ .values(bindable(spec.values))
68
88
  .returning(spec.returning)
69
89
  .executeTakeFirstOrThrow();
70
90
  return result as Row;
@@ -75,9 +95,14 @@ function makeKyselyDriver(
75
95
 
76
96
  async insertMany(spec: InsertManySpec): Promise<Row[]> {
77
97
  try {
98
+ if (dialect === "mysql") {
99
+ const out: Row[] = [];
100
+ for (const row of spec.rows) out.push(await insertAndReadBack(spec.table, row, spec.returning, spec.key));
101
+ return out;
102
+ }
78
103
  const rows = await (db as RawKysely)
79
104
  .insertInto(spec.table)
80
- .values(spec.rows)
105
+ .values(spec.rows.map(bindable))
81
106
  .returning(spec.returning)
82
107
  .execute();
83
108
  return rows as Row[];
@@ -88,8 +113,14 @@ function makeKyselyDriver(
88
113
 
89
114
  async update(spec: UpdateSpec): Promise<Row | null> {
90
115
  try {
91
- let q = (db as RawKysely).updateTable(spec.table).set(spec.values);
116
+ let q = (db as RawKysely).updateTable(spec.table).set(bindable(spec.values));
92
117
  q = applyWhere(q, spec.where);
118
+ if (dialect === "mysql") {
119
+ await q.execute();
120
+ let s = (db as RawKysely).selectFrom(spec.table).select(spec.returning);
121
+ s = applyWhere(s, spec.where);
122
+ return ((await s.executeTakeFirst()) as Row | undefined) ?? null;
123
+ }
93
124
  const rows = await q.returning(spec.returning).execute();
94
125
  return ((rows as Row[])[0]) ?? null;
95
126
  } catch (err) {
@@ -99,7 +130,7 @@ function makeKyselyDriver(
99
130
 
100
131
  async updateMany(spec: UpdateManySpec): Promise<number> {
101
132
  try {
102
- let q = (db as RawKysely).updateTable(spec.table).set(spec.values);
133
+ let q = (db as RawKysely).updateTable(spec.table).set(bindable(spec.values));
103
134
  q = applyWhere(q, spec.where);
104
135
  const result = await q.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined;
105
136
  return Number(result?.numUpdatedRows ?? 0);
@@ -185,11 +216,16 @@ function applyOrderLimit(
185
216
  return out;
186
217
  }
187
218
 
188
- function mapDriverError(err: unknown, table: string, dialect: "sqlite" | "postgres"): unknown {
219
+ function mapDriverError(err: unknown, table: string, dialect: "sqlite" | "postgres" | "mysql"): unknown {
189
220
  if (!(err instanceof Error)) return err;
190
221
  const msg = err.message;
191
222
  const code = (err as { code?: string }).code;
192
223
 
224
+ if (dialect === "mysql") {
225
+ const kind = mysqlConstraintKind(err);
226
+ return kind === null ? err : new ConstraintViolationError(msg, { kind, table, cause: err });
227
+ }
228
+
193
229
  if (dialect === "sqlite") {
194
230
  const kind = sqliteConstraintKind(code, msg);
195
231
  if (kind !== null) {
@@ -0,0 +1,77 @@
1
+ // MySQL support shared by the Kysely and Drizzle ObjectManager drivers.
2
+ //
3
+ // MySQL has no RETURNING, so a driver writes and then reads the row back:
4
+ // - insert: the key is the written key columns, plus the AUTO_INCREMENT id the server
5
+ // reports when the table has one and the caller did not supply it;
6
+ // - update: the row is re-selected with the same WHERE (absent → null).
7
+ // Constraint errors are recognised by mysql2's error `code`, walked through any wrapper's
8
+ // `cause` chain (Drizzle wraps driver errors).
9
+ import type { Row } from "../persistence-driver.js";
10
+
11
+ export type ConstraintKindName = "unique" | "foreign_key" | "not_null" | "check";
12
+
13
+ const MYSQL_CODES: Record<string, ConstraintKindName> = {
14
+ ER_DUP_ENTRY: "unique",
15
+ ER_NO_REFERENCED_ROW_2: "foreign_key",
16
+ ER_ROW_IS_REFERENCED_2: "foreign_key",
17
+ ER_NO_REFERENCED_ROW: "foreign_key",
18
+ ER_ROW_IS_REFERENCED: "foreign_key",
19
+ ER_BAD_NULL_ERROR: "not_null",
20
+ ER_CHECK_CONSTRAINT_VIOLATED: "check",
21
+ };
22
+
23
+ /** The constraint kind a mysql2 error (or a wrapper around one) reports, else null. */
24
+ export function mysqlConstraintKind(err: unknown): ConstraintKindName | null {
25
+ let cur: unknown = err;
26
+ for (let depth = 0; depth < 8 && typeof cur === "object" && cur !== null; depth++) {
27
+ const code = (cur as { code?: unknown }).code;
28
+ if (typeof code === "string" && code in MYSQL_CODES) return MYSQL_CODES[code]!;
29
+ const next = (cur as { cause?: unknown }).cause;
30
+ if (next === cur) break;
31
+ cur = next;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The key-column values of a row just inserted: every key column the caller wrote, and the
38
+ * server-generated AUTO_INCREMENT id for the one it did not. `undefined` when a key column
39
+ * has neither (the insert cannot be read back).
40
+ */
41
+ export function insertedKey(
42
+ key: readonly string[],
43
+ values: Row,
44
+ insertId: number | bigint | undefined,
45
+ ): Row | undefined {
46
+ const out: Row = {};
47
+ let usedInsertId = false;
48
+ for (const col of key) {
49
+ const v = values[col];
50
+ if (v !== undefined && v !== null) {
51
+ out[col] = v;
52
+ } else if (!usedInsertId && insertId !== undefined && Number(insertId) !== 0) {
53
+ out[col] = typeof insertId === "bigint" ? Number(insertId) : insertId;
54
+ usedInsertId = true;
55
+ } else {
56
+ return undefined;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * A row a SQL driver without an array type can bind (SQLite, MySQL). mysql2 expands a JS
64
+ * array into a value LIST (`'x', 'y'`), breaking the column count, and libsql cannot bind one
65
+ * at all; a scalar array lives in a JSON (MySQL) or JSON-in-TEXT (SQLite) column, so it is sent
66
+ * as JSON text. Objects already arrive as JSON text from the type coercer. Drizzle's JSON
67
+ * column modes encode for themselves, so only the Kysely driver calls this.
68
+ */
69
+ export function arraysAsJsonText(values: Row): Row {
70
+ let out: Row | null = null;
71
+ for (const [k, v] of Object.entries(values)) {
72
+ if (!Array.isArray(v)) continue;
73
+ out ??= { ...values };
74
+ out[k] = JSON.stringify(v);
75
+ }
76
+ return out ?? values;
77
+ }
@@ -3,6 +3,7 @@ import {
3
3
  eq, ne, gt, gte, lt, lte, inArray, like, ilike, isNull, not, and, or, asc, desc, sql,
4
4
  type SQL, type SQLWrapper,
5
5
  } from "drizzle-orm";
6
+ import type { SqlDialect } from "../sql-dialect.js";
6
7
  import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
7
8
  import { sortOrderSpec } from "./filter-allowlist.js";
8
9
  import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.js";
@@ -24,7 +25,7 @@ export interface ParseFilterOpts {
24
25
  table: AnyTable;
25
26
  allowlist: FilterAllowlist;
26
27
  sortAllowlist: SortAllowlist;
27
- dialect: "sqlite" | "postgres";
28
+ dialect: SqlDialect;
28
29
  maxNesting?: number;
29
30
  maxInListSize?: number;
30
31
  }
@@ -132,9 +133,13 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
132
133
  // deliberately case-INSENSITIVE — it is a human search box, not the
133
134
  // contract's `like` operator (which is case-sensitive SQL LIKE, ADR-0049).
134
135
  // Postgres uses ILIKE; SQLite's native LIKE folds ASCII case by default,
135
- // which is the intended behavior here.
136
- const matcher = opts.dialect === "postgres" ? ilike : like;
137
- const parts = stringCols.map((col) => matcher(col, term));
136
+ // which is the intended behavior here. MySQL's LIKE follows the column's
137
+ // collation, which is case-insensitive by default and case-sensitive on a
138
+ // `_bin` column, so it folds both sides explicitly.
139
+ const parts = stringCols.map((col) =>
140
+ opts.dialect === "postgres" ? ilike(col, term)
141
+ : opts.dialect === "mysql" ? sql`LOWER(${col}) LIKE LOWER(${term})`
142
+ : like(col, term));
138
143
  // or() is defined as returning SQL | undefined but will always return
139
144
  // SQL when given a non-empty array. parts[0] is always defined here
140
145
  // because stringCols.length > 0 guarantees at least one element.
@@ -150,7 +155,7 @@ function parseNode(
150
155
  node: Record<string, unknown>,
151
156
  table: AnyTable,
152
157
  allowlist: FilterAllowlist,
153
- dialect: "sqlite" | "postgres",
158
+ dialect: SqlDialect,
154
159
  maxNesting: number,
155
160
  maxInList: number,
156
161
  depth: number,
@@ -203,7 +208,7 @@ function compileOp(
203
208
  field: string,
204
209
  op: string,
205
210
  value: unknown,
206
- dialect: "sqlite" | "postgres",
211
+ dialect: SqlDialect,
207
212
  maxInList: number,
208
213
  ): SQL | undefined {
209
214
  if (!rule.ops.includes(op as FilterOp)) {
@@ -234,9 +239,13 @@ function compileOp(
234
239
  // default (and `PRAGMA case_sensitive_like` is connection-global on a
235
240
  // consumer-owned connection), so the sqlite branch lowers to GLOB with
236
241
  // an exactly-translated pattern instead.
242
+ // MySQL's LIKE follows the column's collation, which is case-INSENSITIVE by
243
+ // default, so the pattern carries an explicit binary collation.
237
244
  return dialect === "postgres"
238
245
  ? like(col as any, s)
239
- : sql`${col} GLOB ${likePatternToGlob(s)}`;
246
+ : dialect === "mysql"
247
+ ? sql`${col} LIKE ${s} COLLATE utf8mb4_bin`
248
+ : sql`${col} GLOB ${likePatternToGlob(s)}`;
240
249
  }
241
250
  case "isNull": {
242
251
  // isNull's value is always coerced as boolean (true/false), regardless of the
@@ -27,8 +27,9 @@ import qs from "qs";
27
27
  import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
28
28
  export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
29
29
  import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
30
- import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
30
+ import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js";
31
31
  import { timestampWire } from "../timestamp-wire.js";
32
+ import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
32
33
  import { withContractErrorHandler } from "./route-error-handler.js";
33
34
  import { validationErrorBody } from "../route-errors.js";
34
35
  export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
@@ -84,7 +85,7 @@ export interface CrudRoutesOptions {
84
85
  filterAllowlist?: FilterAllowlist;
85
86
  sortAllowlist?: SortAllowlist;
86
87
  /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
87
- dialect?: "sqlite" | "postgres";
88
+ dialect?: SqlDialect;
88
89
  /**
89
90
  * FR-017 TPH — scope this route set to a single subtype of a single-table-
90
91
  * inheritance base. When set:
@@ -280,15 +281,23 @@ export function mountCreateRoute(opts: VerbOptions): void {
280
281
  : parsed.data;
281
282
  // A constraint the DB enforces (a foreign key from identity.reference, a unique
282
283
  // index) must not surface as a 500 echoing the SQL and its bound parameters.
283
- let result: unknown;
284
+ let row: unknown;
284
285
  try {
285
- result = await opts.db.insert(opts.table).values(values).returning();
286
+ if (supportsReturning(opts.dialect)) {
287
+ row = ((await opts.db.insert(opts.table).values(values).returning()) as unknown[])[0];
288
+ } else {
289
+ // MySQL has no RETURNING: take the key from the body (an assigned key) or from
290
+ // `$returningId()` (AUTO_INCREMENT / `$defaultFn`), then read the row back.
291
+ const [inserted] = (await opts.db.insert(opts.table).values(values).$returningId()) as
292
+ Array<Record<string, unknown> | undefined>;
293
+ const key = (values as Record<string, unknown>).id ?? inserted?.id;
294
+ row = key === undefined ? undefined : await firstRow(opts.db, opts.table, eq(opts.table.id, key));
295
+ }
286
296
  } catch (e) {
287
297
  const f = classifyConstraintError(e);
288
298
  if (f === undefined) { logAndRedact(e); throw new RedactedDatabaseError(); }
289
299
  return reply.code(f.status).send(f.body);
290
300
  }
291
- const row = (result as unknown[])[0];
292
301
  // #214 — read-your-writes: re-read through the replica view so the response
293
302
  // carries derived (origin.passthrough) columns the base table excludes.
294
303
  return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
@@ -341,19 +350,21 @@ export function mountUpdateRoute(opts: VerbOptions): void {
341
350
  return reply.code(400).send({ error: "invalid_id" });
342
351
  }
343
352
  const idCond = eq(opts.table.id, idValue);
344
- let result: unknown;
353
+ const cond = discCond ? and(idCond, discCond) : idCond;
354
+ let row: unknown;
345
355
  try {
346
- result = await opts.db
347
- .update(opts.table)
348
- .set(data)
349
- .where(discCond ? and(idCond, discCond) : idCond)
350
- .returning();
356
+ if (supportsReturning(opts.dialect)) {
357
+ row = ((await opts.db.update(opts.table).set(data).where(cond).returning()) as unknown[])[0];
358
+ } else {
359
+ // MySQL has no RETURNING: update, then read the row back (absent → 404).
360
+ await opts.db.update(opts.table).set(data).where(cond);
361
+ row = await firstRow(opts.db, opts.table, cond);
362
+ }
351
363
  } catch (e) {
352
364
  const f = classifyConstraintError(e);
353
365
  if (f === undefined) { logAndRedact(e); throw new RedactedDatabaseError(); }
354
366
  return reply.code(f.status).send(f.body);
355
367
  }
356
- const row = (result as unknown[])[0];
357
368
  if (row == null) return reply.code(404).send({ error: "not_found" });
358
369
  // #214 — re-read through the replica view so the response carries derived columns.
359
370
  return toWire(await reReadThroughView(opts, row));
@@ -408,19 +419,5 @@ export function mountDeleteRoute(opts: VerbOptions): void {
408
419
  });
409
420
  }
410
421
 
411
- function extractRowCount(result: unknown): number {
412
- if (typeof result === "number") return result;
413
- if (Array.isArray(result)) return result.length;
414
- if (result && typeof result === "object") {
415
- const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
416
- if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
417
- if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
418
- if (typeof obj.rowCount === "number") return obj.rowCount;
419
- // bun:sqlite / better-sqlite3 run() result shape.
420
- if (typeof obj.changes === "number") return obj.changes;
421
- }
422
- return 0;
423
- }
424
-
425
422
  export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
426
423
  export { mountM2mRoute, type M2mRouteOptions } from "./mount-m2m.js";
@@ -1,4 +1,5 @@
1
1
  import type { FastifyInstance, RouteShorthandOptions } from "fastify";
2
+ import { quoteIdent, type SqlDialect } from "../sql-dialect.js";
2
3
  import { sql, eq, and, count } from "drizzle-orm";
3
4
  import qs from "qs";
4
5
  import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js";
@@ -18,7 +19,7 @@ export interface MountReadOnlyOptions {
18
19
  readonly view: AnyView;
19
20
  readonly filterAllowlist: FilterAllowlist;
20
21
  readonly sortAllowlist: SortAllowlist;
21
- readonly dialect: "postgres" | "sqlite";
22
+ readonly dialect: SqlDialect;
22
23
  /** Override default ID column name (defaults to "id"). */
23
24
  readonly idColumn?: string;
24
25
  /**
@@ -113,6 +114,11 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
113
114
  if (Array.isArray(res)) return res as Record<string, unknown>[];
114
115
  return (res as { rows?: Record<string, unknown>[] } | null)?.rows ?? [];
115
116
  }
117
+ if (dialect === "mysql") {
118
+ // mysql2 answers `[rows, fields]`.
119
+ const [rows] = (await db.execute(query)) as [Record<string, unknown>[], unknown];
120
+ return rows;
121
+ }
116
122
  return (await db.all(query)) as Record<string, unknown>[];
117
123
  }
118
124
 
@@ -145,11 +151,11 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
145
151
  const offsetVal = parsePageBound(parsed, "offset") ?? 0;
146
152
  const withCount = isTruthyFlag(parsed["withCount"]);
147
153
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
148
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
154
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
149
155
  const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
150
156
  if (!withCount) return camelRows;
151
157
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
152
- const countRows = await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM "${viewName}"`)) as any[];
158
+ const countRows = await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM ${quoteIdent(dialect, viewName)}`)) as any[];
153
159
  const total: number = Number(countRows[0]?.c ?? 0);
154
160
  return { rows: camelRows, total };
155
161
  }
@@ -204,7 +210,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
204
210
  const { id } = req.params as { id: string };
205
211
  if (useRawSql) {
206
212
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
207
- const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
213
+ const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
208
214
  const row = rows[0] ? camelizeRow(rows[0]) : undefined;
209
215
  return row ?? reply.code(404).send({ error: "not_found" });
210
216
  }
@@ -114,3 +114,26 @@ export async function firstRow(db: unknown, src: unknown, cond: unknown): Promis
114
114
  const rows: unknown = await (db as any).select().from(src).where(cond).limit(1);
115
115
  return (rows as unknown[])[0];
116
116
  }
117
+
118
+ /**
119
+ * Rows a DELETE/UPDATE affected, across the driver result shapes: libsql `rowsAffected`,
120
+ * node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, and mysql2's
121
+ * `[ResultSetHeader, fields]` tuple.
122
+ */
123
+ export function extractRowCount(result: unknown): number {
124
+ if (typeof result === "number") return result;
125
+ // mysql2: `[ResultSetHeader, fields]`, the count on the header.
126
+ if (Array.isArray(result) && typeof (result[0] as { affectedRows?: unknown } | undefined)?.affectedRows === "number") {
127
+ return (result[0] as { affectedRows: number }).affectedRows;
128
+ }
129
+ if (Array.isArray(result)) return result.length;
130
+ if (result && typeof result === "object") {
131
+ const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
132
+ if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
133
+ if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
134
+ if (typeof obj.rowCount === "number") return obj.rowCount;
135
+ // bun:sqlite / better-sqlite3 run() result shape.
136
+ if (typeof obj.changes === "number") return obj.changes;
137
+ }
138
+ return 0;
139
+ }
@@ -54,6 +54,7 @@ import {
54
54
  FIELD_ATTR_OBJECT_REF,
55
55
  FIELD_ATTR_XML_TEXT,
56
56
  VALIDATOR_SUBTYPE_NUMERIC,
57
+ VALIDATOR_SUBTYPE_ARRAY,
57
58
  NORMALIZE_DEFAULT,
58
59
  type NormalizeMode,
59
60
  } from "@metaobjectsdev/metadata";
@@ -64,6 +65,7 @@ import {
64
65
  scalar,
65
66
  range,
66
67
  textContentField,
68
+ withItemBounds,
67
69
  enumField,
68
70
  enumArray,
69
71
  object,
@@ -143,6 +145,22 @@ function fieldSpecFor(
143
145
  format: Format,
144
146
  visited: Set<MetaObject>,
145
147
  depth: number,
148
+ ): FieldSpec {
149
+ const spec = fieldShapeFor(field, owner, format, visited, depth);
150
+ if (!field.resolvedIsArray()) return spec;
151
+ // Element-count bounds: the field's array validator (@min/@max) is the single source of
152
+ // truth, as the numeric validator is for a scalar's range.
153
+ const bounds = field.validators().find((v) => v.subType === VALIDATOR_SUBTYPE_ARRAY);
154
+ if (bounds === undefined) return spec;
155
+ return withItemBounds(spec, bounds.min ?? null, bounds.max ?? null);
156
+ }
157
+
158
+ function fieldShapeFor(
159
+ field: MetaField,
160
+ owner: MetaObject,
161
+ format: Format,
162
+ visited: Set<MetaObject>,
163
+ depth: number,
146
164
  ): FieldSpec {
147
165
  const name = field.name;
148
166
  const required = isRequired(field);
package/src/hono/index.ts CHANGED
@@ -42,7 +42,8 @@ export type {
42
42
  FilterAllowlist,
43
43
  SortAllowlist,
44
44
  } from "../drizzle-fastify/filter-allowlist.js";
45
- import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
45
+ import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js";
46
+ import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
46
47
  import { timestampWire } from "../timestamp-wire.js";
47
48
  // Every handler below is wrapped in guardRoute (an unexpected error answers
48
49
  // `500 { error: "internal" }`, logged server-side) and reads its body through
@@ -104,7 +105,7 @@ export interface CrudRoutesOptions {
104
105
  filterAllowlist?: FilterAllowlist;
105
106
  sortAllowlist?: SortAllowlist;
106
107
  /** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
107
- dialect?: "sqlite" | "postgres";
108
+ dialect?: SqlDialect;
108
109
  }
109
110
 
110
111
  const ALL_VERBS: readonly CrudVerb[] = ["list", "get", "create", "update", "delete"];
@@ -266,15 +267,23 @@ export function mountCreateRoute(opts: VerbOptions): void {
266
267
  // Same redaction contract as the Fastify mount: a constraint the DB enforces must not
267
268
  // come back as a 500 carrying the SQL and its bound parameters. Both mounts are
268
269
  // required to emit byte-identical responses (docs/features/api-contract.md).
269
- let result: unknown;
270
+ let row: unknown;
270
271
  try {
271
- result = await opts.db.insert(opts.table).values(parsed.data).returning();
272
+ if (supportsReturning(opts.dialect)) {
273
+ row = ((await opts.db.insert(opts.table).values(parsed.data).returning()) as unknown[])[0];
274
+ } else {
275
+ // MySQL has no RETURNING — the Fastify mount's rule; see drizzle-fastify/index.ts.
276
+ const values = parsed.data as Record<string, unknown>;
277
+ const [inserted] = (await opts.db.insert(opts.table).values(values).$returningId()) as
278
+ Array<Record<string, unknown> | undefined>;
279
+ const key = values.id ?? inserted?.id;
280
+ row = key === undefined ? undefined : await firstRow(opts.db, opts.table, eq(opts.table.id, key));
281
+ }
272
282
  } catch (err) {
273
283
  const f = classifyConstraintError(err);
274
284
  if (f === undefined) { logAndRedact(err); throw new RedactedDatabaseError(); }
275
285
  return c.json(f.body, f.status as 400 | 409);
276
286
  }
277
- const row = (result as unknown[])[0];
278
287
  // Echo the row through the replica view so derived columns are present (#214).
279
288
  return c.json(toWire(await reReadThroughView(opts, row)), 201);
280
289
  }));
@@ -303,19 +312,19 @@ export function mountUpdateRoute(opts: VerbOptions): void {
303
312
  // LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
304
313
  const idValue = coerceIdForColumn(opts.table.id, id);
305
314
  if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
306
- let result: unknown;
315
+ let row: unknown;
307
316
  try {
308
- result = await opts.db
309
- .update(opts.table)
310
- .set(parsed.data)
311
- .where(eq(opts.table.id, idValue))
312
- .returning();
317
+ if (supportsReturning(opts.dialect)) {
318
+ row = ((await opts.db.update(opts.table).set(parsed.data).where(eq(opts.table.id, idValue)).returning()) as unknown[])[0];
319
+ } else {
320
+ await opts.db.update(opts.table).set(parsed.data).where(eq(opts.table.id, idValue));
321
+ row = await firstRow(opts.db, opts.table, eq(opts.table.id, idValue));
322
+ }
313
323
  } catch (err) {
314
324
  const f = classifyConstraintError(err);
315
325
  if (f === undefined) { logAndRedact(err); throw new RedactedDatabaseError(); }
316
326
  return c.json(f.body, f.status as 400 | 409);
317
327
  }
318
- const row = (result as unknown[])[0];
319
328
  return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
320
329
  });
321
330
  const path = `${opts.path}/:id`;
@@ -358,20 +367,6 @@ export function mountDeleteRoute(opts: VerbOptions): void {
358
367
  }));
359
368
  }
360
369
 
361
- function extractRowCount(result: unknown): number {
362
- if (typeof result === "number") return result;
363
- if (Array.isArray(result)) return result.length;
364
- if (result && typeof result === "object") {
365
- const obj = result as { rowsAffected?: number | bigint; rowCount?: number; changes?: number };
366
- if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
367
- if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
368
- if (typeof obj.rowCount === "number") return obj.rowCount;
369
- // bun:sqlite / better-sqlite3 run() result shape.
370
- if (typeof obj.changes === "number") return obj.changes;
371
- }
372
- return 0;
373
- }
374
-
375
370
  export { mountReadOnlyCrudRoutes, type MountReadOnlyOptions } from "./mount-read-only.js";
376
371
 
377
372
  // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
2
2
  // GET :id only. POST/PATCH/PUT/DELETE return 405. Mirrors the drizzle-fastify
3
3
  // equivalent so the cross-port API contract holds for projection endpoints.
4
4
 
5
+ import { quoteIdent, type SqlDialect } from "../sql-dialect.js";
5
6
  import type { Hono } from "hono";
6
7
  import { sql, eq, and, count } from "drizzle-orm";
7
8
  import qs from "qs";
@@ -28,7 +29,7 @@ export interface MountReadOnlyOptions {
28
29
  readonly view: AnyView;
29
30
  readonly filterAllowlist: FilterAllowlist;
30
31
  readonly sortAllowlist: SortAllowlist;
31
- readonly dialect: "postgres" | "sqlite";
32
+ readonly dialect: SqlDialect;
32
33
  /** Override default ID column name (defaults to "id"). */
33
34
  readonly idColumn?: string;
34
35
  }
@@ -96,6 +97,11 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
96
97
  if (Array.isArray(res)) return res as Record<string, unknown>[];
97
98
  return (res as { rows?: Record<string, unknown>[] } | null)?.rows ?? [];
98
99
  }
100
+ if (dialect === "mysql") {
101
+ // mysql2 answers `[rows, fields]`.
102
+ const [rows] = (await db.execute(query)) as [Record<string, unknown>[], unknown];
103
+ return rows;
104
+ }
99
105
  return (await db.all(query)) as Record<string, unknown>[];
100
106
  }
101
107
 
@@ -121,11 +127,11 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
121
127
  const offsetVal = parsePageBound(parsed, "offset") ?? 0;
122
128
  const withCount = isTruthyFlag(parsed["withCount"]);
123
129
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
124
- const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
130
+ const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
125
131
  const camelRows = rows.map((r: Record<string, unknown>) => camelizeRow(r));
126
132
  if (!withCount) return c.json(camelRows);
127
133
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
128
- const countRows = (await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM "${viewName}"`))) as any[];
134
+ const countRows = (await rawRows(db, dialect, sql.raw(`SELECT COUNT(*) AS c FROM ${quoteIdent(dialect, viewName)}`))) as any[];
129
135
  const total: number = Number(countRows[0]?.c ?? 0);
130
136
  return c.json({ rows: camelRows, total });
131
137
  }
@@ -179,7 +185,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
179
185
  const id = c.req.param("id") ?? "";
180
186
  if (useRawSql) {
181
187
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
182
- const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" WHERE "${idCol}" = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
188
+ const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
183
189
  const row = rows[0] ? camelizeRow(rows[0]) : undefined;
184
190
  return row ? c.json(row) : c.json({ error: "not_found" }, 404);
185
191
  }
@@ -10,7 +10,7 @@ import type {
10
10
  } from "./persistence-driver.js";
11
11
  import {
12
12
  buildSelectSpec, buildCountSpec, buildInsertSpec, buildUpdateSpec, buildDeleteSpec,
13
- resolvePkFields, compileFilter,
13
+ resolvePkFields, compileFilter, primaryKeyColumns,
14
14
  type Filter, type QueryOpts, type DiscriminatorScope,
15
15
  } from "./query-builder.js";
16
16
  import { tphSubtypeOf, type TphSubtype } from "./tph.js";
@@ -263,6 +263,7 @@ export class ObjectManager {
263
263
  table: resolveTableName(entity),
264
264
  rows: validatedRows.map((r) => this.toDbRow(entity, coerceRowOnWrite(entity, r, driver.dialect))),
265
265
  returning: this.allDbColumns(entity),
266
+ key: primaryKeyColumns(entity, this.columnNamingStrategy),
266
267
  };
267
268
  // Wrap the batch in a transaction so a driver-level constraint failure on row N
268
269
  // rolls back rows 1..N-1. Caller-supplied opts.tx already provides this.
@@ -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 {