@spinajs/orm-mysql 2.0.481 → 2.0.484

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
@@ -1,11 +1,103 @@
1
- # `orm-mysql`
1
+ # `@spinajs/orm-mysql`
2
2
 
3
- > TODO: description
3
+ The MySQL / MariaDB dialect driver for [`@spinajs/orm`](../orm), built on
4
+ [`@spinajs/orm-sql`](../orm-sql). Ships two drivers: `MySqlOrmDriver`, and `MySqlSSHOrmDriver`
5
+ which tunnels the connection over SSH.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @spinajs/orm @spinajs/orm-mysql
11
+ ```
4
12
 
5
13
  ## Usage
6
14
 
15
+ ```ts
16
+ import { DI } from '@spinajs/di';
17
+ import { Orm } from '@spinajs/orm';
18
+ import { MySqlOrmDriver } from '@spinajs/orm-mysql';
19
+
20
+ DI.register(MySqlOrmDriver).as('orm-driver-mysql');
21
+ await DI.resolve(Orm);
7
22
  ```
8
- const ormMysql = require('orm-mysql');
9
23
 
10
- // TODO: DEMONSTRATE API
24
+ ```ts
25
+ // configuration
26
+ {
27
+ db: {
28
+ DefaultConnection: 'mysql',
29
+ Connections: [
30
+ {
31
+ Name: 'mysql',
32
+ Driver: 'orm-driver-mysql',
33
+ Host: '127.0.0.1',
34
+ Port: 3306,
35
+ User: 'app',
36
+ Password: 'secret',
37
+ Database: 'app',
38
+ Encoding: 'utf8mb4',
39
+ Pool: { Min: 2, Max: 20 },
40
+ Migration: { OnStartup: true },
41
+ },
42
+ ],
43
+ },
44
+ }
11
45
  ```
46
+
47
+ ## What is distinctive
48
+
49
+ - **`insertIdIsFirstOfBatch: true`** — the only driver where it holds. For a *simple* insert
50
+ (`INSERT ... VALUES (...), (...)`, the only shape the builder produces) InnoDB reserves one
51
+ contiguous block of auto-increment values and `LAST_INSERT_ID()` reports the first, so a
52
+ multi-row insert's keys can be read as `LAST_INSERT_ID() + index`. The ORM applies six guards
53
+ before relying on it.
54
+ - **No `RETURNING`** — `InsertQueryBuilder.returning()` throws `NotSupported` rather than
55
+ silently doing nothing.
56
+ - **Database events are supported**, and **all four isolation levels** are honoured.
57
+ - **Retries are suppressed inside a transaction** — replaying a statement after reconnecting
58
+ would apply it outside the transaction.
59
+ - **DDL is not transactional.** `Migration.Transaction.Mode = PerMigration` cannot roll back a
60
+ `CREATE TABLE`.
61
+
62
+ Only two compiler overrides are needed (`TableExistsCompiler`, `ServerResponseMapper`), which is
63
+ a fair measure of how closely the generic SQL layer tracks MySQL.
64
+
65
+ ## SSH tunnelling
66
+
67
+ ```ts
68
+ DI.register(MySqlSSHOrmDriver).as('orm-driver-mysql-ssh');
69
+ ```
70
+
71
+ ```ts
72
+ {
73
+ Name: 'remote',
74
+ Driver: 'orm-driver-mysql-ssh',
75
+ Host: '10.0.0.5', Port: 3306,
76
+ User: 'app', Password: 'secret', Database: 'app',
77
+ SSH: { Host: 'bastion.example.com', Port: 22, User: 'deploy', PrivateKey: '/home/deploy/.ssh/id_rsa' },
78
+ }
79
+ ```
80
+
81
+ The forward uses local port `12345`, so a second tunnelled connection in the same process will
82
+ collide, and the private key must be unencrypted.
83
+
84
+ ## Documentation
85
+
86
+ Full documentation lives in **[docs/](docs/)**.
87
+
88
+ | | Page |
89
+ | --- | --- |
90
+ | 01 | [Configuration](docs/01-configuration.md) |
91
+ | 02 | [Dialect notes](docs/02-dialect-notes.md) |
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ npm test # unit suite, no server needed
97
+
98
+ docker compose --profile test up -d mysql # from the repo root
99
+ npm run test:integration
100
+ ```
101
+
102
+ The container publishes MySQL on host port **3900**, deliberately not 3306, so it cannot collide
103
+ with a locally installed MySQL. See the [repository README](../../README.md).
@@ -1,31 +1,60 @@
1
- import { QueryContext, OrmDriver, IColumnDescriptor, QueryBuilder, TransactionCallback, ServerResponseMapper, ISupportedFeature, ITransaction } from '@spinajs/orm';
1
+ import { QueryContext, OrmDriver, IColumnDescriptor, ServerResponseMapper, ISupportedFeature, IsolationLevel, ITransactionContext, ITransactionOptions, IPoolMetrics } from '@spinajs/orm';
2
2
  import { SqlDriver } from '@spinajs/orm-sql';
