@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.cjs CHANGED
@@ -168,6 +168,16 @@ async function returnTransactionConnection(connection, rollbackFailed, restore)
168
168
  else connection.destroy();
169
169
  }
170
170
  /**
171
+ * mysql2 accepts only `local`, `Z` and `±HH:MM` spellings for its driver
172
+ * zone, and the ones that resolve to UTC are exactly these. `local` resolves
173
+ * to the Node process's own zone, so it agrees with a UTC session only on a
174
+ * UTC host.
175
+ */
176
+ function isUtcDriverZone(zone) {
177
+ if (zone === "local") return (/* @__PURE__ */ new Date()).getTimezoneOffset() === 0;
178
+ return zone === "Z" || zone === "+00:00" || zone === "-00:00";
179
+ }
180
+ /**
171
181
  * Package version
172
182
  */
173
183
  const VERSION = "0.1.0";
@@ -214,11 +224,19 @@ const MYSQL_ERROR_CODES = {
214
224
  1146: "query",
215
225
  1054: "query"
216
226
  };
227
+ /** ER_LOCK_DEADLOCK: the transaction lost a race, and running it again may succeed. */
228
+ const DEADLOCK = 1213;
217
229
  /**
218
- * Delay helper for retry logic.
230
+ * The MySQL error number an error carries: its own, or that of the driver
231
+ * error it wraps, as a classified or a Drizzle query error does.
219
232
  */
