turbine-orm 0.19.2 → 0.22.0

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 (53) hide show
  1. package/README.md +99 -18
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +6 -3
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio.js +6 -7
  8. package/dist/cjs/client.js +44 -22
  9. package/dist/cjs/dialect.js +116 -0
  10. package/dist/cjs/errors.js +19 -1
  11. package/dist/cjs/generate.js +4 -0
  12. package/dist/cjs/index.js +3 -2
  13. package/dist/cjs/introspect.js +20 -0
  14. package/dist/cjs/mssql.js +1338 -0
  15. package/dist/cjs/mysql.js +1052 -0
  16. package/dist/cjs/powdb.js +851 -0
  17. package/dist/cjs/powql.js +903 -0
  18. package/dist/cjs/query/builder.js +293 -73
  19. package/dist/cjs/sqlite.js +849 -0
  20. package/dist/cjs/typed-sql.js +9 -7
  21. package/dist/cli/index.js +6 -3
  22. package/dist/cli/observe-ui.d.ts +1 -1
  23. package/dist/cli/observe-ui.js +12 -2
  24. package/dist/cli/observe.js +7 -8
  25. package/dist/cli/studio.js +7 -8
  26. package/dist/cli/ui.d.ts +1 -1
  27. package/dist/client.d.ts +23 -1
  28. package/dist/client.js +45 -23
  29. package/dist/dialect.d.ts +258 -1
  30. package/dist/dialect.js +83 -0
  31. package/dist/errors.d.ts +12 -0
  32. package/dist/errors.js +17 -0
  33. package/dist/generate.js +4 -0
  34. package/dist/index.d.ts +3 -3
  35. package/dist/index.js +1 -1
  36. package/dist/introspect.d.ts +18 -0
  37. package/dist/introspect.js +19 -0
  38. package/dist/mssql.d.ts +233 -0
  39. package/dist/mssql.js +1298 -0
  40. package/dist/mysql.d.ts +174 -0
  41. package/dist/mysql.js +1012 -0
  42. package/dist/powdb.d.ts +338 -0
  43. package/dist/powdb.js +802 -0
  44. package/dist/powql.d.ts +153 -0
  45. package/dist/powql.js +900 -0
  46. package/dist/query/builder.d.ts +105 -0
  47. package/dist/query/builder.js +294 -74
  48. package/dist/query/index.d.ts +1 -1
  49. package/dist/sqlite.d.ts +144 -0
  50. package/dist/sqlite.js +842 -0
  51. package/dist/typed-sql.d.ts +7 -5
  52. package/dist/typed-sql.js +9 -7
  53. package/package.json +45 -1