3
3
  import * as mysql from 'mysql2';
4
4
  import { PoolConnection } from 'mysql2';
5
5
  import { Client as SSHClient } from 'ssh2';
6
- import { AsyncLocalStorage } from 'async_hooks';
7
- export interface IMySqlTransactionContext {
6
+ export interface IMySqlTransactionContext extends ITransactionContext {
8
7
  connection: PoolConnection;
9
8
  }
10
9
  export declare class MysqlServerResponseMapper extends ServerResponseMapper {
11
10
  read(data: any): {
12
11
  LastInsertId: any;
13
12
  RowsAffected: any;
13
+ Returning: any[];
14
14
  };
15
15
  }
16
16
  export declare class MySqlOrmDriver extends SqlDriver {
17
17
  protected Pool: mysql.Pool;
18
- protected _executionId: number;
19
- protected TransactionStorage: AsyncLocalStorage<IMySqlTransactionContext>;
20
- private getNextExecutionId;
18
+ /**
19
+ * MySQL/InnoDB honours all four standard levels.
20
+ */
21
+ readonly SupportedIsolationLevels: IsolationLevel[];
21
22
  executeOnDb(stmt: string, params: any[], context: QueryContext): Promise<any>;
23
+ /**
24
+ * True when the error means the transport died rather than the statement being wrong.
25
+ */
26
+ protected isRetryableError(err: unknown): boolean;
27
+ protected _executeOnDbOnce(stmt: string, params: any[], context: QueryContext): Promise<any>;
22
28
  supportedFeatures(): ISupportedFeature;
23
29
  resolve(): void;
30
+ /**
31
+ * mysql2 keeps its pool bookkeeping on the internal `_allConnections` / `_freeConnections` /
32
+ * `_connectionQueue` lists. They are not public API, so every read is guarded — a mysql2
33
+ * upgrade that renames them degrades to zeros rather than crashing the health check.
34
+ */
35
+ poolMetrics(): IPoolMetrics;
24
36
  ping(): Promise<boolean>;
25
37
  connect(): Promise<OrmDriver>;
26
38
  disconnect(): Promise<OrmDriver>;
27
39
  tableInfo(name: string, schema?: string): Promise<IColumnDescriptor[]>;
28
- transaction(queryOrCallback?: QueryBuilder<any>[] | TransactionCallback): Promise<ITransaction>;
40
+ /**
41
+ * Pulls the pooled connection out of a transaction context. The base class only ever hands
42
+ * us contexts this driver's own `_begin` produced, so the cast is safe.
43
+ */
44
+ private txConnection;
45
+ /**
46
+ * Runs a statement on the transaction's own connection, bypassing the ambient-context lookup
47
+ * in `executeOnDb`. Transaction control statements must never land on a pooled connection
48
+ * other than their own.
49
+ */
50
+ private runOnConnection;
51
+ protected _begin(options?: ITransactionOptions): Promise<ITransactionContext>;
52
+ protected _commit(ctx: ITransactionContext): Promise<void>;
53
+ protected _rollback(ctx: ITransactionContext): Promise<void>;
54
+ protected _savepoint(ctx: ITransactionContext, name: string): Promise<void>;
55
+ protected _releaseSavepoint(ctx: ITransactionContext, name: string): Promise<void>;
56
+ protected _rollbackToSavepoint(ctx: ITransactionContext, name: string): Promise<void>;
57
+ protected _dispose(ctx: ITransactionContext): Promise<void>;
29
58
  }
30
59
  export declare class MySqlSSHOrmDriver extends MySqlOrmDriver {
31
60
  protected SshClient: SSHClient;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,YAAY,EAAE,mBAAmB,EAAqC,oBAAoB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACvM,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAY,cAAc,EAAe,MAAM,QAAQ,CAAC;AAG/D,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,MAAM,CAAC;AAE3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,cAAc,CAAC;CAC5B;AAED,qBAAa,yBAA0B,SAAQ,oBAAoB;IAC1D,IAAI,CAAC,IAAI,EAAE,GAAG;;;;CAGtB;AAED,qBAEa,cAAe,SAAQ,SAAS;IAC3C,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC;IAC3B,SAAS,CAAC,YAAY,SAAK;IAC3B,SAAS,CAAC,kBAAkB,8CAAqD;IAEjF,OAAO,CAAC,kBAAkB;IAKnB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC;IAkF7E,iBAAiB,IAAI,iBAAiB;IAItC,OAAO;IAOD,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAS9B,OAAO,IAAI,OAAO,CAAC,SAAS,CAAC;IAyC7B,UAAU,IAAI,OAAO,CAAC,SAAS,CAAC;IAkB1B,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiD5E,WAAW,CAAC,eAAe,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,EAAE,GAAG,mBAAmB,GAAG,OAAO,CAAC,YAAY,CAAC;CA6EvG;AAED,qBAEa,iBAAkB,SAAQ,cAAc;IACnD,SAAS,CAAC,SAAS,EAAE,SAAS,CAAC;IAExB,OAAO;IAYD,UAAU;IAUhB,OAAO,IAAI,OAAO,CAAC,SAAS,CAAC;CAuCrC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAqC,oBAAoB,EAAE,iBAAiB,EAAE,cAAc,EAAE,mBAAmB,EAAE,mBAAmB,EAAmB,YAAY,EAAE,MAAM,cAAc,CAAC;AAC/O,OAAO,EAAoB,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,KAAK,KAAK,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAY,cAAc,EAAe,MAAM,QAAQ,CAAC;AAG/D,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,MAAM,CAAC;AAG3C,MAAM,WAAW,wBAAyB,SAAQ,mBAAmB;IACnE,UAAU,EAAE,cAAc,CAAC;CAC5B;AAED,qBAAa,yBAA0B,SAAQ,oBAAoB;IAC1D,IAAI,CAAC,IAAI,EAAE,GAAG;;;mBAKA,GAAG,EAAE;;CAG3B;AAED,qBAEa,cAAe,SAAQ,SAAS;IAC3C,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC;IAK3B;;OAEG;IACH,SAAgB,wBAAwB,EAAE,cAAc,EAAE,CAA6E;IAEhI,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC;IAMpF;;OAEG;IACH,SAAS,CAAC,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO;IA0BjD,SAAS,CAAC,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC;IA6FrF,iBAAiB,IAAI,iBAAiB;IAQtC,OAAO;IAOd;;;;OAIG;IACI,WAAW,IAAI,YAAY;IAarB,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAW9B,OAAO,IAAI,OAAO,CAAC,SAAS,CAAC;IAmD7B,UAAU,IAAI,OAAO,CAAC,SAAS,CAAC;IAqB1B,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IA8DnF;;;OAGG;IACH,OAAO,CAAC,YAAY;IAIpB;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAMvB,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAkC7E,SAAS,CAAC,OAAO,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAM1D,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ5D,SAAS,CAAC,UAAU,CAAC,GAAG,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3E,SAAS,CAAC,iBAAiB,CAAC,GAAG,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlF,SAAS,CAAC,oBAAoB,CAAC,GAAG,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;cAIrE,QAAQ,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGlE;AAED,qBAEa,iBAAkB,SAAQ,cAAc;IACnD,SAAS,CAAC,SAAS,EAAE,SAAS,CAAC;IAExB,OAAO;IAYD,UAAU;IAUhB,OAAO,IAAI,OAAO,CAAC,SAAS,CAAC;CA4CrC"}
package/lib/cjs/index.js CHANGED
@@ -45,106 +45,175 @@ Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.MySqlSSHOrmDriver = exports.MySqlOrmDriver = exports.MysqlServerResponseMapper = void 0;
46
46
  /* eslint-disable promise/no-promise-in-callback */
47
47
  const di_1 = require("@spinajs/di");
48
- const log_1 = require("@spinajs/log");
48
+ // No LogLevel import: master moved per-query timing out of every driver and into a single
49
+ // `Perf.measure('orm.query', ...)` around SqlDriver.execute, so duplicating it here would
50
+ // emit the same query twice. QueryBuilder / TransactionCallback / ITransaction are gone with
51
+ // the old `{ commit, rollback }` transaction shape this branch replaced. ConnectionState /
52
+ // IPoolMetrics are orm-infra's connection-resilience + pool-telemetry work.
49
53
  const orm_1 = require("@spinajs/orm");
50
54
  const orm_sql_1 = require("@spinajs/orm-sql");
51
55
  const mysql = __importStar(require("mysql2"));
52
56
  const compilers_js_1 = require("./compilers.js");
53
57
  const ssh2_1 = require("ssh2");
54
58
  const fs_1 = __importDefault(require("fs"));
55
- const async_hooks_1 = require("async_hooks");
56
59
  class MysqlServerResponseMapper extends orm_1.ServerResponseMapper {
57
60
  read(data) {
58
- return { LastInsertId: data.LastInsertId, RowsAffected: data.RowsAffected };
61
+ // MySQL has no RETURNING; the identity value is all it reports.
62
+ return {
63
+ LastInsertId: data?.LastInsertId ?? 0,
64
+ RowsAffected: data?.RowsAffected ?? 0,
65
+ Returning: [],
66
+ };
59
67
  }
60
68
  }
61
69
  exports.MysqlServerResponseMapper = MysqlServerResponseMapper;
62
70
  let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
63
71
  constructor() {
64
72
  super(...arguments);
65
- this._executionId = 0;
66
- this.TransactionStorage = new async_hooks_1.AsyncLocalStorage();
67
- }
68
- getNextExecutionId() {
69
- this._executionId = (this._executionId + 1) % Number.MAX_SAFE_INTEGER;
70
- return this._executionId;
73
+ // `_executionId` went with the per-driver query timing master centralised.
74
+ // `TransactionStorage` is no longer declared here either — it moved up to OrmDriver so
75
+ // ambient-connection propagation is part of the contract rather than a MySQL detail.
76
+ /**
77
+ * MySQL/InnoDB honours all four standard levels.
78
+ */
79
+ this.SupportedIsolationLevels = ['READ UNCOMMITTED', 'READ COMMITTED', 'REPEATABLE READ', 'SERIALIZABLE'];
71
80
  }
72
81
  executeOnDb(stmt, params, context) {
82
+ // Reads and writes are both retried: `withReconnect` only re-runs on transport failures,
83
+ // where the statement provably never reached the server.
84
+ return this.withReconnect(() => this._executeOnDbOnce(stmt, params, context));
85
+ }
86
+ /**
87
+ * True when the error means the transport died rather than the statement being wrong.
88
+ */
89
+ isRetryableError(err) {
90
+ // Inside a transaction the connection carried uncommitted state. Reconnecting and replaying
91
+ // one statement would silently apply it OUTSIDE the transaction, so the error must surface.
92
+ if (this.TransactionStorage.getStore()) {
93
+ return false;
94
+ }
95
+ if (super.isRetryableError(err)) {
96
+ return true;
97
+ }
98
+ // mysql2 marks connection-level failures fatal; a fatal error means the connection is gone
99
+ // regardless of which code came with it.
100
+ let current = err;
101
+ let depth = 0;
102
+ while (current && depth < 5) {
103
+ if (current.fatal === true) {
104
+ return true;
105
+ }
106
+ current = current.inner ?? current.cause;
107
+ depth++;
108
+ }
109
+ return false;
110
+ }
111
+ _executeOnDbOnce(stmt, params, context) {
73
112
  const self = this;
74
- const tName = `query-${this.getNextExecutionId()}`;
75
- this.Log.timeStart(`query-${tName}`);
76
- // Check if we're inside a transaction context and use that connection
113
+ // Check if we're inside a transaction context and use that connection.
114
+ // The context comes from the base driver, so `connection` is typed loosely there; only
115
+ // this driver's `_begin` ever populates it, and it always puts a PoolConnection in.
77
116
  const txContext = this.TransactionStorage.getStore();
78
- const queryable = txContext?.connection ?? this.Pool;
79
117
  return new Promise((resolve, reject) => {
80
- queryable.query(stmt, params, function (err, results) {
81
- if (err) {
82
- return reject(new orm_1.OrmException(`Error executing orm command `, {
83
- Host: self.Options.Host,
84
- User: self.Options.User,
85
- Name: self.Options.Name,
86
- }, stmt, params, err));
118
+ const fail = (err) => reject(new orm_1.OrmException(`Error executing orm command `, {
119
+ Host: self.Options.Host,
120
+ User: self.Options.User,
121
+ Name: self.Options.Name,
122
+ }, stmt, params, err));
123
+ const run = (queryable, done) => {
124
+ try {
125
+ queryable.query(stmt, params, function (err, results) {
126
+ done();
127
+ if (err) {
128
+ return fail(err);
129
+ }
130
+ switch (context) {
131
+ case orm_1.QueryContext.Update:
132
+ case orm_1.QueryContext.Delete:
133
+ resolve({
134
+ RowsAffected: results.affectedRows,
135
+ });
136
+ break;
137
+ case orm_1.QueryContext.Insert:
138
+ case orm_1.QueryContext.Upsert:
139
+ resolve({
140
+ RowsAffected: results.affectedRows,
141
+ LastInsertId: results.insertId,
142
+ Returning: [],
143
+ });
144
+ break;
145
+ default:
146
+ resolve(results);
147
+ break;
148
+ }
149
+ });
87
150
  }
88
- switch (context) {
89
- case orm_1.QueryContext.Update:
90
- case orm_1.QueryContext.Delete:
91
- resolve({
92
- RowsAffected: results.affectedRows,
93
- });
94
- break;
95
- case orm_1.QueryContext.Insert:
96
- case orm_1.QueryContext.Upsert:
97
- resolve({
98
- RowsAffected: results.affectedRows,
99
- LastInsertId: results.insertId,
100
- });
101
- break;
102
- default:
103
- resolve(results);
104
- break;
151
+ catch (err) {
152
+ // A synchronous throw would otherwise strand the connection outside the pool.
153
+ done();
154
+ fail(err);
105
155
  }
156
+ };
157
+ if (txContext?.connection) {
158
+ // A transaction owns its connection for its whole lifetime — releasing it after one
159
+ // statement would hand the rest of the transaction to a different connection.
160
+ run(txContext.connection, () => undefined);
161
+ return;
162
+ }
163
+ // Acquiring is the part that queues when the pool is saturated, so it is the part worth
164
+ // timing. Taking the connection explicitly, instead of letting `Pool.query` do it out of
165
+ // sight, is what makes `orm_pool_acquire_seconds` a real number instead of always zero.
166
+ const acquireStart = process.hrtime.bigint();
167
+ this.Pool.getConnection((err, connection) => {
168
+ const seconds = Number(process.hrtime.bigint() - acquireStart) / 1e9;
169
+ this.observeAcquireSeconds(seconds);
170
+ if (err) {
171
+ fail(err);
172
+ return;
173
+ }
174
+ let released = false;
175
+ run(connection, () => {
176
+ if (released) {
177
+ return;
178
+ }
179
+ released = true;
180
+ connection.release();
181
+ });
106
182
  });
107
- })
108
- .then((val) => {
109
- const tDiff = this.Log.timeEnd(`query-${tName}`);
110
- void this.Log.write({
111
- Level: log_1.LogLevel.Trace,
112
- Variables: {
113
- error: undefined,
114
- message: `Executed: ${stmt}, bindings: ${params ? params.join(',') : 'none'}`,
115
- logger: this.Log.Name,
116
- level: 'TRACE',
117
- duration: tDiff,
118
- },
119
- });
120
- return val;
121
- })
122
- .catch((err) => {
123
- const tDiff = this.Log.timeEnd(`query-${tName}`);
124
- void this.Log.write({
125
- Level: log_1.LogLevel.Error,
126
- Variables: {
127
- error: err,
128
- message: `Failed: ${stmt}, bindings: ${params ? params.join(',') : 'none'}`,
129
- logger: this.Log.Name,
130
- level: 'Error',
131
- duration: tDiff,
132
- },
133
- });
134
- throw err;
135
183
  });
136
184
  }
137
185
  supportedFeatures() {
138
- return { events: true };
186
+ // insertIdIsFirstOfBatch: a multi-row `INSERT ... VALUES` is a *simple insert* to InnoDB
187
+ // ( row count known before execution ), so it reserves one contiguous block of
188
+ // auto-increment values and LAST_INSERT_ID() reports the first of them. True even under
189
+ // innodb_autoinc_lock_mode = 2, the MySQL 8 default.
190
+ return { events: true, insertReturning: false, insertIdIsFirstOfBatch: true };
139
191
  }
140
192
  resolve() {
141
193
  super.resolve();
142
194
  this.Container.register(compilers_js_1.MySqlTableExistsCompiler).as(orm_1.TableExistsCompiler);
143
195
  this.Container.register(MysqlServerResponseMapper).as(orm_1.ServerResponseMapper);
144
196
  }
197
+ /**
198
+ * mysql2 keeps its pool bookkeeping on the internal `_allConnections` / `_freeConnections` /
199
+ * `_connectionQueue` lists. They are not public API, so every read is guarded — a mysql2
200
+ * upgrade that renames them degrades to zeros rather than crashing the health check.
201
+ */
202
+ poolMetrics() {
203
+ const pool = this.Pool;
204
+ const size = pool?._allConnections?.length ?? 0;
205
+ const free = pool?._freeConnections?.length ?? 0;
206
+ return {
207
+ Size: size,
208
+ InUse: Math.max(size - free, 0),
209
+ Waiting: pool?._connectionQueue?.length ?? 0,
210
+ };
211
+ }
145
212
  async ping() {
146
213
  try {
147
- await this.executeOnDb('SELECT 1', [], orm_1.QueryContext.Select);
214
+ // deliberately bypasses `withReconnect` — a health probe that reconnects on its own
215
+ // would turn one dead connection into a reconnect storm on every tick.
216
+ await this._executeOnDbOnce('SELECT 1', [], orm_1.QueryContext.Select);
148
217
  return true;
149
218
  }
150
219
  catch {
@@ -154,6 +223,7 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
154
223
  connect() {
155
224
  return new Promise((resolve, reject) => {
156
225
  try {
226
+ const pool = this.resolvedPoolOptions();
157
227
  this.Pool = mysql.createPool({
158
228
  host: this.Options.Host,
159
229
  user: this.Options.User,
@@ -161,7 +231,14 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
161
231
  port: this.Options.Port,
162
232
  database: this.Options.Database,
163
233
  waitForConnections: true,
164
- connectionLimit: this.Options.PoolLimit,
234
+ connectionLimit: pool.Max,
235
+ // mysql2's `maxIdle` is a CEILING on idle connections, not a floor, and it never
236
+ // pre-warms the pool — so there is no direct equivalent of `Pool.Min`. Passing Min
237
+ // straight through would set maxIdle to 0 by default and make mysql2 destroy every
238
+ // released connection, i.e. disable pooling. Instead: an explicit Min becomes the
239
+ // number of connections we let sit idle; Min = 0 keeps mysql2's own default (Max).
240
+ maxIdle: pool.Min > 0 ? pool.Min : pool.Max,
241
+ idleTimeout: pool.IdleTimeout,
165
242
  queueLimit: 0,
166
243
  });
167
244
  // Test the pool connection
@@ -175,6 +252,7 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
175
252
  }
176
253
  // Release the test connection
177
254
  connection.release();
255
+ this.setState(orm_1.ConnectionState.Connected);
178
256
  resolve(this);
179
257
  });
180
258
  }
@@ -192,6 +270,8 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
192
270
  });
193
271
  }
194
272
  disconnect() {
273
+ this.stopHealthCheck();
274
+ this.setState(orm_1.ConnectionState.Disconnected);
195
275
  return new Promise((resolve, reject) => {
196
276
  if (!this.Pool) {
197
277
  resolve(this);
@@ -209,21 +289,31 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
209
289
  });
210
290
  }
211
291
  async tableInfo(name, schema) {
212
- const tblInfo = (await this.executeOnDb(`SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=? ${schema ? 'AND TABLE_SCHEMA=?' : ''} `, schema ? [name, schema] : [name], orm_1.QueryContext.Select));
213
- const isView = (await this.executeOnDb(`SHOW FULL TABLES where \`Tables_in_${schema}\`='${name}'`, [], orm_1.QueryContext.Select));
292
+ const dbSchema = schema ?? this.Options.Database;
293
+ if (!dbSchema) {
294
+ throw new orm_1.OrmException(`Cannot read table info for '${name}': no schema/database configured for this connection ( pass a schema or set Options.Database )`);
295
+ }
296
+ // backtick-quote an identifier, escaping embedded backticks by doubling them
297
+ const escapeId = (id) => '`' + String(id).replace(/`/g, '``') + '`';
298
+ // ORDER BY ORDINAL_POSITION is not decoration. Without it MySQL is free to return the rows
299
+ // in any order — and does: the same table came back as (Code, TenantId) in one run and
300
+ // (TenantId, Code) in the next. Column order is part of what a table descriptor means, so
301
+ // it has to be the table's own order, not whatever the optimizer produced this time.
302
+ const tblInfo = (await this.executeOnDb(`SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=? AND TABLE_SCHEMA=? ORDER BY ORDINAL_POSITION`, [name, dbSchema], orm_1.QueryContext.Select));
303
+ const isView = (await this.executeOnDb(`SHOW FULL TABLES FROM ${escapeId(dbSchema)} WHERE ${escapeId(`Tables_in_${dbSchema}`)}=?`, [name], orm_1.QueryContext.Select));
214
304
  let indexInfo = [];
215
305
  if (!isView || isView.length === 0) {
216
- throw new orm_1.OrmException(`Table ${schema}.${name} does not exist`);
306
+ throw new orm_1.OrmException(`Table ${dbSchema}.${name} does not exist`);
217
307
  }
218
308
  if (!tblInfo || !Array.isArray(tblInfo) || tblInfo.length === 0) {
219
- this.Log.trace(`Table ${schema}.${name} does not have any columns.`);
309
+ this.Log.trace(`Table ${dbSchema}.${name} does not have any columns.`);
220
310
  return null;
221
311
  }
222
312
  if (isView && isView[0].Table_type === 'VIEW') {
223
- this.Log.trace(`Table ${schema}.${name} is a VIEW and dont have indexes set.`);
313
+ this.Log.trace(`Table ${dbSchema}.${name} is a VIEW and dont have indexes set.`);
224
314
  }
225
315
  else {
226
- indexInfo = (await this.executeOnDb(`SHOW INDEXES FROM ${name}`, [], orm_1.QueryContext.Select));
316
+ indexInfo = (await this.executeOnDb(`SHOW INDEXES FROM ${escapeId(name)}`, [], orm_1.QueryContext.Select));
227
317
  }
228
318
  return tblInfo.map((r) => {
229
319
  const isPrimary = indexInfo.find((c) => c.Key_name === 'PRIMARY' && c.Column_name === r.COLUMN_NAME) !== undefined;
@@ -251,75 +341,76 @@ let MySqlOrmDriver = class MySqlOrmDriver extends orm_sql_1.SqlDriver {
251
341
  };
252
342
  });
253
343
  }
254
- transaction(queryOrCallback) {
344
+ /**
345
+ * Pulls the pooled connection out of a transaction context. The base class only ever hands
346
+ * us contexts this driver's own `_begin` produced, so the cast is safe.
347
+ */
348
+ txConnection(ctx) {
349
+ return ctx.connection;
350
+ }
351
+ /**
352
+ * Runs a statement on the transaction's own connection, bypassing the ambient-context lookup
353
+ * in `executeOnDb`. Transaction control statements must never land on a pooled connection
354
+ * other than their own.
355
+ */
356
+ runOnConnection(connection, stmt) {
357
+ return new Promise((resolve, reject) => {
358
+ connection.query(stmt, (err) => (err ? reject(err) : resolve()));
359
+ });
360
+ }
361
+ _begin(options) {
255
362
  return new Promise((resolve, reject) => {
256
- const trx = {
257
- commit: () => Promise.resolve(),
258
- rollback: () => Promise.resolve(),
259
- };
260
- if (!queryOrCallback) {
261
- resolve(trx);
262
- return;
263
- }
264
363
  this.Pool.getConnection((err, connection) => {
265
364
  if (err) {
266
365
  reject(err);
267
366
  return;
268
367
  }
269
- connection.beginTransaction(async (err) => {
270
- if (err) {
368
+ const begin = () => {
369
+ connection.beginTransaction((err) => {
370
+ if (err) {
371
+ connection.release();
372
+ reject(err);
373
+ return;
374
+ }
375
+ resolve({ connection, depth: 0 });
376
+ });
377
+ };
378
+ if (options?.isolation) {
379
+ // isolation levels are a fixed, validated enum — never caller-supplied free text
380
+ this.runOnConnection(connection, `SET TRANSACTION ISOLATION LEVEL ${options.isolation}`).then(begin, (err) => {
271
381
  connection.release();
272
382
  reject(err);
273
- return;
274
- }
275
- try {
276
- // Run the callback/queries within async context so executeOnDb uses this connection
277
- await this.TransactionStorage.run({ connection }, async () => {
278
- if (Array.isArray(queryOrCallback)) {
279
- for (const q of queryOrCallback) {
280
- await q;
281
- }
282
- }
283
- else {
284
- await queryOrCallback(this);
285
- }
286
- });
287
- resolve({
288
- commit: async () => {
289
- return new Promise((res, rej) => {
290
- connection.commit((err) => {
291
- if (err) {
292
- connection.rollback(() => {
293
- connection.release();
294
- rej(err);
295
- });
296
- return;
297
- }
298
- connection.release();
299
- res();
300
- });
301
- });
302
- },
303
- rollback: async () => {
304
- return new Promise((res) => {
305
- connection.rollback(() => {
306
- connection.release();
307
- res();
308
- });
309
- });
310
- },
311
- });
312
- }
313
- catch (ex) {
314
- connection.rollback(() => {
315
- connection.release();
316
- reject(ex);
317
- });
318
- }
319
- });
383
+ });
384
+ return;
385
+ }
386
+ begin();
320
387
  });
321
388
  });
322
389
  }
390
+ _commit(ctx) {
391
+ return new Promise((resolve, reject) => {
392
+ this.txConnection(ctx).commit((err) => (err ? reject(err) : resolve()));
393
+ });
394
+ }
395
+ _rollback(ctx) {
396
+ return new Promise((resolve, reject) => {
397
+ this.txConnection(ctx).rollback((err) => (err ? reject(err) : resolve()));
398
+ });
399
+ }
400
+ // savepoint names cannot be bound parameters, so they are inlined through the identifier
401
+ // escaper rather than passed as `?`
402
+ _savepoint(ctx, name) {
403
+ return this.runOnConnection(this.txConnection(ctx), `SAVEPOINT ${(0, orm_sql_1.escapeIdentifier)(name)}`);
404
+ }
405
+ _releaseSavepoint(ctx, name) {
406
+ return this.runOnConnection(this.txConnection(ctx), `RELEASE SAVEPOINT ${(0, orm_sql_1.escapeIdentifier)(name)}`);
407
+ }
408
+ _rollbackToSavepoint(ctx, name) {
409
+ return this.runOnConnection(this.txConnection(ctx), `ROLLBACK TO SAVEPOINT ${(0, orm_sql_1.escapeIdentifier)(name)}`);
410
+ }
411
+ async _dispose(ctx) {
412
+ this.txConnection(ctx).release();
413
+ }
323
414
  };
324
415
  exports.MySqlOrmDriver = MySqlOrmDriver;
325
416
  exports.MySqlOrmDriver = MySqlOrmDriver = __decorate([
@@ -352,6 +443,7 @@ let MySqlSSHOrmDriver = class MySqlSSHOrmDriver extends MySqlOrmDriver {
352
443
  reject(err);
353
444
  return;
354
445
  }
446
+ const pool = this.resolvedPoolOptions();
355
447
  this.Pool = mysql.createPool({
356
448
  host: 'localhost', // we tunnel via ssh so we use localhost
357
449
  user: this.Options.User,
@@ -359,7 +451,10 @@ let MySqlSSHOrmDriver = class MySqlSSHOrmDriver extends MySqlOrmDriver {
359
451
  port: this.Options.Port,
360
452
  database: this.Options.Database,
361
453
  waitForConnections: true,
362
- connectionLimit: this.Options.PoolLimit,
454
+ connectionLimit: pool.Max,
455
+ // see MySqlOrmDriver.connect — `maxIdle` is a ceiling, so Min = 0 must not reach it
456
+ maxIdle: pool.Min > 0 ? pool.Min : pool.Max,
457
+ idleTimeout: pool.IdleTimeout,
363
458
  queueLimit: 0,
364
459
  stream: stream,
365
460
  });