@nextlyhq/adapter-mysql 0.0.2-alpha.68 → 0.0.2-alpha.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,957 +1,980 @@
1
- import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle';
2
- import { isApplicationError, createDatabaseError, isDatabaseError } from '@nextlyhq/adapter-drizzle/types';
3
- import { checkDialectVersion, acceptsForShare } from '@nextlyhq/adapter-drizzle/version-check';
4
- import { and, eq, getColumns } from 'drizzle-orm';
5
- import { getTableConfig } from 'drizzle-orm/mysql-core';
6
- import { drizzle } from 'drizzle-orm/mysql2';
7
- import mysql from 'mysql2/promise';
8
-
1
+ import { DrizzleAdapter } from "@nextlyhq/adapter-drizzle";
2
+ import { createDatabaseError, isApplicationError, isDatabaseError, poolWaitTimeoutError } from "@nextlyhq/adapter-drizzle/types";
3
+ import { acceptsForShare, checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
4
+ import { and, eq, getColumns } from "drizzle-orm";
5
+ import { getTableConfig } from "drizzle-orm/mysql-core";
6
+ import { drizzle } from "drizzle-orm/mysql2";
7
+ import mysql from "mysql2/promise";
8
+ // src/pool-wait.ts
9
+ /**
10
+ * The bounded wait for a pooled connection.
11
+ *
12
+ * mysql2 has no such bound. A request that finds every connection in use is
13
+ * queued until one is released, however long that takes, so a database that
14
+ * stays busy holds every request behind it indefinitely. This bounds the wait
15
+ * the way pg-pool's `connectionTimeoutMillis` does: past the bound, the
16
+ * request fails with the adapters' own pool-wait timeout error, which core
17
+ * answers with "database busy".
18
+ *
19
+ * Only the wait in the queue: a request that opens a new connection is bounded
20
+ * by mysql2's `connectTimeout` instead, set from the same setting, and a
21
+ * connection that cannot be opened is a database that cannot be reached
22
+ * rather than one that is busy.
23
+ */
24
+ /**
25
+ * Takes a request that gave up out of the pool's queue, so a pool that stays
26
+ * saturated does not collect every request that has already failed, and a
27
+ * released connection goes to one still waiting. Returns whether it was
28
+ * there: with diagnostics tracing on, mysql2 queues a wrapper of the
29
+ * callback rather than the callback itself, and then it stays queued until a
30
+ * connection reaches it.
31
+ */
32
+ function leaveQueue(pool, waiting) {
33
+ const queue = pool._connectionQueue;
34
+ if (!queue) return false;
35
+ for (let at = 0; at < queue.length; at++) if (queue.peekAt(at) === waiting) {
36
+ queue.removeOne(at);
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Makes `pool` fail a request that has waited `boundMs` in its queue for a
43
+ * connection, and returns it. A bound of 0 leaves the wait unbounded, as
44
+ * pg-pool reads a `connectionTimeoutMillis` of 0.
45
+ *
46
+ * Replaces the pool's own `getConnection`, which its `query` and `execute`
47
+ * go through, so every path to a connection is bounded: the adapter's
48
+ * statements, its transactions, and the Drizzle handle core services query
49
+ * through.
50
+ *
51
+ * A request that gives up leaves the queue. Where mysql2 cannot be asked to
52
+ * drop it, the connection it is eventually handed is released straight back
53
+ * rather than held by a request that has already failed.
54
+ */
55
+ function boundPoolWait(pool, boundMs) {
56
+ if (boundMs <= 0) return pool;
57
+ const getConnection = pool.getConnection.bind(pool);
58
+ const bounded = (callback) => {
59
+ let answered = false;
60
+ const wait = {};
61
+ let queued = false;
62
+ const onEnqueue = () => queued = true;
63
+ pool.once("enqueue", onEnqueue);
64
+ const waiting = (err, connection) => {
65
+ if (answered) {
66
+ connection?.release();
67
+ return;
68
+ }
69
+ answered = true;
70
+ clearTimeout(wait.timer);
71
+ callback(err, connection);
72
+ };
73
+ getConnection(waiting);
74
+ pool.removeListener("enqueue", onEnqueue);
75
+ if (!queued || answered) return;
76
+ wait.timer = setTimeout(() => {
77
+ answered = true;
78
+ leaveQueue(pool, waiting);
79
+ callback(poolWaitTimeoutError(boundMs));
80
+ }, boundMs);
81
+ };
82
+ pool.getConnection = bounded;
83
+ return pool;
84
+ }
9
85
  // src/index.ts
86
+ /**
87
+ * @nextlyhq/adapter-mysql
88
+ *
89
+ * MySQL database adapter for Nextly.
90
+ * Extends DrizzleAdapter from @nextlyhq/adapter-drizzle to provide MySQL-specific functionality.
91
+ *
92
+ * @remarks
93
+ * This adapter uses the mysql2 package for database connectivity and provides:
94
+ * - Connection pooling via mysql2 Pool
95
+ * - Full transaction support with isolation levels
96
+ * - CRUD operations with workarounds for missing RETURNING clause
97
+ * - MySQL-specific error classification
98
+ * - Automatic retry for deadlocks (error 1213)
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * import { createMySqlAdapter } from '@nextlyhq/adapter-mysql';
103
+ *
104
+ * const adapter = createMySqlAdapter({
105
+ * url: process.env.DATABASE_URL!,
106
+ * });
107
+ *
108
+ * await adapter.connect();
109
+ *
110
+ * // Query data
111
+ * const users = await adapter.select('users', {
112
+ * where: { and: [{ column: 'status', op: '=', value: 'active' }] },
113
+ * limit: 10,
114
+ * });
115
+ *
116
+ * await adapter.disconnect();
117
+ * ```
118
+ *
119
+ * @packageDocumentation
120
+ */
121
+ /**
122
+ * Puts back what a transaction changed on its connection's session. False
123
+ * when that could not be done, and the connection must not be pooled.
124
+ */
10
125
  async function restoreSession(connection, restore) {
11
- if (restore === null) return true;
12
- if (restore.lockWaitTimeout === null) return false;
13
- try {
14
- await connection.query(
15
- `SET SESSION innodb_lock_wait_timeout = ${restore.lockWaitTimeout}`
16
- );
17
- return true;
18
- } catch {
19
- return false;
20
- }
126
+ if (restore === null) return true;
127
+ if (restore.lockWaitTimeout === null || restore.sqlMode === null) return false;
128
+ try {
129
+ if (restore.lockWaitTimeout !== void 0) await connection.query(`SET SESSION innodb_lock_wait_timeout = ${restore.lockWaitTimeout}`);
130
+ if (restore.sqlMode !== void 0) await connection.query("SET SESSION sql_mode = ?", [restore.sqlMode]);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
21
135
  }
136
+ /**
137
+ * Ends a transaction's hold on its connection: back to the pool once its
138
+ * session is as the transaction found it, or closed when it is not. A
139
+ * connection whose ROLLBACK failed (`rollbackFailed`) may still be inside
140
+ * the transaction, and the next request handed it would write into a
141
+ * transaction nobody commits.
142
+ */
22
143
  async function returnTransactionConnection(connection, rollbackFailed, restore) {
23
- if (!rollbackFailed && await restoreSession(connection, restore)) {
24
- connection.release();
25
- } else {
26
- connection.destroy();
27
- }
144
+ if (!rollbackFailed && await restoreSession(connection, restore)) connection.release();
145
+ else connection.destroy();
28
146
  }
29
- var VERSION = "0.1.0";
30
- var DEFAULT_POOL_CONFIG = {
31
- max: 10,
32
- idleTimeoutMs: 3e4,
33
- connectionTimeoutMs: 1e4
147
+ /**
148
+ * Package version
149
+ */
150
+ const VERSION = "0.1.0";
151
+ /**
152
+ * Default pool configuration values.
153
+ */
154
+ const DEFAULT_POOL_CONFIG = {
155
+ min: 2,
156
+ max: 10,
157
+ idleTimeoutMs: 3e4,
158
+ connectionTimeoutMs: 15e3
34
159
  };
35
- var MYSQL_ERROR_CODES = {
36
- // Unique/Duplicate key violations
37
- 1022: "unique_violation",
38
- // ER_DUP_KEY
39
- 1062: "unique_violation",
40
- // ER_DUP_ENTRY
41
- 1169: "unique_violation",
42
- // ER_DUP_UNIQUE
43
- 1586: "unique_violation",
44
- // ER_DUP_ENTRY_WITH_KEY_NAME
45
- // Foreign key violations
46
- 1216: "foreign_key_violation",
47
- // ER_NO_REFERENCED_ROW
48
- 1217: "foreign_key_violation",
49
- // ER_ROW_IS_REFERENCED
50
- 1451: "foreign_key_violation",
51
- // ER_ROW_IS_REFERENCED_2
52
- 1452: "foreign_key_violation",
53
- // ER_NO_REFERENCED_ROW_2
54
- // Not null violations
55
- 1048: "not_null_violation",
56
- // ER_BAD_NULL_ERROR
57
- 1364: "not_null_violation",
58
- // ER_NO_DEFAULT_FOR_FIELD
59
- // Check constraint violations (MySQL 8.0.16+)
60
- 3819: "check_violation",
61
- // ER_CHECK_CONSTRAINT_VIOLATED
62
- // Deadlock
63
- 1213: "deadlock",
64
- // ER_LOCK_DEADLOCK
65
- // Timeout
66
- 1205: "timeout",
67
- // ER_LOCK_WAIT_TIMEOUT
68
- // Connection errors
69
- 1040: "connection",
70
- // ER_CON_COUNT_ERROR - Too many connections
71
- 1042: "connection",
72
- // ER_BAD_HOST_ERROR
73
- 1043: "connection",
74
- // ER_HANDSHAKE_ERROR
75
- 1044: "connection",
76
- // ER_DBACCESS_DENIED_ERROR
77
- 1045: "connection",
78
- // ER_ACCESS_DENIED_ERROR
79
- 1129: "connection",
80
- // ER_HOST_IS_BLOCKED
81
- 1130: "connection",
82
- // ER_HOST_NOT_PRIVILEGED
83
- 2002: "connection",
84
- // CR_CONNECTION_ERROR
85
- 2003: "connection",
86
- // CR_CONN_HOST_ERROR
87
- 2006: "connection",
88
- // CR_SERVER_GONE_ERROR
89
- 2013: "connection",
90
- // CR_SERVER_LOST
91
- // Query errors
92
- 1064: "query",
93
- // ER_PARSE_ERROR
94
- 1146: "query",
95
- // ER_NO_SUCH_TABLE
96
- 1054: "query"
97
- // ER_BAD_FIELD_ERROR
160
+ /**
161
+ * MySQL error codes mapping to DatabaseErrorKind.
162
+ *
163
+ * @see https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html
164
+ */
165
+ const MYSQL_ERROR_CODES = {
166
+ 1022: "unique_violation",
167
+ 1062: "unique_violation",
168
+ 1169: "unique_violation",
169
+ 1586: "unique_violation",
170
+ 1216: "foreign_key_violation",
171
+ 1217: "foreign_key_violation",
172
+ 1451: "foreign_key_violation",
173
+ 1452: "foreign_key_violation",
174
+ 1048: "not_null_violation",
175
+ 1364: "not_null_violation",
176
+ 3819: "check_violation",
177
+ 1213: "deadlock",
178
+ 1205: "timeout",
179
+ 1040: "connection",
180
+ 1042: "connection",
181
+ 1043: "connection",
182
+ 1044: "connection",
183
+ 1045: "connection",
184
+ 1129: "connection",
185
+ 1130: "connection",
186
+ 2002: "connection",
187
+ 2003: "connection",
188
+ 2006: "connection",
189
+ 2013: "connection",
190
+ 1064: "query",
191
+ 1146: "query",
192
+ 1054: "query"
98
193
  };
194
+ /**
195
+ * Delay helper for retry logic.
196
+ */
99
197
  function delay(ms) {
100
- return new Promise((resolve) => setTimeout(resolve, ms));
198
+ return new Promise((resolve) => setTimeout(resolve, ms));
101
199
  }
200
+ /**
201
+ * MySQL database adapter for Nextly.
202
+ *
203
+ * Extends the base DrizzleAdapter to provide MySQL-specific functionality
204
+ * using the mysql2 package.
205
+ *
206
+ * @remarks
207
+ * MySQL has some limitations compared to PostgreSQL:
208
+ * - No native RETURNING clause (requires INSERT then SELECT)
209
+ * - No native ILIKE (uses LOWER() LIKE workaround)
210
+ * - No native JSONB (uses JSON type)
211
+ * - No array types
212
+ * - Savepoints disabled for safety (MySQL has nested transaction quirks)
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * const adapter = new MySqlAdapter({
217
+ * url: 'mysql://user:pass@localhost:3306/mydb',
218
+ * pool: { max: 20 },
219
+ * });
220
+ *
221
+ * await adapter.connect();
222
+ * ```
223
+ */
102
224
  var MySqlAdapter = class extends DrizzleAdapter {
103
- // getDrizzle memoization: drizzle v1's constructor builds a relational
104
- // query builder per table in the relations config (~40 tables), and the
105
- // service layer resolves an instance on every db access — construct once
106
- // per relations object (identity-stable: the schema registry caches it
107
- // and hands out a NEW object on invalidation, which naturally misses
108
- // this cache and produces a fresh instance).
109
- drizzleByRelations = /* @__PURE__ */ new WeakMap();
110
- drizzleBare;
111
- /**
112
- * The database dialect - always 'mysql' for this adapter.
113
- */
114
- dialect = "mysql";
115
- /**
116
- * Adapter configuration.
117
- */
118
- config;
119
- /**
120
- * Connection pool instance.
121
- */
122
- pool = null;
123
- /**
124
- * Connection state flag.
125
- */
126
- connected = false;
127
- /**
128
- * Whether the server accepts `SELECT ... FOR SHARE`, read from its version
129
- * string at connect. Undefined until then, which callers treat as not known.
130
- */
131
- sharedRowLock;
132
- /**
133
- * Creates a new MySQL adapter instance.
134
- *
135
- * @param config - Adapter configuration
136
- */
137
- constructor(config) {
138
- super();
139
- this.config = config;
140
- }
141
- /**
142
- * Connect to the MySQL database.
143
- * Creates a connection pool using mysql2.
144
- *
145
- * @remarks
146
- * This method initializes the connection pool and verifies connectivity
147
- * by executing a simple query. It is idempotent - calling it multiple
148
- * times will not create multiple pools.
149
- *
150
- * @throws {DatabaseError} If connection fails
151
- */
152
- async connect() {
153
- if (this.connected && this.pool) {
154
- return;
155
- }
156
- try {
157
- const poolConfig = this.buildPoolConfig();
158
- this.pool = mysql.createPool(poolConfig);
159
- const connection = await this.pool.getConnection();
160
- try {
161
- await connection.query("SELECT 1");
162
- const { variant } = await checkDialectVersion(connection, "mysql", {
163
- // Why: route variant warnings through the adapter's logger so
164
- // users see a single, consistent log surface.
165
- onWarning: (msg) => this.config.logger?.warn?.(msg)
166
- });
167
- this.sharedRowLock = acceptsForShare(variant);
168
- this.connected = true;
169
- if (this.config.logger?.info) {
170
- this.config.logger.info("MySQL connection established", {
171
- host: this.config.host ?? "from URL",
172
- database: this.config.database ?? "from URL"
173
- });
174
- }
175
- } finally {
176
- connection.release();
177
- }
178
- } catch (error) {
179
- if (this.pool) {
180
- await this.pool.end().catch(() => {
181
- });
182
- this.pool = null;
183
- }
184
- throw this.classifyError(error);
185
- }
186
- }
187
- /**
188
- * Disconnect from the MySQL database.
189
- * Gracefully closes the connection pool.
190
- *
191
- * @remarks
192
- * This method is idempotent - calling it multiple times is safe.
193
- * It waits for all connections to be released before shutting down.
194
- */
195
- async disconnect() {
196
- const pool = this.pool;
197
- this.pool = null;
198
- this.drizzleBare = void 0;
199
- this.drizzleByRelations = /* @__PURE__ */ new WeakMap();
200
- if (!pool) {
201
- return;
202
- }
203
- try {
204
- await pool.end();
205
- if (this.config.logger?.info) {
206
- this.config.logger.info("MySQL connection closed");
207
- }
208
- } finally {
209
- this.connected = false;
210
- }
211
- }
212
- /**
213
- * Check if connected to the database.
214
- */
215
- isConnected() {
216
- return this.connected && this.pool !== null;
217
- }
218
- /**
219
- * Get connection pool statistics.
220
- * Returns null if not connected.
221
- *
222
- * @remarks
223
- * MySQL2 pool exposes different stats than pg:
224
- * - _allConnections: all connections
225
- * - _freeConnections: idle connections
226
- * - _connectionQueue: waiting requests
227
- */
228
- getPoolStats() {
229
- if (!this.pool) {
230
- return null;
231
- }
232
- const poolInternal = this.pool;
233
- const internal = poolInternal.pool;
234
- if (!internal) {
235
- return {
236
- total: 0,
237
- idle: 0,
238
- waiting: 0,
239
- active: 0
240
- };
241
- }
242
- const total = internal._allConnections?.length ?? 0;
243
- const idle = internal._freeConnections?.length ?? 0;
244
- const waiting = internal._connectionQueue?.length ?? 0;
245
- return {
246
- total,
247
- idle,
248
- waiting,
249
- active: total - idle
250
- };
251
- }
252
- /**
253
- * Execute a raw SQL query.
254
- *
255
- * @param sql - SQL query string with ? placeholders
256
- * @param params - Query parameters
257
- * @returns Query results
258
- *
259
- * @throws {DatabaseError} If query execution fails
260
- */
261
- async executeQuery(sql, params = []) {
262
- const pool = this.ensurePool();
263
- const startTime = Date.now();
264
- try {
265
- const [rows] = await pool.query(
266
- sql,
267
- params
268
- );
269
- if (this.config.logger?.query) {
270
- const durationMs = Date.now() - startTime;
271
- this.config.logger.query(sql, params, durationMs);
272
- }
273
- return rows;
274
- } catch (error) {
275
- throw this.classifyError(error, sql);
276
- }
277
- }
278
- /**
279
- * Execute work within a transaction.
280
- *
281
- * @param work - Function containing transactional operations
282
- * @param options - Transaction options (isolation level, timeout, retry)
283
- * @returns Result of the work function
284
- *
285
- * @remarks
286
- * MySQL transactions support isolation levels. Automatic retry is
287
- * implemented for deadlocks (error 1213) when `retryCount` is specified.
288
- *
289
- * Note: Savepoints are disabled in this adapter for safety due to
290
- * MySQL's quirks with nested transactions.
291
- */
292
- async transaction(work, options) {
293
- return this.runInFlowTransaction(
294
- () => this.runTransactionAttempts(work, options)
295
- );
296
- }
297
- /** The attempts `transaction` makes, each on a connection of its own. */
298
- async runTransactionAttempts(work, options) {
299
- const pool = this.ensurePool();
300
- const maxAttempts = (options?.retryCount ?? 0) + 1;
301
- const retryDelayMs = options?.retryDelayMs ?? 100;
302
- let lastError;
303
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
304
- const connection = await pool.getConnection();
305
- const startTime = Date.now();
306
- let rollbackFailed = false;
307
- let restore = null;
308
- try {
309
- restore = await this.beginTransaction(connection, options);
310
- const ctx = this.createTransactionContext(connection);
311
- const result = await work(ctx);
312
- await connection.query("COMMIT");
313
- if (this.config.logger?.debug) {
314
- const durationMs = Date.now() - startTime;
315
- this.config.logger.debug("Transaction committed", {
316
- attempt,
317
- durationMs
318
- });
319
- }
320
- return result;
321
- } catch (error) {
322
- await connection.query("ROLLBACK").catch(() => {
323
- rollbackFailed = true;
324
- });
325
- lastError = error;
326
- if (isApplicationError(error)) throw error;
327
- const mysqlError = error;
328
- const isRetryable = mysqlError.errno === 1213;
329
- if (isRetryable && attempt < maxAttempts) {
330
- if (this.config.logger?.warn) {
331
- this.config.logger.warn(
332
- `Transaction failed with deadlock, retrying (${attempt}/${maxAttempts})`,
333
- { errno: mysqlError.errno, attempt }
334
- );
335
- }
336
- await delay(retryDelayMs * attempt);
337
- continue;
338
- }
339
- throw this.classifyError(error);
340
- } finally {
341
- await returnTransactionConnection(connection, rollbackFailed, restore);
342
- }
343
- }
344
- throw this.classifyError(lastError);
345
- }
346
- /**
347
- * Get MySQL database capabilities.
348
- *
349
- * @remarks
350
- * MySQL has some limitations:
351
- * - No JSONB (uses JSON)
352
- * - No arrays
353
- * - No native ILIKE
354
- * - No RETURNING clause
355
- * - Savepoints disabled for safety
356
- */
357
- getCapabilities() {
358
- return {
359
- dialect: "mysql",
360
- supportsJsonb: false,
361
- // MySQL uses JSON, not JSONB
362
- supportsJson: true,
363
- supportsArrays: false,
364
- // MySQL doesn't support array types
365
- supportsGeneratedColumns: true,
366
- // MySQL 5.7.6+
367
- supportsFts: true,
368
- // MySQL has FULLTEXT indexes
369
- supportsIlike: false,
370
- // No native ILIKE, use LOWER() LIKE
371
- supportsReturning: false,
372
- // No RETURNING clause in MySQL
373
- supportsSavepoints: false,
374
- // Disabled for safety per approved approach
375
- supportsOnConflict: true,
376
- // ON DUPLICATE KEY UPDATE
377
- maxParamsPerQuery: 65535,
378
- // MySQL limit
379
- maxIdentifierLength: 64,
380
- // MySQL limit
381
- sharedRowLock: this.sharedRowLock
382
- // Known once connected
383
- };
384
- }
385
- /**
386
- * Build a placeholder for MySQL (uses ? instead of $1, $2, etc.)
387
- *
388
- * @param _index - Parameter index (ignored for MySQL)
389
- * @returns The ? placeholder
390
- */
391
- buildPlaceholder(_index) {
392
- return "?";
393
- }
394
- /**
395
- * Build multiple placeholders for MySQL.
396
- *
397
- * @param count - Number of placeholders needed
398
- * @param _startIndex - Starting index (ignored for MySQL)
399
- * @returns Comma-separated ? placeholders
400
- */
401
- buildPlaceholders(count, _startIndex = 0) {
402
- return Array(count).fill("?").join(", ");
403
- }
404
- /**
405
- * Escape an identifier for MySQL (uses backticks instead of double quotes).
406
- *
407
- * @param identifier - The identifier to escape
408
- * @returns Escaped identifier with backticks
409
- */
410
- escapeIdentifier(identifier) {
411
- return `\`${identifier.replace(/`/g, "``")}\``;
412
- }
413
- // ============================================================
414
- // Protected Helper Methods
415
- // ============================================================
416
- /**
417
- * Ensures pool is connected and returns it.
418
- *
419
- * @throws {DatabaseError} If not connected
420
- */
421
- ensurePool() {
422
- if (!this.pool) {
423
- throw createDatabaseError({
424
- kind: "connection",
425
- message: "MySqlAdapter is not connected. Call connect() first."
426
- });
427
- }
428
- return this.pool;
429
- }
430
- /**
431
- * Return the typed Drizzle instance for MySQL.
432
- * Guarded for server-only usage and requires an active connection.
433
- *
434
- * @param schema - Optional schema for relational queries (db.query.*)
435
- * @returns Drizzle ORM instance wrapping the mysql2 pool connection
436
- * @throws {Error} If called in browser or not connected
437
- */
438
- getDrizzle(relations) {
439
- if (typeof window !== "undefined") {
440
- throw new Error("getDrizzle() is server-only");
441
- }
442
- const pool = this.ensurePool();
443
- if (!relations) {
444
- this.drizzleBare ??= this.poolDrizzle(pool);
445
- return this.drizzleBare;
446
- }
447
- let cached = this.drizzleByRelations.get(relations);
448
- if (!cached) {
449
- cached = this.poolDrizzle(pool, relations);
450
- this.drizzleByRelations.set(relations, cached);
451
- }
452
- return cached;
453
- }
454
- /**
455
- * A Drizzle handle on the pool, for `getDrizzle`.
456
- *
457
- * Its `transaction` runs on a connection this adapter checks out and
458
- * returns itself (`pinDrizzleTransactions`): destroyed rather than pooled
459
- * when no ROLLBACK could be confirmed on it. And it counts for
460
- * `transactionOpenInFlow`, as the adapter's does.
461
- */
462
- poolDrizzle(pool, relations) {
463
- const onClient = (client) => relations ? drizzle({ client, relations }) : drizzle({ client });
464
- const callbackPool = pool.pool;
465
- return this.trackFlowTransactions(
466
- this.pinDrizzleTransactions(onClient(callbackPool), async () => {
467
- const connection = await pool.getConnection();
468
- return {
469
- drizzle: onClient(
470
- connection.connection
471
- ),
472
- rollback: () => connection.query("ROLLBACK"),
473
- release: (dirty) => {
474
- if (dirty) connection.destroy();
475
- else connection.release();
476
- }
477
- };
478
- })
479
- );
480
- }
481
- /**
482
- * Builds mysql2 Pool configuration from adapter config.
483
- */
484
- buildPoolConfig() {
485
- const config = {};
486
- if (this.config.url) {
487
- config.uri = this.config.url;
488
- } else {
489
- if (this.config.host) config.host = this.config.host;
490
- if (this.config.port) config.port = this.config.port;
491
- if (this.config.database) config.database = this.config.database;
492
- if (this.config.user) config.user = this.config.user;
493
- if (this.config.password) config.password = this.config.password;
494
- }
495
- config.connectionLimit = this.config.pool?.max ?? DEFAULT_POOL_CONFIG.max;
496
- config.idleTimeout = this.config.pool?.idleTimeoutMs ?? DEFAULT_POOL_CONFIG.idleTimeoutMs;
497
- config.connectTimeout = this.config.pool?.connectionTimeoutMs ?? DEFAULT_POOL_CONFIG.connectionTimeoutMs;
498
- config.waitForConnections = true;
499
- config.queueLimit = 0;
500
- if (this.config.ssl) {
501
- if (typeof this.config.ssl === "boolean") {
502
- config.ssl = this.config.ssl ? {} : void 0;
503
- } else {
504
- config.ssl = {
505
- rejectUnauthorized: this.config.ssl.rejectUnauthorized,
506
- ca: this.config.ssl.ca,
507
- cert: this.config.ssl.cert,
508
- key: this.config.ssl.key
509
- };
510
- }
511
- }
512
- if (this.config.timezone) {
513
- config.timezone = this.config.timezone;
514
- }
515
- if (this.config.charset) {
516
- config.charset = this.config.charset;
517
- }
518
- config.multipleStatements = false;
519
- config.dateStrings = false;
520
- return config;
521
- }
522
- /**
523
- * Begins a transaction with the specified options, and returns what it
524
- * changed on the session for `restoreSession` to put back.
525
- */
526
- async beginTransaction(connection, options) {
527
- if (options?.isolationLevel) {
528
- const isolationMap = {
529
- "read uncommitted": "READ UNCOMMITTED",
530
- "read committed": "READ COMMITTED",
531
- "repeatable read": "REPEATABLE READ",
532
- serializable: "SERIALIZABLE"
533
- };
534
- const level = isolationMap[options.isolationLevel];
535
- if (level) {
536
- await connection.query(`SET TRANSACTION ISOLATION LEVEL ${level}`);
537
- }
538
- }
539
- if (options?.readOnly) {
540
- await connection.query("SET TRANSACTION READ ONLY");
541
- }
542
- await connection.query("START TRANSACTION");
543
- if (options?.timeoutMs) {
544
- const [rows] = await connection.query(
545
- "SELECT @@SESSION.innodb_lock_wait_timeout AS timeout"
546
- );
547
- const previous = Number(rows[0]?.timeout);
548
- const timeoutSeconds = Math.ceil(options.timeoutMs / 1e3);
549
- await connection.query(
550
- `SET SESSION innodb_lock_wait_timeout = ${timeoutSeconds}`
551
- );
552
- return { lockWaitTimeout: Number.isInteger(previous) ? previous : null };
553
- }
554
- return null;
555
- }
556
- /**
557
- * Creates a TransactionContext for the given connection.
558
- *
559
- * @remarks
560
- * Note: Savepoint methods are not implemented (set to undefined)
561
- * as savepoints are disabled in this adapter per approved approach.
562
- */
563
- /**
564
- * Upsert with the meaning `INSERT … ON CONFLICT (target)` has on PostgreSQL
565
- * and SQLite, on the tables where MySQL can keep that promise.
566
- *
567
- * MySQL cannot limit a duplicate to one key: `ON DUPLICATE KEY UPDATE`
568
- * fires on a collision with ANY unique key, and a failed insert does not
569
- * say reliably which one. So the conflict target must be the table's only
570
- * unique key apart from its primary key, and any other table is refused
571
- * before anything is written. On such a table the row is inserted first —
572
- * its validation and triggers apply, as they do before `ON CONFLICT`
573
- * elsewhere — and a duplicate is resolved by a locking read of the target
574
- * key, which sees the latest committed row rather than the transaction's
575
- * snapshot. The row found is kept as it is when there is nothing to update
576
- * (a no-op assignment would still run its update triggers), or updated by
577
- * its key. No row there means the collision was on the primary key, and it
578
- * is refused as `ON CONFLICT (target)` refuses it.
579
- *
580
- * The steps run in the caller's transaction, or in one of their own, since a
581
- * locking read locks nothing past its statement under autocommit. Rows are
582
- * read back by their primary key, so a trigger that rewrites the conflict
583
- * value still returns the row stored. The conflict columns must also be
584
- * given values: MySQL cannot look a row up by a key the database would only
585
- * fill in itself. A conflict is resolved on the caller's key value: a
586
- * BEFORE INSERT trigger that moves the key onto another row's value is
587
- * refused as a unique violation rather than resolved onto that row. A failed insert rolls back only itself on MySQL, so a
588
- * caller's transaction stays usable.
589
- */
590
- async upsert(table, data, options, executor) {
591
- const tableObj = this.getTableObject(table);
592
- if (!tableObj) return super.upsert(table, data, options, executor);
593
- let target;
594
- try {
595
- target = this.upsertTarget(tableObj, table, data, options);
596
- } catch (error) {
597
- throw this.handleQueryError(error, "upsert", table);
598
- }
599
- if (executor === void 0) {
600
- return this.transaction((tx) => tx.upsert(table, data, options));
601
- }
602
- try {
603
- const db = executor;
604
- if (target.nullKey) {
605
- return await this.insertRow(db, tableObj, data) ?? data;
606
- }
607
- return await this.insertOrResolve(
608
- db,
609
- tableObj,
610
- data,
611
- options,
612
- target.where
613
- );
614
- } catch (error) {
615
- throw this.handleQueryError(error, "upsert", table);
616
- }
617
- }
618
- /**
619
- * What an upsert is keyed on: the target's condition, and whether its key
620
- * holds a NULL. Refuses a target this dialect cannot keep to, and a conflict
621
- * column left for the database to fill — MySQL cannot look a row up by a key
622
- * value only the database would supply.
623
- */
624
- upsertTarget(tableObj, table, data, options) {
625
- this.assertUpsertTarget(table, data, options, true);
626
- const key = this.conflictKey(tableObj, table, options.conflictColumns);
627
- const values = options.conflictColumns.map((column) => data[column]);
628
- return {
629
- where: and(...key.map((column, i) => eq(column, values[i]))),
630
- nullKey: values.some((value) => value === null)
631
- };
632
- }
633
- /**
634
- * The table's columns for a conflict target, refusing a target MySQL cannot
635
- * keep to: one that is not a primary or unique key, which an update could
636
- * spread over several rows, and one beside which the table has another
637
- * unique key, a collision on which MySQL would not tell apart.
638
- */
639
- conflictKey(tableObj, table, conflictColumns) {
640
- const record = tableObj;
641
- const columns = conflictColumns.map((name) => record[name]);
642
- const names = new Set(columns.map((column) => column?.name));
643
- const isTarget = (key) => key.length === names.size && key.every((name) => names.has(name));
644
- const { primary, unique } = keysOf(tableObj);
645
- if (primary.length === 0) {
646
- throw createDatabaseError({
647
- kind: "query",
648
- message: `upsert on ${table}: MySQL upserts need a table with a primary key, to read back the row they write.`
649
- });
650
- }
651
- const known = columns.every((column) => column !== void 0);
652
- if (!known || !(isTarget(primary) || unique.some((k) => isTarget(k.columns)))) {
653
- throw createDatabaseError({
654
- kind: "query",
655
- message: `upsert on ${table}: the conflict columns (${conflictColumns.join(", ")}) are not a primary or unique key of the table.`
656
- });
657
- }
658
- const others = unique.filter((key) => !isTarget(key.columns));
659
- if (others.length > 0) {
660
- throw createDatabaseError({
661
- kind: "query",
662
- message: `upsert on ${table}: MySQL cannot limit a duplicate to the conflict columns (${conflictColumns.join(", ")}) while the table has other unique keys (${others.map((key) => key.name).join(", ")}). Upsert only on a table whose one unique key besides the primary key is the conflict target, or insert and update inside a transaction.`
663
- });
664
- }
665
- return columns;
666
- }
667
- /** The update an upsert applies to an existing row: never its key. */
668
- updateValues(data, options) {
669
- const key = new Set(options.conflictColumns);
670
- const update = {};
671
- for (const column of options.updateColumns ?? Object.keys(data)) {
672
- if (!key.has(column) && column in data) update[column] = data[column];
673
- }
674
- return update;
675
- }
676
- /** The row matching `where`, by a locking (current) read. */
677
- async lockedRow(db, tableObj, where) {
678
- const rows = await db.select().from(tableObj).where(where).limit(1).for("update");
679
- return rows[0];
680
- }
681
- /**
682
- * Insert the row, or resolve a duplicate on the target key. The insert is
683
- * read back by the primary key it reports, not by the conflict key: a
684
- * BEFORE INSERT trigger may have rewritten the conflict value, and the row
685
- * stored is the one to return.
686
- *
687
- * A duplicate is resolved when a row holds the TARGET key, whichever unique
688
- * key MySQL names in its error. PostgreSQL checks the target's index before
689
- * any other, so a row colliding on the target and on another key resolves
690
- * on the target there too; refusing it here because MySQL happened to check
691
- * the other index first would answer that case differently. A target row
692
- * committed by another transaction after this insert failed is the order in
693
- * which that transaction ran first, where PostgreSQL also resolves on it.
694
- */
695
- async insertOrResolve(db, tableObj, data, options, where) {
696
- let failure;
697
- try {
698
- const stored = await this.insertRow(db, tableObj, data);
699
- return stored ?? await this.lockedRow(db, tableObj, where);
700
- } catch (error) {
701
- if (this.classifyError(error).kind !== "unique_violation") throw error;
702
- failure = error;
703
- }
704
- const existing = await this.lockedRow(db, tableObj, where);
705
- if (existing === void 0) throw failure;
706
- const update = this.updateValues(data, options);
707
- if (Object.keys(update).length === 0) return existing;
708
- await db.update(tableObj).set(update).where(where);
709
- const identity = this.byIdentity(tableObj, {
710
- ...existing,
711
- ...update
712
- });
713
- const updated = identity === void 0 ? void 0 : await this.lockedRow(db, tableObj, identity);
714
- return updated ?? this.lockedRow(db, tableObj, where);
715
- }
716
- /**
717
- * Insert a row and read it back by its primary key: the value the caller
718
- * gave, or the one MySQL or the model generated, which `$returningId()`
719
- * reports and a given value it does not. Undefined when the table has no
720
- * primary key to read by.
721
- */
722
- async insertRow(db, tableObj, data) {
723
- const [generated] = await db.insert(tableObj).values(data).$returningId();
724
- const given = Object.fromEntries(
725
- Object.entries(data).filter(
726
- ([, value]) => value !== void 0 && value !== null
727
- )
728
- );
729
- const identity = this.byIdentity(tableObj, { ...generated, ...given });
730
- return identity === void 0 ? void 0 : this.lockedRow(db, tableObj, identity);
731
- }
732
- /**
733
- * A row's primary key as a condition, from a record keyed as the model's
734
- * properties are. Undefined when the table has no primary key or the record
735
- * lacks part of it.
736
- */
737
- byIdentity(tableObj, record) {
738
- const primary = new Set(keysOf(tableObj).primary);
739
- const parts = Object.entries(getColumns(tableObj)).filter(
740
- ([, column]) => primary.has(column.name)
741
- );
742
- if (parts.length === 0 || parts.some(
743
- ([name]) => record[name] === void 0 || record[name] === null
744
- )) {
745
- return void 0;
746
- }
747
- return and(
748
- ...parts.map(([name, column]) => eq(column, record[name]))
749
- );
750
- }
751
- /** A table-level `primaryKey({ columns })`, read through the MySQL table config. */
752
- compositePrimaryKey(tableObj) {
753
- return getTableConfig(
754
- tableObj
755
- ).primaryKeys.flatMap((key) => key.columns);
756
- }
757
- createTransactionContext(connection) {
758
- const txConnection = connection.connection;
759
- const txHandles = this.transactionDrizzleHandles(
760
- (relations) => relations ? drizzle({ client: txConnection, relations }) : drizzle({ client: txConnection })
761
- );
762
- const txDb = txHandles.bare;
763
- return {
764
- drizzleWithRelations: txHandles.withRelations,
765
- execute: async (sql, params = []) => {
766
- const [rows] = await connection.query(
767
- sql,
768
- params
769
- );
770
- return rows;
771
- },
772
- // Run on the transaction-bound Drizzle instance rather than the pool, so
773
- // the statement is part of this transaction and sees its uncommitted rows.
774
- runStatement: async (statement) => {
775
- await txDb().execute(statement);
776
- },
777
- // mysql2 answers a `[rows, fields]` tuple; the transaction-bound instance
778
- // keeps the read inside this transaction so it sees its uncommitted writes.
779
- queryStatement: async (statement) => {
780
- const result = await txDb().execute(statement);
781
- if (!Array.isArray(result)) {
782
- throw this.createDatabaseError(
783
- "query",
784
- "Drizzle statement returned a result shape this adapter does not recognise; refusing to report it as an empty result.",
785
- void 0
786
- );
787
- }
788
- return result[0];
789
- },
790
- lockRow: async (table, id) => {
791
- const idColumn = this.escapeIdentifier("id");
792
- await connection.query(
793
- `SELECT ${idColumn} FROM ${this.escapeIdentifier(table)} WHERE ${idColumn} = ? FOR UPDATE`,
794
- [id]
795
- );
796
- },
797
- insert: async (table, data, options) => {
798
- const mapped = this.mapRowToRawSql(this.getTableObject(table), data);
799
- const columns = Object.keys(mapped);
800
- const values = Object.values(mapped);
801
- const placeholders = this.buildPlaceholders(values.length, 0);
802
- const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
803
- const [result] = await connection.query(sql, values);
804
- const ret = options?.returning;
805
- if (Array.isArray(ret) && ret.length === 0) {
806
- return void 0;
807
- }
808
- const insertTableObj = this.getTableObject(table);
809
- const aliases = this.dateWallClockAliases(insertTableObj, ret ?? "*");
810
- const spelled = aliases.map(
811
- (a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`
812
- ).join(", ");
813
- const projected = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(insertTableObj, ret).map((c) => this.escapeIdentifier(c)).join(", ");
814
- const selectList = spelled ? `${projected}, ${spelled}` : projected;
815
- const idValue = result.insertId ? result.insertId : mapped.id;
816
- if (idValue !== void 0) {
817
- const [rows2] = await connection.query(
818
- `SELECT ${selectList} FROM ${this.escapeIdentifier(table)} WHERE id = ?`,
819
- [idValue]
820
- );
821
- return this.mapRowFromRawSql(insertTableObj, rows2[0], aliases);
822
- }
823
- const whereClauses = columns.map(
824
- (c) => `${this.escapeIdentifier(c)} = ?`
825
- );
826
- const [rows] = await connection.query(
827
- `SELECT ${selectList} FROM ${this.escapeIdentifier(table)} WHERE ${whereClauses.join(" AND ")} LIMIT 1`,
828
- values
829
- );
830
- return this.mapRowFromRawSql(insertTableObj, rows[0], aliases);
831
- },
832
- insertMany: async (table, data, options) => {
833
- if (data.length === 0) return [];
834
- const retMany = options?.returning;
835
- const skipReread = Array.isArray(retMany) && retMany.length === 0;
836
- const tableObj = this.getTableObject(table);
837
- const mappedRecords = data.map((r) => this.mapRowToRawSql(tableObj, r));
838
- const columns = Object.keys(mappedRecords[0]);
839
- const allValues = [];
840
- const valuesClauses = [];
841
- for (const record of mappedRecords) {
842
- const placeholders = [];
843
- for (const col of columns) {
844
- allValues.push(record[col]);
845
- placeholders.push("?");
846
- }
847
- valuesClauses.push(`(${placeholders.join(", ")})`);
848
- }
849
- const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
850
- const [result] = await connection.query(
851
- sql,
852
- allValues
853
- );
854
- if (!skipReread && result.insertId && result.affectedRows > 0) {
855
- const ids = [];
856
- for (let i = 0; i < result.affectedRows; i++) {
857
- ids.push(result.insertId + i);
858
- }
859
- const placeholders = ids.map(() => "?").join(", ");
860
- const bulkAliases = this.dateWallClockAliases(tableObj, "*");
861
- const bulkSpelled = bulkAliases.map(
862
- (a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`
863
- ).join(", ");
864
- const [rows] = await connection.query(
865
- `SELECT *${bulkSpelled ? `, ${bulkSpelled}` : ""} FROM ${this.escapeIdentifier(table)} WHERE id IN (${placeholders})`,
866
- ids
867
- );
868
- return rows.map(
869
- (r) => this.mapRowFromRawSql(tableObj, r, bulkAliases)
870
- );
871
- }
872
- return [];
873
- },
874
- // Adapter-built, as `insert` above is; `transactionUpdate` says why.
875
- // A column the model does not declare binds natively, as every value
876
- // on this path's insert does.
877
- update: this.transactionUpdate(
878
- txDb,
879
- async (statement) => {
880
- await txDb().execute(statement);
881
- return void 0;
882
- },
883
- // Structured values as JSON text: mysql2's formatter spells an object
884
- // as `key = value` pairs and an array as a value list, neither JSON.
885
- (value) => this.bindUnmodeledStructuredAsJson(value)
886
- ),
887
- ...this.createTransactionForwarders(txDb),
888
- // Savepoints disabled per approved approach
889
- savepoint: void 0,
890
- rollbackToSavepoint: void 0,
891
- releaseSavepoint: void 0
892
- };
893
- }
894
- /**
895
- * Classifies a MySQL error into a DatabaseError.
896
- *
897
- * @param error - Original error from mysql2
898
- * @param sql - SQL statement that caused the error (optional)
899
- * @returns DatabaseError with proper classification
900
- */
901
- classifyError(error, sql) {
902
- if (isDatabaseError(error)) return error;
903
- const mysqlError = this.driverErrorIn(error, (candidate) => typeof candidate.errno === "number");
904
- const kind = mysqlError.errno && MYSQL_ERROR_CODES[mysqlError.errno] || "unknown";
905
- let message = mysqlError.message ?? String(error);
906
- if (sql && kind === "query") {
907
- message = `Query failed: ${message}`;
908
- }
909
- return createDatabaseError({
910
- kind,
911
- message,
912
- code: mysqlError.code ?? mysqlError.errno?.toString(),
913
- detail: mysqlError.sql,
914
- cause: error instanceof Error ? error : void 0
915
- });
916
- }
225
+ drizzleByRelations = /* @__PURE__ */ new WeakMap();
226
+ drizzleBare;
227
+ /**
228
+ * The database dialect - always 'mysql' for this adapter.
229
+ */
230
+ dialect = "mysql";
231
+ /**
232
+ * Adapter configuration.
233
+ */
234
+ config;
235
+ /**
236
+ * Connection pool instance.
237
+ */
238
+ pool = null;
239
+ /**
240
+ * Connection state flag.
241
+ */
242
+ connected = false;
243
+ /**
244
+ * Whether the server accepts `SELECT ... FOR SHARE`, read from its version
245
+ * string at connect. Undefined until then, which callers treat as not known.
246
+ */
247
+ sharedRowLock;
248
+ /**
249
+ * Creates a new MySQL adapter instance.
250
+ *
251
+ * @param config - Adapter configuration
252
+ */
253
+ constructor(config) {
254
+ super();
255
+ this.config = config;
256
+ }
257
+ /**
258
+ * Connect to the MySQL database.
259
+ * Creates a connection pool using mysql2.
260
+ *
261
+ * @remarks
262
+ * This method initializes the connection pool and verifies connectivity
263
+ * by executing a simple query. It is idempotent - calling it multiple
264
+ * times will not create multiple pools.
265
+ *
266
+ * @throws {DatabaseError} If connection fails
267
+ */
268
+ async connect() {
269
+ if (this.connected && this.pool) return;
270
+ try {
271
+ const poolConfig = this.buildPoolConfig();
272
+ const pool = mysql.createPool(poolConfig);
273
+ boundPoolWait(pool.pool, poolConfig.connectTimeout ?? 0);
274
+ this.pool = pool;
275
+ const connection = await this.pool.getConnection();
276
+ try {
277
+ await connection.query("SELECT 1");
278
+ const { variant } = await checkDialectVersion(connection, "mysql", { onWarning: (msg) => this.config.logger?.warn?.(msg) });
279
+ this.sharedRowLock = acceptsForShare(variant);
280
+ this.connected = true;
281
+ if (this.config.logger?.info) this.config.logger.info("MySQL connection established", {
282
+ host: this.config.host ?? "from URL",
283
+ database: this.config.database ?? "from URL"
284
+ });
285
+ } finally {
286
+ connection.release();
287
+ }
288
+ } catch (error) {
289
+ if (this.pool) {
290
+ await this.pool.end().catch(() => {});
291
+ this.pool = null;
292
+ }
293
+ throw this.classifyError(error);
294
+ }
295
+ }
296
+ /**
297
+ * Disconnect from the MySQL database.
298
+ * Gracefully closes the connection pool.
299
+ *
300
+ * @remarks
301
+ * This method is idempotent - calling it multiple times is safe.
302
+ * It waits for all connections to be released before shutting down.
303
+ */
304
+ async disconnect() {
305
+ const pool = this.pool;
306
+ this.pool = null;
307
+ this.drizzleBare = void 0;
308
+ this.drizzleByRelations = /* @__PURE__ */ new WeakMap();
309
+ if (!pool) return;
310
+ try {
311
+ await pool.end();
312
+ if (this.config.logger?.info) this.config.logger.info("MySQL connection closed");
313
+ } finally {
314
+ this.connected = false;
315
+ }
316
+ }
317
+ /**
318
+ * Check if connected to the database.
319
+ */
320
+ isConnected() {
321
+ return this.connected && this.pool !== null;
322
+ }
323
+ /**
324
+ * Get connection pool statistics.
325
+ * Returns null if not connected.
326
+ *
327
+ * @remarks
328
+ * MySQL2 pool exposes different stats than pg:
329
+ * - _allConnections: all connections
330
+ * - _freeConnections: idle connections
331
+ * - _connectionQueue: waiting requests
332
+ */
333
+ getPoolStats() {
334
+ if (!this.pool) return null;
335
+ const internal = this.pool.pool;
336
+ if (!internal) return {
337
+ total: 0,
338
+ idle: 0,
339
+ waiting: 0,
340
+ active: 0
341
+ };
342
+ const total = internal._allConnections?.length ?? 0;
343
+ const idle = internal._freeConnections?.length ?? 0;
344
+ return {
345
+ total,
346
+ idle,
347
+ waiting: internal._connectionQueue?.length ?? 0,
348
+ active: total - idle
349
+ };
350
+ }
351
+ /**
352
+ * Execute a raw SQL query.
353
+ *
354
+ * @param sql - SQL query string with ? placeholders
355
+ * @param params - Query parameters
356
+ * @returns Query results
357
+ *
358
+ * @throws {DatabaseError} If query execution fails
359
+ */
360
+ async executeQuery(sql, params = []) {
361
+ const pool = this.ensurePool();
362
+ const startTime = Date.now();
363
+ try {
364
+ this.notePoolAccess(sql);
365
+ const [rows] = await pool.query(sql, params);
366
+ if (this.config.logger?.query) {
367
+ const durationMs = Date.now() - startTime;
368
+ this.config.logger.query(sql, params, durationMs);
369
+ }
370
+ return rows;
371
+ } catch (error) {
372
+ throw this.classifyError(error, sql);
373
+ }
374
+ }
375
+ /**
376
+ * Execute work within a transaction.
377
+ *
378
+ * @param work - Function containing transactional operations
379
+ * @param options - Transaction options (isolation level, timeout, retry)
380
+ * @returns Result of the work function
381
+ *
382
+ * @remarks
383
+ * MySQL transactions support isolation levels. Automatic retry is
384
+ * implemented for deadlocks (error 1213) when `retryCount` is specified.
385
+ *
386
+ * Note: Savepoints are disabled in this adapter for safety due to
387
+ * MySQL's quirks with nested transactions.
388
+ */
389
+ async transaction(work, options) {
390
+ return this.runInFlowTransaction(() => this.runTransactionAttempts(work, options));
391
+ }
392
+ /** The attempts `transaction` makes, each on a connection of its own. */
393
+ async runTransactionAttempts(work, options) {
394
+ const pool = this.ensurePool();
395
+ const maxAttempts = (options?.retryCount ?? 0) + 1;
396
+ const retryDelayMs = options?.retryDelayMs ?? 100;
397
+ let lastError;
398
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
399
+ const connection = await this.connectFlowTransaction(() => pool.getConnection());
400
+ const startTime = Date.now();
401
+ let rollbackFailed = false;
402
+ const restore = {};
403
+ try {
404
+ await this.beginTransaction(connection, options, restore);
405
+ const result = await work(this.createTransactionContext(connection));
406
+ await connection.query("COMMIT");
407
+ if (this.config.logger?.debug) {
408
+ const durationMs = Date.now() - startTime;
409
+ this.config.logger.debug("Transaction committed", {
410
+ attempt,
411
+ durationMs
412
+ });
413
+ }
414
+ return result;
415
+ } catch (error) {
416
+ await connection.query("ROLLBACK").catch(() => {
417
+ rollbackFailed = true;
418
+ });
419
+ lastError = error;
420
+ if (isApplicationError(error)) throw error;
421
+ const mysqlError = error;
422
+ if (mysqlError.errno === 1213 && attempt < maxAttempts) {
423
+ if (this.config.logger?.warn) this.config.logger.warn(`Transaction failed with deadlock, retrying (${attempt}/${maxAttempts})`, {
424
+ errno: mysqlError.errno,
425
+ attempt
426
+ });
427
+ await delay(retryDelayMs * attempt);
428
+ continue;
429
+ }
430
+ throw this.classifyError(error);
431
+ } finally {
432
+ await returnTransactionConnection(connection, rollbackFailed, restore);
433
+ }
434
+ }
435
+ throw this.classifyError(lastError);
436
+ }
437
+ /**
438
+ * Get MySQL database capabilities.
439
+ *
440
+ * @remarks
441
+ * MySQL has some limitations:
442
+ * - No JSONB (uses JSON)
443
+ * - No arrays
444
+ * - No native ILIKE
445
+ * - No RETURNING clause
446
+ * - Savepoints disabled for safety
447
+ */
448
+ getCapabilities() {
449
+ return {
450
+ dialect: "mysql",
451
+ supportsJsonb: false,
452
+ supportsJson: true,
453
+ supportsArrays: false,
454
+ supportsGeneratedColumns: true,
455
+ supportsFts: true,
456
+ supportsIlike: false,
457
+ supportsReturning: false,
458
+ supportsSavepoints: false,
459
+ supportsOnConflict: true,
460
+ maxParamsPerQuery: 65535,
461
+ maxIdentifierLength: 64,
462
+ sharedRowLock: this.sharedRowLock
463
+ };
464
+ }
465
+ /**
466
+ * Build a placeholder for MySQL (uses ? instead of $1, $2, etc.)
467
+ *
468
+ * @param _index - Parameter index (ignored for MySQL)
469
+ * @returns The ? placeholder
470
+ */
471
+ buildPlaceholder(_index) {
472
+ return "?";
473
+ }
474
+ /**
475
+ * Build multiple placeholders for MySQL.
476
+ *
477
+ * @param count - Number of placeholders needed
478
+ * @param _startIndex - Starting index (ignored for MySQL)
479
+ * @returns Comma-separated ? placeholders
480
+ */
481
+ buildPlaceholders(count, _startIndex = 0) {
482
+ return Array(count).fill("?").join(", ");
483
+ }
484
+ /**
485
+ * Escape an identifier for MySQL (uses backticks instead of double quotes).
486
+ *
487
+ * @param identifier - The identifier to escape
488
+ * @returns Escaped identifier with backticks
489
+ */
490
+ escapeIdentifier(identifier) {
491
+ return `\`${identifier.replace(/`/g, "``")}\``;
492
+ }
493
+ /**
494
+ * Ensures pool is connected and returns it.
495
+ *
496
+ * @throws {DatabaseError} If not connected
497
+ */
498
+ ensurePool() {
499
+ if (!this.pool) throw createDatabaseError({
500
+ kind: "connection",
501
+ message: "MySqlAdapter is not connected. Call connect() first."
502
+ });
503
+ return this.pool;
504
+ }
505
+ /**
506
+ * Return the typed Drizzle instance for MySQL.
507
+ * Guarded for server-only usage and requires an active connection.
508
+ *
509
+ * @param schema - Optional schema for relational queries (db.query.*)
510
+ * @returns Drizzle ORM instance wrapping the mysql2 pool connection
511
+ * @throws {Error} If called in browser or not connected
512
+ */
513
+ getDrizzle(relations) {
514
+ if (typeof window !== "undefined") throw new Error("getDrizzle() is server-only");
515
+ const pool = this.ensurePool();
516
+ if (!relations) {
517
+ this.drizzleBare ??= this.poolDrizzle(pool);
518
+ return this.drizzleBare;
519
+ }
520
+ let cached = this.drizzleByRelations.get(relations);
521
+ if (!cached) {
522
+ cached = this.poolDrizzle(pool, relations);
523
+ this.drizzleByRelations.set(relations, cached);
524
+ }
525
+ return cached;
526
+ }
527
+ /**
528
+ * A Drizzle handle on the pool, for `getDrizzle`.
529
+ *
530
+ * Its `transaction` runs on a connection this adapter checks out and
531
+ * returns itself (`pinDrizzleTransactions`): destroyed rather than pooled
532
+ * when no ROLLBACK could be confirmed on it. And it counts for
533
+ * `transactionOpenInFlow`, as the adapter's does.
534
+ */
535
+ poolDrizzle(pool, relations) {
536
+ const onClient = (client) => relations ? drizzle({
537
+ client,
538
+ relations
539
+ }) : drizzle({ client });
540
+ const callbackPool = this.observedPool(pool.pool, [
541
+ "query",
542
+ "execute",
543
+ "getConnection"
544
+ ]);
545
+ return this.trackFlowTransactions(this.pinDrizzleTransactions(onClient(callbackPool), async () => {
546
+ const connection = await this.connectFlowTransaction(() => pool.getConnection());
547
+ return {
548
+ drizzle: onClient(connection.connection),
549
+ rollback: () => connection.query("ROLLBACK"),
550
+ release: (dirty) => {
551
+ if (dirty) connection.destroy();
552
+ else connection.release();
553
+ }
554
+ };
555
+ }));
556
+ }
557
+ /**
558
+ * Builds mysql2 Pool configuration from adapter config.
559
+ */
560
+ buildPoolConfig() {
561
+ const config = {};
562
+ if (this.config.url) config.uri = this.config.url;
563
+ else {
564
+ if (this.config.host) config.host = this.config.host;
565
+ if (this.config.port) config.port = this.config.port;
566
+ if (this.config.database) config.database = this.config.database;
567
+ if (this.config.user) config.user = this.config.user;
568
+ if (this.config.password) config.password = this.config.password;
569
+ }
570
+ config.connectionLimit = this.config.pool?.max ?? DEFAULT_POOL_CONFIG.max;
571
+ config.idleTimeout = this.config.pool?.idleTimeoutMs ?? DEFAULT_POOL_CONFIG.idleTimeoutMs;
572
+ config.connectTimeout = this.config.pool?.connectionTimeoutMs ?? DEFAULT_POOL_CONFIG.connectionTimeoutMs;
573
+ config.waitForConnections = true;
574
+ config.queueLimit = 0;
575
+ if (this.config.ssl) {
576
+ if (typeof this.config.ssl === "boolean") config.ssl = this.config.ssl ? {} : void 0;
577
+ else config.ssl = {
578
+ rejectUnauthorized: this.config.ssl.rejectUnauthorized,
579
+ ca: this.config.ssl.ca,
580
+ cert: this.config.ssl.cert,
581
+ key: this.config.ssl.key
582
+ };
583
+ }
584
+ if (this.config.timezone) config.timezone = this.config.timezone;
585
+ if (this.config.charset) config.charset = this.config.charset;
586
+ config.multipleStatements = false;
587
+ config.dateStrings = false;
588
+ return config;
589
+ }
590
+ /**
591
+ * Begins a transaction with the specified options, recording in `changed`
592
+ * each session setting it changes, as it changes it, for `restoreSession`
593
+ * to put back.
594
+ */
595
+ async beginTransaction(connection, options, changed) {
596
+ if (options?.isolationLevel) {
597
+ const level = {
598
+ "read uncommitted": "READ UNCOMMITTED",
599
+ "read committed": "READ COMMITTED",
600
+ "repeatable read": "REPEATABLE READ",
601
+ serializable: "SERIALIZABLE"
602
+ }[options.isolationLevel];
603
+ if (level) await connection.query(`SET TRANSACTION ISOLATION LEVEL ${level}`);
604
+ }
605
+ if (options?.readOnly) await connection.query("SET TRANSACTION READ ONLY");
606
+ await connection.query("START TRANSACTION");
607
+ if (options?.timeoutMs) {
608
+ const [rows] = await connection.query("SELECT @@SESSION.innodb_lock_wait_timeout AS timeout");
609
+ const previous = Number(rows[0]?.timeout);
610
+ const timeoutSeconds = Math.ceil(options.timeoutMs / 1e3);
611
+ await connection.query(`SET SESSION innodb_lock_wait_timeout = ${timeoutSeconds}`);
612
+ changed.lockWaitTimeout = Number.isInteger(previous) ? previous : null;
613
+ }
614
+ if (options?.strictSqlMode) {
615
+ const [rows] = await connection.query("SELECT @@SESSION.sql_mode AS mode");
616
+ const previous = rows[0]?.mode;
617
+ await connection.query("SET SESSION sql_mode = TRIM(BOTH ',' FROM CONCAT(@@SESSION.sql_mode, ',STRICT_ALL_TABLES'))");
618
+ changed.sqlMode = typeof previous === "string" ? previous : null;
619
+ }
620
+ }
621
+ /**
622
+ * Creates a TransactionContext for the given connection.
623
+ *
624
+ * @remarks
625
+ * Note: Savepoint methods are not implemented (set to undefined)
626
+ * as savepoints are disabled in this adapter per approved approach.
627
+ */
628
+ /**
629
+ * Upsert with the meaning `INSERT … ON CONFLICT (target)` has on PostgreSQL
630
+ * and SQLite, on the tables where MySQL can keep that promise.
631
+ *
632
+ * MySQL cannot limit a duplicate to one key: `ON DUPLICATE KEY UPDATE`
633
+ * fires on a collision with ANY unique key, and a failed insert does not
634
+ * say reliably which one. So the conflict target must be the table's only
635
+ * unique key apart from its primary key, and any other table is refused
636
+ * before anything is written. On such a table the row is inserted first —
637
+ * its validation and triggers apply, as they do before `ON CONFLICT`
638
+ * elsewhere — and a duplicate is resolved by a locking read of the target
639
+ * key, which sees the latest committed row rather than the transaction's
640
+ * snapshot. The row found is kept as it is when there is nothing to update
641
+ * (a no-op assignment would still run its update triggers), or updated by
642
+ * its key. No row there means the collision was on the primary key, and it
643
+ * is refused as `ON CONFLICT (target)` refuses it.
644
+ *
645
+ * The steps run in the caller's transaction, or in one of their own, since a
646
+ * locking read locks nothing past its statement under autocommit. Rows are
647
+ * read back by their primary key, so a trigger that rewrites the conflict
648
+ * value still returns the row stored. The conflict columns must also be
649
+ * given values: MySQL cannot look a row up by a key the database would only
650
+ * fill in itself. A conflict is resolved on the caller's key value: a
651
+ * BEFORE INSERT trigger that moves the key onto another row's value is
652
+ * refused as a unique violation rather than resolved onto that row. A failed insert rolls back only itself on MySQL, so a
653
+ * caller's transaction stays usable.
654
+ */
655
+ async upsert(table, data, options, executor) {
656
+ const tableObj = this.getTableObject(table);
657
+ if (!tableObj) return super.upsert(table, data, options, executor);
658
+ let target;
659
+ try {
660
+ target = this.upsertTarget(tableObj, table, data, options);
661
+ } catch (error) {
662
+ throw this.handleQueryError(error, "upsert", table);
663
+ }
664
+ if (executor === void 0) return this.transaction((tx) => tx.upsert(table, data, options));
665
+ try {
666
+ const db = executor;
667
+ if (target.nullKey) return await this.insertRow(db, tableObj, data) ?? data;
668
+ return await this.insertOrResolve(db, tableObj, data, options, target.where);
669
+ } catch (error) {
670
+ throw this.handleQueryError(error, "upsert", table);
671
+ }
672
+ }
673
+ /**
674
+ * What an upsert is keyed on: the target's condition, and whether its key
675
+ * holds a NULL. Refuses a target this dialect cannot keep to, and a conflict
676
+ * column left for the database to fill — MySQL cannot look a row up by a key
677
+ * value only the database would supply.
678
+ */
679
+ upsertTarget(tableObj, table, data, options) {
680
+ this.assertUpsertTarget(table, data, options, true);
681
+ const key = this.conflictKey(tableObj, table, options.conflictColumns);
682
+ const values = options.conflictColumns.map((column) => data[column]);
683
+ return {
684
+ where: and(...key.map((column, i) => eq(column, values[i]))),
685
+ nullKey: values.some((value) => value === null)
686
+ };
687
+ }
688
+ /**
689
+ * The table's columns for a conflict target, refusing a target MySQL cannot
690
+ * keep to: one that is not a primary or unique key, which an update could
691
+ * spread over several rows, and one beside which the table has another
692
+ * unique key, a collision on which MySQL would not tell apart.
693
+ */
694
+ conflictKey(tableObj, table, conflictColumns) {
695
+ const record = tableObj;
696
+ const columns = conflictColumns.map((name) => record[name]);
697
+ const names = new Set(columns.map((column) => column?.name));
698
+ const isTarget = (key) => key.length === names.size && key.every((name) => names.has(name));
699
+ const { primary, unique } = keysOf(tableObj);
700
+ if (primary.length === 0) throw createDatabaseError({
701
+ kind: "query",
702
+ message: `upsert on ${table}: MySQL upserts need a table with a primary key, to read back the row they write.`
703
+ });
704
+ if (!columns.every((column) => column !== void 0) || !(isTarget(primary) || unique.some((k) => isTarget(k.columns)))) throw createDatabaseError({
705
+ kind: "query",
706
+ message: `upsert on ${table}: the conflict columns (${conflictColumns.join(", ")}) are not a primary or unique key of the table.`
707
+ });
708
+ const others = unique.filter((key) => !isTarget(key.columns));
709
+ if (others.length > 0) throw createDatabaseError({
710
+ kind: "query",
711
+ message: `upsert on ${table}: MySQL cannot limit a duplicate to the conflict columns (${conflictColumns.join(", ")}) while the table has other unique keys (${others.map((key) => key.name).join(", ")}). Upsert only on a table whose one unique key besides the primary key is the conflict target, or insert and update inside a transaction.`
712
+ });
713
+ return columns;
714
+ }
715
+ /** The update an upsert applies to an existing row: never its key. */
716
+ updateValues(data, options) {
717
+ const key = new Set(options.conflictColumns);
718
+ const update = {};
719
+ for (const column of options.updateColumns ?? Object.keys(data)) if (!key.has(column) && column in data) update[column] = data[column];
720
+ return update;
721
+ }
722
+ /** The row matching `where`, by a locking (current) read. */
723
+ async lockedRow(db, tableObj, where) {
724
+ return (await db.select().from(tableObj).where(where).limit(1).for("update"))[0];
725
+ }
726
+ /**
727
+ * Insert the row, or resolve a duplicate on the target key. The insert is
728
+ * read back by the primary key it reports, not by the conflict key: a
729
+ * BEFORE INSERT trigger may have rewritten the conflict value, and the row
730
+ * stored is the one to return.
731
+ *
732
+ * A duplicate is resolved when a row holds the TARGET key, whichever unique
733
+ * key MySQL names in its error. PostgreSQL checks the target's index before
734
+ * any other, so a row colliding on the target and on another key resolves
735
+ * on the target there too; refusing it here because MySQL happened to check
736
+ * the other index first would answer that case differently. A target row
737
+ * committed by another transaction after this insert failed is the order in
738
+ * which that transaction ran first, where PostgreSQL also resolves on it.
739
+ */
740
+ async insertOrResolve(db, tableObj, data, options, where) {
741
+ let failure;
742
+ try {
743
+ return await this.insertRow(db, tableObj, data) ?? await this.lockedRow(db, tableObj, where);
744
+ } catch (error) {
745
+ if (this.classifyError(error).kind !== "unique_violation") throw error;
746
+ failure = error;
747
+ }
748
+ const existing = await this.lockedRow(db, tableObj, where);
749
+ if (existing === void 0) throw failure;
750
+ const update = this.updateValues(data, options);
751
+ if (Object.keys(update).length === 0) return existing;
752
+ await db.update(tableObj).set(update).where(where);
753
+ const identity = this.byIdentity(tableObj, {
754
+ ...existing,
755
+ ...update
756
+ });
757
+ return (identity === void 0 ? void 0 : await this.lockedRow(db, tableObj, identity)) ?? this.lockedRow(db, tableObj, where);
758
+ }
759
+ /**
760
+ * Insert a row and read it back by its primary key: the value the caller
761
+ * gave, or the one MySQL or the model generated, which `$returningId()`
762
+ * reports and a given value it does not. Undefined when the table has no
763
+ * primary key to read by.
764
+ */
765
+ async insertRow(db, tableObj, data) {
766
+ const [generated] = await db.insert(tableObj).values(data).$returningId();
767
+ const given = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== void 0 && value !== null));
768
+ const identity = this.byIdentity(tableObj, {
769
+ ...generated,
770
+ ...given
771
+ });
772
+ return identity === void 0 ? void 0 : this.lockedRow(db, tableObj, identity);
773
+ }
774
+ /**
775
+ * A row's primary key as a condition, from a record keyed as the model's
776
+ * properties are. Undefined when the table has no primary key or the record
777
+ * lacks part of it.
778
+ */
779
+ byIdentity(tableObj, record) {
780
+ const primary = new Set(keysOf(tableObj).primary);
781
+ const parts = Object.entries(getColumns(tableObj)).filter(([, column]) => primary.has(column.name));
782
+ if (parts.length === 0 || parts.some(([name]) => record[name] === void 0 || record[name] === null)) return;
783
+ return and(...parts.map(([name, column]) => eq(column, record[name])));
784
+ }
785
+ /** A table-level `primaryKey({ columns })`, read through the MySQL table config. */
786
+ compositePrimaryKey(tableObj) {
787
+ return getTableConfig(tableObj).primaryKeys.flatMap((key) => key.columns);
788
+ }
789
+ createTransactionContext(connection) {
790
+ const txConnection = connection.connection;
791
+ const txHandles = this.transactionDrizzleHandles((relations) => relations ? drizzle({
792
+ client: txConnection,
793
+ relations
794
+ }) : drizzle({ client: txConnection }));
795
+ const txDb = txHandles.bare;
796
+ return {
797
+ drizzleWithRelations: txHandles.withRelations,
798
+ execute: async (sql, params = []) => {
799
+ const [rows] = await connection.query(sql, params);
800
+ return rows;
801
+ },
802
+ runStatement: async (statement) => {
803
+ await txDb().execute(statement);
804
+ },
805
+ queryStatement: async (statement) => {
806
+ const result = await txDb().execute(statement);
807
+ if (!Array.isArray(result)) throw this.createDatabaseError("query", "Drizzle statement returned a result shape this adapter does not recognise; refusing to report it as an empty result.", void 0);
808
+ return result[0];
809
+ },
810
+ lockRow: async (table, id) => {
811
+ const idColumn = this.escapeIdentifier("id");
812
+ await connection.query(`SELECT ${idColumn} FROM ${this.escapeIdentifier(table)} WHERE ${idColumn} = ? FOR UPDATE`, [id]);
813
+ },
814
+ insert: async (table, data, options) => {
815
+ const mapped = this.mapRowToRawSql(this.getTableObject(table), data);
816
+ const columns = Object.keys(mapped);
817
+ const values = Object.values(mapped);
818
+ const placeholders = this.buildPlaceholders(values.length, 0);
819
+ const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
820
+ const [result] = await connection.query(sql, values);
821
+ const ret = options?.returning;
822
+ if (Array.isArray(ret) && ret.length === 0) return;
823
+ const insertTableObj = this.getTableObject(table);
824
+ const aliases = this.dateWallClockAliases(insertTableObj, ret ?? "*");
825
+ const spelled = aliases.map((a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`).join(", ");
826
+ const projected = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(insertTableObj, ret).map((c) => this.escapeIdentifier(c)).join(", ");
827
+ const selectList = spelled ? `${projected}, ${spelled}` : projected;
828
+ const idValue = result.insertId ? result.insertId : mapped.id;
829
+ if (idValue !== void 0) {
830
+ const [rows] = await connection.query(`SELECT ${selectList} FROM ${this.escapeIdentifier(table)} WHERE id = ?`, [idValue]);
831
+ return this.mapRowFromRawSql(insertTableObj, rows[0], aliases);
832
+ }
833
+ const whereClauses = columns.map((c) => `${this.escapeIdentifier(c)} = ?`);
834
+ const [rows] = await connection.query(`SELECT ${selectList} FROM ${this.escapeIdentifier(table)} WHERE ${whereClauses.join(" AND ")} LIMIT 1`, values);
835
+ return this.mapRowFromRawSql(insertTableObj, rows[0], aliases);
836
+ },
837
+ insertMany: async (table, data, options) => {
838
+ if (data.length === 0) return [];
839
+ const retMany = options?.returning;
840
+ const skipReread = Array.isArray(retMany) && retMany.length === 0;
841
+ const tableObj = this.getTableObject(table);
842
+ const mappedRecords = data.map((r) => this.mapRowToRawSql(tableObj, r));
843
+ const columns = Object.keys(mappedRecords[0]);
844
+ const allValues = [];
845
+ const valuesClauses = [];
846
+ for (const record of mappedRecords) {
847
+ const placeholders = [];
848
+ for (const col of columns) {
849
+ allValues.push(record[col]);
850
+ placeholders.push("?");
851
+ }
852
+ valuesClauses.push(`(${placeholders.join(", ")})`);
853
+ }
854
+ const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
855
+ const [result] = await connection.query(sql, allValues);
856
+ if (!skipReread && result.insertId && result.affectedRows > 0) {
857
+ const ids = [];
858
+ for (let i = 0; i < result.affectedRows; i++) ids.push(result.insertId + i);
859
+ const placeholders = ids.map(() => "?").join(", ");
860
+ const bulkAliases = this.dateWallClockAliases(tableObj, "*");
861
+ const bulkSpelled = bulkAliases.map((a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`).join(", ");
862
+ const [rows] = await connection.query(`SELECT *${bulkSpelled ? `, ${bulkSpelled}` : ""} FROM ${this.escapeIdentifier(table)} WHERE id IN (${placeholders})`, ids);
863
+ return rows.map((r) => this.mapRowFromRawSql(tableObj, r, bulkAliases));
864
+ }
865
+ return [];
866
+ },
867
+ update: this.transactionUpdate(txDb, async (statement) => {
868
+ await txDb().execute(statement);
869
+ }, (value) => this.bindUnmodeledStructuredAsJson(value)),
870
+ ...this.createTransactionForwarders(txDb),
871
+ savepoint: void 0,
872
+ rollbackToSavepoint: void 0,
873
+ releaseSavepoint: void 0
874
+ };
875
+ }
876
+ /**
877
+ * Classifies a MySQL error into a DatabaseError.
878
+ *
879
+ * @param error - Original error from mysql2
880
+ * @param sql - SQL statement that caused the error (optional)
881
+ * @returns DatabaseError with proper classification
882
+ */
883
+ classifyError(error, sql) {
884
+ if (isDatabaseError(error)) return error;
885
+ const mysqlError = this.driverErrorIn(error, (candidate) => typeof candidate.errno === "number");
886
+ const kind = mysqlError.errno && MYSQL_ERROR_CODES[mysqlError.errno] || "unknown";
887
+ let message = mysqlError.message ?? String(error);
888
+ if (sql && kind === "query") message = `Query failed: ${message}`;
889
+ return createDatabaseError({
890
+ kind,
891
+ message,
892
+ code: mysqlError.code ?? mysqlError.errno?.toString(),
893
+ detail: mysqlError.sql,
894
+ cause: error instanceof Error ? error : void 0
895
+ });
896
+ }
917
897
  };
