@nextlyhq/adapter-mysql 0.0.2-alpha.73 → 0.0.2-alpha.74

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,5 +1,5 @@
1
1
  import { DrizzleAdapter } from "@nextlyhq/adapter-drizzle";
2
- import { createDatabaseError, isApplicationError, isDatabaseError, poolWaitTimeoutError } from "@nextlyhq/adapter-drizzle/types";
2
+ import { createDatabaseError, isDatabaseError, poolWaitTimeoutError } from "@nextlyhq/adapter-drizzle/types";
3
3
  import { acceptsForShare, checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
4
4
  import { and, eq, getColumns } from "drizzle-orm";
5
5
  import { getTableConfig } from "drizzle-orm/mysql-core";
@@ -145,6 +145,16 @@ async function returnTransactionConnection(connection, rollbackFailed, restore)
145
145
  else connection.destroy();
146
146
  }
147
147
  /**
148
+ * mysql2 accepts only `local`, `Z` and `±HH:MM` spellings for its driver
149
+ * zone, and the ones that resolve to UTC are exactly these. `local` resolves
150
+ * to the Node process's own zone, so it agrees with a UTC session only on a
151
+ * UTC host.
152
+ */
153
+ function isUtcDriverZone(zone) {
154
+ if (zone === "local") return (/* @__PURE__ */ new Date()).getTimezoneOffset() === 0;
155
+ return zone === "Z" || zone === "+00:00" || zone === "-00:00";
156
+ }
157
+ /**
148
158
  * Package version
149
159
  */
150
160
  const VERSION = "0.1.0";
@@ -191,11 +201,19 @@ const MYSQL_ERROR_CODES = {
191
201
  1146: "query",
192
202
  1054: "query"
193
203
  };
204
+ /** ER_LOCK_DEADLOCK: the transaction lost a race, and running it again may succeed. */
205
+ const DEADLOCK = 1213;
194
206
  /**
195
- * Delay helper for retry logic.
207
+ * The MySQL error number an error carries: its own, or that of the driver
208
+ * error it wraps, as a classified or a Drizzle query error does.
196
209
  */
