@benjosivo/mysql 1.2.2 → 1.3.0

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
@@ -38,7 +38,8 @@ Call `init()` once per process, at startup. Each consuming project supplies its
38
38
  | `connectionLimit` | no | `50` | passed through to `mysql2` pool |
39
39
  | `queueLimit` | no | `0` | passed through to `mysql2` pool |
40
40
  | `multipleStatements` | no | `true` | passed through to `mysql2` pool |
41
- | `staleTransactionMs` | no | `300000` (5 min) | idle transactions older than this are auto rolled back |
41
+ | `staleTransactionMs` | no | `300000` (5 min) | a transaction with no statement activity for this long is auto rolled back |
42
+ | `defaultQueryTimeoutMs` | no | — (off) | default per-statement timeout; overridable per call with `timeoutMs` |
42
43
  | `onError` | no | `console.error` | called with `(error, context)` whenever the client can't throw the error directly (e.g. background retry loops) |
43
44
 
44
45
  Calling `init()` again (e.g. to reconfigure) closes the previous pool first.
@@ -46,10 +47,69 @@ Calling `init()` again (e.g. to reconfigure) closes the previous pool first.
46
47
  ### Queries
47
48
 
48
49
  ```ts
49
- executeMySQLQuery(query, values?, returnFieldTypes?, connKey?)
50
+ executeMySQLQuery3<T>({ query, values?, connKey?, returnFieldTypes?, returnListTables?, timeoutMs? })
50
51
  ```
51
52
 
52
- Returns the rows directly on success. On failure it returns `{ error, values, connKey }` instead of throwing (matches `mysql2`'s row-result shape so existing call sites don't need try/catch).
53
+ **Preferred for new code.** Always resolves to a wrapper — it never throws and never returns
54
+ bare rows, so every result is safe to inspect before use:
55
+
56
+ ```ts
57
+ const res = await executeMySQLQuery3<User[]>({ query: 'SELECT * FROM users WHERE id = ?', values: [userId] });
58
+
59
+ if (!res.ok) {
60
+ // res.error is always a non-empty string
61
+ logger.error(res.error, res.code, res.errno, res.sqlState);
62
+ return;
63
+ }
64
+
65
+ res.rows[0].email; // narrowed by `ok` — TypeScript knows rows is present
66
+ ```
67
+
68
+ `T` types the rows: `User[]` for a SELECT, `ResultSetHeader` for an INSERT/UPDATE. It defaults to
69
+ `any`, so untyped calls keep working.
70
+
71
+ | On success | On failure |
72
+ |---|---|
73
+ | `ok: true` | `ok: false` |
74
+ | `rows: T` | `error: string` (never empty), `code?`, `errno?`, `sqlState?`, `cause?` |
75
+ | `fieldsType: []` unless `returnFieldTypes` | `stage`: which step failed (`execute`, `transaction-lookup`, `procedure`, …) |
76
+ | `tables: []` unless `returnListTables` | `values`: the parameters that were sent |
77
+ | `connKey: string \| null` | `connKey: string \| null` |
78
+
79
+ Because the union is discriminated on `ok`, TypeScript will not let you read `rows` off a result
80
+ you haven't checked.
81
+
82
+ #### Retries
83
+
84
+ Only *transient* failures are retried (up to 5 attempts, with a backoff): deadlocks, lock timeouts,
85
+ lost connections, and a server that is out of connections. Deterministic failures — duplicate key,
86
+ bad syntax, constraint violations, unknown column, access denied — are returned on the **first**
87
+ attempt, since re-sending them can only produce the same error.
88
+
89
+ #### Timeouts
90
+
91
+ `timeoutMs` (or `defaultQueryTimeoutMs` in the config) bounds a statement, covering both the wait
92
+ for a pooled connection and the query itself. On expiry the statement is aborted server-side with
93
+ `KILL QUERY` so it stops consuming resources, and the failure comes back as
94
+ `{ ok: false, code: 'ETIMEDOUT' }`. Timed-out statements are never retried — a killed write must
95
+ not be silently re-applied.
96
+
97
+ ```ts
98
+ const res = await executeMySQLQuery3({ query: 'SELECT ...', values, timeoutMs: 10_000 });
99
+ ```
100
+
101
+ #### Legacy shape
102
+
103
+ ```ts
104
+ executeMySQLQuery(query, values?, returnFieldTypes?, connKey?, timeoutMs?)
105
+ executeMySQLQuery2({ query, values?, connKey?, returnFieldTypes?, returnListTables?, timeoutMs? })
106
+ ```
107
+
108
+ Unchanged: these return the rows directly on success (or `{ rows, fieldsType, connKey, tables }`
109
+ when a transaction or extra metadata was requested), and `{ error, values, connKey }` on failure
110
+ instead of throwing. Failures now also carry `code`, `errno` and `sqlState` alongside the message.
111
+ They share the retry, timeout and transaction handling described above; `executeMySQLQuery2` is a
112
+ thin adapter over `executeMySQLQuery3`.
53
113
 
54
114
  ### Statements prepared statements can't run
55
115
 
@@ -71,10 +131,11 @@ await runMySQLQuery('SELECT * FROM users WHERE id = ?', [userId], connKey);
71
131
  ```
72
132
 
73
133
  ```ts
74
- runMySQLQuery(query, values?, connKey?)
134
+ runMySQLQuery(query, values?, connKey?, timeoutMs?)
75
135
  ```
76
136
 
77
- Same result shape as `executeMySQLQuery`: rows on success, `{ error, values, connKey }` on failure.
137
+ Same result shape as `executeMySQLQuery`: rows on success, `{ error, values, connKey }` on failure
138
+ (with `code`/`errno`/`sqlState` when MySQL supplied them).
78
139
  It's deliberately simple — no deadlock/lock-timeout retry — so prefer `executeMySQLQuery` for
79
140
  regular application queries and keep this one for statements the prepared-statement protocol
80
141
  refuses.
@@ -82,16 +143,29 @@ refuses.
82
143
  ### Transactions
83
144
 
84
145
  ```ts