918
- var TABLE_KEYS = /* @__PURE__ */ new WeakMap();
898
+ const TABLE_KEYS = /* @__PURE__ */ new WeakMap();
899
+ /**
900
+ * A table's primary key and its unique keys other than the primary key, read
901
+ * from its model once and kept for the table's lifetime: a model is not
902
+ * changed in place, a new schema brings new table objects.
903
+ */
919
904
  function keysOf(tableObj) {
920
- const cached = TABLE_KEYS.get(tableObj);
921
- if (cached) return cached;
922
- const config = getTableConfig(tableObj);
923
- const primary = [
924
- ...config.columns.filter((column) => column.primary),
925
- ...config.primaryKeys.flatMap((key) => key.columns)
926
- ].map((column) => column.name);
927
- const unique = [
928
- ...config.columns.filter((column) => column.isUnique && !column.primary).map((column) => ({
929
- // Drizzle's own name for an unnamed column-level unique key.
930
- name: column.uniqueName ?? `${config.name}_${column.name}_unique`,
931
- columns: [column.name]
932
- })),
933
- ...config.uniqueConstraints.map((constraint) => ({
934
- name: constraint.name,
935
- columns: constraint.columns.map((column) => column.name)
936
- })),
937
- ...config.indexes.filter((index) => index.config.unique === true).map((index) => ({
938
- name: index.config.name,
939
- columns: index.config.columns.map(
940
- (column) => column.name
941
- )
942
- }))
943
- ];
944
- const keys = { primary, unique };
945
- TABLE_KEYS.set(tableObj, keys);
946
- return keys;
905
+ const cached = TABLE_KEYS.get(tableObj);
906
+ if (cached) return cached;
907
+ const config = getTableConfig(tableObj);
908
+ const keys = {
909
+ primary: [...config.columns.filter((column) => column.primary), ...config.primaryKeys.flatMap((key) => key.columns)].map((column) => column.name),
910
+ unique: [
911
+ ...config.columns.filter((column) => column.isUnique && !column.primary).map((column) => ({
912
+ name: column.uniqueName ?? `${config.name}_${column.name}_unique`,
913
+ columns: [column.name]
914
+ })),
915
+ ...config.uniqueConstraints.map((constraint) => ({
916
+ name: constraint.name,
917
+ columns: constraint.columns.map((column) => column.name)
918
+ })),
919
+ ...config.indexes.filter((index) => index.config.unique === true).map((index) => ({
920
+ name: index.config.name,
921
+ columns: index.config.columns.map((column) => column.name)
922
+ }))
923
+ ]
924
+ };
925
+ TABLE_KEYS.set(tableObj, keys);
926
+ return keys;
947
927
  }
