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

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