@nextlyhq/adapter-mysql 0.0.2-alpha.65 → 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/dist/index.mjs CHANGED
@@ -1,10 +1,31 @@
1
1
  import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle';
2
2
  import { isApplicationError, createDatabaseError, isDatabaseError } from '@nextlyhq/adapter-drizzle/types';
3
- import { checkDialectVersion } from '@nextlyhq/adapter-drizzle/version-check';
3
+ import { checkDialectVersion, acceptsForShare } from '@nextlyhq/adapter-drizzle/version-check';
4
+ import { and, eq, getColumns } from 'drizzle-orm';
5
+ import { getTableConfig } from 'drizzle-orm/mysql-core';
4
6
  import { drizzle } from 'drizzle-orm/mysql2';
5
7
  import mysql from 'mysql2/promise';
6
8
 
7
9
  // src/index.ts
10
+ async function restoreSession(connection, restore) {
11
+ if (restore === null) return true;
12
+ if (restore.lockWaitTimeout === null) return false;
13
+ try {
14
+ await connection.query(
15
+ `SET SESSION innodb_lock_wait_timeout = ${restore.lockWaitTimeout}`
16
+ );
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ async function returnTransactionConnection(connection, rollbackFailed, restore) {
23
+ if (!rollbackFailed && await restoreSession(connection, restore)) {
24
+ connection.release();
25
+ } else {
26
+ connection.destroy();
27
+ }
28
+ }
8
29
  var VERSION = "0.1.0";
9
30
  var DEFAULT_POOL_CONFIG = {
10
31
  max: 10,
@@ -103,6 +124,11 @@ var MySqlAdapter = class extends DrizzleAdapter {
103
124
  * Connection state flag.
104
125
  */
105
126
  connected = false;
127
+ /**
128
+ * Whether the server accepts `SELECT ... FOR SHARE`, read from its version
129
+ * string at connect. Undefined until then, which callers treat as not known.
130
+ */
131
+ sharedRowLock;
106
132
  /**
107
133
  * Creates a new MySQL adapter instance.
108
134
  *
@@ -133,11 +159,12 @@ var MySqlAdapter = class extends DrizzleAdapter {
133
159
  const connection = await this.pool.getConnection();
134
160
  try {
135
161
  await connection.query("SELECT 1");
136
- await checkDialectVersion(connection, "mysql", {
162
+ const { variant } = await checkDialectVersion(connection, "mysql", {
137
163
  // Why: route variant warnings through the adapter's logger so
138
164
  // users see a single, consistent log surface.
139
165
  onWarning: (msg) => this.config.logger?.warn?.(msg)
140
166
  });
167
+ this.sharedRowLock = acceptsForShare(variant);
141
168
  this.connected = true;
142
169
  if (this.config.logger?.info) {
143
170
  this.config.logger.info("MySQL connection established", {
@@ -263,6 +290,12 @@ var MySqlAdapter = class extends DrizzleAdapter {
263
290
  * MySQL's quirks with nested transactions.
264
291
  */
265
292
  async transaction(work, options) {
293
+ return this.runInFlowTransaction(
294
+ () => this.runTransactionAttempts(work, options)
295
+ );
296
+ }
297
+ /** The attempts `transaction` makes, each on a connection of its own. */
298
+ async runTransactionAttempts(work, options) {
266
299
  const pool = this.ensurePool();
267
300
  const maxAttempts = (options?.retryCount ?? 0) + 1;
268
301
  const retryDelayMs = options?.retryDelayMs ?? 100;
@@ -270,8 +303,10 @@ var MySqlAdapter = class extends DrizzleAdapter {
270
303
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
271
304
  const connection = await pool.getConnection();
272
305
  const startTime = Date.now();
306
+ let rollbackFailed = false;
307
+ let restore = null;
273
308
  try {
274
- await this.beginTransaction(connection, options);
309
+ restore = await this.beginTransaction(connection, options);
275
310
  const ctx = this.createTransactionContext(connection);
276
311
  const result = await work(ctx);
277
312
  await connection.query("COMMIT");
@@ -285,6 +320,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
285
320
  return result;
286
321
  } catch (error) {
287
322
  await connection.query("ROLLBACK").catch(() => {
323
+ rollbackFailed = true;
288
324
  });
289
325
  lastError = error;
290
326
  if (isApplicationError(error)) throw error;
@@ -302,7 +338,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
302
338
  }
303
339
  throw this.classifyError(error);
304
340
  } finally {
305
- connection.release();
341
+ await returnTransactionConnection(connection, rollbackFailed, restore);
306
342
  }
307
343
  }
308
344
  throw this.classifyError(lastError);
@@ -340,8 +376,10 @@ var MySqlAdapter = class extends DrizzleAdapter {
340
376
  // ON DUPLICATE KEY UPDATE
341
377
  maxParamsPerQuery: 65535,
342
378
  // MySQL limit
343
- maxIdentifierLength: 64
379
+ maxIdentifierLength: 64,
344
380
  // MySQL limit
381
+ sharedRowLock: this.sharedRowLock
382
+ // Known once connected
345
383
  };
346
384
  }
347
385
  /**
@@ -402,18 +440,44 @@ var MySqlAdapter = class extends DrizzleAdapter {
402
440
  throw new Error("getDrizzle() is server-only");
403
441
  }
404
442
  const pool = this.ensurePool();
405
- const client = pool.pool;
406
443
  if (!relations) {
407
- this.drizzleBare ??= drizzle({ client });
444
+ this.drizzleBare ??= this.poolDrizzle(pool);
408
445
  return this.drizzleBare;
409
446
  }
410
447
  let cached = this.drizzleByRelations.get(relations);
411
448
  if (!cached) {
412
- cached = drizzle({ client, relations });
449
+ cached = this.poolDrizzle(pool, relations);
413
450
  this.drizzleByRelations.set(relations, cached);
414
451
  }
415
452
  return cached;
416
453
  }
454
+ /**
455
+ * A Drizzle handle on the pool, for `getDrizzle`.
456
+ *
457
+ * Its `transaction` runs on a connection this adapter checks out and
458
+ * returns itself (`pinDrizzleTransactions`): destroyed rather than pooled
459
+ * when no ROLLBACK could be confirmed on it. And it counts for
460
+ * `transactionOpenInFlow`, as the adapter's does.
461
+ */
462
+ poolDrizzle(pool, relations) {
463
+ const onClient = (client) => relations ? drizzle({ client, relations }) : drizzle({ client });
464
+ const callbackPool = pool.pool;
465
+ return this.trackFlowTransactions(
466
+ this.pinDrizzleTransactions(onClient(callbackPool), async () => {
467
+ const connection = await pool.getConnection();
468
+ return {
469
+ drizzle: onClient(
470
+ connection.connection
471
+ ),
472
+ rollback: () => connection.query("ROLLBACK"),
473
+ release: (dirty) => {
474
+ if (dirty) connection.destroy();
475
+ else connection.release();
476
+ }
477
+ };
478
+ })
479
+ );
480
+ }
417
481
  /**
418
482
  * Builds mysql2 Pool configuration from adapter config.
419
483
  */
@@ -456,7 +520,8 @@ var MySqlAdapter = class extends DrizzleAdapter {
456
520
  return config;
457
521
  }
458
522
  /**
459
- * Begins a transaction with the specified options.
523
+ * Begins a transaction with the specified options, and returns what it
524
+ * changed on the session for `restoreSession` to put back.
460
525
  */
461
526
  async beginTransaction(connection, options) {
462
527
  if (options?.isolationLevel) {
@@ -476,11 +541,17 @@ var MySqlAdapter = class extends DrizzleAdapter {
476
541
  }
477
542
  await connection.query("START TRANSACTION");
478
543
  if (options?.timeoutMs) {
544
+ const [rows] = await connection.query(
545
+ "SELECT @@SESSION.innodb_lock_wait_timeout AS timeout"
546
+ );
547
+ const previous = Number(rows[0]?.timeout);
479
548
  const timeoutSeconds = Math.ceil(options.timeoutMs / 1e3);
480
549
  await connection.query(
481
550
  `SET SESSION innodb_lock_wait_timeout = ${timeoutSeconds}`
482
551
  );
552
+ return { lockWaitTimeout: Number.isInteger(previous) ? previous : null };
483
553
  }
554
+ return null;
484
555
  }
485
556
  /**
486
557
  * Creates a TransactionContext for the given connection.
@@ -489,13 +560,208 @@ var MySqlAdapter = class extends DrizzleAdapter {
489
560
  * Note: Savepoint methods are not implemented (set to undefined)
490
561
  * as savepoints are disabled in this adapter per approved approach.
491
562
  */
492
- createTransactionContext(connection) {
493
- const buildTxExecutor = () => drizzle({
494
- client: connection.connection
563
+ /**
564
+ * Upsert with the meaning `INSERT … ON CONFLICT (target)` has on PostgreSQL
565
+ * and SQLite, on the tables where MySQL can keep that promise.
566
+ *
567
+ * MySQL cannot limit a duplicate to one key: `ON DUPLICATE KEY UPDATE`
568
+ * fires on a collision with ANY unique key, and a failed insert does not
569
+ * say reliably which one. So the conflict target must be the table's only
570
+ * unique key apart from its primary key, and any other table is refused
571
+ * before anything is written. On such a table the row is inserted first —
572
+ * its validation and triggers apply, as they do before `ON CONFLICT`
573
+ * elsewhere — and a duplicate is resolved by a locking read of the target
574
+ * key, which sees the latest committed row rather than the transaction's
575
+ * snapshot. The row found is kept as it is when there is nothing to update
576
+ * (a no-op assignment would still run its update triggers), or updated by
577
+ * its key. No row there means the collision was on the primary key, and it
578
+ * is refused as `ON CONFLICT (target)` refuses it.
579
+ *
580
+ * The steps run in the caller's transaction, or in one of their own, since a
581
+ * locking read locks nothing past its statement under autocommit. Rows are
582
+ * read back by their primary key, so a trigger that rewrites the conflict
583
+ * value still returns the row stored. The conflict columns must also be
584
+ * given values: MySQL cannot look a row up by a key the database would only
585
+ * fill in itself. A conflict is resolved on the caller's key value: a
586
+ * BEFORE INSERT trigger that moves the key onto another row's value is
587
+ * refused as a unique violation rather than resolved onto that row. A failed insert rolls back only itself on MySQL, so a
588
+ * caller's transaction stays usable.
589
+ */
590
+ async upsert(table, data, options, executor) {
591
+ const tableObj = this.getTableObject(table);
592
+ if (!tableObj) return super.upsert(table, data, options, executor);
593
+ let target;
594
+ try {
595
+ target = this.upsertTarget(tableObj, table, data, options);
596
+ } catch (error) {
597
+ throw this.handleQueryError(error, "upsert", table);
598
+ }
599
+ if (executor === void 0) {
600
+ return this.transaction((tx) => tx.upsert(table, data, options));
601
+ }
602
+ try {
603
+ const db = executor;
604
+ if (target.nullKey) {
605
+ return await this.insertRow(db, tableObj, data) ?? data;
606
+ }
607
+ return await this.insertOrResolve(
608
+ db,
609
+ tableObj,
610
+ data,
611
+ options,
612
+ target.where
613
+ );
614
+ } catch (error) {
615
+ throw this.handleQueryError(error, "upsert", table);
616
+ }
617
+ }
618
+ /**
619
+ * What an upsert is keyed on: the target's condition, and whether its key
620
+ * holds a NULL. Refuses a target this dialect cannot keep to, and a conflict
621
+ * column left for the database to fill — MySQL cannot look a row up by a key
622
+ * value only the database would supply.
623
+ */
624
+ upsertTarget(tableObj, table, data, options) {
625
+ this.assertUpsertTarget(table, data, options, true);
626
+ const key = this.conflictKey(tableObj, table, options.conflictColumns);
627
+ const values = options.conflictColumns.map((column) => data[column]);
628
+ return {
629
+ where: and(...key.map((column, i) => eq(column, values[i]))),
630
+ nullKey: values.some((value) => value === null)
631
+ };
632
+ }
633
+ /**
634
+ * The table's columns for a conflict target, refusing a target MySQL cannot
635
+ * keep to: one that is not a primary or unique key, which an update could
636
+ * spread over several rows, and one beside which the table has another
637
+ * unique key, a collision on which MySQL would not tell apart.
638
+ */
639
+ conflictKey(tableObj, table, conflictColumns) {
640
+ const record = tableObj;
641
+ const columns = conflictColumns.map((name) => record[name]);
642
+ const names = new Set(columns.map((column) => column?.name));
643
+ const isTarget = (key) => key.length === names.size && key.every((name) => names.has(name));
644
+ const { primary, unique } = keysOf(tableObj);
645
+ if (primary.length === 0) {
646
+ throw createDatabaseError({
647
+ kind: "query",
648
+ message: `upsert on ${table}: MySQL upserts need a table with a primary key, to read back the row they write.`
649
+ });
650
+ }
651
+ const known = columns.every((column) => column !== void 0);
652
+ if (!known || !(isTarget(primary) || unique.some((k) => isTarget(k.columns)))) {
653
+ throw createDatabaseError({
654
+ kind: "query",
655
+ message: `upsert on ${table}: the conflict columns (${conflictColumns.join(", ")}) are not a primary or unique key of the table.`
656
+ });
657
+ }
658
+ const others = unique.filter((key) => !isTarget(key.columns));
659
+ if (others.length > 0) {
660
+ throw createDatabaseError({
661
+ kind: "query",
662
+ 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.`
663
+ });
664
+ }
665
+ return columns;
666
+ }
667
+ /** The update an upsert applies to an existing row: never its key. */
668
+ updateValues(data, options) {
669
+ const key = new Set(options.conflictColumns);
670
+ const update = {};
671
+ for (const column of options.updateColumns ?? Object.keys(data)) {
672
+ if (!key.has(column) && column in data) update[column] = data[column];
673
+ }
674
+ return update;
675
+ }
676
+ /** The row matching `where`, by a locking (current) read. */
677
+ async lockedRow(db, tableObj, where) {
678
+ const rows = await db.select().from(tableObj).where(where).limit(1).for("update");
679
+ return rows[0];
680
+ }
681
+ /**
682
+ * Insert the row, or resolve a duplicate on the target key. The insert is
683
+ * read back by the primary key it reports, not by the conflict key: a
684
+ * BEFORE INSERT trigger may have rewritten the conflict value, and the row
685
+ * stored is the one to return.
686
+ *
687
+ * A duplicate is resolved when a row holds the TARGET key, whichever unique
688
+ * key MySQL names in its error. PostgreSQL checks the target's index before
689
+ * any other, so a row colliding on the target and on another key resolves
690
+ * on the target there too; refusing it here because MySQL happened to check
691
+ * the other index first would answer that case differently. A target row
692
+ * committed by another transaction after this insert failed is the order in
693
+ * which that transaction ran first, where PostgreSQL also resolves on it.
694
+ */
695
+ async insertOrResolve(db, tableObj, data, options, where) {
696
+ let failure;
697
+ try {
698
+ const stored = await this.insertRow(db, tableObj, data);
699
+ return stored ?? await this.lockedRow(db, tableObj, where);
700
+ } catch (error) {
701
+ if (this.classifyError(error).kind !== "unique_violation") throw error;
702
+ failure = error;
703
+ }
704
+ const existing = await this.lockedRow(db, tableObj, where);
705
+ if (existing === void 0) throw failure;
706
+ const update = this.updateValues(data, options);
707
+ if (Object.keys(update).length === 0) return existing;
708
+ await db.update(tableObj).set(update).where(where);
709
+ const identity = this.byIdentity(tableObj, {
710
+ ...existing,
711
+ ...update
495
712
  });
496
- let txExecutor;
497
- const txDb = () => txExecutor ??= buildTxExecutor();
713
+ const updated = identity === void 0 ? void 0 : await this.lockedRow(db, tableObj, identity);
714
+ return updated ?? this.lockedRow(db, tableObj, where);
715
+ }
716
+ /**
717
+ * Insert a row and read it back by its primary key: the value the caller
718
+ * gave, or the one MySQL or the model generated, which `$returningId()`
719
+ * reports and a given value it does not. Undefined when the table has no
720
+ * primary key to read by.
721
+ */
722
+ async insertRow(db, tableObj, data) {
723
+ const [generated] = await db.insert(tableObj).values(data).$returningId();
724
+ const given = Object.fromEntries(
725
+ Object.entries(data).filter(
726
+ ([, value]) => value !== void 0 && value !== null
727
+ )
728
+ );
729
+ const identity = this.byIdentity(tableObj, { ...generated, ...given });
730
+ return identity === void 0 ? void 0 : this.lockedRow(db, tableObj, identity);
731
+ }
732
+ /**
733
+ * A row's primary key as a condition, from a record keyed as the model's
734
+ * properties are. Undefined when the table has no primary key or the record
735
+ * lacks part of it.
736
+ */
737
+ byIdentity(tableObj, record) {
738
+ const primary = new Set(keysOf(tableObj).primary);
739
+ const parts = Object.entries(getColumns(tableObj)).filter(
740
+ ([, column]) => primary.has(column.name)
741
+ );
742
+ if (parts.length === 0 || parts.some(
743
+ ([name]) => record[name] === void 0 || record[name] === null
744
+ )) {
745
+ return void 0;
746
+ }
747
+ return and(
748
+ ...parts.map(([name, column]) => eq(column, record[name]))
749
+ );
750
+ }
751
+ /** A table-level `primaryKey({ columns })`, read through the MySQL table config. */
752
+ compositePrimaryKey(tableObj) {
753
+ return getTableConfig(
754
+ tableObj
755
+ ).primaryKeys.flatMap((key) => key.columns);
756
+ }
757
+ createTransactionContext(connection) {
758
+ const txConnection = connection.connection;
759
+ const txHandles = this.transactionDrizzleHandles(
760
+ (relations) => relations ? drizzle({ client: txConnection, relations }) : drizzle({ client: txConnection })
761
+ );
762
+ const txDb = txHandles.bare;
498
763
  return {
764
+ drizzleWithRelations: txHandles.withRelations,
499
765
  execute: async (sql, params = []) => {
500
766
  const [rows] = await connection.query(
501
767
  sql,
@@ -605,6 +871,19 @@ var MySqlAdapter = class extends DrizzleAdapter {
605
871
  }
606
872
  return [];
607
873
  },
874
+ // Adapter-built, as `insert` above is; `transactionUpdate` says why.
875
+ // A column the model does not declare binds natively, as every value
876
+ // on this path's insert does.
877
+ update: this.transactionUpdate(
878
+ txDb,
879
+ async (statement) => {
880
+ await txDb().execute(statement);
881
+ return void 0;
882
+ },
883
+ // Structured values as JSON text: mysql2's formatter spells an object
884
+ // as `key = value` pairs and an array as a value list, neither JSON.
885
+ (value) => this.bindUnmodeledStructuredAsJson(value)
886
+ ),
608
887
  ...this.createTransactionForwarders(txDb),
609
888
  // Savepoints disabled per approved approach
610
889
  savepoint: void 0,
@@ -621,7 +900,7 @@ var MySqlAdapter = class extends DrizzleAdapter {
621
900
  */
622
901
  classifyError(error, sql) {
623
902
  if (isDatabaseError(error)) return error;
624
- const mysqlError = error;
903
+ const mysqlError = this.driverErrorIn(error, (candidate) => typeof candidate.errno === "number");
625
904
  const kind = mysqlError.errno && MYSQL_ERROR_CODES[mysqlError.errno] || "unknown";
626
905
  let message = mysqlError.message ?? String(error);
627
906
  if (sql && kind === "query") {
@@ -636,6 +915,36 @@ var MySqlAdapter = class extends DrizzleAdapter {
636
915
  });
637
916
  }
638
917
  };
918
+ var TABLE_KEYS = /* @__PURE__ */ new WeakMap();
919
+ function keysOf(tableObj) {
920
+ const cached = TABLE_KEYS.get(tableObj);
921
+ if (cached) return cached;
922
+ const config = getTableConfig(tableObj);
923
+ const primary = [
924
+ ...config.columns.filter((column) => column.primary),
925
+ ...config.primaryKeys.flatMap((key) => key.columns)
926
+ ].map((column) => column.name);
927
+ const unique = [
928
+ ...config.columns.filter((column) => column.isUnique && !column.primary).map((column) => ({
929
+ // Drizzle's own name for an unnamed column-level unique key.
930
+ name: column.uniqueName ?? `${config.name}_${column.name}_unique`,
931
+ columns: [column.name]
932
+ })),
933
+ ...config.uniqueConstraints.map((constraint) => ({
934
+ name: constraint.name,
935
+ columns: constraint.columns.map((column) => column.name)
936
+ })),
937
+ ...config.indexes.filter((index) => index.config.unique === true).map((index) => ({
938
+ name: index.config.name,
939
+ columns: index.config.columns.map(
940
+ (column) => column.name
941
+ )
942
+ }))
943
+ ];
944
+ const keys = { primary, unique };
945
+ TABLE_KEYS.set(tableObj, keys);
946
+ return keys;
947
+ }
639
948
  function createMySqlAdapter(config) {
640
949
  return new MySqlAdapter(config);
641
950
  }