@nextlyhq/adapter-mysql 0.0.2-alpha.7 → 0.0.2-alpha.71

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,603 +1,980 @@
1
- import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle';
2
- import { createDatabaseError, isDatabaseError } from '@nextlyhq/adapter-drizzle/types';
3
- import { checkDialectVersion } from '@nextlyhq/adapter-drizzle/version-check';
4
- import { drizzle } from 'drizzle-orm/mysql2';
5
- import mysql from 'mysql2/promise';
6
-
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
+ }
7
85
  // src/index.ts
8
- var VERSION = "0.1.0";
9
- var DEFAULT_POOL_CONFIG = {
10
- max: 10,
11
- idleTimeoutMs: 3e4,
12
- connectionTimeoutMs: 1e4
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
+ */
125
+ async function restoreSession(connection, restore) {
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
+ }
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
+ */
143
+ async function returnTransactionConnection(connection, rollbackFailed, restore) {
144
+ if (!rollbackFailed && await restoreSession(connection, restore)) connection.release();
145
+ else connection.destroy();
146
+ }
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
13
159
  };
14
- var MYSQL_ERROR_CODES = {
15
- // Unique/Duplicate key violations
16
- 1022: "unique_violation",
17
- // ER_DUP_KEY
18
- 1062: "unique_violation",
19
- // ER_DUP_ENTRY
20
- 1169: "unique_violation",
21
- // ER_DUP_UNIQUE
22
- 1586: "unique_violation",
23
- // ER_DUP_ENTRY_WITH_KEY_NAME
24
- // Foreign key violations
25
- 1216: "foreign_key_violation",
26
- // ER_NO_REFERENCED_ROW
27
- 1217: "foreign_key_violation",
28
- // ER_ROW_IS_REFERENCED
29
- 1451: "foreign_key_violation",
30
- // ER_ROW_IS_REFERENCED_2
31
- 1452: "foreign_key_violation",
32
- // ER_NO_REFERENCED_ROW_2
33
- // Not null violations
34
- 1048: "not_null_violation",
35
- // ER_BAD_NULL_ERROR
36
- 1364: "not_null_violation",
37
- // ER_NO_DEFAULT_FOR_FIELD
38
- // Check constraint violations (MySQL 8.0.16+)
39
- 3819: "check_violation",
40
- // ER_CHECK_CONSTRAINT_VIOLATED
41
- // Deadlock
42
- 1213: "deadlock",
43
- // ER_LOCK_DEADLOCK
44
- // Timeout
45
- 1205: "timeout",
46
- // ER_LOCK_WAIT_TIMEOUT
47
- // Connection errors
48
- 1040: "connection",
49
- // ER_CON_COUNT_ERROR - Too many connections
50
- 1042: "connection",
51
- // ER_BAD_HOST_ERROR
52
- 1043: "connection",
53
- // ER_HANDSHAKE_ERROR
54
- 1044: "connection",
55
- // ER_DBACCESS_DENIED_ERROR
56
- 1045: "connection",
57
- // ER_ACCESS_DENIED_ERROR
58
- 1129: "connection",
59
- // ER_HOST_IS_BLOCKED
60
- 1130: "connection",
61
- // ER_HOST_NOT_PRIVILEGED
62
- 2002: "connection",
63
- // CR_CONNECTION_ERROR
64
- 2003: "connection",
65
- // CR_CONN_HOST_ERROR
66
- 2006: "connection",
67
- // CR_SERVER_GONE_ERROR
68
- 2013: "connection",
69
- // CR_SERVER_LOST
70
- // Query errors
71
- 1064: "query",
72
- // ER_PARSE_ERROR
73
- 1146: "query",
74
- // ER_NO_SUCH_TABLE
75
- 1054: "query"
76
- // 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"
77
193
  };
