@nextlyhq/adapter-mysql 0.0.2-alpha.66 → 0.0.2-alpha.67

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/README.md CHANGED
@@ -83,11 +83,11 @@ MySQL lacks a few features Nextly uses natively in PostgreSQL. The adapter emula
83
83
 
84
84
  ## Compatibility
85
85
 
86
- | Tool | Version |
87
- | -------- | ------- |
88
- | Node.js | 20+ |
89
- | `mysql2` | 3+ |
90
- | `nextly` | 0.0.x |
86
+ | Tool | Version |
87
+ | -------- | --------------------- |
88
+ | Node.js | 20.19+, 22.12+ or 24+ |
89
+ | `mysql2` | 3+ |
90
+ | `nextly` | 0.0.x |
91
91
 
92
92
  ## Documentation
93
93
 
package/dist/index.cjs CHANGED
@@ -3,6 +3,7 @@
3
3
  var adapterDrizzle = require('@nextlyhq/adapter-drizzle');
4
4
  var types = require('@nextlyhq/adapter-drizzle/types');
5
5
  var versionCheck = require('@nextlyhq/adapter-drizzle/version-check');
6
+ var drizzleOrm = require('drizzle-orm');
6
7
  var mysqlCore = require('drizzle-orm/mysql-core');
7
8
  var mysql2 = require('drizzle-orm/mysql2');
8
9
  var mysql = require('mysql2/promise');
@@ -12,6 +13,25 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
13
  var mysql__default = /*#__PURE__*/_interopDefault(mysql);
13
14
 
14
15
  // src/index.ts
16
+ async function restoreSession(connection, restore) {
17
+ if (restore === null) return true;
18
+ if (restore.lockWaitTimeout === null) return false;
19
+ try {
20
+ await connection.query(
21
+ `SET SESSION innodb_lock_wait_timeout = ${restore.lockWaitTimeout}`
22
+ );
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function returnTransactionConnection(connection, rollbackFailed, restore) {
29
+ if (!rollbackFailed && await restoreSession(connection, restore)) {
30
+ connection.release();
31
+ } else {
32
+ connection.destroy();
33
+ }
34
+ }
15
35
  var VERSION = "0.1.0";
16
36
  var DEFAULT_POOL_CONFIG = {
17
37
  max: 10,
@@ -110,6 +130,11 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
110
130
  * Connection state flag.
111
131
  */
112
132
  connected = false;
133
+ /**
134
+ * Whether the server accepts `SELECT ... FOR SHARE`, read from its version
135
+ * string at connect. Undefined until then, which callers treat as not known.
136
+ */
137
+ sharedRowLock;
113
138
  /**
114
139
  * Creates a new MySQL adapter instance.
115
140
  *
@@ -140,11 +165,12 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
140
165
  const connection = await this.pool.getConnection();
141
166
  try {
142
167
  await connection.query("SELECT 1");
143
- await versionCheck.checkDialectVersion(connection, "mysql", {
168
+ const { variant } = await versionCheck.checkDialectVersion(connection, "mysql", {
144
169
  // Why: route variant warnings through the adapter's logger so
145
170
  // users see a single, consistent log surface.
146
171
  onWarning: (msg) => this.config.logger?.warn?.(msg)
147
172
  });
173
+ this.sharedRowLock = versionCheck.acceptsForShare(variant);
148
174
  this.connected = true;
149
175
  if (this.config.logger?.info) {
150
176
  this.config.logger.info("MySQL connection established", {
@@ -270,6 +296,12 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
270
296
  * MySQL's quirks with nested transactions.
271
297
  */
272
298
  async transaction(work, options) {
299
+ return this.runInFlowTransaction(
300
+ () => this.runTransactionAttempts(work, options)
301
+ );
302
+ }
303
+ /** The attempts `transaction` makes, each on a connection of its own. */
304
+ async runTransactionAttempts(work, options) {
273
305
  const pool = this.ensurePool();
274
306
  const maxAttempts = (options?.retryCount ?? 0) + 1;
275
307
  const retryDelayMs = options?.retryDelayMs ?? 100;
@@ -277,8 +309,10 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
277
309
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
278
310
  const connection = await pool.getConnection();
279
311
  const startTime = Date.now();
312
+ let rollbackFailed = false;
313
+ let restore = null;
280
314
  try {
281
- await this.beginTransaction(connection, options);
315
+ restore = await this.beginTransaction(connection, options);
282
316
  const ctx = this.createTransactionContext(connection);
283
317
  const result = await work(ctx);
284
318
  await connection.query("COMMIT");
@@ -292,6 +326,7 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
292
326
  return result;
293
327
  } catch (error) {
294
328
  await connection.query("ROLLBACK").catch(() => {
329
+ rollbackFailed = true;
295
330
  });
296
331
  lastError = error;
297
332
  if (types.isApplicationError(error)) throw error;
@@ -309,7 +344,7 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
309
344
  }
310
345
  throw this.classifyError(error);
311
346
  } finally {
312
- connection.release();
347
+ await returnTransactionConnection(connection, rollbackFailed, restore);
313
348
  }
314
349
  }
315
350
  throw this.classifyError(lastError);
@@ -347,8 +382,10 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
347
382
  // ON DUPLICATE KEY UPDATE
348
383
  maxParamsPerQuery: 65535,
349
384
  // MySQL limit
350
- maxIdentifierLength: 64
385
+ maxIdentifierLength: 64,
351
386
  // MySQL limit
387
+ sharedRowLock: this.sharedRowLock
388
+ // Known once connected
352
389
  };
353
390
  }