928
+ /**
929
+ * Create a MySQL database adapter.
930
+ *
931
+ * @param config - MySQL adapter configuration
932
+ * @returns A new MySqlAdapter instance
933
+ *
934
+ * @example
935
+ * ```typescript
936
+ * // Simple usage with URL
937
+ * const adapter = createMySqlAdapter({
938
+ * url: 'mysql://user:pass@localhost:3306/mydb',
939
+ * });
940
+ *
941
+ * // Full configuration
942
+ * const adapter = createMySqlAdapter({
943
+ * url: process.env.DATABASE_URL!,
944
+ * pool: {
945
+ * min: 2,
946
+ * max: 20,
947
+ * idleTimeoutMs: 30000,
948
+ * connectionTimeoutMs: 10000,
949
+ * },
950
+ * ssl: {
951
+ * rejectUnauthorized: true,
952
+ * },
953
+ * });
954
+ *
955
+ * await adapter.connect();
956
+ * ```
957
+ */
948
958
  function createMySqlAdapter(config) {
949
- return new MySqlAdapter(config);
959
+ return new MySqlAdapter(config);
950
960
  }
961
+ /**
962
+ * Type guard to check if a value is a MySqlAdapter.
963
+ *
964
+ * @param value - Value to check
965
+ * @returns True if value is a MySqlAdapter instance
966
+ *
967
+ * @example
968
+ * ```typescript
969
+ * if (isMySqlAdapter(adapter)) {
970
+ * // TypeScript knows adapter is MySqlAdapter
971
+ * console.log('Using MySQL');
972
+ * }
973
+ * ```
974
+ */
951
975
  function isMySqlAdapter(value) {
952
- return value instanceof MySqlAdapter;
976
+ return value instanceof MySqlAdapter;
953
977
  }
954
-
955
978
  export { MySqlAdapter, VERSION, createMySqlAdapter, isMySqlAdapter };
956
- //# sourceMappingURL=index.mjs.map
979
+
957
980
  //# sourceMappingURL=index.mjs.map