194
+ /**
195
+ * Delay helper for retry logic.
196
+ */
78
197
  function delay(ms) {
79
- return new Promise((resolve) => setTimeout(resolve, ms));
198
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
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
+ */
81
224
  var MySqlAdapter = class extends DrizzleAdapter {
82
- /**
83
- * The database dialect - always 'mysql' for this adapter.
84
- */
85
- dialect = "mysql";
86
- /**
87
- * Adapter configuration.
88
- */
89
- config;
90
- /**
91
- * Connection pool instance.
92
- */
93
- pool = null;
94
- /**
95
- * Connection state flag.
96
- */
97
- connected = false;
98
- /**
99
- * Creates a new MySQL adapter instance.
100
- *
101
- * @param config - Adapter configuration
102
- */
103
- constructor(config) {
104
- super();
105
- this.config = config;
106
- }
107
- /**
108
- * Connect to the MySQL database.
109
- * Creates a connection pool using mysql2.
110
- *
111
- * @remarks
112
- * This method initializes the connection pool and verifies connectivity
113
- * by executing a simple query. It is idempotent - calling it multiple
114
- * times will not create multiple pools.
115
- *
116
- * @throws {DatabaseError} If connection fails
117
- */
118
- async connect() {
119
- if (this.connected && this.pool) {
120
- return;
121
- }
122
- try {
123
- const poolConfig = this.buildPoolConfig();
124
- this.pool = mysql.createPool(poolConfig);
125
- const connection = await this.pool.getConnection();
126
- try {
127
- await connection.query("SELECT 1");
128
- await checkDialectVersion(connection, "mysql", {
129
- // Why: route variant warnings through the adapter's logger so
130
- // users see a single, consistent log surface.
131
- onWarning: (msg) => this.config.logger?.warn?.(msg)
132
- });
133
- this.connected = true;
134
- if (this.config.logger?.info) {
135
- this.config.logger.info("MySQL connection established", {
136
- host: this.config.host ?? "from URL",
137
- database: this.config.database ?? "from URL"
138
- });
139
- }
140
- } finally {
141
- connection.release();
142
- }
143
- } catch (error) {
144
- if (this.pool) {
145
- await this.pool.end().catch(() => {
146
- });
147
- this.pool = null;
148
- }
149
- throw this.classifyError(error);
150
- }
151
- }
152
- /**
153
- * Disconnect from the MySQL database.
154
- * Gracefully closes the connection pool.
155
- *
156
- * @remarks
157
- * This method is idempotent - calling it multiple times is safe.
158
- * It waits for all connections to be released before shutting down.
159
- */
160
- async disconnect() {
161
- if (!this.pool) {
162
- return;
163
- }
164
- try {
165
- await this.pool.end();
166
- if (this.config.logger?.info) {
167
- this.config.logger.info("MySQL connection closed");
168
- }
169
- } finally {
170
- this.pool = null;
171
- this.connected = false;
172
- }
173
- }
174
- /**
175
- * Check if connected to the database.
176
- */
177
- isConnected() {
178
- return this.connected && this.pool !== null;
179
- }
180
- /**
181
- * Get connection pool statistics.
182
- * Returns null if not connected.
183
- *
184
- * @remarks
185
- * MySQL2 pool exposes different stats than pg:
186
- * - _allConnections: all connections
187
- * - _freeConnections: idle connections
188
- * - _connectionQueue: waiting requests
189
- */
190
- getPoolStats() {
191
- if (!this.pool) {
192
- return null;
193
- }
194
- const poolInternal = this.pool;
195
- const internal = poolInternal.pool;
196
- if (!internal) {
197
- return {
198
- total: 0,
199
- idle: 0,
200
- waiting: 0,
201
- active: 0
202
- };
203
- }
204
- const total = internal._allConnections?.length ?? 0;
205
- const idle = internal._freeConnections?.length ?? 0;
206
- const waiting = internal._connectionQueue?.length ?? 0;
207
- return {
208
- total,
209
- idle,
210
- waiting,
211
- active: total - idle
212
- };
213
- }
214
- /**
215
- * Execute a raw SQL query.
216
- *
217
- * @param sql - SQL query string with ? placeholders
218
- * @param params - Query parameters
219
- * @returns Query results
220
- *
221
- * @throws {DatabaseError} If query execution fails
222
- */
223
- async executeQuery(sql, params = []) {
224
- const pool = this.ensurePool();
225
- const startTime = Date.now();
226
- try {
227
- const [rows] = await pool.query(
228
- sql,
229
- params
230
- );
231
- if (this.config.logger?.query) {
232
- const durationMs = Date.now() - startTime;
233
- this.config.logger.query(sql, params, durationMs);
234
- }
235
- return rows;
236
- } catch (error) {
237
- throw this.classifyError(error, sql);
238
- }
239
- }
240
- /**
241
- * Execute work within a transaction.
242
- *
243
- * @param work - Function containing transactional operations
244
- * @param options - Transaction options (isolation level, timeout, retry)
245
- * @returns Result of the work function
246
- *
247
- * @remarks
248
- * MySQL transactions support isolation levels. Automatic retry is
249
- * implemented for deadlocks (error 1213) when `retryCount` is specified.
250
- *
251
- * Note: Savepoints are disabled in this adapter for safety due to
252
- * MySQL's quirks with nested transactions.
253
- */
254
- async transaction(work, options) {
255
- const pool = this.ensurePool();
256
- const maxAttempts = (options?.retryCount ?? 0) + 1;
257
- const retryDelayMs = options?.retryDelayMs ?? 100;
258
- let lastError;
259
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
260
- const connection = await pool.getConnection();
261
- const startTime = Date.now();
262
- try {
263
- await this.beginTransaction(connection, options);
264
- const ctx = this.createTransactionContext(connection);
265
- const result = await work(ctx);
266
- await connection.query("COMMIT");
267
- if (this.config.logger?.debug) {
268
- const durationMs = Date.now() - startTime;
269
- this.config.logger.debug("Transaction committed", {
270
- attempt,
271
- durationMs
272
- });
273
- }
274
- return result;
275
- } catch (error) {
276
- await connection.query("ROLLBACK").catch(() => {
277
- });
278
- lastError = error;
279
- const mysqlError = error;
280
- const isRetryable = mysqlError.errno === 1213;
281
- if (isRetryable && attempt < maxAttempts) {
282
- if (this.config.logger?.warn) {
283
- this.config.logger.warn(
284
- `Transaction failed with deadlock, retrying (${attempt}/${maxAttempts})`,
285
- { errno: mysqlError.errno, attempt }
286
- );
287
- }
288
- await delay(retryDelayMs * attempt);
289
- continue;
290
- }
291
- throw this.classifyError(error);
292
- } finally {
293
- connection.release();
294
- }
295
- }
296
- throw this.classifyError(lastError);
297
- }
298
- /**
299
- * Get MySQL database capabilities.
300
- *
301
- * @remarks
302
- * MySQL has some limitations:
303
- * - No JSONB (uses JSON)
304
- * - No arrays
305
- * - No native ILIKE
306
- * - No RETURNING clause
307
- * - Savepoints disabled for safety
308
- */
309
- getCapabilities() {
310
- return {
311
- dialect: "mysql",
312
- supportsJsonb: false,
313
- // MySQL uses JSON, not JSONB
314
- supportsJson: true,
315
- supportsArrays: false,
316
- // MySQL doesn't support array types
317
- supportsGeneratedColumns: true,
318
- // MySQL 5.7.6+
319
- supportsFts: true,
320
- // MySQL has FULLTEXT indexes
321
- supportsIlike: false,
322
- // No native ILIKE, use LOWER() LIKE
323
- supportsReturning: false,
324
- // No RETURNING clause in MySQL
325
- supportsSavepoints: false,
326
- // Disabled for safety per approved approach
327
- supportsOnConflict: true,
328
- // ON DUPLICATE KEY UPDATE
329
- maxParamsPerQuery: 65535,
330
- // MySQL limit
331
- maxIdentifierLength: 64
332
- // MySQL limit
333
- };
334
- }
335
- /**
336
- * Build a placeholder for MySQL (uses ? instead of $1, $2, etc.)
337
- *
338
- * @param _index - Parameter index (ignored for MySQL)
339
- * @returns The ? placeholder
340
- */
341
- buildPlaceholder(_index) {
342
- return "?";
343
- }
344
- /**
345
- * Build multiple placeholders for MySQL.
346
- *
347
- * @param count - Number of placeholders needed
348
- * @param _startIndex - Starting index (ignored for MySQL)
349
- * @returns Comma-separated ? placeholders
350
- */
351
- buildPlaceholders(count, _startIndex = 0) {
352
- return Array(count).fill("?").join(", ");
353
- }
354
- /**
355
- * Escape an identifier for MySQL (uses backticks instead of double quotes).
356
- *
357
- * @param identifier - The identifier to escape
358
- * @returns Escaped identifier with backticks
359
- */
360
- escapeIdentifier(identifier) {
361
- return `\`${identifier.replace(/`/g, "``")}\``;
362
- }
363
- // ============================================================
364
- // Protected Helper Methods
365
- // ============================================================
366
- /**
367
- * Ensures pool is connected and returns it.
368
- *
369
- * @throws {DatabaseError} If not connected
370
- */
371
- ensurePool() {
372
- if (!this.pool) {
373
- throw createDatabaseError({
374
- kind: "connection",
375
- message: "MySqlAdapter is not connected. Call connect() first."
376
- });
377
- }
378
- return this.pool;
379
- }
380
- /**
381
- * Return the typed Drizzle instance for MySQL.
382
- * Guarded for server-only usage and requires an active connection.
383
- *
384
- * @param schema - Optional schema for relational queries (db.query.*)
385
- * @returns Drizzle ORM instance wrapping the mysql2 pool connection
386
- * @throws {Error} If called in browser or not connected
387
- */
388
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
389
- getDrizzle(schema) {
390
- if (typeof window !== "undefined") {
391
- throw new Error("getDrizzle() is server-only");
392
- }
393
- const pool = this.ensurePool();
394
- return schema ? drizzle({ client: pool, schema, mode: "default" }) : drizzle(pool);
395
- }
396
- /**
397
- * Builds mysql2 Pool configuration from adapter config.
398
- */
399
- buildPoolConfig() {
400
- const config = {};
401
- if (this.config.url) {
402
- config.uri = this.config.url;
403
- } else {
404
- if (this.config.host) config.host = this.config.host;
405
- if (this.config.port) config.port = this.config.port;
406
- if (this.config.database) config.database = this.config.database;
407
- if (this.config.user) config.user = this.config.user;
408
- if (this.config.password) config.password = this.config.password;
409
- }
410
- config.connectionLimit = this.config.pool?.max ?? DEFAULT_POOL_CONFIG.max;
411
- config.idleTimeout = this.config.pool?.idleTimeoutMs ?? DEFAULT_POOL_CONFIG.idleTimeoutMs;
412
- config.connectTimeout = this.config.pool?.connectionTimeoutMs ?? DEFAULT_POOL_CONFIG.connectionTimeoutMs;
413
- config.waitForConnections = true;
414
- config.queueLimit = 0;
415
- if (this.config.ssl) {
416
- if (typeof this.config.ssl === "boolean") {
417
- config.ssl = this.config.ssl ? {} : void 0;
418
- } else {
419
- config.ssl = {
420
- rejectUnauthorized: this.config.ssl.rejectUnauthorized,
421
- ca: this.config.ssl.ca,
422
- cert: this.config.ssl.cert,
423
- key: this.config.ssl.key
424
- };
425
- }
426
- }
427
- if (this.config.timezone) {
428
- config.timezone = this.config.timezone;
429
- }
430
- if (this.config.charset) {
431
- config.charset = this.config.charset;
432
- }
433
- config.multipleStatements = false;
434
- config.dateStrings = false;
435
- return config;
436
- }
437
- /**
438
- * Begins a transaction with the specified options.
439
- */
440
- async beginTransaction(connection, options) {
441
- if (options?.isolationLevel) {
442
- const isolationMap = {
443
- "read uncommitted": "READ UNCOMMITTED",
444
- "read committed": "READ COMMITTED",
445
- "repeatable read": "REPEATABLE READ",
446
- serializable: "SERIALIZABLE"
447
- };
448
- const level = isolationMap[options.isolationLevel];
449
- if (level) {
450
- await connection.query(`SET TRANSACTION ISOLATION LEVEL ${level}`);
451
- }
452
- }
453
- if (options?.readOnly) {
454
- await connection.query("SET TRANSACTION READ ONLY");
455
- }
456
- await connection.query("START TRANSACTION");
457
- if (options?.timeoutMs) {
458
- const timeoutSeconds = Math.ceil(options.timeoutMs / 1e3);
459
- await connection.query(
460
- `SET SESSION innodb_lock_wait_timeout = ${timeoutSeconds}`
461
- );
462
- }
463
- }
464
- /**
465
- * Creates a TransactionContext for the given connection.
466
- *
467
- * @remarks
468
- * Note: Savepoint methods are not implemented (set to undefined)
469
- * as savepoints are disabled in this adapter per approved approach.
470
- */
471
- createTransactionContext(connection) {
472
- return {
473
- execute: async (sql, params = []) => {
474
- const [rows] = await connection.query(
475
- sql,
476
- params
477
- );
478
- return rows;
479
- },
480
- insert: async (table, data, _options) => {
481
- const columns = Object.keys(data);
482
- const values = Object.values(data);
483
- const placeholders = this.buildPlaceholders(values.length, 0);
484
- const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
485
- const [result] = await connection.query(sql, values);
486
- if (result.insertId) {
487
- const [rows2] = await connection.query(
488
- `SELECT * FROM ${this.escapeIdentifier(table)} WHERE id = ?`,
489
- [result.insertId]
490
- );
491
- return rows2[0];
492
- }
493
- const whereClauses = columns.map(
494
- (c) => `${this.escapeIdentifier(c)} = ?`
495
- );
496
- const [rows] = await connection.query(
497
- `SELECT * FROM ${this.escapeIdentifier(table)} WHERE ${whereClauses.join(" AND ")} LIMIT 1`,
498
- values
499
- );
500
- return rows[0];
501
- },
502
- insertMany: async (table, data, _options) => {
503
- if (data.length === 0) return [];
504
- const columns = Object.keys(data[0]);
505
- const allValues = [];
506
- const valuesClauses = [];
507
- for (const record of data) {
508
- const placeholders = [];
509
- for (const col of columns) {
510
- allValues.push(record[col]);
511
- placeholders.push("?");
512
- }
513
- valuesClauses.push(`(${placeholders.join(", ")})`);
514
- }
515
- const sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
516
- const [result] = await connection.query(
517
- sql,
518
- allValues
519
- );
520
- if (result.insertId && result.affectedRows > 0) {
521
- const ids = [];
522
- for (let i = 0; i < result.affectedRows; i++) {
523
- ids.push(result.insertId + i);
524
- }
525
- const placeholders = ids.map(() => "?").join(", ");
526
- const [rows] = await connection.query(
527
- `SELECT * FROM ${this.escapeIdentifier(table)} WHERE id IN (${placeholders})`,
528
- ids
529
- );
530
- return rows;
531
- }
532
- return [];
533
- },
534
- // TransactionContext CRUD methods delegate to the adapter's CRUD
535
- // which uses Drizzle query API via the TableResolver.
536
- select: async (table, options) => {
537
- return this.select(table, options);
538
- },
539
- selectOne: async (table, options) => {
540
- return this.selectOne(table, options);
541
- },
542
- update: async (table, data, where, options) => {
543
- return this.update(table, data, where, options);
544
- },
545
- delete: async (table, where, _options) => {
546
- return this.delete(table, where);
547
- },
548
- upsert: async (table, data, options) => {
549
- return this.upsert(table, data, options);
550
- },
551
- // Savepoints disabled per approved approach
552
- savepoint: void 0,
553
- rollbackToSavepoint: void 0,
554
- releaseSavepoint: void 0
555
- };
556
- }
557
- /**
558
- * Classifies a MySQL error into a DatabaseError.
559
- *
560
- * @param error - Original error from mysql2
561
- * @param sql - SQL statement that caused the error (optional)
562
- * @returns DatabaseError with proper classification
563
- */
564
- classifyError(error, sql) {
565
- if (isDatabaseError(error)) return error;
566
- const mysqlError = error;
567
- const kind = mysqlError.errno && MYSQL_ERROR_CODES[mysqlError.errno] || "unknown";
568
- let message = mysqlError.message ?? String(error);
569
- if (sql && kind === "query") {
570
- message = `Query failed: ${message}`;
571
- }
572
- return createDatabaseError({
573
- kind,
574
- message,
575
- code: mysqlError.code ?? mysqlError.errno?.toString(),
576
- detail: mysqlError.sql,
577
- cause: error instanceof Error ? error : void 0
578
- });
579
- }
580
- /**
581
- * Override handleQueryError to use MySQL-specific classification.
582
- */
583
- handleQueryError(error, operation, table) {
584
- const dbError = this.classifyError(error);
585
- if (!dbError.message.includes(operation)) {
586
- dbError.message = `${operation} operation failed on table '${table}': ${dbError.message}`;
587
- }
588
- if (!dbError.table) {
589
- dbError.table = table;
590
- }
591
- return dbError;
592
- }
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
+ }
593
897
  };
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
+ */
904
+ function keysOf(tableObj) {
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;
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
+ */
594
958
  function createMySqlAdapter(config) {
595
- return new MySqlAdapter(config);
959
+ return new MySqlAdapter(config);
596
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
+ */
597
975
  function isMySqlAdapter(value) {
598
- return value instanceof MySqlAdapter;
976
+ return value instanceof MySqlAdapter;
599
977
  }
600
-
601
978
  export { MySqlAdapter, VERSION, createMySqlAdapter, isMySqlAdapter };
602
- //# sourceMappingURL=index.mjs.map
979
+
603
980
  //# sourceMappingURL=index.mjs.map