354
391
  /**
@@ -409,18 +446,44 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
409
446
  throw new Error("getDrizzle() is server-only");
410
447
  }
411
448
  const pool = this.ensurePool();
412
- const client = pool.pool;
413
449
  if (!relations) {
414
- this.drizzleBare ??= mysql2.drizzle({ client });
450
+ this.drizzleBare ??= this.poolDrizzle(pool);
415
451
  return this.drizzleBare;
416
452
  }
417
453
  let cached = this.drizzleByRelations.get(relations);
418
454
  if (!cached) {
419
- cached = mysql2.drizzle({ client, relations });
455
+ cached = this.poolDrizzle(pool, relations);
420
456
  this.drizzleByRelations.set(relations, cached);
421
457
  }
422
458
  return cached;
423
459
  }
460
+ /**
461
+ * A Drizzle handle on the pool, for `getDrizzle`.
462
+ *
463
+ * Its `transaction` runs on a connection this adapter checks out and
464
+ * returns itself (`pinDrizzleTransactions`): destroyed rather than pooled
465
+ * when no ROLLBACK could be confirmed on it. And it counts for
466
+ * `transactionOpenInFlow`, as the adapter's does.
467
+ */
468
+ poolDrizzle(pool, relations) {
469
+ const onClient = (client) => relations ? mysql2.drizzle({ client, relations }) : mysql2.drizzle({ client });
470
+ const callbackPool = pool.pool;
471
+ return this.trackFlowTransactions(
472
+ this.pinDrizzleTransactions(onClient(callbackPool), async () => {
473
+ const connection = await pool.getConnection();
474
+ return {
475
+ drizzle: onClient(
476
+ connection.connection
477
+ ),
478
+ rollback: () => connection.query("ROLLBACK"),
479
+ release: (dirty) => {
480
+ if (dirty) connection.destroy();
481
+ else connection.release();
482
+ }
483
+ };
484
+ })
485
+ );
486
+ }
424
487
  /**
425
488
  * Builds mysql2 Pool configuration from adapter config.
426
489
  */
@@ -463,7 +526,8 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
463
526
  return config;
464
527
  }
465
528
  /**
466
- * Begins a transaction with the specified options.
529
+ * Begins a transaction with the specified options, and returns what it
530
+ * changed on the session for `restoreSession` to put back.
467
531
  */
468
532
  async beginTransaction(connection, options) {
469
533
  if (options?.isolationLevel) {
@@ -483,11 +547,17 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
483
547
  }
484
548
  await connection.query("START TRANSACTION");
485
549
  if (options?.timeoutMs) {
550
+ const [rows] = await connection.query(
551
+ "SELECT @@SESSION.innodb_lock_wait_timeout AS timeout"
552
+ );
553
+ const previous = Number(rows[0]?.timeout);
486
554
  const timeoutSeconds = Math.ceil(options.timeoutMs / 1e3);
487
555
  await connection.query(
488
556
  `SET SESSION innodb_lock_wait_timeout = ${timeoutSeconds}`
489
557
  );
558
+ return { lockWaitTimeout: Number.isInteger(previous) ? previous : null };
490
559
  }
560
+ return null;
491
561
  }