@@ -0,0 +1,1052 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm/mysql — MySQL 8 engine (driver-injected, optional peer)
4
+ *
5
+ * Binds Turbine to MySQL 8 via the `mysql2` driver. `mysql2` is **not** a root
6
+ * dependency — it is an **optional peer**: `npm i turbine-orm` pulls nothing
7
+ * extra, and only consumers who `import 'turbine-orm/mysql'` install `mysql2`
8
+ * themselves. The factory loads it through a dynamic `import('mysql2/promise')`
9
+ * so importing this module never crashes when `mysql2` is absent for a consumer
10
+ * who does not use it. Turbine's root runtime dependency stays exactly `pg`.
11
+ *
12
+ * ## The two hard MySQL realities this engine solves
13
+ *
14
+ * 1. **No `RETURNING`.** `INSERT`/`UPDATE`/`DELETE` cannot return the affected
15
+ * rows. `mysqlDialect.resultStrategy = 'reselect'`: the executor runs the
16
+ * write, then issues a follow-up `SELECT` by primary key (using mysql2's
17
+ * `insertId` for auto-increment PKs) / by the `where` predicate, so
18
+ * `create`/`update`/`upsert`/`delete` still return real rows. This is the
19
+ * first shipped engine to exercise the Phase-0 `reselect` path.
20
+ * 2. **Positional `?` is broken for this builder.** Turbine pushes params in
21
+ * 1-indexed generation order but EMITS them in a different SQL-text position
22
+ * (e.g. a `with`-relation `LIMIT` lands in the SELECT list, ahead of the
23
+ * outer `WHERE`). Postgres reconciles this via numbered `$N`; positional `?`
24
+ * silently mis-binds. So `mysqlDialect` uses **mysql2 named placeholders**
25
+ * (`:p1`, `:p2`, …) and the driver shim binds via a `{ p1, p2, … }` object —
26
+ * exactly mirroring `$N` semantics regardless of text order. (See
27
+ * `turbine-orm/sqlite` for the same fix.)
28
+ *
29
+ * ## Capabilities & limits (vs PostgreSQL)
30
+ *
31
+ * - **Single query nested relations preserved** via MySQL 8 `JSON_OBJECT` /
32
+ * `JSON_ARRAYAGG` with `COALESCE(…, JSON_ARRAY())`. `JSON_ARRAYAGG` has no
33
+ * inline `ORDER BY` argument, so every ordered to-many relation is forced
34
+ * through the inner-subquery rewrite (`aggSupportsInlineOrderBy = false`).
35
+ * Nested subresults are `CAST(… AS JSON)`-wrapped so MySQL embeds them as real
36
+ * nested JSON instead of double-encoding a scalar subquery result as a string.
37
+ * - **`createMany` returns an empty array** (count-not-rows). MySQL has no
38
+ * `RETURNING` and the bulk-insert `insertId` is only the first generated id, so
39
+ * re-selecting N rows reliably is unsafe. The rows ARE inserted; re-query if you
40
+ * need them back. (Plan §4 documented divergence.)
41
+ * - **Unsupported (throw `UnsupportedFeatureError`):** pgvector distance ops,
42
+ * LISTEN/NOTIFY (`$listen`/`$notify`), RLS `sessionContext` (no GUCs).
43
+ * - **Advisory-lock migration locking** is available in principle via
44
+ * `GET_LOCK`/`RELEASE_LOCK` (`supportsAdvisoryLock = true`); the migrate CLI is
45
+ * still PostgreSQL-only, so this flag documents intent for a future adapter.
46
+ * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — note this
47
+ * can defeat indexes unless a functional/generated index exists.
48
+ * - **bignum:** mysql2 is configured `supportBigNumbers:true, bigNumberStrings:false`
49
+ * — the same safe-int policy Turbine uses for Postgres `int8` (number when it
50
+ * fits, decimal string otherwise). `DECIMAL` comes back as a string; `TINYINT(1)`
51
+ * binds booleans as 1/0. No global parser state is mutated.
52
+ * - **Version:** MySQL **8.0+** required (5.7 lacks `JSON_ARRAYAGG`); MariaDB is
53
+ * unsupported. The factory probes `SELECT VERSION()` and fails fast otherwise.
54
+ *
55
+ * ## Example
56
+ *
57
+ * ```ts
58
+ * import { turbineMysql } from 'turbine-orm/mysql';
59
+ * import { SCHEMA } from './generated/turbine/metadata.js';
60
+ *
61
+ * const db = await turbineMysql('mysql://user:pass@localhost:3306/app', SCHEMA);
62
+ * const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
63
+ * await db.disconnect();
64
+ * ```
65
+ */
66
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
67
+ if (k2 === undefined) k2 = k;
68
+ var desc = Object.getOwnPropertyDescriptor(m, k);
69
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
70
+ desc = { enumerable: true, get: function() { return m[k]; } };
71
+ }
72
+ Object.defineProperty(o, k2, desc);
73
+ }) : (function(o, m, k, k2) {
74
+ if (k2 === undefined) k2 = k;
75
+ o[k2] = m[k];
76
+ }));
77
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
78
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
79
+ }) : function(o, v) {
80
+ o["default"] = v;
81
+ });
82
+ var __importStar = (this && this.__importStar) || (function () {
83
+ var ownKeys = function(o) {
84
+ ownKeys = Object.getOwnPropertyNames || function (o) {
85
+ var ar = [];
86
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
87
+ return ar;
88
+ };
89
+ return ownKeys(o);
90
+ };
91
+ return function (mod) {
92
+ if (mod && mod.__esModule) return mod;
93
+ var result = {};
94
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
95
+ __setModuleDefault(result, mod);
96
+ return result;
97
+ };
98
+ })();
99
+ Object.defineProperty(exports, "__esModule", { value: true });
100
+ exports.mysqlDialect = exports.MysqlPool = void 0;
101
+ exports.mysqlTypeToTs = mysqlTypeToTs;
102
+ exports.introspectMysqlWith = introspectMysqlWith;
103
+ exports.introspectMysql = introspectMysql;
104
+ exports.turbineMysql = turbineMysql;
105
+ const client_js_1 = require("./client.js");
106
+ const dialect_js_1 = require("./dialect.js");
107
+ const errors_js_1 = require("./errors.js");
108
+ const schema_js_1 = require("./schema.js");
109
+ // ---------------------------------------------------------------------------
110
+ // Value coercion (params in)
111
+ // ---------------------------------------------------------------------------
112
+ /**
113
+ * Coerce an arbitrary JS value into something mysql2 can bind. Booleans →
114
+ * 1/0 (MySQL has no boolean type; `TINYINT(1)` stores 1/0), `undefined`/`null`
115
+ * → NULL, `Date`/`Uint8Array` pass through (mysql2 formats them), and any
116
+ * remaining object/array → JSON text (matches how Turbine already pre-serializes
117
+ * JSON filter / `IN`-list params).
118
+ */
119
+ function toMysqlParam(value) {
120
+ if (value === undefined || value === null)
121
+ return null;
122
+ switch (typeof value) {
123
+ case 'boolean':
124
+ return value ? 1 : 0;
125
+ case 'number':
126
+ case 'bigint':
127
+ case 'string':
128
+ return value;
129
+ }
130
+ if (value instanceof Date)
131
+ return value;
132
+ if (value instanceof Uint8Array)
133
+ return value; // also covers Buffer
134
+ return JSON.stringify(value);
135
+ }
136
+ /**
137
+ * Bind the positional `params[]` (in 1-indexed generation order) to the named
138
+ * `:p1`, `:p2`, … placeholders the dialect emits. Mapping by NAME makes binding
139
+ * independent of where each placeholder lands in the SQL text — the same
140
+ * guarantee Postgres' numbered `$N` gives. Returns `undefined` for parameter-less
141
+ * statements (BEGIN/COMMIT/DDL) so they bind nothing.
142
+ */
143
+ function toNamedBinding(values) {
144
+ if (values.length === 0)
145
+ return undefined;
146
+ const named = {};
147
+ for (let i = 0; i < values.length; i++) {
148
+ named[`p${i + 1}`] = toMysqlParam(values[i]);
149
+ }
150
+ return named;
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Result shaping + error translation
154
+ // ---------------------------------------------------------------------------
155
+ /** Shape a mysql2 `[result]` into a `pg.QueryResult`-like object. */
156
+ function shapeResult(result) {
157
+ if (Array.isArray(result)) {
158
+ return { rows: result, rowCount: result.length };
159
+ }
160
+ // Write statement → ResultSetHeader (no rows). Expose insertId/lastID for the
161
+ // `reselect` strategy to re-fetch the auto-increment row.
162
+ const header = (result ?? {});
163
+ const insertId = header.insertId;
164
+ return { rows: [], rowCount: header.affectedRows ?? 0, insertId, lastID: insertId };
165
+ }
166
+ /**
167
+ * Augment a mysql2 driver error with the Postgres-shaped `.code` (SQLSTATE) and
168
+ * detail fields that `wrapPgError` understands, so MySQL constraint failures
169
+ * surface as the same typed Turbine errors as Postgres (E008/E009/E010/E011) and
170
+ * deadlock / lock-wait-timeout become retryable (E012/E013). The original mysql2
171
+ * error (with its real message) is preserved as the wrapped error's `.cause`
172
+ * downstream. Returns the value unchanged when it is not a recognizable mysql2
173
+ * error. We only annotate here — `wrapPgError` (invoked downstream in the query
174
+ * executor / transaction proxy) does the actual translation.
175
+ */
176
+ function augmentMysqlError(err) {
177
+ if (!err || typeof err !== 'object')
178
+ return err;
179
+ const e = err;
180
+ if (typeof e.errno !== 'number')
181
+ return err;
182
+ const target = e;
183
+ const msg = e.sqlMessage ?? e.message ?? '';
184
+ switch (e.errno) {
185
+ // ER_DUP_ENTRY → unique violation
186
+ case 1062: {
187
+ // "Duplicate entry 'x' for key 'users.email'"
188
+ const m = /for key '([^']+)'/i.exec(msg);
189
+ target.code = '23505';
190
+ if (m?.[1]) {
191
+ const key = m[1];
192
+ const parts = key.split('.');
193
+ target.constraint = parts.length > 1 ? parts[parts.length - 1] : key;
194
+ }
195
+ return err;
196
+ }
197
+ // ER_ROW_IS_REFERENCED_2 / ER_NO_REFERENCED_ROW_2 → foreign key violation
198
+ case 1451:
199
+ case 1452:
200
+ target.code = '23503';
201
+ return err;
202
+ // ER_BAD_NULL_ERROR / ER_NO_DEFAULT_FOR_FIELD → not-null violation
203
+ case 1048:
204
+ case 1364: {
205
+ const m = /Column '([^']+)'/i.exec(msg);
206
+ target.code = '23502';
207
+ if (m?.[1])
208
+ target.column = m[1];
209
+ return err;
210
+ }
211
+ // ER_CHECK_CONSTRAINT_VIOLATED (8.0.16+) / ER_CONSTRAINT_FAILED → check violation
212
+ case 3819:
213
+ case 3813:
214
+ target.code = '23514';
215
+ return err;
216
+ // ER_LOCK_DEADLOCK → deadlock (retryable, maps to pg 40P01)
217
+ case 1213:
218
+ target.code = '40P01';
219
+ return err;
220
+ // ER_LOCK_WAIT_TIMEOUT → treat as a serialization failure (retryable, pg 40001)
221
+ case 1205:
222
+ target.code = '40001';
223
+ return err;
224
+ default:
225
+ return err;
226
+ }
227
+ }
228
+ function normalizeQueryArgs(arg, values) {
229
+ if (typeof arg === 'string')
230
+ return { text: arg, params: values ?? [] };
231
+ return { text: arg.text, params: arg.values ?? values ?? [] };
232
+ }
233
+ /**
234
+ * Run one statement against a mysql2 queryable (pool or connection). Parameterized
235
+ * statements use `.execute()` (mysql2 server-side prepared statements, cached per
236
+ * connection); parameter-less statements (transaction control, DDL) use `.query()`,
237
+ * since not every such statement can be prepared. Named placeholders are bound via
238
+ * the `{ p1, p2, … }` object built from the positional params.
239
+ */
240
+ async function execOne(runner, sql, params) {
241
+ const binding = toNamedBinding(params);
242
+ try {
243
+ const [result] = binding ? await runner.execute(sql, binding) : await runner.query(sql);
244
+ return shapeResult(result);
245
+ }
246
+ catch (err) {
247
+ throw augmentMysqlError(err);
248
+ }
249
+ }
250
+ /**
251
+ * Run a statement on a dedicated transaction connection. MySQL cannot take an
252
+ * inline isolation level on `START TRANSACTION`, so {@link mysqlDialect.beginStatement}
253
+ * emits `SET TRANSACTION ISOLATION LEVEL <level>; START TRANSACTION`. mysql2 runs
254
+ * one statement per call (multi-statements stay OFF by design), so split exactly
255
+ * that dialect-generated compound and run the parts in order. Gated on the precise
256
+ * transaction-control prefix — the query builder never emits `SET TRANSACTION
257
+ * ISOLATION LEVEL`, so no builder/user SQL is ever split.
258
+ */
259
+ async function runOnConnection(conn, sql, params) {
260
+ if (params.length === 0 && /^SET TRANSACTION ISOLATION LEVEL /i.test(sql) && sql.includes('; ')) {
261
+ let last = shapeResult([]);
262
+ for (const part of sql.split('; ')) {
263
+ const trimmed = part.trim();
264
+ if (trimmed)
265
+ last = await execOne(conn, trimmed, []);
266
+ }
267
+ return last;
268
+ }
269
+ return execOne(conn, sql, params);
270
+ }
271
+ /**
272
+ * A {@link PgCompatPool} backed by a `mysql2` pool. Non-transaction queries run
273
+ * on the pool directly (any connection); `connect()` checks out a dedicated
274
+ * connection so a transaction's `START TRANSACTION` / `COMMIT` / `ROLLBACK` /
275
+ * `SAVEPOINT` nesting all run on the same physical connection.
276
+ */
277
+ class MysqlPool {
278
+ /** The underlying mysql2 pool — exposed as an escape hatch (seed / DDL / advanced ops). */
279
+ pool;
280
+ closed = false;
281
+ constructor(pool) {
282
+ this.pool = pool;
283
+ }
284
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape; execOne returns plain objects.
285
+ async query(text, values) {
286
+ const { text: sql, params } = normalizeQueryArgs(text, values);
287
+ return execOne(this.pool, sql, params);
288
+ }
289
+ async connect() {
290
+ const conn = await this.pool.getConnection();
291
+ return {
292
+ // biome-ignore lint/suspicious/noExplicitAny: see query() above.
293
+ query: async (text, values) => {
294
+ const { text: sql, params } = normalizeQueryArgs(text, values);
295
+ return runOnConnection(conn, sql, params);
296
+ },
297
+ release: () => conn.release(),
298
+ };
299
+ }
300
+ async end() {
301
+ if (this.closed)
302
+ return;
303
+ this.closed = true;
304
+ await this.pool.end();
305
+ }
306
+ }
307
+ exports.MysqlPool = MysqlPool;
308
+ // ---------------------------------------------------------------------------
309
+ // Type mapping (MySQL data type → TypeScript)
310
+ // ---------------------------------------------------------------------------
311
+ /**
312
+ * Map a MySQL column type to a TypeScript type. `dialectType` is the
313
+ * `information_schema.COLUMNS.DATA_TYPE` (e.g. `bigint`, `varchar`, `datetime`);
314
+ * `columnType` is the full `COLUMN_TYPE` (e.g. `tinyint(1)`, `enum('a','b')`)
315
+ * used to detect `TINYINT(1)` booleans.
316
+ */
317
+ function mysqlTypeToTs(dialectType, nullable, columnType) {
318
+ const t = dialectType.toLowerCase();
319
+ const full = (columnType ?? '').toLowerCase();
320
+ let base;
321
+ if (t === 'tinyint' && full === 'tinyint(1)')
322
+ base = 'boolean';
323
+ else if (/^(tinyint|smallint|mediumint|int|integer|bigint)$/.test(t))
324
+ base = 'number';
325
+ else if (/^(decimal|numeric)$/.test(t))
326
+ base = 'string';
327
+ else if (/^(float|double|real)$/.test(t))
328
+ base = 'number';
329
+ else if (/^(datetime|timestamp|date)$/.test(t))
330
+ base = 'Date';
331
+ else if (t === 'json')
332
+ base = 'unknown';
333
+ else if (/(blob|binary|varbinary)/.test(t))
334
+ base = 'Uint8Array';
335
+ else if (/(char|text|enum|set|time|year|bit)/.test(t))
336
+ base = 'string';
337
+ else
338
+ base = 'unknown';
339
+ return nullable ? `${base} | null` : base;
340
+ }
341
+ /** Is a MySQL declared type a date/time type (so values coerce back to `Date`)? */
342
+ function isMysqlDateType(dialectType) {
343
+ const t = dialectType.toLowerCase();
344
+ return (0, schema_js_1.isDateType)(t) || /^(datetime|timestamp|date)$/.test(t);
345
+ }
346
+ /** Map a schema-builder (Postgres-flavored) column type to MySQL DDL. */
347
+ function mysqlColumnType(type, maxLength) {
348
+ const t = type.toUpperCase();
349
+ if (/BIGSERIAL/.test(t))
350
+ return 'BIGINT AUTO_INCREMENT';
351
+ if (/SERIAL/.test(t))
352
+ return 'INT AUTO_INCREMENT';
353
+ if (/BIGINT|INT8/.test(t))
354
+ return 'BIGINT';
355
+ if (/SMALLINT|INT2/.test(t))
356
+ return 'SMALLINT';
357
+ if (/INTEGER|INT4|\bINT\b/.test(t))
358
+ return 'INT';
359
+ if (/BOOL/.test(t))
360
+ return 'TINYINT(1)';
361
+ if (/DOUBLE|FLOAT8/.test(t))
362
+ return 'DOUBLE';
363
+ if (/REAL|FLOAT4|FLOAT/.test(t))
364
+ return 'FLOAT';
365
+ if (/NUMERIC|DECIMAL|MONEY/.test(t))
366
+ return 'DECIMAL(65,30)';
367
+ if (/JSONB|JSON/.test(t))
368
+ return 'JSON';
369
+ if (/UUID/.test(t))
370
+ return 'CHAR(36)';
371
+ if (/TIMESTAMPTZ|TIMESTAMP|DATETIME/.test(t))
372
+ return 'DATETIME';
373
+ if (/\bDATE\b/.test(t))
374
+ return 'DATE';
375
+ if (/\bTIME\b/.test(t))
376
+ return 'TIME';
377
+ if (/BYTEA|BLOB/.test(t))
378
+ return 'BLOB';
379
+ if (/VARCHAR/.test(t))
380
+ return maxLength != null ? `VARCHAR(${maxLength})` : 'VARCHAR(255)';
381
+ if (/CHAR/.test(t))
382
+ return maxLength != null ? `CHAR(${maxLength})` : 'CHAR(255)';
383
+ if (/TEXT|CLOB/.test(t))
384
+ return 'TEXT';
385
+ if (/ENUM/.test(t))
386
+ return 'TEXT';
387
+ return type;
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // mysqlDialect — the full Dialect contract for MySQL 8
391
+ // ---------------------------------------------------------------------------
392
+ /**
393
+ * MySQL 8 implementation of the {@link Dialect} contract. Backtick identifier
394
+ * quoting, named `:pN` placeholders (NOT positional `?` — see the module
395
+ * docstring), `JSON_OBJECT` / `JSON_ARRAYAGG` for the single-query nested
396
+ * relation engine (`CAST(… AS JSON)`-wrapped nested subresults), no `RETURNING`
397
+ * (`resultStrategy = 'reselect'`), `INSERT … ON DUPLICATE KEY UPDATE` upserts,
398
+ * and the Postgres-only capabilities disabled (vector / LISTEN-NOTIFY / RLS).
399
+ */
400
+ exports.mysqlDialect = {
401
+ ...dialect_js_1.postgresDialect,
402
+ name: 'mysql',
403
+ // No RETURNING → run the write, then re-SELECT by PK/where (Phase-0 reselect).
404
+ resultStrategy: 'reselect',
405
+ supportsReturning: false,
406
+ supportsILike: false,
407
+ supportsVector: false,
408
+ supportsListenNotify: false,
409
+ supportsRLS: false,
410
+ // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
411
+ supportsAdvisoryLock: true,
412
+ // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
413
+ // rewrite for every ordered to-many relation.
414
+ aggSupportsInlineOrderBy: false,
415
+ // mysql2's binary (prepared) protocol sends JS numbers as DOUBLE, which MySQL's
416
+ // LIMIT/OFFSET reject ("Incorrect arguments to mysqld_stmt_execute"). Render
417
+ // pagination as inline integer literals (Turbine-validated, injection-safe).
418
+ inlineLimitOffset: true,
419
+ jsonPathSupport: 'function',
420
+ emptyJsonArrayLiteral: 'JSON_ARRAY()',
421
+ nullJsonLiteral: 'NULL',
422
+ // Named placeholders (`:p1`, `:p2`, …) — NOT positional `?`. Turbine pushes
423
+ // params in 1-indexed generation order but may EMIT them in a different SQL
424
+ // text position; positional `?` mis-binds. mysql2 named parameters bind by
425
+ // name, so `:pN` ↔ `params[N-1]` mirrors Postgres' `$N` regardless of text
426
+ // order. The driver shim binds via a `{ p1, p2, … }` object.
427
+ paramPlaceholder(index) {
428
+ return `:p${index}`;
429
+ },
430
+ quoteIdentifier(name) {
431
+ return `\`${name.replace(/`/g, '``')}\``;
432
+ },
433
+ buildJsonObject(pairs) {
434
+ const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
435
+ return `JSON_OBJECT(${args.join(', ')})`;
436
+ },
437
+ // JSON_ARRAYAGG over an empty group returns NULL → COALESCE to JSON_ARRAY().
438
+ // The orderBy argument is ignored (MySQL's JSON_ARRAYAGG has no ORDER BY arg);
439
+ // aggSupportsInlineOrderBy=false guarantees the builder never passes one.
440
+ buildJsonArrayAgg(jsonObjectExpr) {
441
+ return `COALESCE(JSON_ARRAYAGG(${jsonObjectExpr}), JSON_ARRAY())`;
442
+ },
443
+ /**
444
+ * Wrap a nested correlated subquery for embedding inside a parent `JSON_OBJECT`.
445
+ * MySQL double-encodes a scalar subquery result as a JSON *string* unless it is
446
+ * explicitly typed JSON, so `CAST((subquery) AS JSON)` forces the nested value
447
+ * to be embedded as real JSON (objects/arrays), not a quoted string — the same
448
+ * intent as SQLite's `json(...)` wrap. The fallback (`JSON_ARRAY()` / `NULL`) is
449
+ * already JSON-typed.
450
+ */
451
+ wrapJsonSubresult(subquery, fallback) {
452
+ return `COALESCE(CAST((${subquery}) AS JSON), ${fallback})`;
453
+ },
454
+ // MySQL aggregate casts: COUNT → SIGNED (BIGINT, comes back as a JS number when
455
+ // safe), AVG/float → DECIMAL (string, the aggregate transform Number()-coerces).
456
+ castAggregate(expr, target) {
457
+ return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
458
+ },
459
+ // No array params in MySQL. JSON_TABLE expands a single JSON-array param into a
460
+ // row set, keeping ONE placeholder (so the SQL cache stays valid regardless of
461
+ // list length) and handling the empty-list case (zero rows). MySQL coerces the
462
+ // non-JSON `expr` to JSON for the comparison, so this works for numbers AND
463
+ // strings. JSON_TABLE requires MySQL 8.0+.
464
+ buildInClause(expr, paramRef, negated) {
465
+ const inner = `SELECT \`v\` FROM JSON_TABLE(${paramRef}, '$[*]' COLUMNS (\`v\` JSON PATH '$')) AS \`jt\``;
466
+ return `${expr} ${negated ? 'NOT IN' : 'IN'} (${inner})`;
467
+ },
468
+ inClauseParam(values) {
469
+ return JSON.stringify(values ?? []);
470
+ },
471
+ // No RETURNING — resultStrategy 'reselect' re-fetches the row.
472
+ buildReturningClause() {
473
+ return '';
474
+ },
475
+ buildInsertStatement(input) {
476
+ return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})`;
477
+ },
478
+ buildBulkInsertStatement(input) {
479
+ // No UNNEST in MySQL — emit multi-row VALUES with flattened, named
480
+ // placeholders (`:p1`, `:p2`, …) matching the flat param order.
481
+ let n = 0;
482
+ const placeholders = input.rowValues
483
+ .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
484
+ .join(', ');
485
+ // skipDuplicates → no-op ON DUPLICATE KEY UPDATE (keeps the existing row).
486
+ const firstCol = input.columns[0] ?? '`id`';
487
+ const conflict = input.skipDuplicates ? ` ON DUPLICATE KEY UPDATE ${firstCol} = ${firstCol}` : '';
488
+ return {
489
+ sql: `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES ${placeholders}${conflict}`,
490
+ params: input.rowValues.flat(),
491
+ };
492
+ },
493
+ buildUpsertStatement(input) {
494
+ // MySQL ignores the explicit conflict target — ON DUPLICATE KEY UPDATE keys
495
+ // off the table's PK/unique indexes. The `where`-derived conflictColumns
496
+ // (input.conflictColumns) must therefore correspond to a real unique/PK index
497
+ // for the upsert to target the intended row (plan §4 / R9).
498
+ return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
499
+ ` ON DUPLICATE KEY UPDATE ${input.updateSetClauses.join(', ')}`);
500
+ },
501
+ buildInsensitiveLike(column, paramRef) {
502
+ // Deterministic case-insensitive match. NOTE: LOWER(col) can defeat an index
503
+ // unless a functional/generated index exists.
504
+ return `LOWER(${column}) LIKE LOWER(${paramRef})`;
505
+ },
506
+ buildJsonContains(column, paramRef) {
507
+ return `JSON_CONTAINS(${column}, ${paramRef})`;
508
+ },
509
+ buildJsonPathExtract(column, pathParamRef) {
510
+ return `JSON_UNQUOTE(JSON_EXTRACT(${column}, ${pathParamRef}))`;
511
+ },
512
+ // ---- Type mapping -------------------------------------------------------
513
+ typeToTypeScript(dialectType, nullable) {
514
+ return mysqlTypeToTs(dialectType, nullable);
515
+ },
516
+ // MySQL has no true array columns; bulk insert uses multi-row VALUES, so no
517
+ // array cast is ever needed. Disables the UNNEST path.
518
+ arrayType: undefined,
519
+ // ---- DDL ----------------------------------------------------------------
520
+ buildColumnType(input) {
521
+ return mysqlColumnType(input.type, input.maxLength);
522
+ },
523
+ buildColumnDefinition(input) {
524
+ const isSerial = /serial/i.test(input.type);
525
+ const parts = [input.name, this.buildColumnType(input)];
526
+ // AUTO_INCREMENT columns must be a key; emit PRIMARY KEY inline for a serial PK.
527
+ if (input.primaryKey)
528
+ parts.push('PRIMARY KEY');
529
+ else if (input.unique)
530
+ parts.push('UNIQUE');
531
+ if (input.notNull && !isSerial && !input.primaryKey)
532
+ parts.push('NOT NULL');
533
+ if (input.defaultValue != null)
534
+ parts.push(`DEFAULT ${input.defaultValue}`);
535
+ if (input.references)
536
+ parts.push(`REFERENCES ${input.references.table}(${input.references.column})`);
537
+ return parts.join(' ');
538
+ },
539
+ buildCreateIndexStatement(input) {
540
+ return `CREATE INDEX ${input.name} ON ${input.table}(${input.columns.join(', ')});`;
541
+ },
542
+ buildCreateTableStatement(input) {
543
+ const body = input.definitions.map((d) => ` ${d}`).join(',\n');
544
+ return `CREATE TABLE ${input.table} (\n${body}\n);`;
545
+ },
546
+ // ---- Migration tracking -------------------------------------------------
547
+ // SelectApplied / UpdateChecksum / DeleteApplied inherit from postgresDialect:
548
+ // they call `this.paramPlaceholder(n)` (→ `:pN`) and standard SQL, all valid
549
+ // MySQL. The tracking-table DDL and the upsert INSERT need MySQL forms.
550
+ buildMigrationTrackingTable(table) {
551
+ return `
552
+ CREATE TABLE IF NOT EXISTS ${table} (
553
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
554
+ name VARCHAR(255) NOT NULL UNIQUE,
555
+ checksum VARCHAR(255) NOT NULL,
556
+ applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
557
+ );
558
+ `;
559
+ },
560
+ buildMigrationInsertApplied(table) {
561
+ return `INSERT INTO ${table} (name, checksum) VALUES (${this.paramPlaceholder(1)}, ${this.paramPlaceholder(2)}) ON DUPLICATE KEY UPDATE name = name`;
562
+ },
563
+ // ---- Transaction control ------------------------------------------------
564
+ beginStatement(isolationLevel) {
565
+ // MySQL cannot take an inline isolation level on START TRANSACTION. The
566
+ // driver shim (runOnConnection) splits this exact compound and runs the
567
+ // parts in order on the same connection.
568
+ return isolationLevel
569
+ ? `SET TRANSACTION ISOLATION LEVEL ${isolationLevel}; START TRANSACTION`
570
+ : 'START TRANSACTION';
571
+ },
572
+ buildSetSessionConfig() {
573
+ // Never reached: supportsRLS=false makes $transaction throw before calling
574
+ // this. Guarded here too so a misuse fails loudly rather than emitting GUC SQL.
575
+ throw new errors_js_1.UnsupportedFeatureError('sessionContext (RLS session GUCs)', 'mysql', 'MySQL has no transaction-local GUCs / set_config equivalent.');
576
+ },
577
+ // ---- Streaming ----------------------------------------------------------
578
+ /**
579
+ * The {@link StreamableConnection} seam only exposes `query()`, not mysql2's
580
+ * native `.stream()`, so the "stream" fetches once and yields the rows in
581
+ * `batchSize` chunks. True server-side streaming would require direct mysql2
582
+ * access (documented limitation).
583
+ */
584
+ async *openStream(connection, sql, params, batchSize) {
585
+ const result = await connection.query(sql, params);
586
+ for (let i = 0; i < result.rows.length; i += batchSize) {
587
+ yield result.rows.slice(i, i + batchSize);
588
+ }
589
+ },
590
+ // ---- Introspection ------------------------------------------------------
591
+ introspector: {
592
+ async introspect(options) {
593
+ return introspectMysql(options);
594
+ },
595
+ },
596
+ };
597
+ const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
598
+ /**
599
+ * Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
600
+ * junctions) from a flat foreign-key list. Mirrors the PostgreSQL / SQLite
601
+ * introspectors so the produced {@link SchemaMetadata} has an identical relation
602
+ * shape across engines.
603
+ */
604
+ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
605
+ const tableSet = new Set(tableNames);
606
+ const fkCounts = new Map();
607
+ for (const fk of foreignKeys) {
608
+ const key = `${fk.sourceTable}->${fk.targetTable}`;
609
+ fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
610
+ }
611
+ const relationsByTable = new Map();
612
+ for (const fk of foreignKeys) {
613
+ if (!tableSet.has(fk.targetTable))
614
+ continue;
615
+ const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
616
+ const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
617
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
618
+ const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
619
+ ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
620
+ : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
621
+ if (!relationsByTable.has(fk.sourceTable))
622
+ relationsByTable.set(fk.sourceTable, {});
623
+ relationsByTable.get(fk.sourceTable)[belongsToName] = {
624
+ type: 'belongsTo',
625
+ name: belongsToName,
626
+ from: fk.sourceTable,
627
+ to: fk.targetTable,
628
+ foreignKey,
629
+ referenceKey,
630
+ };
631
+ const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
632
+ ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
633
+ : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
634
+ if (!relationsByTable.has(fk.targetTable))
635
+ relationsByTable.set(fk.targetTable, {});
636
+ relationsByTable.get(fk.targetTable)[hasManyName] = {
637
+ type: 'hasMany',
638
+ name: hasManyName,
639
+ from: fk.targetTable,
640
+ to: fk.sourceTable,
641
+ foreignKey,
642
+ referenceKey,
643
+ };
644
+ }
645
+ // Conservative many-to-many auto-detection (additive): a table J is a pure
646
+ // junction iff PK is exactly two columns, exactly two single-column FKs whose
647
+ // source columns ARE the PK, two distinct target tables, and no payload columns.
648
+ for (const tableName of tableNames) {
649
+ const pk = pkByTable.get(tableName) ?? [];
650
+ if (pk.length !== 2)
651
+ continue;
652
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
653
+ if (tableFks.length !== 2)
654
+ continue;
655
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
656
+ continue;
657
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
658
+ const pkSet = new Set(pk);
659
+ if (!fkCols.every((c) => pkSet.has(c)))
660
+ continue;
661
+ if (new Set(fkCols).size !== 2)
662
+ continue;
663
+ const [fkA, fkB] = tableFks;
664
+ if (fkA.targetTable === fkB.targetTable)
665
+ continue;
666
+ const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
667
+ if (jCols.length !== 2)
668
+ continue;
669
+ const addM2M = (self, other) => {
670
+ const sourceTbl = self.targetTable;
671
+ const targetTbl = other.targetTable;
672
+ const relName = (0, schema_js_1.snakeToCamel)(targetTbl);
673
+ if (!relationsByTable.has(sourceTbl))
674
+ relationsByTable.set(sourceTbl, {});
675
+ const existing = relationsByTable.get(sourceTbl);
676
+ if (existing[relName])
677
+ return;
678
+ existing[relName] = {
679
+ type: 'manyToMany',
680
+ name: relName,
681
+ from: sourceTbl,
682
+ to: targetTbl,
683
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
684
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
685
+ through: {
686
+ table: tableName,
687
+ sourceKey: self.sourceColumns[0],
688
+ targetKey: other.sourceColumns[0],
689
+ },
690
+ };
691
+ };
692
+ addM2M(fkA, fkB);
693
+ addM2M(fkB, fkA);
694
+ }
695
+ return relationsByTable;
696
+ }
697
+ /**
698
+ * Introspect a MySQL database into the same {@link SchemaMetadata} shape the
699
+ * Postgres catalog introspector produces, using a caller-supplied query
700
+ * executor (so tests can dogfood an already-open mysql2 pool/connection).
701
+ *
702
+ * @param exec Runs a parameterized (`:p1`, `:p2`, …) query and returns rows.
703
+ * @param schema The MySQL database (schema) name to introspect.
704
+ * @param options Optional include / exclude table-name filters.
705
+ */
706
+ async function introspectMysqlWith(exec, schema, options = {}) {
707
+ // ----- Tables -----
708
+ let tableNames = (await exec("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
709
+ if (options.include?.length) {
710
+ const inc = new Set(options.include);
711
+ tableNames = tableNames.filter((t) => inc.has(t));
712
+ }
713
+ if (options.exclude?.length) {
714
+ const exc = new Set(options.exclude);
715
+ tableNames = tableNames.filter((t) => !exc.has(t));
716
+ }
717
+ const tableSet = new Set(tableNames);
718
+ // ----- Columns -----
719
+ const columnRows = (await exec(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY,
720
+ COLUMN_DEFAULT, EXTRA, CHARACTER_MAXIMUM_LENGTH
721
+ FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = :p1 ORDER BY TABLE_NAME, ORDINAL_POSITION`, [schema]));
722
+ const columnsByTable = new Map();
723
+ const enums = {};
724
+ for (const c of columnRows) {
725
+ if (!tableSet.has(c.TABLE_NAME))
726
+ continue;
727
+ const nullable = c.IS_NULLABLE === 'YES' && c.COLUMN_KEY !== 'PRI';
728
+ const dataType = c.DATA_TYPE.toLowerCase();
729
+ // Inline ENUM parsing from COLUMN_TYPE: enum('a','b','c')
730
+ if (dataType === 'enum') {
731
+ const m = /^enum\((.*)\)$/i.exec(c.COLUMN_TYPE);
732
+ if (m?.[1]) {
733
+ enums[`${c.TABLE_NAME}.${c.COLUMN_NAME}`] = m[1].split(',').map((s) => s
734
+ .trim()
735
+ .replace(/^'(.*)'$/, '$1')
736
+ .replace(/''/g, "'"));
737
+ }
738
+ }
739
+ const maxLen = c.CHARACTER_MAXIMUM_LENGTH != null ? num(c.CHARACTER_MAXIMUM_LENGTH) : undefined;
740
+ const col = {
741
+ name: c.COLUMN_NAME,
742
+ field: (0, schema_js_1.snakeToCamel)(c.COLUMN_NAME),
743
+ dialectType: dataType,
744
+ pgType: dataType,
745
+ tsType: mysqlTypeToTs(dataType, nullable, c.COLUMN_TYPE),
746
+ nullable,
747
+ hasDefault: c.COLUMN_DEFAULT !== null || /auto_increment/i.test(c.EXTRA),
748
+ isArray: false,
749
+ arrayType: undefined,
750
+ pgArrayType: 'text[]',
751
+ };
752
+ if (maxLen !== undefined)
753
+ col.maxLength = maxLen;
754
+ if (!columnsByTable.has(c.TABLE_NAME))
755
+ columnsByTable.set(c.TABLE_NAME, []);
756
+ columnsByTable.get(c.TABLE_NAME).push(col);
757
+ }
758
+ // ----- Primary keys (ordered) -----
759
+ const pkRows = (await exec(`SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION FROM information_schema.KEY_COLUMN_USAGE
760
+ WHERE TABLE_SCHEMA = :p1 AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY TABLE_NAME, ORDINAL_POSITION`, [schema]));
761
+ const pkByTable = new Map();
762
+ for (const r of pkRows) {
763
+ if (!tableSet.has(r.TABLE_NAME))
764
+ continue;
765
+ if (!pkByTable.has(r.TABLE_NAME))
766
+ pkByTable.set(r.TABLE_NAME, []);
767
+ pkByTable.get(r.TABLE_NAME).push(r.COLUMN_NAME);
768
+ }
769
+ // ----- Foreign keys (grouped by constraint for composite FKs) -----
770
+ const fkRows = (await exec(`SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, CONSTRAINT_NAME, ORDINAL_POSITION
771
+ FROM information_schema.KEY_COLUMN_USAGE
772
+ WHERE TABLE_SCHEMA = :p1 AND REFERENCED_TABLE_NAME IS NOT NULL
773
+ ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`, [schema]));
774
+ const fkByConstraint = new Map();
775
+ for (const r of fkRows) {
776
+ if (!tableSet.has(r.TABLE_NAME))
777
+ continue;
778
+ const key = `${r.TABLE_NAME}.${r.CONSTRAINT_NAME}`;
779
+ let entry = fkByConstraint.get(key);
780
+ if (!entry) {
781
+ entry = {
782
+ sourceTable: r.TABLE_NAME,
783
+ sourceColumns: [],
784
+ targetTable: r.REFERENCED_TABLE_NAME,
785
+ targetColumns: [],
786
+ constraintName: r.CONSTRAINT_NAME,
787
+ };
788
+ fkByConstraint.set(key, entry);
789
+ }
790
+ entry.sourceColumns.push(r.COLUMN_NAME);
791
+ entry.targetColumns.push(r.REFERENCED_COLUMN_NAME);
792
+ }
793
+ const foreignKeys = [...fkByConstraint.values()].filter((fk) => tableSet.has(fk.targetTable));
794
+ // ----- Indexes + unique constraints -----
795
+ const idxRows = (await exec(`SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX FROM information_schema.STATISTICS
796
+ WHERE TABLE_SCHEMA = :p1 ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`, [schema]));
797
+ const indexGroups = new Map();
798
+ for (const r of idxRows) {
799
+ if (!tableSet.has(r.TABLE_NAME))
800
+ continue;
801
+ const key = `${r.TABLE_NAME}.${r.INDEX_NAME}`;
802
+ let g = indexGroups.get(key);
803
+ if (!g) {
804
+ g = { table: r.TABLE_NAME, name: r.INDEX_NAME, unique: num(r.NON_UNIQUE) === 0, columns: [] };
805
+ indexGroups.set(key, g);
806
+ }
807
+ g.columns.push(r.COLUMN_NAME);
808
+ }
809
+ const indexesByTable = new Map();
810
+ const uniqueByTable = new Map();
811
+ for (const g of indexGroups.values()) {
812
+ if (g.name === 'PRIMARY')
813
+ continue; // PK is tracked separately
814
+ if (!indexesByTable.has(g.table))
815
+ indexesByTable.set(g.table, []);
816
+ indexesByTable.get(g.table).push({
817
+ name: g.name,
818
+ columns: g.columns,
819
+ unique: g.unique,
820
+ definition: `${g.unique ? 'UNIQUE ' : ''}INDEX ${g.name} ON ${g.table}(${g.columns.join(', ')})`,
821
+ });
822
+ if (g.unique && g.columns.length > 0) {
823
+ if (!uniqueByTable.has(g.table))
824
+ uniqueByTable.set(g.table, []);
825
+ uniqueByTable.get(g.table).push(g.columns);
826
+ }
827
+ }
828
+ // ----- Relations -----
829
+ const relationsByTable = buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable);
830
+ // ----- Assemble TableMetadata -----
831
+ const tables = {};
832
+ for (const tableName of tableNames) {
833
+ const columns = columnsByTable.get(tableName) ?? [];
834
+ const columnMap = {};
835
+ const reverseColumnMap = {};
836
+ const dateColumns = new Set();
837
+ const dialectTypes = {};
838
+ const pgTypes = {};
839
+ const allColumns = [];
840
+ for (const col of columns) {
841
+ columnMap[col.field] = col.name;
842
+ reverseColumnMap[col.name] = col.field;
843
+ allColumns.push(col.name);
844
+ dialectTypes[col.name] = col.dialectType ?? col.pgType;
845
+ pgTypes[col.name] = col.pgType;
846
+ if (isMysqlDateType(col.dialectType ?? col.pgType))
847
+ dateColumns.add(col.name);
848
+ }
849
+ tables[tableName] = {
850
+ name: tableName,
851
+ columns,
852
+ columnMap,
853
+ reverseColumnMap,
854
+ dateColumns,
855
+ dialectTypes,
856
+ pgTypes,
857
+ allColumns,
858
+ primaryKey: pkByTable.get(tableName) ?? [],
859
+ uniqueColumns: uniqueByTable.get(tableName) ?? [],
860
+ relations: relationsByTable.get(tableName) ?? {},
861
+ indexes: indexesByTable.get(tableName) ?? [],
862
+ };
863
+ }
864
+ return { tables, enums };
865
+ }
866
+ /**
867
+ * Open a short-lived mysql2 connection from `options.connectionString`,
868
+ * introspect the database (schema = `options.schema` or the connection's current
869
+ * database), and close it. Wraps {@link introspectMysqlWith} for the
870
+ * {@link DialectIntrospector} seam used by `introspect()` / `npx turbine generate`.
871
+ */
872
+ async function introspectMysql(options) {
873
+ const createPool = await loadCreatePool();
874
+ const config = parseMysqlConfig(options.connectionString);
875
+ const pool = createPool({ ...config, ...MYSQL_DRIVER_FLAGS });
876
+ try {
877
+ const exec = async (sql, params) => (await execOne(pool, sql, params)).rows;
878
+ let schemaName = options.schema ?? config.database;
879
+ if (!schemaName) {
880
+ const rows = await exec('SELECT DATABASE() AS db', []);
881
+ schemaName = rows[0]?.db ?? '';
882
+ }
883
+ if (!schemaName) {
884
+ throw new errors_js_1.ConnectionError('[turbine] Could not determine the MySQL database to introspect — pass a database in the connection string or a `schema` option.');
885
+ }
886
+ return introspectMysqlWith(exec, schemaName, { include: options.include, exclude: options.exclude });
887
+ }
888
+ finally {
889
+ await pool.end();
890
+ }
891
+ }
892
+ // ---------------------------------------------------------------------------
893
+ // mysql2 loading + config
894
+ // ---------------------------------------------------------------------------
895
+ /** mysql2 connection flags Turbine pins for correct behavior. */
896
+ const MYSQL_DRIVER_FLAGS = {
897
+ // Named placeholders (`:pN`) — see paramPlaceholder + toNamedBinding.
898
+ namedPlaceholders: true,
899
+ // Safe-int policy matching Postgres int8: number when it fits, decimal string
900
+ // otherwise. DECIMAL always comes back as a string. No global state mutated.
901
+ supportBigNumbers: true,
902
+ bigNumberStrings: false,
903
+ // DATETIME/TIMESTAMP → JS Date interpreted as UTC (matches Postgres).
904
+ dateStrings: false,
905
+ timezone: 'Z',
906
+ // Force JSON columns to raw strings so the nested-relation parser
907
+ // (parseNestedRow) always takes its well-tested JSON.parse path, instead of
908
+ // mysql2's auto-parsed objects or a Buffer. Top-level JSON columns therefore
909
+ // come back as strings (consistent with the SQLite engine; parse them yourself).
910
+ // biome-ignore lint/suspicious/noExplicitAny: mysql2 typeCast field shape (has .type and .string()).
911
+ typeCast: (field, next) => (field.type === 'JSON' ? field.string() : next()),
912
+ };
913
+ /** Parse a `mysql://user:pass@host:port/db` connection string into mysql2 config. */
914
+ function parseMysqlConfig(connectionString) {
915
+ try {
916
+ const u = new URL(connectionString);
917
+ const config = {
918
+ host: u.hostname || 'localhost',
919
+ port: u.port ? Number(u.port) : 3306,
920
+ };
921
+ if (u.username)
922
+ config.user = decodeURIComponent(u.username);
923
+ if (u.password)
924
+ config.password = decodeURIComponent(u.password);
925
+ const db = u.pathname.replace(/^\//, '');
926
+ if (db)
927
+ config.database = decodeURIComponent(db);
928
+ return config;
929
+ }
930
+ catch {
931
+ throw new errors_js_1.ConnectionError(`[turbine] Invalid MySQL connection string: "${connectionString}"`);
932
+ }
933
+ }
934
+ /**
935
+ * Dynamically load `mysql2/promise`'s `createPool`. Kept out of the module's
936
+ * static import graph so `import 'turbine-orm/mysql'` never throws when `mysql2`
937
+ * is absent for a consumer that does not use the factory.
938
+ */
939
+ async function loadCreatePool() {
940
+ let mod;
941
+ try {
942
+ mod = (await Promise.resolve().then(() => __importStar(require('mysql2/promise'))));
943
+ }
944
+ catch (err) {
945
+ throw new errors_js_1.ConnectionError("[turbine] turbine-orm/mysql requires the optional peer dependency 'mysql2'. Install it: npm i mysql2. " +
946
+ `(${err.message})`);
947
+ }
948
+ const createPool = mod.createPool ?? mod.default?.createPool;
949
+ if (typeof createPool !== 'function') {
950
+ throw new errors_js_1.ConnectionError("[turbine] Loaded 'mysql2/promise' but it has no createPool export.");
951
+ }
952
+ return createPool;
953
+ }
954
+ /**
955
+ * Fail fast on unsupported servers: MySQL 8.0+ is required (5.7 lacks
956
+ * JSON_ARRAYAGG) and MariaDB is unsupported (its JSON_ARRAYAGG semantics differ).
957
+ */
958
+ function assertSupportedVersion(version) {
959
+ if (/mariadb/i.test(version)) {
960
+ throw new errors_js_1.ConnectionError(`[turbine] MariaDB is not supported by turbine-orm/mysql (got "${version}"). Use MySQL 8.0+.`);
961
+ }
962
+ const m = /^(\d+)\.(\d+)/.exec(version);
963
+ const major = m ? Number(m[1]) : 0;
964
+ if (major < 8) {
965
+ throw new errors_js_1.ConnectionError(`[turbine] turbine-orm/mysql requires MySQL 8.0+ (5.7 lacks JSON_ARRAYAGG); got "${version}".`);
966
+ }
967
+ }
968
+ function isMysql2Pool(x) {
969
+ return (!!x &&
970
+ typeof x.getConnection === 'function' &&
971
+ typeof x.query === 'function' &&
972
+ typeof x.end === 'function');
973
+ }
974
+ /**
975
+ * Create a {@link TurbineClient} bound to MySQL 8 via `mysql2`.
976
+ *
977
+ * Pass one of:
978
+ * - a connection string (`'mysql://user:pass@host:3306/db'`),
979
+ * - a mysql2 connection config object (`{ host, user, password, database }`), or
980
+ * - an existing mysql2 pool / {@link MysqlPool} (injection — you own its lifecycle,
981
+ * `disconnect()` is a no-op, advanced config like SSL lives here).
982
+ *
983
+ * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
984
+ * (named placeholders, bignum, UTC dates, JSON-as-string), probes `SELECT VERSION()`
985
+ * to reject MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created.
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * import { turbineMysql } from 'turbine-orm/mysql';
990
+ * const db = await turbineMysql('mysql://root:root@localhost:3306/app', SCHEMA);
991
+ * ```
992
+ */
993
+ async function turbineMysql(target, schema, options = {}) {
994
+ let pool;
995
+ let owns = false;
996
+ if (target instanceof MysqlPool) {
997
+ pool = target;
998
+ }
999
+ else if (isMysql2Pool(target)) {
1000
+ pool = new MysqlPool(target);
1001
+ }
1002
+ else {
1003
+ const createPool = await loadCreatePool();
1004
+ const baseConfig = typeof target === 'string' ? parseMysqlConfig(target) : target;
1005
+ const mysql2Pool = createPool({
1006
+ ...baseConfig,
1007
+ connectionLimit: options.connectionLimit ?? 10,
1008
+ ...MYSQL_DRIVER_FLAGS,
1009
+ });
1010
+ // Every NEW physical connection: disable backslash string escaping so the
1011
+ // builder's `LIKE … ESCAPE '\'` clause (a single literal backslash) is valid
1012
+ // MySQL — by default MySQL would parse `'\'` as an escaped quote (syntax
1013
+ // error). This also makes string-literal semantics match Postgres. Turbine
1014
+ // parameterizes every value, so no behavior depends on backslash escaping.
1015
+ //
1016
+ // NOTE: even on a `mysql2/promise` pool, the 'connection' event yields the
1017
+ // *raw* (callback-style) connection — its `.query()` returns a non-thenable
1018
+ // `Query`, so `.catch()`/`await` on it throws "result of query that is not a
1019
+ // promise". Use the callback form here (fire-and-forget): if the SET fails we
1020
+ // fall back to MySQL's default escaping, harmless since every value is a param.
1021
+ mysql2Pool.on?.('connection', (rawConn) => {
1022
+ rawConn.query("SET SESSION sql_mode = CONCAT(@@sql_mode, ',NO_BACKSLASH_ESCAPES')", () => { });
1023
+ });
1024
+ pool = new MysqlPool(mysql2Pool);
1025
+ owns = true;
1026
+ }
1027
+ // Probe the server version (fail fast on MySQL < 8.0 / MariaDB).
1028
+ const versionRows = (await pool.query('SELECT VERSION() AS v')).rows;
1029
+ assertSupportedVersion(String(versionRows[0]?.v ?? ''));
1030
+ const client = new client_js_1.TurbineClient({
1031
+ pool,
1032
+ dialect: exports.mysqlDialect,
1033
+ preparedStatements: false,
1034
+ logging: options.logging,
1035
+ defaultLimit: options.defaultLimit,
1036
+ warnOnUnlimited: options.warnOnUnlimited,
1037
+ }, schema);
1038
+ if (owns) {
1039
+ // Turbine built this pool, so disconnect()/end() must close it. External
1040
+ // pools (injection) stay the caller's responsibility (disconnect() no-op),
1041
+ // consistent with turbineHttp / turbineSqlite.
1042
+ const baseDisconnect = client.disconnect.bind(client);
1043
+ const close = async () => {
1044
+ await baseDisconnect();
1045
+ await pool.end();
1046
+ };
1047
+ const patch = client;
1048
+ patch.disconnect = close;
1049
+ patch.end = close;
1050
+ }
1051
+ return client;
1052
+ }