220
- function delay(ms) {
221
- return new Promise((resolve) => setTimeout(resolve, ms));
233
+ function errnoOf(error) {
234
+ for (let at = error, hops = 0; hops < 4; hops++) {
235
+ if (typeof at !== "object" || at === null) return void 0;
236
+ const errno = Reflect.get(at, "errno");
237
+ if (typeof errno === "number") return errno;
238
+ at = Reflect.get(at, "cause");
239
+ }
222
240
  }
223
241
  /**
224
242
  * MySQL database adapter for Nextly.
@@ -294,8 +312,14 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
294
312
  async connect() {
295
313
  if (this.connected && this.pool) return;
296
314
  try {
315
+ this.warnOnDriverZoneDisagreement();
297
316
  const poolConfig = this.buildPoolConfig();
298
317
  const pool = mysql2_promise.default.createPool(poolConfig);
318
+ pool.pool.on("connection", (connection) => {
319
+ connection.query("SET SESSION time_zone = '+00:00'", (error) => {
320
+ if (error) connection.destroy();
321
+ });
322
+ });
299
323
  boundPoolWait(pool.pool, poolConfig.connectTimeout ?? 0);
300
324
  this.pool = pool;
301
325
  const connection = await this.pool.getConnection();
@@ -414,9 +438,29 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
414
438
  *
415
439
  * Note: Savepoints are disabled in this adapter for safety due to
416
440
  * MySQL's quirks with nested transactions.
441
+ *
442
+ * A call made inside another transaction's work therefore joins it flat,
443
+ * on its connection: its statements are the outer's. Its failure cannot be
444
+ * undone alone, so it fails the whole transaction, even when the outer
445
+ * catches it: the outer's COMMIT is refused, it rolls back, and it rejects
446
+ * with the error that failed the joined call; any later joined call fails
447
+ * with that error at once. A joined call keeps the outer's options and is
448
+ * never retried itself. `retryWhen` is asked first, of every error that
449
+ * ends an attempt, application ones included.
417
450
  */
418
- async transaction(work, options) {
419
- return this.runInFlowTransaction(() => this.runTransactionAttempts(work, options));
451
+ transaction(work, options) {
452
+ return this.openOrJoin(work, options, {
453
+ run: (_attempt, openedAt) => this.runAttempt(work, options, openedAt),
454
+ retryable: (error) => errnoOf(error) === DEADLOCK,
455
+ describe: (error) => {
456
+ const errno = errnoOf(error);
457
+ return {
458
+ label: errno === DEADLOCK ? "deadlock" : "a retryable error",
459
+ detail: { errno }
460
+ };
461
+ },
462
+ logger: this.config.logger
463
+ });
420
464
  }
421
465
  /**
422
466
  * Runs `work` on a connection of its own, opened for it with the pool's
@@ -458,6 +502,7 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
458
502
  try {
459
503
  try {
460
504
  await connection.query("SET SESSION autocommit = 1");
505
+ await connection.query("SET SESSION time_zone = '+00:00'");
461
506
  if (options?.strictSqlMode) await this.enterStrictMode(connection, {});
462
507
  } catch (error) {
463
508
  throw this.classifyError(error);
@@ -476,50 +521,48 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
476
521
  connection.destroy();
477
522
  }
478
523
  }
479
- /** The attempts `transaction` makes, each on a connection of its own. */
480
- async runTransactionAttempts(work, options) {
524
+ /** One attempt at an outermost transaction, on a connection of its own. */
525
+ async runAttempt(work, options, openedAt) {
481
526
  const pool = this.ensurePool();
482
- const maxAttempts = (options?.retryCount ?? 0) + 1;
483
- const retryDelayMs = options?.retryDelayMs ?? 100;
484
- let lastError;
485
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
486
- const connection = await this.connectFlowTransaction(() => pool.getConnection());
487
- const startTime = Date.now();
488
- let rollbackFailed = false;
489
- const restore = {};
490
- try {
491
- await this.beginTransaction(connection, options, restore);
492
- const result = await work(this.createTransactionContext(connection));
527
+ this.notePoolAccess();
528
+ const connection = await pool.getConnection();
529
+ let rollbackFailed = false;
530
+ const restore = {};
531
+ const settled = await this.settleOutermost({
532
+ context: this.createTransactionContext(connection),
533
+ join: this.joinSteps(connection),
534
+ openedAt,
535
+ begin: () => this.beginTransaction(connection, options, restore),
536
+ commit: async () => {
493
537
  await connection.query("COMMIT");
494
- if (this.config.logger?.debug) {
495
- const durationMs = Date.now() - startTime;
496
- this.config.logger.debug("Transaction committed", {
497
- attempt,
498
- durationMs
499
- });
500
- }
501
- return result;
502
- } catch (error) {
503
- await connection.query("ROLLBACK").catch(() => {
538
+ },
539
+ rollback: async () => {
540
+ await connection.query("ROLLBACK").catch((rollbackError) => {
504
541
  rollbackFailed = true;
542
+ throw rollbackError;
505
543
  });
506
- lastError = error;
507
- if ((0, _nextlyhq_adapter_drizzle_types.isApplicationError)(error)) throw error;
508
- const mysqlError = error;
509
- if (mysqlError.errno === 1213 && attempt < maxAttempts) {
510
- if (this.config.logger?.warn) this.config.logger.warn(`Transaction failed with deadlock, retrying (${attempt}/${maxAttempts})`, {
511
- errno: mysqlError.errno,
512
- attempt
513
- });
514
- await delay(retryDelayMs * attempt);
515
- continue;
516
- }
517
- throw this.classifyError(error);
518
- } finally {
519
- await returnTransactionConnection(connection, rollbackFailed, restore);
520
544
  }
521
- }
522
- throw this.classifyError(lastError);
545
+ }, work, options);
546
+ await returnTransactionConnection(connection, rollbackFailed, restore);
547
+ return settled;
548
+ }
549
+ /**
550
+ * How a call made inside a transaction on `connection` joins it: flat, on
551
+ * the same connection, with nothing to send. Any failure inside it fails
552
+ * the whole transaction, since no savepoint can undo the part alone.
553
+ */
554
+ joinSteps(connection) {
555
+ const callbackConnection = connection.connection;
556
+ return this.joinStepsOn(() => this.createTransactionContext(connection), (relations) => relations ? (0, drizzle_orm_mysql2.drizzle)({
557
+ client: callbackConnection,
558
+ relations
559
+ }) : (0, drizzle_orm_mysql2.drizzle)({ client: callbackConnection }), (context) => ({
560
+ context,
561
+ begin() {},
562
+ commit() {},
563
+ rollback() {},
564
+ dooms: () => true
565
+ }));
523
566
  }
524
567
  /**
525
568
  * Get MySQL database capabilities.
@@ -630,17 +673,36 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
630
673
  "execute",
631
674
  "getConnection"
632
675
  ]);
633
- return this.trackFlowTransactions(this.pinDrizzleTransactions(onClient(callbackPool), async () => {
634
- const connection = await this.connectFlowTransaction(() => pool.getConnection());
676
+ return this.joiningHandle(onClient(callbackPool), async () => {
677
+ const connection = await pool.getConnection();
635
678
  return {
636
679
  drizzle: onClient(connection.connection),
637
680
  rollback: () => connection.query("ROLLBACK"),
638
681
  release: (dirty) => {
639
682
  if (dirty) connection.destroy();
640
683
  else connection.release();
641
- }
684
+ },
685
+ join: this.joinSteps(connection)
642
686
  };
643
- }));
687
+ }, relations);
688
+ }
689
+ /**
690
+ * Warns when a caller's configured driver zone does not agree with the
691
+ * pinned UTC session.
692
+ *
693
+ * mysql2's driver-level `timezone` decides how it parses the date strings
694
+ * the pinned session answers with and how it binds raw Date parameters, so
695
+ * a non-UTC spelling there would shift values a round-trip fails to
696
+ * cancel. The configuration is aligned to the pin (`buildConnectionConfig`
697
+ * sets UTC unconditionally, for pooled and standalone connections alike),
698
+ * never rewritten silently: the caller configured a reading, and this is
699
+ * where that reading is named as lost. Runs from `connect`, once per pool
700
+ * build — the configuration cannot change between calls.
701
+ */
702
+ warnOnDriverZoneDisagreement() {
703
+ const requestedZone = this.config.timezone ?? "local";
704
+ if (isUtcDriverZone(requestedZone)) return;
705
+ 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.`);
644
706
  }
645
707
  /**
646
708
  * Builds mysql2 Pool configuration from adapter config.
@@ -661,7 +723,7 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
661
723
  const config = this.buildAddressConfig();
662
724
  config.connectTimeout = this.config.pool?.connectionTimeoutMs ?? DEFAULT_POOL_CONFIG.connectionTimeoutMs;
663
725
  if (this.config.ssl) config.ssl = this.buildSslConfig(this.config.ssl);
664
- if (this.config.timezone) config.timezone = this.config.timezone;
726
+ config.timezone = "Z";
665
727
  if (this.config.charset) config.charset = this.config.charset;
666
728
  config.multipleStatements = false;
667
729
  config.dateStrings = false;
@@ -924,7 +986,8 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
924
986
  await connection.query(`SELECT ${idColumn} FROM ${this.escapeIdentifier(table)} WHERE ${idColumn} = ? FOR UPDATE`, [id]);
925
987
  },
926
988
  insert: async (table, data, options) => {
927
- const mapped = this.mapRowToRawSql(this.getTableObject(table), data);
989
+ const insertTableObj = this.resolvedTableObject(table);
990
+ const mapped = this.mapRowToRawSql(insertTableObj, data);
928
991
  const columns = Object.keys(mapped);
929
992
  const values = Object.values(mapped);
930
993
  const placeholders = this.buildPlaceholders(values.length, 0);
@@ -932,7 +995,6 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
932
995
  const [result] = await connection.query(sql, values);
933
996
  const ret = options?.returning;
934
997
  if (Array.isArray(ret) && ret.length === 0) return;
935
- const insertTableObj = this.getTableObject(table);
936
998
  const aliases = this.dateWallClockAliases(insertTableObj, ret ?? "*");
937
999
  const spelled = aliases.map((a) => `DATE_FORMAT(${this.escapeIdentifier(a.sqlName)}, '%Y-%m-%dT%H:%i:%s.%f') AS ${this.escapeIdentifier(a.alias)}`).join(", ");
938
1000
  const projected = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(insertTableObj, ret).map((c) => this.escapeIdentifier(c)).join(", ");
@@ -950,7 +1012,7 @@ var MySqlAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
950
1012
  if (data.length === 0) return [];
951
1013
  const retMany = options?.returning;
952
1014
  const skipReread = Array.isArray(retMany) && retMany.length === 0;
953
- const tableObj = this.getTableObject(table);
1015
+ const tableObj = this.resolvedTableObject(table);
954
1016
  const mappedRecords = data.map((r) => this.mapRowToRawSql(tableObj, r));
955
1017
  const columns = Object.keys(mappedRecords[0]);
956
1018
  const allValues = [];