492
562
  /**
493
563
  * Creates a TransactionContext for the given connection.
@@ -496,6 +566,194 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
496
566
  * Note: Savepoint methods are not implemented (set to undefined)
497
567
  * as savepoints are disabled in this adapter per approved approach.
498
568
  */
569
+ /**
570
+ * Upsert with the meaning `INSERT … ON CONFLICT (target)` has on PostgreSQL
571
+ * and SQLite, on the tables where MySQL can keep that promise.
572
+ *
573
+ * MySQL cannot limit a duplicate to one key: `ON DUPLICATE KEY UPDATE`
574
+ * fires on a collision with ANY unique key, and a failed insert does not
575
+ * say reliably which one. So the conflict target must be the table's only
576
+ * unique key apart from its primary key, and any other table is refused
577
+ * before anything is written. On such a table the row is inserted first —
578
+ * its validation and triggers apply, as they do before `ON CONFLICT`
579
+ * elsewhere — and a duplicate is resolved by a locking read of the target
580
+ * key, which sees the latest committed row rather than the transaction's
581
+ * snapshot. The row found is kept as it is when there is nothing to update
582
+ * (a no-op assignment would still run its update triggers), or updated by
583
+ * its key. No row there means the collision was on the primary key, and it
584
+ * is refused as `ON CONFLICT (target)` refuses it.
585
+ *
586
+ * The steps run in the caller's transaction, or in one of their own, since a
587
+ * locking read locks nothing past its statement under autocommit. Rows are
588
+ * read back by their primary key, so a trigger that rewrites the conflict
589
+ * value still returns the row stored. The conflict columns must also be
590
+ * given values: MySQL cannot look a row up by a key the database would only
591
+ * fill in itself. A conflict is resolved on the caller's key value: a
592
+ * BEFORE INSERT trigger that moves the key onto another row's value is
593
+ * refused as a unique violation rather than resolved onto that row. A failed insert rolls back only itself on MySQL, so a
594
+ * caller's transaction stays usable.
595
+ */
596
+ async upsert(table, data, options, executor) {
597
+ const tableObj = this.getTableObject(table);
598
+ if (!tableObj) return super.upsert(table, data, options, executor);
599
+ let target;
600
+ try {
601
+ target = this.upsertTarget(tableObj, table, data, options);
602
+ } catch (error) {
603
+ throw this.handleQueryError(error, "upsert", table);
604
+ }
605
+ if (executor === void 0) {
606
+ return this.transaction((tx) => tx.upsert(table, data, options));
607
+ }
608
+ try {
609
+ const db = executor;
610
+ if (target.nullKey) {
611
+ return await this.insertRow(db, tableObj, data) ?? data;
612
+ }
613
+ return await this.insertOrResolve(
614
+ db,
615
+ tableObj,
616
+ data,
617
+ options,
618
+ target.where
619
+ );
620
+ } catch (error) {
621
+ throw this.handleQueryError(error, "upsert", table);
622
+ }
623
+ }
624
+ /**
625
+ * What an upsert is keyed on: the target's condition, and whether its key
626
+ * holds a NULL. Refuses a target this dialect cannot keep to, and a conflict
627
+ * column left for the database to fill — MySQL cannot look a row up by a key
628
+ * value only the database would supply.
629
+ */
630
+ upsertTarget(tableObj, table, data, options) {
631
+ this.assertUpsertTarget(table, data, options, true);
632
+ const key = this.conflictKey(tableObj, table, options.conflictColumns);
633
+ const values = options.conflictColumns.map((column) => data[column]);
634
+ return {
635
+ where: drizzleOrm.and(...key.map((column, i) => drizzleOrm.eq(column, values[i]))),
636
+ nullKey: values.some((value) => value === null)
637
+ };
638
+ }
639
+ /**
640
+ * The table's columns for a conflict target, refusing a target MySQL cannot
641
+ * keep to: one that is not a primary or unique key, which an update could
642
+ * spread over several rows, and one beside which the table has another
643
+ * unique key, a collision on which MySQL would not tell apart.
644
+ */
645
+ conflictKey(tableObj, table, conflictColumns) {
646
+ const record = tableObj;
647
+ const columns = conflictColumns.map((name) => record[name]);
648
+ const names = new Set(columns.map((column) => column?.name));
649
+ const isTarget = (key) => key.length === names.size && key.every((name) => names.has(name));
650
+ const { primary, unique } = keysOf(tableObj);
651
+ if (primary.length === 0) {
652
+ throw types.createDatabaseError({
653
+ kind: "query",
654
+ message: `upsert on ${table}: MySQL upserts need a table with a primary key, to read back the row they write.`
655
+ });
656
+ }
657
+ const known = columns.every((column) => column !== void 0);
658
+ if (!known || !(isTarget(primary) || unique.some((k) => isTarget(k.columns)))) {
659
+ throw types.createDatabaseError({
660
+ kind: "query",
661
+ message: `upsert on ${table}: the conflict columns (${conflictColumns.join(", ")}) are not a primary or unique key of the table.`
662
+ });
663
+ }
664
+ const others = unique.filter((key) => !isTarget(key.columns));
665
+ if (others.length > 0) {
666
+ throw types.createDatabaseError({
667
+ kind: "query",
668
+ 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.`
669
+ });
670
+ }
671
+ return columns;
672
+ }
673
+ /** The update an upsert applies to an existing row: never its key. */
674
+ updateValues(data, options) {
675
+ const key = new Set(options.conflictColumns);
676
+ const update = {};
677
+ for (const column of options.updateColumns ?? Object.keys(data)) {
678
+ if (!key.has(column) && column in data) update[column] = data[column];
679
+ }
680
+ return update;
681
+ }
682
+ /** The row matching `where`, by a locking (current) read. */
683
+ async lockedRow(db, tableObj, where) {
684
+ const rows = await db.select().from(tableObj).where(where).limit(1).for("update");
685
+ return rows[0];
686
+ }
687
+ /**
688
+ * Insert the row, or resolve a duplicate on the target key. The insert is
689
+ * read back by the primary key it reports, not by the conflict key: a
690
+ * BEFORE INSERT trigger may have rewritten the conflict value, and the row
691
+ * stored is the one to return.
692
+ *
693
+ * A duplicate is resolved when a row holds the TARGET key, whichever unique
694
+ * key MySQL names in its error. PostgreSQL checks the target's index before
695
+ * any other, so a row colliding on the target and on another key resolves
696
+ * on the target there too; refusing it here because MySQL happened to check
697
+ * the other index first would answer that case differently. A target row
698
+ * committed by another transaction after this insert failed is the order in
699
+ * which that transaction ran first, where PostgreSQL also resolves on it.
700
+ */
701
+ async insertOrResolve(db, tableObj, data, options, where) {
702
+ let failure;
703
+ try {
704
+ const stored = await this.insertRow(db, tableObj, data);
705
+ return stored ?? await this.lockedRow(db, tableObj, where);
706
+ } catch (error) {
707
+ if (this.classifyError(error).kind !== "unique_violation") throw error;
708
+ failure = error;
709
+ }
710
+ const existing = await this.lockedRow(db, tableObj, where);
711
+ if (existing === void 0) throw failure;
712
+ const update = this.updateValues(data, options);
713
+ if (Object.keys(update).length === 0) return existing;
714
+ await db.update(tableObj).set(update).where(where);
715
+ const identity = this.byIdentity(tableObj, {
716
+ ...existing,
717
+ ...update
718
+ });
719
+ const updated = identity === void 0 ? void 0 : await this.lockedRow(db, tableObj, identity);
720
+ return updated ?? this.lockedRow(db, tableObj, where);
721
+ }
722
+ /**
723
+ * Insert a row and read it back by its primary key: the value the caller
724
+ * gave, or the one MySQL or the model generated, which `$returningId()`
725
+ * reports and a given value it does not. Undefined when the table has no
726
+ * primary key to read by.
727
+ */
728
+ async insertRow(db, tableObj, data) {
729
+ const [generated] = await db.insert(tableObj).values(data).$returningId();
730
+ const given = Object.fromEntries(
731
+ Object.entries(data).filter(
732
+ ([, value]) => value !== void 0 && value !== null
733
+ )
734
+ );
735
+ const identity = this.byIdentity(tableObj, { ...generated, ...given });
736
+ return identity === void 0 ? void 0 : this.lockedRow(db, tableObj, identity);
737
+ }
738
+ /**
739
+ * A row's primary key as a condition, from a record keyed as the model's
740
+ * properties are. Undefined when the table has no primary key or the record
741
+ * lacks part of it.
742
+ */
743
+ byIdentity(tableObj, record) {
744
+ const primary = new Set(keysOf(tableObj).primary);
745
+ const parts = Object.entries(drizzleOrm.getColumns(tableObj)).filter(
746
+ ([, column]) => primary.has(column.name)
747
+ );
748
+ if (parts.length === 0 || parts.some(
749
+ ([name]) => record[name] === void 0 || record[name] === null
750
+ )) {
751
+ return void 0;
752
+ }
753
+ return drizzleOrm.and(
754
+ ...parts.map(([name, column]) => drizzleOrm.eq(column, record[name]))
755
+ );
756
+ }
499
757
  /** A table-level `primaryKey({ columns })`, read through the MySQL table config. */
500
758
  compositePrimaryKey(tableObj) {
501
759
  return mysqlCore.getTableConfig(
@@ -503,12 +761,13 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
503
761
  ).primaryKeys.flatMap((key) => key.columns);
504
762
  }
505
763
  createTransactionContext(connection) {
506
- const buildTxExecutor = () => mysql2.drizzle({
507
- client: connection.connection
508
- });
509
- let txExecutor;
510
- const txDb = () => txExecutor ??= buildTxExecutor();
764
+ const txConnection = connection.connection;
765
+ const txHandles = this.transactionDrizzleHandles(
766
+ (relations) => relations ? mysql2.drizzle({ client: txConnection, relations }) : mysql2.drizzle({ client: txConnection })
767
+ );
768
+ const txDb = txHandles.bare;
511
769
  return {
770
+ drizzleWithRelations: txHandles.withRelations,
512
771
  execute: async (sql, params = []) => {
513
772
  const [rows] = await connection.query(
514
773
  sql,
@@ -647,7 +906,7 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
647
906
  */
648
907
  classifyError(error, sql) {
649
908
  if (types.isDatabaseError(error)) return error;
650
- const mysqlError = error;
909
+ const mysqlError = this.driverErrorIn(error, (candidate) => typeof candidate.errno === "number");
651
910
  const kind = mysqlError.errno && MYSQL_ERROR_CODES[mysqlError.errno] || "unknown";
652
911
  let message = mysqlError.message ?? String(error);
653
912
  if (sql && kind === "query") {
@@ -662,6 +921,36 @@ var MySqlAdapter = class extends adapterDrizzle.DrizzleAdapter {
662
921
  });
663
922
  }
664
923
  };
924
+ var TABLE_KEYS = /* @__PURE__ */ new WeakMap();
925
+ function keysOf(tableObj) {
926
+ const cached = TABLE_KEYS.get(tableObj);
927
+ if (cached) return cached;
928
+ const config = mysqlCore.getTableConfig(tableObj);
929
+ const primary = [
930
+ ...config.columns.filter((column) => column.primary),
931
+ ...config.primaryKeys.flatMap((key) => key.columns)
932
+ ].map((column) => column.name);
933
+ const unique = [
934
+ ...config.columns.filter((column) => column.isUnique && !column.primary).map((column) => ({
935
+ // Drizzle's own name for an unnamed column-level unique key.
936
+ name: column.uniqueName ?? `${config.name}_${column.name}_unique`,
937
+ columns: [column.name]
938
+ })),
939
+ ...config.uniqueConstraints.map((constraint) => ({
940
+ name: constraint.name,
941
+ columns: constraint.columns.map((column) => column.name)
942
+ })),
943
+ ...config.indexes.filter((index) => index.config.unique === true).map((index) => ({
944
+ name: index.config.name,
945
+ columns: index.config.columns.map(
946
+ (column) => column.name
947
+ )
948
+ }))
949
+ ];
950
+ const keys = { primary, unique };
951
+ TABLE_KEYS.set(tableObj, keys);
952
+ return keys;
953
+ }
665
954
  function createMySqlAdapter(config) {
666
955
  return new MySqlAdapter(config);
667
956
  }