197
- function delay(ms) {
198
- return new Promise((resolve) => setTimeout(resolve, ms));
210
+ function errnoOf(error) {
211
+ for (let at = error, hops = 0; hops < 4; hops++) {
212
+ if (typeof at !== "object" || at === null) return void 0;
213
+ const errno = Reflect.get(at, "errno");
214
+ if (typeof errno === "number") return errno;
215
+ at = Reflect.get(at, "cause");
216
+ }
199
217
  }
200
218
  /**
201
219
  * MySQL database adapter for Nextly.
@@ -271,8 +289,14 @@ var MySqlAdapter = class extends DrizzleAdapter {
271
289
  async connect() {
272
290
  if (this.connected && this.pool) return;
273
291
  try {
292
+ this.warnOnDriverZoneDisagreement();
274
293
  const poolConfig = this.buildPoolConfig();
275
294
  const pool = mysql.createPool(poolConfig);
295
+ pool.pool.on("connection", (connection) => {
296
+ connection.query("SET SESSION time_zone = '+00:00'", (error) => {
297
+ if (error) connection.destroy();
298
+ });
299
+ });
276
300
  boundPoolWait(pool.pool, poolConfig.connectTimeout ?? 0);
277
301
  this.pool = pool;
278
302
  const connection = await this.pool.getConnection();
@@ -391,9 +415,29 @@ var MySqlAdapter = class extends DrizzleAdapter {
391
415
  *
392
416
  * Note: Savepoints are disabled in this adapter for safety due to
393
417
  * MySQL's quirks with nested transactions.
418
+ *
419
+ * A call made inside another transaction's work therefore joins it flat,
420
+ * on its connection: its statements are the outer's. Its failure cannot be
421
+ * undone alone, so it fails the whole transaction, even when the outer
422
+ * catches it: the outer's COMMIT is refused, it rolls back, and it rejects
423
+ * with the error that failed the joined call; any later joined call fails
424
+ * with that error at once. A joined call keeps the outer's options and is
425
+ * never retried itself. `retryWhen` is asked first, of every error that
426
+ * ends an attempt, application ones included.
394
427
  */
395
- async transaction(work, options) {
396
- return this.runInFlowTransaction(() => this.runTransactionAttempts(work, options));
428
+ transaction(work, options) {
429
+ return this.openOrJoin(work, options, {
430
+ run: (_attempt, openedAt) => this.runAttempt(work, options, openedAt),
431
+ retryable: (error) => errnoOf(error) === DEADLOCK,
432
+ describe: (error) => {
433
+ const errno = errnoOf(error);
434
+ return {
435
+ label: errno === DEADLOCK ? "deadlock" : "a retryable error",
436
+ detail: { errno }
437
+ };
438
+ },
439
+ logger: this.config.logger
440
+ });
397
441
  }
398
442
  /**
399
443
  * Runs `work` on a connection of its own, opened for it with the pool's
@@ -435,6 +479,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
435
479
  try {
436
480
  try {
437
481
  await connection.query("SET SESSION autocommit = 1");
482
+ await connection.query("SET SESSION time_zone = '+00:00'");
438
483
  if (options?.strictSqlMode) await this.enterStrictMode(connection, {});
439
484
  } catch (error) {
440
485
  throw this.classifyError(error);
@@ -453,50 +498,48 @@ var MySqlAdapter = class extends DrizzleAdapter {
453
498
  connection.destroy();
454
499
  }
455
500
  }
456
- /** The attempts `transaction` makes, each on a connection of its own. */
457
- async runTransactionAttempts(work, options) {
501
+ /** One attempt at an outermost transaction, on a connection of its own. */
502
+ async runAttempt(work, options, openedAt) {
458
503
  const pool = this.ensurePool();
459
- const maxAttempts = (options?.retryCount ?? 0) + 1;
460
- const retryDelayMs = options?.retryDelayMs ?? 100;
461
- let lastError;
462
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
463
- const connection = await this.connectFlowTransaction(() => pool.getConnection());
464
- const startTime = Date.now();
465
- let rollbackFailed = false;
466
- const restore = {};
467
- try {
468
- await this.beginTransaction(connection, options, restore);
469
- const result = await work(this.createTransactionContext(connection));
504
+ this.notePoolAccess();
505
+ const connection = await pool.getConnection();
506
+ let rollbackFailed = false;
507
+ const restore = {};
508
+ const settled = await this.settleOutermost({
509
+ context: this.createTransactionContext(connection),
510
+ join: this.joinSteps(connection),
511
+ openedAt,
512
+ begin: () => this.beginTransaction(connection, options, restore),
513
+ commit: async () => {
470
514
  await connection.query("COMMIT");
471
- if (this.config.logger?.debug) {
472
- const durationMs = Date.now() - startTime;
473
- this.config.logger.debug("Transaction committed", {
474
- attempt,
475
- durationMs
476
- });
477
- }
478
- return result;
479
- } catch (error) {
480
- await connection.query("ROLLBACK").catch(() => {
515
+ },
516
+ rollback: async () => {
517
+ await connection.query("ROLLBACK").catch((rollbackError) => {
481
518
  rollbackFailed = true;
519
+ throw rollbackError;
482
520
  });
483
- lastError = error;
484
- if (isApplicationError(error)) throw error;
485
- const mysqlError = error;
486
- if (mysqlError.errno === 1213 && attempt < maxAttempts) {
487
- if (this.config.logger?.warn) this.config.logger.warn(`Transaction failed with deadlock, retrying (${attempt}/${maxAttempts})`, {
488
- errno: mysqlError.errno,
489
- attempt
490
- });
491
- await delay(retryDelayMs * attempt);
492
- continue;
493
- }
494
- throw this.classifyError(error);
495
- } finally {
496
- await returnTransactionConnection(connection, rollbackFailed, restore);
497
521
  }
498
- }
499
- throw this.classifyError(lastError);
522
+ }, work, options);
523
+ await returnTransactionConnection(connection, rollbackFailed, restore);
524
+ return settled;
525
+ }
526
+ /**
527
+ * How a call made inside a transaction on `connection` joins it: flat, on
528
+ * the same connection, with nothing to send. Any failure inside it fails
529
+ * the whole transaction, since no savepoint can undo the part alone.
530
+ */
531
+ joinSteps(connection) {
532
+ const callbackConnection = connection.connection;
533
+ return this.joinStepsOn(() => this.createTransactionContext(connection), (relations) => relations ? drizzle({
534
+ client: callbackConnection,
535
+ relations
536
+ }) : drizzle({ client: callbackConnection }), (context) => ({
537
+ context,
538
+ begin() {},
539
+ commit() {},
540
+ rollback() {},
541
+ dooms: () => true
542
+ }));
500
543
  }
501
544
  /**
502
545
  * Get MySQL database capabilities.
@@ -607,17 +650,36 @@ var MySqlAdapter = class extends DrizzleAdapter {
607
650
  "execute",
608
651
  "getConnection"
609
652
  ]);
610
- return this.trackFlowTransactions(this.pinDrizzleTransactions(onClient(callbackPool), async () => {
611
- const connection = await this.connectFlowTransaction(() => pool.getConnection());
653
+ return this.joiningHandle(onClient(callbackPool), async () => {
654
+ const connection = await pool.getConnection();
612
655
  return {
613
656
  drizzle: onClient(connection.connection),
614
657
  rollback: () => connection.query("ROLLBACK"),
615
658
  release: (dirty) => {
616
659
  if (dirty) connection.destroy();
617
660
  else connection.release();
618
- }
661
+ },
662
+ join: this.joinSteps(connection)
619
663
  };
620
- }));
664
+ }, relations);
665
+ }
666
+ /**
667
+ * Warns when a caller's configured driver zone does not agree with the
668
+ * pinned UTC session.
669
+ *
670
+ * mysql2's driver-level `timezone` decides how it parses the date strings
671
+ * the pinned session answers with and how it binds raw Date parameters, so
672
+ * a non-UTC spelling there would shift values a round-trip fails to
673
+ * cancel. The configuration is aligned to the pin (`buildConnectionConfig`
674
+ * sets UTC unconditionally, for pooled and standalone connections alike),
675
+ * never rewritten silently: the caller configured a reading, and this is
676
+ * where that reading is named as lost. Runs from `connect`, once per pool
677
+ * build — the configuration cannot change between calls.
678
+ */
679
+ warnOnDriverZoneDisagreement() {
680
+ const requestedZone = this.config.timezone ?? "local";
681
+ if (isUtcDriverZone(requestedZone)) return;
682
+ this.config.logger?.warn?.(`Driver timezone "${requestedZone}" is not UTC; it is aligned to the pinned UTC session and no longer shifts reads or writes.`);
621
683
  }
622
684
  /**
623
685
  * Builds mysql2 Pool configuration from adapter config.
@@ -638,7 +700,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
638
700
  const config = this.buildAddressConfig();
639
701
  config.connectTimeout = this.config.pool?.connectionTimeoutMs ?? DEFAULT_POOL_CONFIG.connectionTimeoutMs;
640
702
  if (this.config.ssl) config.ssl = this.buildSslConfig(this.config.ssl);
641
- if (this.config.timezone) config.timezone = this.config.timezone;
703
+ config.timezone = "Z";
642
704
  if (this.config.charset) config.charset = this.config.charset;
643
705
  config.multipleStatements = false;
644
706
  config.dateStrings = false;
@@ -901,7 +963,8 @@ var MySqlAdapter = class extends DrizzleAdapter {
901
963
  await connection.query(`SELECT ${idColumn} FROM ${this.escapeIdentifier(table)} WHERE ${idColumn} = ? FOR UPDATE`, [id]);
902
964
  },
903
965
  insert: async (table, data, options) => {
904
- const mapped = this.mapRowToRawSql(this.getTableObject(table), data);
966
+ const insertTableObj = this.resolvedTableObject(table);
967
+ const mapped = this.mapRowToRawSql(insertTableObj, data);
905
968
  const columns = Object.keys(mapped);
906
969
  const values = Object.values(mapped);
907
970
  const placeholders = this.buildPlaceholders(values.length, 0);
@@ -909,7 +972,6 @@ var MySqlAdapter = class extends DrizzleAdapter {
909
972
  const [result] = await connection.query(sql, values);
910
973
  const ret = options?.returning;
911
974
  if (Array.isArray(ret) && ret.length === 0) return;
912
- const insertTableObj = this.getTableObject(table);
913
975
  const aliases = this.dateWallClockAliases(insertTableObj, ret ?? "*");
914
976
  const spelled = aliases.map((a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`).join(", ");
915
977
  const projected = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(insertTableObj, ret).map((c) => this.escapeIdentifier(c)).join(", ");
@@ -927,7 +989,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
927
989
  if (data.length === 0) return [];
928
990
  const retMany = options?.returning;
929
991
  const skipReread = Array.isArray(retMany) && retMany.length === 0;
930
- const tableObj = this.getTableObject(table);
992
+ const tableObj = this.resolvedTableObject(table);
931
993
  const mappedRecords = data.map((r) => this.mapRowToRawSql(tableObj, r));
932
994
  const columns = Object.keys(mappedRecords[0]);
933
995
  const allValues = [];