85
- const { connKey, error } = await executeMySQLQuery2({ query: 'INSERT ...', values, connKey: true });
86
- if (error) { /* handle */ }
146
+ const opened = await executeMySQLQuery3({ query: 'INSERT ...', values, connKey: true });
147
+ if (!opened.ok) return opened.error;
148
+
149
+ await executeMySQLQuery3({ query: 'UPDATE ...', values, connKey: opened.connKey }); // same transaction
150
+
151
+ await connectionCommit(opened.connKey!);
152
+ // or: await connectionRollback(opened.connKey!);
153
+ ```
87
154
 
88
- await executeMySQLQuery2({ query: 'UPDATE ...', values, connKey }); // reuse the same transaction
155
+ A transaction is retired as soon as it can no longer be used — MySQL rolled it back after a
156
+ deadlock, its connection was lost, or the background sweep found it idle. Its connection is
157
+ released immediately, and any later use reports *why*:
89
158
 
90
- await connectionCommit(connKey);
91
- // or: await connectionRollback(connKey);
159
+ ```ts
160
+ // Transaction <key> is no longer usable: MySQL rolled it back after a deadlock
92
161
  ```
93
162
 
94
- Transactions left open longer than `staleTransactionMs` are automatically rolled back by a background sweep.
163
+ `connectionRollback` on a transaction that was already retired succeeds (there is nothing left to
164
+ undo); `connectionCommit` reports an error, because the data did not make it. Both always free the
165
+ connection, even if the COMMIT or ROLLBACK itself fails.
166
+
167
+ `staleTransactionMs` measures **idle** time: a transaction is swept only when no statement has run
168
+ on it for that long, so a long-running but active transaction is left alone.
95
169
 
96
170
  ### Shutdown
97
171
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import mysql from 'mysql2/promise';
2
1
  export interface MySQLClientConfig {
3
2
  host: string;
4
3
  user: string;
@@ -10,21 +9,93 @@ export interface MySQLClientConfig {
10
9
  multipleStatements?: boolean;
11
10
  /** How long an open transaction can sit idle before it's automatically rolled back. Default: 5 minutes. */
12
11
  staleTransactionMs?: number;
12
+ /**
13
+ * Default per-statement timeout, in ms. Applied to every query that doesn't pass its own
14
+ * `timeoutMs`. Off by default — a statement waits as long as MySQL lets it.
15
+ */
16
+ defaultQueryTimeoutMs?: number;
13
17
  /** Called whenever this client encounters an error it can't throw directly (e.g. inside retry loops). Defaults to console.error. */
14
18
  onError?: (error: unknown, context: string) => void;
15
19
  }
16
- export declare function init(clientConfig: MySQLClientConfig): Promise<void>;
17
- export declare function closeMySQLConnection(): Promise<void>;
18
- export declare function loopKillOpenTRX(): void;
19
- export declare function stopLoopKillOpenTRX(): void;
20
- export declare function executeMySQLQuery(query: string, values?: any[], returnFieldTypes?: boolean, connKey?: boolean | string): Promise<any>;
21
- export declare function executeMySQLQuery2(opt: {
20
+ export interface MySQLFieldType {
21
+ fieldName: string;
22
+ fieldType: string;
23
+ }
24
+ export interface MySQLQueryOptions {
22
25
  query: string;
23
26
  values?: any[];
27
+ /** `true` opens a new transaction, a string runs inside the transaction with that key. */
24
28
  connKey?: boolean | string;
25
29
  returnFieldTypes?: boolean;
26
30
  returnListTables?: boolean | string;
27
- }): Promise<any>;
31
+ /** Per-statement timeout in ms. Falls back to `defaultQueryTimeoutMs`; 0/undefined disables it. */
32
+ timeoutMs?: number;
33
+ }
34
+ /** Where a failure happened, so callers can tell a dead transaction from a rejected statement. */
35
+ export type MySQLFailureStage = 'not-initialized' | 'invalid-input' | 'transaction-create' | 'transaction-lookup' | 'execute' | 'procedure';
36
+ export interface MySQLQuerySuccess<T> {
37
+ ok: true;
38
+ rows: T;
39
+ /** Populated when `returnFieldTypes` was requested, `[]` otherwise. */
40
+ fieldsType: MySQLFieldType[];
41
+ /** Populated when `returnListTables` was requested, `[]` otherwise. */
42
+ tables: string[];
43
+ /** The transaction this ran in, `null` when it ran straight on the pool. */
44
+ connKey: string | null;
45
+ }
46
+ export interface MySQLQueryFailure {
47
+ ok: false;
48
+ /** Always a non-empty message — never undefined, never an empty string. */
49
+ error: string;
50
+ stage: MySQLFailureStage;
51
+ /** MySQL / driver error code, e.g. `ER_DUP_ENTRY`, `ECONNRESET`, `ETIMEDOUT`. */
52
+ code?: string;
53
+ errno?: number;
54
+ sqlState?: string;
55
+ values: any[];
56
+ connKey: string | null;
57
+ /** The original error, untouched, for logging. */
58
+ cause?: unknown;
59
+ }
60
+ export type MySQLQueryResult<T = any> = MySQLQuerySuccess<T> | MySQLQueryFailure;
61
+ export declare function init(clientConfig: MySQLClientConfig): Promise<void>;
62
+ export declare function closeMySQLConnection(): Promise<void>;
63
+ export declare function loopKillOpenTRX(): void;
64
+ export declare function stopLoopKillOpenTRX(): void;
65
+ export declare function connectionCommit(keyConn: string): Promise<{
66
+ error?: unknown;
67
+ }>;
68
+ export declare function connectionRollback(keyConn: string): Promise<{
69
+ error?: unknown;
70
+ }>;
71
+ export declare function executeMySQLQuery(query: string, values?: any[], returnFieldTypes?: boolean, connKey?: boolean | string, timeoutMs?: number): Promise<any>;
72
+ /**
73
+ * Runs a query and **always** resolves to a wrapper — it never throws and never returns
74
+ * bare rows, so every result is safe to inspect before use:
75
+ *
76
+ * ```ts
77
+ * const res = await executeMySQLQuery3<User[]>({ query: 'SELECT ...', values });
78
+ * if (!res.ok) return res.error; // always a non-empty string, plus code/errno/sqlState
79
+ * res.rows[0].email; // narrowed by `ok`, guaranteed present
80
+ * ```
81
+ *
82
+ * `T` types the rows: `User[]` for a SELECT, `ResultSetHeader` for an INSERT/UPDATE.
83
+ *
84
+ * Retries are limited to transient failures (deadlock, lock timeout, lost connection,
85
+ * server out of connections). Deterministic errors — duplicate key, bad syntax, constraint
86
+ * violations — come back on the first attempt.
87
+ */
88
+ export declare function executeMySQLQuery3<T = any>(opt: MySQLQueryOptions): Promise<MySQLQueryResult<T>>;
89
+ /**
90
+ * Legacy shape, kept for existing call sites: returns the rows directly on success
91
+ * (or `{ rows, fieldsType, connKey, tables }` when a transaction or extra metadata was
92
+ * requested), and `{ error, values, connKey }` on failure — now carrying `code`, `errno`
93
+ * and `sqlState` alongside the message.
94
+ *
95
+ * New code should prefer {@link executeMySQLQuery3}, which always returns a typed
96
+ * `{ ok, ... }` wrapper.
97
+ */
98
+ export declare function executeMySQLQuery2(opt: MySQLQueryOptions): Promise<any>;
28
99
  /**
29
100
  * Runs a statement through mysql2's `query()` (text protocol) instead of `execute()`
30
101
  * (prepared-statement protocol).
@@ -41,14 +112,4 @@ export declare function executeMySQLQuery2(opt: {
41
112
  * Returns the rows on success, or `{ error, values, connKey }` on failure —
42
113
  * same shape as `executeMySQLQuery`.
43
114
  */
44
- export declare function runMySQLQuery(query: string, values?: any[], connKey?: string): Promise<mysql.RowDataPacket[][] | mysql.OkPacket[] | mysql.ResultSetHeader[] | mysql.RowDataPacket[] | mysql.OkPacket | mysql.ResultSetHeader | {
45
- error: any;
46
- values: any[];
47
- connKey: string | null;
48
- } | [mysql.RowDataPacket[], mysql.ResultSetHeader]>;
49
- export declare function connectionCommit(keyConn: string): Promise<{
50
- error?: unknown;
51
- }>;
52
- export declare function connectionRollback(keyConn: string): Promise<{
53
- error?: unknown;
54
- }>;
115
+ export declare function runMySQLQuery(query: string, values?: any[], connKey?: string, timeoutMs?: number): Promise<any>;
package/dist/index.js CHANGED
@@ -40,14 +40,24 @@ exports.init = init;
40
40
  exports.closeMySQLConnection = closeMySQLConnection;
41
41
  exports.loopKillOpenTRX = loopKillOpenTRX;
42
42
  exports.stopLoopKillOpenTRX = stopLoopKillOpenTRX;
43
+ exports.connectionCommit = connectionCommit;
44
+ exports.connectionRollback = connectionRollback;
43
45
  exports.executeMySQLQuery = executeMySQLQuery;
46
+ exports.executeMySQLQuery3 = executeMySQLQuery3;
44
47
  exports.executeMySQLQuery2 = executeMySQLQuery2;
45
48
  exports.runMySQLQuery = runMySQLQuery;
46
- exports.connectionCommit = connectionCommit;
47
- exports.connectionRollback = connectionRollback;
48
49
  const crypto_1 = require("crypto");
49
50
  const parserModule = __importStar(require("node-sql-parser"));
50
51
  const promise_1 = __importDefault(require("mysql2/promise"));
52
+ /* -------------------------------------------------------------------------- */
53
+ /* Client state */
54
+ /* -------------------------------------------------------------------------- */
55
+ const DEADLOCK_RETRY_DELAY_MS = 100;
56
+ const LOCK_TIMEOUT_RETRY_DELAY_MS = 500;
57
+ const TRANSIENT_RETRY_DELAY_MS = 100;
58
+ const MAX_EXECUTE_ATTEMPTS = 5;
59
+ const POOL_CHECK_INTERVAL_MS = 5 * 60000;
60
+ const DEFAULT_STALE_TRANSACTION_MS = 5 * 60000;
51
61
  let config;
52
62
  let pool;
53
63
  let lastConnectionCheck = 0;
@@ -55,7 +65,12 @@ function defaultOnError(error, context) {
55
65
  console.error(`[mysql-client] ${context}`, error);
56
66
  }
57
67
  function handleError(error, context) {
58
- (config?.onError ?? defaultOnError)(error, context);
68
+ try {
69
+ (config?.onError ?? defaultOnError)(error, context);
70
+ }
71
+ catch {
72
+ // A broken onError hook must never take down the query that reported through it.
73
+ }
59
74
  }
60
75
  function requireConfig() {
61
76
  if (!config)
@@ -93,6 +108,7 @@ async function closeMySQLConnection() {
93
108
  return;
94
109
  const rollbackPromises = [...listTransaction.keys()].map(connectionRollback);
95
110
  await Promise.all(rollbackPromises);
111
+ listTransaction.clear();
96
112
  try {
97
113
  await pool.end();
98
114
  }
@@ -100,23 +116,187 @@ async function closeMySQLConnection() {
100
116
  pool = undefined;
101
117
  config = undefined;
102
118
  lastConnectionCheck = 0;
119
+ ensurePoolPromise = null;
120
+ }
121
+ /* -------------------------------------------------------------------------- */
122
+ /* Errors */
123
+ /* -------------------------------------------------------------------------- */
124
+ /**
125
+ * Errors worth retrying: the statement was rejected by something temporary
126
+ * (a lock, a saturated server, a dropped socket) rather than by its own content.
127
+ * Deterministic failures — bad syntax, duplicate keys, constraint violations,
128
+ * missing columns, denied access — are returned on the first attempt, because
129
+ * re-sending them can only ever produce the same error.
130
+ */
131
+ const TRANSIENT_ERROR_CODES = new Set([
132
+ // MySQL-side contention / resource pressure
133
+ 'ER_LOCK_DEADLOCK',
134
+ 'ER_LOCK_WAIT_TIMEOUT',
135
+ 'ER_LOCK_TABLE_FULL',
136
+ 'ER_TOO_MANY_USER_CONNECTIONS',
137
+ 'ER_CON_COUNT_ERROR',
138
+ 'ER_OUT_OF_RESOURCES',
139
+ // driver / socket level
140
+ 'PROTOCOL_CONNECTION_LOST',
141
+ 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
142
+ 'PROTOCOL_SEQUENCE_TIMEOUT',
143
+ 'PROTOCOL_PACKETS_OUT_OF_ORDER',
144
+ 'ECONNRESET',
145
+ 'ECONNREFUSED',
146
+ 'EPIPE',
147
+ 'ETIMEDOUT',
148
+ 'EHOSTUNREACH',
149
+ 'ENETUNREACH',
150
+ 'EAI_AGAIN',
151
+ ]);
152
+ function isTransientError(error) {
153
+ // Our own timeout: the statement was killed on purpose, so re-running it would
154
+ // just burn another full timeout (and could double-apply a write).
155
+ if (error?.isQueryTimeout === true)
156
+ return false;
157
+ if (typeof error?.code === 'string' && TRANSIENT_ERROR_CODES.has(error.code))
158
+ return true;
159
+ return error?.fatal === true;
160
+ }
161
+ function isConnectionLost(error) {
162
+ return (error?.code === 'PROTOCOL_CONNECTION_LOST' ||
163
+ error?.code === 'ECONNRESET' ||
164
+ error?.code === 'ECONNREFUSED' ||
165
+ error?.code === 'EPIPE' ||
166
+ error?.code === 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR' ||
167
+ error?.fatal === true);
168
+ }
169
+ /** Always returns a usable, non-empty message — that's what `MySQLQueryFailure.error` promises. */
170
+ function describeError(error) {
171
+ if (typeof error === 'string' && error.length > 0)
172
+ return error;
173
+ const message = error?.message;
174
+ if (typeof message === 'string' && message.length > 0)
175
+ return message;
176
+ const code = error?.code;
177
+ if (typeof code === 'string' && code.length > 0)
178
+ return code;
179
+ try {
180
+ const text = String(error);
181
+ if (text && text !== '[object Object]' && text !== 'undefined' && text !== 'null')
182
+ return text;
183
+ }
184
+ catch { }
185
+ return 'Unknown MySQL error';
186
+ }
187
+ function errorDetails(error) {
188
+ const details = {};
189
+ if (typeof error?.code === 'string')
190
+ details.code = error.code;
191
+ if (typeof error?.errno === 'number')
192
+ details.errno = error.errno;
193
+ if (typeof error?.sqlState === 'string')
194
+ details.sqlState = error.sqlState;
195
+ return details;
196
+ }
197
+ function fail(stage, error, values, connKey) {
198
+ return { ok: false, stage, error: describeError(error), ...errorDetails(error), values, connKey, cause: error };
199
+ }
200
+ function timeoutError(message) {
201
+ const error = new Error(message);
202
+ error.code = 'ETIMEDOUT';
203
+ error.isQueryTimeout = true;
204
+ return error;
205
+ }
206
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
207
+ /**
208
+ * Rejects with a timeout error if `promise` hasn't settled within `ms`.
209
+ * A late *success* is handed to `onAbandon` so whatever it holds (a pooled
210
+ * connection, say) still gets cleaned up; a late failure is swallowed, which
211
+ * also keeps it from surfacing as an unhandled rejection.
212
+ */
213
+ function withTimeout(promise, ms, message, onAbandon) {
214
+ return new Promise((resolve, reject) => {
215
+ let settled = false;
216
+ const timer = setTimeout(() => {
217
+ if (settled)
218
+ return;
219
+ settled = true;
220
+ reject(timeoutError(message));
221
+ }, ms);
222
+ if (typeof timer.unref === 'function')
223
+ timer.unref();
224
+ promise.then((value) => {
225
+ clearTimeout(timer);
226
+ if (settled) {
227
+ onAbandon?.(value);
228
+ return;
229
+ }
230
+ settled = true;
231
+ resolve(value);
232
+ }, (error) => {
233
+ clearTimeout(timer);
234
+ if (settled)
235
+ return;
236
+ settled = true;
237
+ reject(error);
238
+ });
239
+ });
103
240
  }
104
241
  const listTransaction = new Map();
242
+ function safeRelease(conn) {
243
+ try {
244
+ conn.release();
245
+ }
246
+ catch { }
247
+ }
248
+ function safeDestroy(conn) {
249
+ try {
250
+ conn.destroy();
251
+ }
252
+ catch { }
253
+ }
254
+ /**
255
+ * Retires a transaction: optionally rolls it back, always frees its connection, and marks
256
+ * the entry dead. Never throws — this runs on cleanup paths where the caller is already
257
+ * handling another error.
258
+ */
259
+ async function killTransaction(keyConn, reason, rollback) {
260
+ const tr = listTransaction.get(keyConn);
261
+ if (!tr || tr.dead)
262
+ return;
263
+ tr.dead = reason;
264
+ tr.lastUpdate = Date.now();
265
+ try {
266
+ if (rollback)
267
+ await tr.conn.rollback();
268
+ safeRelease(tr.conn);
269
+ }
270
+ catch (error) {
271
+ handleError(error, `MySQL: could not roll back transaction ${keyConn} (${reason})`);
272
+ safeDestroy(tr.conn);
273
+ }
274
+ }
105
275
  let killOpenTRXInterval = null;
106
276
  async function killOpenTRX() {
107
- const staleTransactionMs = config?.staleTransactionMs ?? 5 * 60000;
108
- const rollbackPromises = [];
109
- for (const [key, value] of listTransaction) {
110
- if (value.timestamp + staleTransactionMs < Date.now()) {
111
- rollbackPromises.push(connectionRollback(key));
277
+ const staleTransactionMs = config?.staleTransactionMs ?? DEFAULT_STALE_TRANSACTION_MS;
278
+ const now = Date.now();
279
+ const pending = [];
280
+ for (const [key, entry] of listTransaction) {
281
+ if (entry.inFlight > 0)
282
+ continue; // a statement is running: busy, not idle
283
+ if (entry.lastUpdate + staleTransactionMs > now)
284
+ continue;
285
+ if (entry.dead) {
286
+ // Already retired and its connection freed — drop the bookkeeping entry.
287
+ listTransaction.delete(key);
288
+ continue;
112
289
  }
290
+ pending.push(killTransaction(key, `it sat idle for more than ${staleTransactionMs}ms`, true));
113
291
  }
114
- await Promise.all(rollbackPromises);
292
+ await Promise.all(pending);
115
293
  }
116
294
  function loopKillOpenTRX() {
117
295
  if (killOpenTRXInterval)
118
296
  return;
119
- killOpenTRXInterval = setInterval(killOpenTRX, 1000);
297
+ killOpenTRXInterval = setInterval(() => {
298
+ killOpenTRX().catch((error) => handleError(error, 'MySQL: killOpenTRX'));
299
+ }, 1000);
120
300
  }
121
301
  function stopLoopKillOpenTRX() {
122
302
  if (!killOpenTRXInterval)
@@ -124,178 +304,349 @@ function stopLoopKillOpenTRX() {
124
304
  clearInterval(killOpenTRXInterval);
125
305
  killOpenTRXInterval = null;
126
306
  }
127
- async function ensurePool() {
307
+ async function createTransaction() {
308
+ const keyConn = (0, crypto_1.randomUUID)();
128
309
  try {
310
+ await ensurePool();
129
311
  const conn = await requirePool().getConnection();
130
- conn.release();
131
- lastConnectionCheck = Date.now();
312
+ try {
313
+ await conn.beginTransaction();
314
+ }
315
+ catch (error) {
316
+ safeRelease(conn);
317
+ throw error;
318
+ }
319
+ listTransaction.set(keyConn, { conn, createdAt: Date.now(), lastUpdate: Date.now(), inFlight: 0 });
320
+ return { conn, connKey: keyConn };
132
321
  }
133
- catch {
322
+ catch (error) {
323
+ handleError(error, 'MySQL: createTransaction');
324
+ listTransaction.delete(keyConn);
325
+ return { error };
326
+ }
327
+ }
328
+ async function connectionCommit(keyConn) {
329
+ const tr = listTransaction.get(keyConn);
330
+ if (!tr) {
331
+ const msg = `Transaction key not found: ${keyConn}`;
332
+ handleError(msg, 'MySQL: connectionCommit');
333
+ return { error: msg };
334
+ }
335
+ // Claim the entry first: whatever happens below, this key is finished and the
336
+ // background sweep must not touch it.
337
+ listTransaction.delete(keyConn);
338
+ if (tr.dead) {
339
+ const msg = `Transaction ${keyConn} was already rolled back: ${tr.dead}`;
340
+ handleError(msg, 'MySQL: connectionCommit');
341
+ return { error: msg };
342
+ }
343
+ try {
344
+ await tr.conn.commit();
345
+ safeRelease(tr.conn);
346
+ return {};
347
+ }
348
+ catch (error) {
349
+ handleError(error, 'MySQL: connectionCommit');
134
350
  try {
135
- await requirePool().end();
351
+ await tr.conn.rollback();
352
+ safeRelease(tr.conn);
136
353
  }
137
- catch { }
354
+ catch {
355
+ safeDestroy(tr.conn);
356
+ }
357
+ return { error };
358
+ }
359
+ }
360
+ async function connectionRollback(keyConn) {
361
+ const tr = listTransaction.get(keyConn);
362
+ if (!tr) {
363
+ const msg = `Transaction key not found: ${keyConn}`;
364
+ handleError(msg, 'MySQL: connectionRollback');
365
+ return { error: msg };
366
+ }
367
+ listTransaction.delete(keyConn);
368
+ // Already rolled back and released elsewhere — nothing left to undo.
369
+ if (tr.dead)
370
+ return {};
371
+ try {
372
+ await tr.conn.rollback();
373
+ safeRelease(tr.conn);
374
+ return {};
375
+ }
376
+ catch (error) {
377
+ handleError(error, 'MySQL: connectionRollback');
378
+ // The connection is in an unknown state — never hand it back to the pool.
379
+ safeDestroy(tr.conn);
380
+ return { error };
381
+ }
382
+ }
383
+ /* -------------------------------------------------------------------------- */
384
+ /* Pool health */
385
+ /* -------------------------------------------------------------------------- */
386
+ let ensurePoolPromise = null;
387
+ /**
388
+ * Verifies the pool can still hand out connections and rebuilds it if it can't.
389
+ * Concurrent callers share one check, so a burst of queries hitting a dead pool
390
+ * can't tear it down and recreate it several times over.
391
+ */
392
+ async function ensurePool() {
393
+ if (ensurePoolPromise)
394
+ return ensurePoolPromise;
395
+ const run = (async () => {
138
396
  try {
139
- createPool();
397
+ const conn = await requirePool().getConnection();
398
+ conn.release();
399
+ lastConnectionCheck = Date.now();
140
400
  }
141
- catch (err) {
142
- handleError(err, 'MySQL: ensurePool — could not recreate pool');
401
+ catch {
402
+ lastConnectionCheck = 0;
403
+ try {
404
+ await requirePool().end();
405
+ }
406
+ catch { }
407
+ try {
408
+ createPool();
409
+ }
410
+ catch (err) {
411
+ handleError(err, 'MySQL: ensurePool — could not recreate pool');
412
+ }
143
413
  }
414
+ })();
415
+ ensurePoolPromise = run.finally(() => {
416
+ ensurePoolPromise = null;
417
+ });
418
+ return ensurePoolPromise;
419
+ }
420
+ /* -------------------------------------------------------------------------- */
421
+ /* Statement execution */
422
+ /* -------------------------------------------------------------------------- */
423
+ function threadIdOf(conn) {
424
+ const id = conn?.threadId ?? conn?.connection?.threadId;
425
+ return typeof id === 'number' ? id : undefined;
426
+ }
427
+ /**
428
+ * Aborts the statement running on `conn` from a *different* connection.
429
+ * `KILL QUERY` leaves the connection (and any transaction on it) alive, unlike
430
+ * closing the socket. Returns false if the statement could not be interrupted.
431
+ */
432
+ async function killQuery(conn) {
433
+ const threadId = threadIdOf(conn);
434
+ if (threadId === undefined)
435
+ return false;
436
+ try {
437
+ // KILL isn't supported by the prepared-statement protocol and takes no placeholders,
438
+ // hence query() with the (already numeric) id interpolated.
439
+ await requirePool().query(`KILL QUERY ${threadId}`);
440
+ return true;
441
+ }
442
+ catch (error) {
443
+ handleError(error, `MySQL: could not KILL QUERY ${threadId} after a timeout`);
444
+ return false;
144
445
  }
145
446
  }
146
- async function executeMySQLQuery(query, values = [], returnFieldTypes = false, connKey) {
147
- return executeMySQLQuery2({ query, values, returnFieldTypes, connKey });
447
+ /**
448
+ * Runs one statement, optionally under a timeout that covers both waiting for a
449
+ * pooled connection and the query itself. On timeout the statement is killed
450
+ * server-side so it stops consuming resources, and the connection is never
451
+ * returned to the pool with work still running on it.
452
+ */
453
+ async function runStatement(conn, connKey, query, values, timeoutMs, mode = 'execute') {
454
+ const entry = connKey ? listTransaction.get(connKey) : undefined;
455
+ if (entry) {
456
+ entry.inFlight++;
457
+ entry.lastUpdate = Date.now();
458
+ }
459
+ const run = async (target) => {
460
+ const result = mode === 'query' ? await target.query(query, values) : await target.execute(query, values);
461
+ return { rows: result?.[0], fields: (result?.[1] ?? []) };
462
+ };
463
+ try {
464
+ if (!timeoutMs || timeoutMs <= 0) {
465
+ return await run(conn ?? requirePool());
466
+ }
467
+ const deadline = Date.now() + timeoutMs;
468
+ let borrowed;
469
+ let target = conn;
470
+ if (!target) {
471
+ // Acquire explicitly: without a connection handle there is no way to
472
+ // interrupt the statement later, and a saturated pool can block forever.
473
+ target = borrowed = await withTimeout(requirePool().getConnection(), timeoutMs, `Timed out after ${timeoutMs}ms waiting for a connection from the pool`, safeRelease);
474
+ }
475
+ const exec = run(target);
476
+ try {
477
+ const result = await withTimeout(exec, Math.max(1, deadline - Date.now()), `Query timed out after ${timeoutMs}ms`);
478
+ if (borrowed)
479
+ safeRelease(borrowed);
480
+ return result;
481
+ }
482
+ catch (error) {
483
+ if (error?.isQueryTimeout !== true) {
484
+ if (borrowed)
485
+ safeRelease(borrowed);
486
+ throw error;
487
+ }
488
+ const killed = await killQuery(target);
489
+ if (borrowed) {
490
+ // The statement may still be settling; this connection must not be reused.
491
+ safeDestroy(borrowed);
492
+ }
493
+ else if (!killed && connKey) {
494
+ await killTransaction(connKey, `a statement timed out after ${timeoutMs}ms and could not be interrupted`, false);
495
+ }
496
+ throw error;
497
+ }
498
+ }
499
+ finally {
500
+ if (entry) {
501
+ entry.inFlight--;
502
+ entry.lastUpdate = Date.now();
503
+ }
504
+ }
148
505
  }
149
- async function executeMySQLQuery2(opt) {
150
- requireConfig();
151
- const { query, values = [], returnFieldTypes = false, returnListTables = false } = opt;
506
+ function checkStoredProcedureResult(rows, query) {
507
+ const message = rows?.[0]?.[0]?.['@message'] ?? '';
508
+ if (!message.toLowerCase().startsWith('ok')) {
509
+ return message || `No message returned from procedure: ${query}`;
510
+ }
511
+ return null;
512
+ }
513
+ function resolveFieldTypes(fields) {
514
+ if (!promise_1.default.Types)
515
+ throw new Error('mysql.Types is not available');
516
+ return fields.map((field) => {
517
+ const typeName = Object.keys(promise_1.default.Types).find((key) => promise_1.default.Types[key] === field.type);
518
+ return { fieldName: field.name, fieldType: typeName ?? '' };
519
+ });
520
+ }
521
+ function getTablesFromQuery(query) {
522
+ try {
523
+ const { Parser } = parserModule.default ?? parserModule;
524
+ const parser = new Parser();
525
+ const { tableList } = parser.parse(query);
526
+ const cleanTableList = tableList.map((table) => {
527
+ const parts = table.split('::');
528
+ return parts[parts.length - 1];
529
+ });
530
+ return cleanTableList;
531
+ }
532
+ catch {
533
+ return [];
534
+ }
535
+ }
536
+ /* -------------------------------------------------------------------------- */
537
+ /* Public query API */
538
+ /* -------------------------------------------------------------------------- */
539
+ async function executeMySQLQuery(query, values = [], returnFieldTypes = false, connKey, timeoutMs) {
540
+ return executeMySQLQuery2({ query, values, returnFieldTypes, connKey, timeoutMs });
541
+ }
542
+ /**
543
+ * Runs a query and **always** resolves to a wrapper — it never throws and never returns
544
+ * bare rows, so every result is safe to inspect before use:
545
+ *
546
+ * ```ts
547
+ * const res = await executeMySQLQuery3<User[]>({ query: 'SELECT ...', values });
548
+ * if (!res.ok) return res.error; // always a non-empty string, plus code/errno/sqlState
549
+ * res.rows[0].email; // narrowed by `ok`, guaranteed present
550
+ * ```
551
+ *
552
+ * `T` types the rows: `User[]` for a SELECT, `ResultSetHeader` for an INSERT/UPDATE.
553
+ *
554
+ * Retries are limited to transient failures (deadlock, lock timeout, lost connection,
555
+ * server out of connections). Deterministic errors — duplicate key, bad syntax, constraint
556
+ * violations — come back on the first attempt.
557
+ */
558
+ async function executeMySQLQuery3(opt) {
559
+ const { values = [], returnFieldTypes = false, returnListTables = false } = opt;
152
560
  let { connKey } = opt;
153
- async function executeWithRetry(opt) {
154
- /**
155
- * Executes a query with automatic retry on deadlock and lock timeout.
156
- *
157
- * - `callerOwnsConnection`: caller holds a transaction and manages commit/rollback.
158
- * - Deadlock → transaction already rolled back by MySQL, throw immediately.
159
- * - Timeout → statement rolled back, transaction still alive — wait and retry
160
- * up to MAX_EXECUTE_ATTEMPTS; throw on last attempt so caller rollbacks.
161
- * - `createsNewTransaction`: we own the transaction internally.
162
- * - Deadlock → rollback + recreate transaction, retry.
163
- * - Timeout → rollback + recreate transaction, retry with longer delay.
164
- * - Neither (plain pool):
165
- * - Both → wait (with appropriate delay) and retry against the pool.
166
- */
167
- const { query, values, callerOwnsConnection, createsNewTransaction } = opt;
168
- let { conn, connKey } = opt;
169
- function isConnectionLost(error) {
170
- return (error?.code === 'PROTOCOL_CONNECTION_LOST' ||
171
- error?.code === 'ECONNRESET' ||
172
- error?.code === 'ECONNREFUSED' ||
173
- error?.code === 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR' ||
174
- error?.fatal === true);
175
- }
561
+ const keyOf = () => (typeof connKey === 'string' ? connKey : null);
562
+ if (!config || !pool) {
563
+ const msg = 'MySQL client not initialized. Call init(config) before using this package.';
564
+ handleError(msg, 'MySQL: executeMySQLQuery3');
565
+ return fail('not-initialized', msg, values, keyOf());
566
+ }
567
+ if (typeof opt.query !== 'string') {
568
+ const msg = `Invalid query: expected a string, received ${typeof opt.query}`;
569
+ handleError(msg, 'MySQL: executeMySQLQuery3');
570
+ return fail('invalid-input', msg, values, keyOf());
571
+ }
572
+ const query = opt.query;
573
+ const timeoutMs = opt.timeoutMs ?? config.defaultQueryTimeoutMs;
574
+ const createsNewTransaction = connKey === true;
575
+ const usesExistingTransaction = typeof connKey === 'string' && connKey.length > 0;
576
+ /**
577
+ * Executes a query with automatic retry on transient failures.
578
+ *
579
+ * - `callerOwnsConnection`: caller holds a transaction and manages commit/rollback.
580
+ * - Deadlock → transaction already rolled back by MySQL, retire it and throw.
581
+ * - Timeout → statement rolled back, transaction still alive — wait and retry
582
+ * up to MAX_EXECUTE_ATTEMPTS; throw on last attempt so caller rollbacks.
583
+ * - Conn lost → transaction is gone, retire it and throw.
584
+ * - `createsNewTransaction`: we own the transaction internally.
585
+ * - Any retryable error → roll back, open a fresh transaction, retry.
586
+ * - Neither (plain pool):
587
+ * - Wait (delay depends on the error) and retry against the pool.
588
+ */
589
+ async function executeWithRetry(startConn, startKey, callerOwnsConnection) {
590
+ let conn = startConn;
591
+ let key = startKey;
176
592
  for (let attempt = 1; attempt <= MAX_EXECUTE_ATTEMPTS; attempt++) {
177
593
  try {
178
- const [rows, fields] = await (conn ?? requirePool()).execute(query, values);
179
- if (conn && connKey) {
180
- const activeTr = listTransaction.get(connKey);
181
- if (activeTr) {
182
- activeTr.lastValidConn = conn;
183
- }
184
- }
185
- return { rows, fields: fields, connKey };
594
+ const { rows, fields } = await runStatement(conn, key, query, values, timeoutMs);
595
+ return { rows, fields, connKey: key };
186
596
  }
187
597
  catch (error) {
188
598
  const isDeadlock = error?.code === 'ER_LOCK_DEADLOCK';
189
599
  const isLockTimeout = error?.code === 'ER_LOCK_WAIT_TIMEOUT';
190
- const isLockError = isDeadlock || isLockTimeout;
191
600
  const isConnLost = isConnectionLost(error);
192
- if (attempt === MAX_EXECUTE_ATTEMPTS) {
193
- if (createsNewTransaction && connKey) {
194
- try {
195
- await connectionRollback(connKey);
196
- }
197
- catch { }
198
- }
199
- throw error;
601
+ const lastAttempt = attempt === MAX_EXECUTE_ATTEMPTS;
602
+ // A lost connection takes its transaction with it; a deadlock has already
603
+ // been rolled back by MySQL. Either way the transaction is finished.
604
+ if (key && (isConnLost || isDeadlock)) {
605
+ await killTransaction(key, isConnLost ? 'the connection was lost' : 'MySQL rolled it back after a deadlock', false);
606
+ if (createsNewTransaction)
607
+ listTransaction.delete(key);
608
+ conn = undefined;
200
609
  }
201
- // Fatal connection loss requires cleanup.
202
- if (isConnLost) {
203
- if (createsNewTransaction && connKey) {
204
- try {
205
- await connectionRollback(connKey);
206
- }
207
- catch { }
208
- conn = undefined;
209
- const reqTr = await createTransaction();
210
- if ('error' in reqTr)
211
- throw new Error('Failed to recreate transaction after lost connection');
212
- conn = reqTr.conn;
213
- connKey = reqTr.connKey;
214
- continue;
610
+ // The caller's transaction can only survive a lock timeout: that rolls back
611
+ // the statement, not the transaction. Anything else is theirs to clean up.
612
+ const callerTransactionIsDead = callerOwnsConnection && (isDeadlock || isConnLost);
613
+ if (!isTransientError(error) || callerTransactionIsDead || lastAttempt) {
614
+ if (createsNewTransaction && key) {
615
+ // Keep the (dead) entry so the caller's own rollback of the key we
616
+ // report back succeeds instead of erroring with "key not found".
617
+ await killTransaction(key, `the query failed: ${describeError(error)}`, !isConnLost && !isDeadlock);
215
618
  }
216
- if (callerOwnsConnection) {
217
- throw error;
218
- }
219
- await ensurePool();
220
- continue;
221
- }
222
- // Deadlock + caller-owned: MySQL already rolled back the entire transaction,
223
- // retrying on the same connection is not possible — throw so the caller rollbacks.
224
- if (isDeadlock && callerOwnsConnection) {
225
- if (connKey)
226
- connectionRollback(connKey);
619
+ if (key)
620
+ error.connKey = key;
227
621
  throw error;
228
622
  }
229
- // Non-lock error: re-check the pool and retry
230
- if (!isLockError) {
231
- if (!conn)
232
- await ensurePool(); // pool health is irrelevant when we own a connection
233
- continue;
234
- }
235
- // Timeout + caller-owned: only the statement was rolled back, the transaction
236
- // is still alive — wait and retry the statement on the same connection.
237
- // The caller will rollback the transaction itself if we ultimately throw.
238
- if (isLockTimeout && callerOwnsConnection) {
239
- await new Promise((r) => setTimeout(r, LOCK_TIMEOUT_RETRY_DELAY_MS * attempt));
240
- continue;
241
- }
242
- // Deadlock: MySQL already rolled back the transaction — retry quickly.
243
- // Timeout: something is holding a lock — wait longer before retrying.
244
- const retryDelay = isDeadlock ? DEADLOCK_RETRY_DELAY_MS : LOCK_TIMEOUT_RETRY_DELAY_MS;
245
- await new Promise((r) => setTimeout(r, retryDelay * attempt));
623
+ const retryDelay = isDeadlock ? DEADLOCK_RETRY_DELAY_MS : isLockTimeout ? LOCK_TIMEOUT_RETRY_DELAY_MS : TRANSIENT_RETRY_DELAY_MS;
624
+ await sleep(retryDelay * attempt);
246
625
  if (createsNewTransaction) {
247
- // For both error types, our transaction is no longer usable — recreate it
248
- try {
249
- conn?.release();
626
+ // Our transaction is unusable — retire it and open a fresh one to retry in.
627
+ if (key) {
628
+ await killTransaction(key, `retrying after ${describeError(error)}`, true);
629
+ listTransaction.delete(key);
250
630
  }
251
- catch { }
252
631
  conn = undefined;
253
632
  const reqTr = await createTransaction();
254
- if ('error' in reqTr)
255
- throw new Error(`Failed to recreate transaction after ${isDeadlock ? 'deadlock' : 'lock timeout'}`);
633
+ if ('error' in reqTr) {
634
+ const failure = new Error(`Failed to recreate transaction after ${describeError(error)}: ${describeError(reqTr.error)}`);
635
+ failure.code = reqTr.error?.code;
636
+ failure.cause = reqTr.error;
637
+ throw failure;
638
+ }
256
639
  conn = reqTr.conn;
257
- connKey = reqTr.connKey;
640
+ key = reqTr.connKey;
641
+ }
642
+ else if (!conn) {
643
+ await ensurePool();
258
644
  }
259
645
  }
260
646
  }
261
647
  throw new Error('MySQL: executeWithRetry reached unexpected end');
262
648
  }
263
- function checkStoredProcedureResult(rows, query) {
264
- const message = rows?.[0]?.[0]?.['@message'] ?? '';
265
- if (!message.toLowerCase().startsWith('ok')) {
266
- return message || `No message returned from procedure: ${query}`;
267
- }
268
- return null;
269
- }
270
- function resolveFieldTypes(fields) {
271
- if (!promise_1.default.Types)
272
- throw new Error('mysql.Types is not available');
273
- return fields.map((field) => {
274
- const typeName = Object.keys(promise_1.default.Types).find((key) => promise_1.default.Types[key] === field.type);
275
- return { fieldName: field.name, fieldType: typeName ?? '' };
276
- });
277
- }
278
- function getTablesFromQuery(query) {
279
- try {
280
- const { Parser } = parserModule.default ?? parserModule;
281
- const parser = new Parser();
282
- const { tableList } = parser.parse(query);
283
- const cleanTableList = tableList.map((table) => {
284
- const parts = table.split('::');
285
- return parts[parts.length - 1];
286
- });
287
- return cleanTableList;
288
- }
289
- catch {
290
- return [];
291
- }
292
- }
293
- const DEADLOCK_RETRY_DELAY_MS = 100;
294
- const LOCK_TIMEOUT_RETRY_DELAY_MS = 500;
295
- const MAX_EXECUTE_ATTEMPTS = 5;
296
- const createsNewTransaction = connKey === true;
297
- const usesExistingTransaction = !!connKey && typeof connKey === 'string';
298
- if (lastConnectionCheck + 5 * 60000 <= Date.now() && !usesExistingTransaction) {
649
+ if (!usesExistingTransaction && lastConnectionCheck + POOL_CHECK_INTERVAL_MS <= Date.now()) {
299
650
  await ensurePool();
300
651
  }
301
652
  let conn;
@@ -303,7 +654,7 @@ async function executeMySQLQuery2(opt) {
303
654
  if (createsNewTransaction) {
304
655
  const reqTr = await createTransaction();
305
656
  if ('error' in reqTr)
306
- return { error: reqTr.error };
657
+ return fail('transaction-create', reqTr.error, values, null);
307
658
  connKey = reqTr.connKey;
308
659
  conn = reqTr.conn;
309
660
  }
@@ -311,63 +662,82 @@ async function executeMySQLQuery2(opt) {
311
662
  const tr = listTransaction.get(connKey);
312
663
  if (!tr) {
313
664
  const msg = `Transaction key not found: ${connKey}`;
314
- handleError(msg, 'MySQL: executeMySQLQuery2');
315
- return { error: msg };
665
+ handleError(msg, 'MySQL: executeMySQLQuery3');
666
+ return fail('transaction-lookup', msg, values, connKey);
667
+ }
668
+ if (tr.dead) {
669
+ const msg = `Transaction ${connKey} is no longer usable: ${tr.dead}`;
670
+ handleError(msg, 'MySQL: executeMySQLQuery3');
671
+ return fail('transaction-lookup', msg, values, connKey);
316
672
  }
317
673
  tr.lastUpdate = Date.now();
318
674
  conn = tr.conn;
319
675
  }
320
- const { rows, fields, connKey: updatedConnKey, } = await executeWithRetry({
321
- query,
322
- values,
323
- conn,
324
- connKey: typeof connKey === 'string' ? connKey : undefined,
325
- callerOwnsConnection: usesExistingTransaction,
326
- createsNewTransaction,
327
- });
676
+ const { rows, fields, connKey: updatedConnKey, } = await executeWithRetry(conn, typeof connKey === 'string' ? connKey : undefined, usesExistingTransaction);
328
677
  if (updatedConnKey)
329
678
  connKey = updatedConnKey;
330
679
  if (query.trim().toLowerCase().startsWith('call')) {
331
680
  const procError = checkStoredProcedureResult(rows, query);
332
681
  if (procError) {
333
682
  if (createsNewTransaction && typeof connKey === 'string') {
334
- try {
335
- await connectionRollback(connKey);
336
- }
337
- catch { }
683
+ await killTransaction(connKey, `the procedure reported: ${procError}`, true);
338
684
  }
339
- return { error: procError, values };
685
+ return fail('procedure', procError, values, keyOf());
340
686
  }
341
687
  }
342
- const fieldsType = returnFieldTypes ? resolveFieldTypes(fields) : [];
343
- const tables = returnListTables ? getTablesFromQuery(query) : [];
344
- if (createsNewTransaction || returnFieldTypes || returnListTables) {
345
- return { rows, fieldsType, connKey, tables };
346
- }
347
- return rows;
688
+ return {
689
+ ok: true,
690
+ rows: rows,
691
+ fieldsType: returnFieldTypes ? resolveFieldTypes(fields) : [],
692
+ tables: returnListTables ? getTablesFromQuery(query) : [],
693
+ connKey: keyOf(),
694
+ };
348
695
  }
349
696
  catch (error) {
350
- try {
351
- const errorMsg = String(error?.message ?? error);
352
- const queryStr = query.length > 200 ? `${query.substring(0, 200)}...` : query;
353
- if (connKey && typeof connKey === 'string' && errorMsg.includes('new command when connection is in closed state')) {
354
- const activeTr = listTransaction.get(connKey);
355
- if (activeTr) {
356
- console.log(`lastValidConn (lastUpdate : ${activeTr.lastUpdate}) : `);
357
- console.log(activeTr.lastValidConn);
358
- console.log(`conn (creation : ${activeTr.creation}) : `);
359
- console.log(conn);
360
- }
361
- }
362
- handleError(error, `MySQL${error?.code ? ` [${error.code}]` : ''}${connKey ? ' (using connection)' : ''}\n${errorMsg}\n\nparams: ${values}\n\nquery: ${queryStr}`);
363
- }
364
- catch (handlingError) {
365
- handleError(handlingError, 'MySQL: error inside error handler');
697
+ // executeWithRetry reports which transaction the failure ended on, which may be a
698
+ // recreated one rather than the key we started with.
699
+ if (typeof error?.connKey === 'string')
700
+ connKey = error.connKey;
701
+ const errorMsg = describeError(error);
702
+ const queryStr = query.length > 200 ? `${query.substring(0, 200)}...` : query;
703
+ handleError(error, `MySQL${error?.code ? ` [${error.code}]` : ''}${connKey ? ' (using connection)' : ''}\n${errorMsg}\n\nparams: ${values}\n\nquery: ${queryStr}`);
704
+ return fail('execute', error, values, keyOf());
705
+ }
706
+ }
707
+ /**
708
+ * Legacy shape, kept for existing call sites: returns the rows directly on success
709
+ * (or `{ rows, fieldsType, connKey, tables }` when a transaction or extra metadata was
710
+ * requested), and `{ error, values, connKey }` on failure — now carrying `code`, `errno`
711
+ * and `sqlState` alongside the message.
712
+ *
713
+ * New code should prefer {@link executeMySQLQuery3}, which always returns a typed
714
+ * `{ ok, ... }` wrapper.
715
+ */
716
+ async function executeMySQLQuery2(opt) {
717
+ requireConfig(); // unchanged: calling this before init() is a programming error, so it throws
718
+ const { values = [], returnFieldTypes = false, returnListTables = false } = opt;
719
+ const createsNewTransaction = opt.connKey === true;
720
+ const result = await executeMySQLQuery3(opt);
721
+ if (result.ok) {
722
+ if (createsNewTransaction || returnFieldTypes || returnListTables) {
723
+ return { rows: result.rows, fieldsType: result.fieldsType, connKey: result.connKey ?? undefined, tables: result.tables };
366
724
  }
367
- if (error?.code === 'ER_LOCK_DEADLOCK' || error?.code === 'ER_LOCK_WAIT_TIMEOUT') {
368
- return { error: 'A lock error occurred, please retry', values, connKey: connKey ?? null };
725
+ return result.rows;
726
+ }
727
+ switch (result.stage) {
728
+ case 'transaction-create':
729
+ return { error: result.cause ?? result.error };
730
+ case 'transaction-lookup':
731
+ case 'not-initialized':
732
+ case 'invalid-input':
733
+ return { error: result.error };
734
+ case 'procedure':
735
+ return { error: result.error, values };
736
+ default: {
737
+ const isLockError = result.code === 'ER_LOCK_DEADLOCK' || result.code === 'ER_LOCK_WAIT_TIMEOUT';
738
+ const error = isLockError ? 'A lock error occurred, please retry' : (result.cause?.message ?? result.cause ?? result.error);
739
+ return { error, values, connKey: result.connKey, code: result.code, errno: result.errno, sqlState: result.sqlState };
369
740
  }
370
- return { error: error?.message ?? error, values, connKey: connKey ?? null };
371
741
  }
372
742
  }
373
743
  /**
@@ -386,8 +756,8 @@ async function executeMySQLQuery2(opt) {
386
756
  * Returns the rows on success, or `{ error, values, connKey }` on failure —
387
757
  * same shape as `executeMySQLQuery`.
388
758
  */
389
- async function runMySQLQuery(query, values = [], connKey) {
390
- requireConfig();
759
+ async function runMySQLQuery(query, values = [], connKey, timeoutMs) {
760
+ const cfg = requireConfig();
391
761
  let conn;
392
762
  try {
393
763
  if (connKey) {
@@ -397,71 +767,24 @@ async function runMySQLQuery(query, values = [], connKey) {
397
767
  handleError(msg, 'MySQL: runMySQLQuery');
398
768
  return { error: msg, values, connKey };
399
769
  }
770
+ if (tr.dead) {
771
+ const msg = `Transaction ${connKey} is no longer usable: ${tr.dead}`;
772
+ handleError(msg, 'MySQL: runMySQLQuery');
773
+ return { error: msg, values, connKey };
774
+ }
400
775
  tr.lastUpdate = Date.now();
401
776
  conn = tr.conn;
402
777
  }
403
- else if (lastConnectionCheck + 5 * 60000 <= Date.now()) {
778
+ else if (lastConnectionCheck + POOL_CHECK_INTERVAL_MS <= Date.now()) {
404
779
  await ensurePool();
405
780
  }
406
- const [rows] = await (conn ?? requirePool()).query(query, values);
781
+ const { rows } = await runStatement(conn, connKey, query, values, timeoutMs ?? cfg.defaultQueryTimeoutMs, 'query');
407
782
  return rows;
408
783
  }
409
784
  catch (error) {
410
- const errorMsg = String(error?.message ?? error);
785
+ const errorMsg = describeError(error);
411
786
  const queryStr = query.length > 200 ? `${query.substring(0, 200)}...` : query;
412
787
  handleError(error, `MySQL${error?.code ? ` [${error.code}]` : ''}${connKey ? ' (using connection)' : ''}\n${errorMsg}\n\nparams: ${values}\n\nquery: ${queryStr}`);
413
- return { error: error?.message ?? error, values, connKey: connKey ?? null };
414
- }
415
- }
416
- async function createTransaction() {
417
- const keyConn = (0, crypto_1.randomUUID)();
418
- try {
419
- await ensurePool();
420
- const conn = await requirePool().getConnection();
421
- await conn.beginTransaction();
422
- listTransaction.set(keyConn, { conn, timestamp: Date.now(), lastUpdate: Date.now(), lastValidConn: conn, creation: Date.now() });
423
- return { conn, connKey: keyConn };
424
- }
425
- catch (error) {
426
- handleError(error, 'MySQL: createTransaction');
427
- try {
428
- listTransaction.delete(keyConn);
429
- }
430
- catch { }
431
- return { error };
432
- }
433
- }
434
- async function connectionCommit(keyConn) {
435
- const tr = listTransaction.get(keyConn);
436
- if (!tr) {
437
- const msg = `Transaction key not found: ${keyConn}`;
438
- handleError(msg, 'MySQL: connectionCommit');
439
- return { error: msg };
440
- }
441
- try {
442
- await tr.conn.commit();
443
- await tr.conn.release();
444
- listTransaction.delete(keyConn);
445
- return {};
446
- }
447
- catch (error) {
448
- handleError(error, 'MySQL: connectionCommit');
449
- return { error };
450
- }
451
- }
452
- async function connectionRollback(keyConn) {
453
- const tr = listTransaction.get(keyConn);
454
- if (!tr) {
455
- handleError(`Transaction key not found: ${keyConn}`, 'MySQL: connectionRollback');
456
- return { error: `Transaction key not found: ${keyConn}` };
457
- }
458
- try {
459
- await tr.conn.rollback();
460
- await tr.conn.release();
461
- listTransaction.delete(keyConn);
462
- return {};
463
- }
464
- catch (error) {
465
- return { error };
788
+ return { error: error?.message ?? error, values, connKey: connKey ?? null, ...errorDetails(error) };
466
789
  }
467
790
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benjosivo/mysql",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [