@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 +85 -11
- package/dist/index.d.ts +80 -19
- package/dist/index.js +571 -248
- package/package.json +1 -1
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) |
|
|
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
|
-
|
|
50
|
+
executeMySQLQuery3<T>({ query, values?, connKey?, returnFieldTypes?, returnListTables?, timeoutMs? })
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
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
|
|
86
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
//
|
|
159
|
+
```ts
|
|
160
|
+
// Transaction <key> is no longer usable: MySQL rolled it back after a deadlock
|
|
92
161
|
```
|
|
93
162
|
|
|
94
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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 ??
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
292
|
+
await Promise.all(pending);
|
|
115
293
|
}
|
|
116
294
|
function loopKillOpenTRX() {
|
|
117
295
|
if (killOpenTRXInterval)
|
|
118
296
|
return;
|
|
119
|
-
killOpenTRXInterval = setInterval(
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
397
|
+
const conn = await requirePool().getConnection();
|
|
398
|
+
conn.release();
|
|
399
|
+
lastConnectionCheck = Date.now();
|
|
140
400
|
}
|
|
141
|
-
catch
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 (
|
|
217
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
315
|
-
return
|
|
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
|
-
|
|
335
|
-
await connectionRollback(connKey);
|
|
336
|
-
}
|
|
337
|
-
catch { }
|
|
683
|
+
await killTransaction(connKey, `the procedure reported: ${procError}`, true);
|
|
338
684
|
}
|
|
339
|
-
return
|
|
685
|
+
return fail('procedure', procError, values, keyOf());
|
|
340
686
|
}
|
|
341
687
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 +
|
|
778
|
+
else if (lastConnectionCheck + POOL_CHECK_INTERVAL_MS <= Date.now()) {
|
|
404
779
|
await ensurePool();
|
|
405
780
|
}
|
|
406
|
-
const
|
|
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 =
|
|
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
|
}
|