@nextlyhq/adapter-mysql 0.0.1

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 ADDED
@@ -0,0 +1,603 @@
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
+
7
+ // src/index.ts
8
+ var VERSION = "0.1.0";
9
+ var DEFAULT_POOL_CONFIG = {
10
+ max: 10,
11
+ idleTimeoutMs: 3e4,
12
+ connectionTimeoutMs: 1e4
13
+ };
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
77
+ };
78
+ function delay(ms) {
79
+ return new Promise((resolve) => setTimeout(resolve, ms));
80
+ }
81
+ 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
+ }
593
+ };
594
+ function createMySqlAdapter(config) {
595
+ return new MySqlAdapter(config);
596
+ }
597
+ function isMySqlAdapter(value) {
598
+ return value instanceof MySqlAdapter;
599
+ }
600
+
601
+ export { MySqlAdapter, VERSION, createMySqlAdapter, isMySqlAdapter };
602
+ //# sourceMappingURL=index.mjs.map
603
+ //# sourceMappingURL=index.mjs.map