@pma-network/sql 1.0.9 → 1.2.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/dist/MySQL.d.ts +119 -16
- package/dist/MySQL.d.ts.map +1 -1
- package/dist/MySQL.js +560 -130
- package/dist/MySQL.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/type-casting.d.ts +4 -0
- package/dist/type-casting.d.ts.map +1 -0
- package/dist/type-casting.js +46 -0
- package/dist/type-casting.js.map +1 -0
- package/dist/types.d.ts +74 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/MySQL.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import mysql from "mysql2/promise";
|
|
5
|
+
import namedPlaceholders from "named-placeholders";
|
|
6
|
+
import { typeCast } from "./type-casting.js";
|
|
4
7
|
/**
|
|
5
8
|
* MySQL database wrapper with connection pooling and named parameter support.
|
|
6
9
|
* Automatically reads connection configuration from environment variables.
|
|
@@ -12,37 +15,425 @@ export class MySQL {
|
|
|
12
15
|
slowQueryThreshold;
|
|
13
16
|
resourceName;
|
|
14
17
|
collectMetrics;
|
|
15
|
-
versionPrefix =
|
|
18
|
+
versionPrefix = "";
|
|
16
19
|
metricsExportResource;
|
|
17
20
|
metricsExportFunction;
|
|
21
|
+
defaultQueryOptions = {
|
|
22
|
+
timeout: 15_000,
|
|
23
|
+
retryCount: 3,
|
|
24
|
+
};
|
|
25
|
+
sshProcess = null;
|
|
26
|
+
initializationPromise = null;
|
|
27
|
+
schemas = new Map();
|
|
28
|
+
autoSync = false;
|
|
29
|
+
syncComplete = false;
|
|
18
30
|
/**
|
|
19
31
|
* Creates a new MySQL instance with connection pooling.
|
|
20
32
|
* If no config is provided, it will automatically look for connection strings in environment variables.
|
|
21
33
|
*
|
|
34
|
+
* SSH tunnel connections (mysql-ssh://) are supported and initialized automatically.
|
|
35
|
+
*
|
|
22
36
|
* @param config - Optional database configuration. If omitted, uses environment variables.
|
|
23
37
|
* @throws {Error} If no connection string is found or if connection string validation fails
|
|
24
38
|
*/
|
|
25
39
|
constructor(config) {
|
|
26
40
|
this.resourceName = this.getResourceName();
|
|
27
|
-
this.debug = this.getConvarBool(
|
|
28
|
-
this.slowQueryThreshold = this.getConvarInt(
|
|
29
|
-
this.collectMetrics = this.getConvarBool(
|
|
30
|
-
this.metricsExportResource = this.getConvar(
|
|
31
|
-
this.metricsExportFunction = this.getConvar(
|
|
41
|
+
this.debug = this.getConvarBool("mysql_debug", false);
|
|
42
|
+
this.slowQueryThreshold = this.getConvarInt("mysql_slow_query_warning", 150);
|
|
43
|
+
this.collectMetrics = this.getConvarBool("mysql_query_metrics", false);
|
|
44
|
+
this.metricsExportResource = this.getConvar("mysql_metrics_export_resource");
|
|
45
|
+
this.metricsExportFunction = this.getConvar("mysql_metrics_export_function");
|
|
32
46
|
this.setupConvarListeners();
|
|
47
|
+
this.namedPlaceholdersCompiler = namedPlaceholders();
|
|
33
48
|
const finalConfig = config || this.getDefaultConfig();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
// Register schemas from config
|
|
50
|
+
if (finalConfig.schemas) {
|
|
51
|
+
for (const [name, schema] of Object.entries(finalConfig.schemas)) {
|
|
52
|
+
this.schemas.set(name, schema);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
this.autoSync = finalConfig.autoSync ?? false;
|
|
56
|
+
if (finalConfig.ssh || this.isSSHConnectionString(finalConfig.connectionString)) {
|
|
57
|
+
this.initializationPromise = this.initializeSSH(finalConfig);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
this.initializePool(finalConfig);
|
|
61
|
+
this.fetchServerVersion();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async initializeSSH(config) {
|
|
65
|
+
if (this.isSSHConnectionString(config.connectionString)) {
|
|
66
|
+
await this.initializeFromSSHConnectionString(config.connectionString);
|
|
67
|
+
}
|
|
68
|
+
else if (config.ssh) {
|
|
69
|
+
await this.initializeWithSSH(config, config.ssh);
|
|
70
|
+
}
|
|
71
|
+
this.fetchServerVersion();
|
|
72
|
+
}
|
|
73
|
+
async ensureInitialized() {
|
|
74
|
+
if (this.initializationPromise) {
|
|
75
|
+
await this.initializationPromise;
|
|
76
|
+
}
|
|
77
|
+
if (this.autoSync && !this.syncComplete) {
|
|
78
|
+
await this.sync();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Defines a table schema that can be synced to the database.
|
|
83
|
+
*
|
|
84
|
+
* @param tableName - Name of the table
|
|
85
|
+
* @param schema - Table schema definition
|
|
86
|
+
* @example
|
|
87
|
+
* db.defineSchema('users', {
|
|
88
|
+
* columns: {
|
|
89
|
+
* id: { type: 'INT', primaryKey: true, autoIncrement: true },
|
|
90
|
+
* name: { type: 'VARCHAR(255)', nullable: false },
|
|
91
|
+
* email: { type: 'VARCHAR(255)', unique: true },
|
|
92
|
+
* created_at: { type: 'TIMESTAMP', default: 'CURRENT_TIMESTAMP' }
|
|
93
|
+
* },
|
|
94
|
+
* indexes: ['INDEX idx_email (email)']
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* // Or use shorthand string syntax
|
|
98
|
+
* db.defineSchema('logs', {
|
|
99
|
+
* columns: {
|
|
100
|
+
* id: 'INT AUTO_INCREMENT PRIMARY KEY',
|
|
101
|
+
* message: 'TEXT NOT NULL',
|
|
102
|
+
* level: 'VARCHAR(20) DEFAULT "info"'
|
|
103
|
+
* }
|
|
104
|
+
* });
|
|
105
|
+
*/
|
|
106
|
+
defineSchema(tableName, schema) {
|
|
107
|
+
this.schemas.set(tableName, schema);
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Syncs all defined schemas to the database, creating tables if they don't exist.
|
|
112
|
+
*
|
|
113
|
+
* @returns Promise that resolves when sync is complete
|
|
114
|
+
* @example
|
|
115
|
+
* db.defineSchema('users', { ... });
|
|
116
|
+
* db.defineSchema('posts', { ... });
|
|
117
|
+
* await db.sync();
|
|
118
|
+
*/
|
|
119
|
+
async sync() {
|
|
120
|
+
if (this.initializationPromise) {
|
|
121
|
+
await this.initializationPromise;
|
|
122
|
+
}
|
|
123
|
+
for (const [tableName, schema] of this.schemas) {
|
|
124
|
+
await this.createTableIfNotExists(tableName, schema);
|
|
125
|
+
}
|
|
126
|
+
this.syncComplete = true;
|
|
127
|
+
}
|
|
128
|
+
buildColumnDefinition(columnName, column) {
|
|
129
|
+
if (typeof column === "string") {
|
|
130
|
+
return `\`${columnName}\` ${column}`;
|
|
131
|
+
}
|
|
132
|
+
const parts = [`\`${columnName}\``, column.type];
|
|
133
|
+
if (column.nullable === false) {
|
|
134
|
+
parts.push("NOT NULL");
|
|
135
|
+
}
|
|
136
|
+
if (column.default !== undefined) {
|
|
137
|
+
if (column.default === null) {
|
|
138
|
+
parts.push("DEFAULT NULL");
|
|
139
|
+
}
|
|
140
|
+
else if (typeof column.default === "string" &&
|
|
141
|
+
(column.default.toUpperCase() === "CURRENT_TIMESTAMP" ||
|
|
142
|
+
column.default.toUpperCase().startsWith("CURRENT_TIMESTAMP"))) {
|
|
143
|
+
parts.push(`DEFAULT ${column.default}`);
|
|
144
|
+
}
|
|
145
|
+
else if (typeof column.default === "string") {
|
|
146
|
+
parts.push(`DEFAULT '${column.default}'`);
|
|
147
|
+
}
|
|
148
|
+
else if (typeof column.default === "boolean") {
|
|
149
|
+
parts.push(`DEFAULT ${column.default ? 1 : 0}`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
parts.push(`DEFAULT ${column.default}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (column.autoIncrement) {
|
|
156
|
+
parts.push("AUTO_INCREMENT");
|
|
157
|
+
}
|
|
158
|
+
if (column.unique) {
|
|
159
|
+
parts.push("UNIQUE");
|
|
160
|
+
}
|
|
161
|
+
if (column.primaryKey) {
|
|
162
|
+
parts.push("PRIMARY KEY");
|
|
163
|
+
}
|
|
164
|
+
if (column.references) {
|
|
165
|
+
let refClause = `REFERENCES ${column.references}`;
|
|
166
|
+
if (column.onDelete) {
|
|
167
|
+
refClause += ` ON DELETE ${column.onDelete}`;
|
|
168
|
+
}
|
|
169
|
+
if (column.onUpdate) {
|
|
170
|
+
refClause += ` ON UPDATE ${column.onUpdate}`;
|
|
171
|
+
}
|
|
172
|
+
parts.push(refClause);
|
|
173
|
+
}
|
|
174
|
+
return parts.join(" ");
|
|
175
|
+
}
|
|
176
|
+
async createTableIfNotExists(tableName, schema) {
|
|
177
|
+
const columnDefs = [];
|
|
178
|
+
for (const [columnName, column] of Object.entries(schema.columns)) {
|
|
179
|
+
columnDefs.push(this.buildColumnDefinition(columnName, column));
|
|
180
|
+
}
|
|
181
|
+
// Add composite primary key if specified separately
|
|
182
|
+
if (schema.primaryKey) {
|
|
183
|
+
const pkColumns = Array.isArray(schema.primaryKey)
|
|
184
|
+
? schema.primaryKey
|
|
185
|
+
: [schema.primaryKey];
|
|
186
|
+
columnDefs.push(`PRIMARY KEY (${pkColumns.map((c) => `\`${c}\``).join(", ")})`);
|
|
187
|
+
}
|
|
188
|
+
// Add indexes
|
|
189
|
+
if (schema.indexes) {
|
|
190
|
+
columnDefs.push(...schema.indexes);
|
|
191
|
+
}
|
|
192
|
+
const engine = schema.engine || "InnoDB";
|
|
193
|
+
const charset = schema.charset || "utf8mb4";
|
|
194
|
+
const collate = schema.collate || "utf8mb4_unicode_ci";
|
|
195
|
+
const sql = `CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
|
196
|
+
${columnDefs.join(",\n\t\t\t")}
|
|
197
|
+
) ENGINE=${engine} DEFAULT CHARSET=${charset} COLLATE=${collate}`;
|
|
198
|
+
await this.rawExecute(sql);
|
|
199
|
+
if (this.debug) {
|
|
200
|
+
console.log(`^2Schema synced: ${tableName}^0`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Creates a new MySQL instance with SSH tunnel support.
|
|
205
|
+
* Use this factory method when connecting through an SSH bastion/jump host.
|
|
206
|
+
*
|
|
207
|
+
* Supports two formats:
|
|
208
|
+
* 1. Separate SSH config object
|
|
209
|
+
* 2. mysql-ssh:// connection string (uses SSH_AUTH_SOCK for auth by default)
|
|
210
|
+
*
|
|
211
|
+
* @param config - Database configuration with SSH tunnel settings
|
|
212
|
+
* @returns Promise resolving to a MySQL instance connected through SSH tunnel
|
|
213
|
+
* @throws {Error} If SSH connection fails
|
|
214
|
+
* @throws {Error} If no connection string is found
|
|
215
|
+
* @example
|
|
216
|
+
* // Using mysql-ssh:// URL (recommended for 1Password/ssh-agent)
|
|
217
|
+
* const db = await MySQL.createWithSSH({
|
|
218
|
+
* connectionString: 'mysql-ssh://deploy@bastion.example.com/mydb?user=admin&password=secret'
|
|
219
|
+
* });
|
|
220
|
+
*
|
|
221
|
+
* // Using separate SSH config
|
|
222
|
+
* const db = await MySQL.createWithSSH({
|
|
223
|
+
* connectionString: 'mysql://user:pass@localhost:3306/database',
|
|
224
|
+
* ssh: {
|
|
225
|
+
* host: 'bastion.example.com',
|
|
226
|
+
* username: 'ssh-user',
|
|
227
|
+
* }
|
|
228
|
+
* });
|
|
229
|
+
*/
|
|
230
|
+
static async createWithSSH(config) {
|
|
231
|
+
const instance = Object.create(MySQL.prototype);
|
|
232
|
+
instance.resourceName = instance.getResourceName();
|
|
233
|
+
instance.debug = instance.getConvarBool("mysql_debug", false);
|
|
234
|
+
instance.slowQueryThreshold = instance.getConvarInt("mysql_slow_query_warning", 150);
|
|
235
|
+
instance.collectMetrics = instance.getConvarBool("mysql_query_metrics", false);
|
|
236
|
+
instance.metricsExportResource = instance.getConvar("mysql_metrics_export_resource");
|
|
237
|
+
instance.metricsExportFunction = instance.getConvar("mysql_metrics_export_function");
|
|
238
|
+
instance.defaultQueryOptions = { timeout: 15_000, retryCount: 3 };
|
|
239
|
+
instance.sshProcess = null;
|
|
240
|
+
instance.setupConvarListeners();
|
|
241
|
+
instance.namedPlaceholdersCompiler = namedPlaceholders();
|
|
242
|
+
const finalConfig = config || instance.getDefaultConfig();
|
|
243
|
+
if (instance.isSSHConnectionString(finalConfig.connectionString)) {
|
|
244
|
+
await instance.initializeFromSSHConnectionString(finalConfig.connectionString);
|
|
245
|
+
}
|
|
246
|
+
else if (finalConfig.ssh) {
|
|
247
|
+
await instance.initializeWithSSH(finalConfig, finalConfig.ssh);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
instance.initializePool(finalConfig);
|
|
251
|
+
}
|
|
252
|
+
instance.fetchServerVersion();
|
|
253
|
+
return instance;
|
|
254
|
+
}
|
|
255
|
+
isSSHConnectionString(connectionString) {
|
|
256
|
+
return connectionString?.startsWith("mysql-ssh://") ?? false;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Parses a mysql-ssh:// connection string into SSH and MySQL configs.
|
|
260
|
+
*
|
|
261
|
+
* Format: mysql-ssh://sshuser@sshhost[:sshport]/database?user=dbuser&password=dbpass[&host=dbhost][&port=dbport]
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* mysql-ssh://deploy@bastion.example.com/mydb?user=admin&password=secret
|
|
265
|
+
* mysql-ssh://deploy@bastion.example.com:2222/mydb?user=admin&password=secret&host=internal-db&port=3307
|
|
266
|
+
*/
|
|
267
|
+
parseSSHConnectionString(connectionString) {
|
|
268
|
+
const url = new URL(connectionString.replace("mysql-ssh://", "http://"));
|
|
269
|
+
if (!url.username) {
|
|
270
|
+
throw new Error("mysql-ssh:// URL must include SSH username (e.g., mysql-ssh://user@host/db)");
|
|
271
|
+
}
|
|
272
|
+
const dbUser = url.searchParams.get("user");
|
|
273
|
+
const dbPassword = url.searchParams.get("password");
|
|
274
|
+
const dbHost = url.searchParams.get("host") || "127.0.0.1";
|
|
275
|
+
const dbPort = url.searchParams.get("port");
|
|
276
|
+
const connectionLimit = url.searchParams.get("connectionLimit");
|
|
277
|
+
const charset = url.searchParams.get("charset");
|
|
278
|
+
if (!dbUser) {
|
|
279
|
+
throw new Error("mysql-ssh:// URL must include database user (e.g., ?user=dbuser&password=dbpass)");
|
|
280
|
+
}
|
|
281
|
+
const mysqlOptions = {
|
|
282
|
+
host: dbHost,
|
|
283
|
+
port: dbPort ? parseInt(dbPort, 10) : 3306,
|
|
284
|
+
user: dbUser,
|
|
285
|
+
password: dbPassword || undefined,
|
|
286
|
+
database: url.pathname.slice(1),
|
|
287
|
+
};
|
|
288
|
+
if (connectionLimit) {
|
|
289
|
+
mysqlOptions.connectionLimit = parseInt(connectionLimit, 10);
|
|
290
|
+
}
|
|
291
|
+
if (charset) {
|
|
292
|
+
mysqlOptions.charset = charset;
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
ssh: {
|
|
296
|
+
host: url.hostname,
|
|
297
|
+
port: url.port ? parseInt(url.port, 10) : 22,
|
|
298
|
+
username: url.username,
|
|
299
|
+
},
|
|
300
|
+
mysql: mysqlOptions,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
initializePool(config) {
|
|
304
|
+
const poolConfig = config.connectionString
|
|
305
|
+
? this.parseConnectionString(config.connectionString)
|
|
306
|
+
: config;
|
|
37
307
|
this.pool = mysql.createPool({
|
|
38
308
|
waitForConnections: true,
|
|
39
309
|
connectionLimit: 10,
|
|
40
310
|
queueLimit: 0,
|
|
41
|
-
|
|
311
|
+
typeCast: typeCast,
|
|
42
312
|
...poolConfig,
|
|
43
313
|
});
|
|
44
|
-
|
|
45
|
-
|
|
314
|
+
}
|
|
315
|
+
async initializeWithSSH(config, sshConfig) {
|
|
316
|
+
const poolConfig = config.connectionString
|
|
317
|
+
? this.parseConnectionString(config.connectionString)
|
|
318
|
+
: config;
|
|
319
|
+
const dbHost = poolConfig.host || "127.0.0.1";
|
|
320
|
+
const dbPort = poolConfig.port || 3306;
|
|
321
|
+
const { localPort } = await this.createSSHTunnel(sshConfig, dbHost, dbPort);
|
|
322
|
+
this.pool = mysql.createPool({
|
|
323
|
+
waitForConnections: true,
|
|
324
|
+
connectionLimit: 10,
|
|
325
|
+
queueLimit: 0,
|
|
326
|
+
typeCast: typeCast,
|
|
327
|
+
...poolConfig,
|
|
328
|
+
host: "127.0.0.1",
|
|
329
|
+
port: localPort,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async initializeFromSSHConnectionString(connectionString) {
|
|
333
|
+
const { ssh, mysql: mysqlConfig } = this.parseSSHConnectionString(connectionString);
|
|
334
|
+
const dbHost = mysqlConfig.host || "127.0.0.1";
|
|
335
|
+
const dbPort = mysqlConfig.port || 3306;
|
|
336
|
+
const { localPort } = await this.createSSHTunnel(ssh, dbHost, dbPort);
|
|
337
|
+
this.pool = mysql.createPool({
|
|
338
|
+
waitForConnections: true,
|
|
339
|
+
connectionLimit: 10,
|
|
340
|
+
queueLimit: 0,
|
|
341
|
+
typeCast: typeCast,
|
|
342
|
+
...mysqlConfig,
|
|
343
|
+
host: "127.0.0.1",
|
|
344
|
+
port: localPort,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async createSSHTunnel(sshConfig, dbHost, dbPort) {
|
|
348
|
+
// Find an available local port by briefly binding to port 0
|
|
349
|
+
const localPort = await new Promise((resolve, reject) => {
|
|
350
|
+
const srv = require("node:net").createServer();
|
|
351
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
352
|
+
const port = srv.address()?.port;
|
|
353
|
+
srv.close(() => resolve(port));
|
|
354
|
+
});
|
|
355
|
+
srv.on("error", reject);
|
|
356
|
+
});
|
|
357
|
+
const sshPort = sshConfig.port || 22;
|
|
358
|
+
const args = [
|
|
359
|
+
"-N",
|
|
360
|
+
"-L",
|
|
361
|
+
`${localPort}:${dbHost}:${dbPort}`,
|
|
362
|
+
"-p",
|
|
363
|
+
String(sshPort),
|
|
364
|
+
"-o",
|
|
365
|
+
"StrictHostKeyChecking=accept-new",
|
|
366
|
+
"-o",
|
|
367
|
+
"ExitOnForwardFailure=yes",
|
|
368
|
+
];
|
|
369
|
+
if (sshConfig.privateKey) {
|
|
370
|
+
const keyPath = typeof sshConfig.privateKey === "string"
|
|
371
|
+
? sshConfig.privateKey
|
|
372
|
+
: undefined;
|
|
373
|
+
if (keyPath) {
|
|
374
|
+
args.push("-i", keyPath);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (sshConfig.agent === false) {
|
|
378
|
+
args.push("-o", "IdentityAgent=none");
|
|
379
|
+
}
|
|
380
|
+
else if (typeof sshConfig.agent === "string") {
|
|
381
|
+
args.push("-o", `IdentityAgent=${sshConfig.agent}`);
|
|
382
|
+
}
|
|
383
|
+
args.push(`${sshConfig.username}@${sshConfig.host}`);
|
|
384
|
+
this.sshProcess = spawn("ssh", args, {
|
|
385
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
386
|
+
env: {
|
|
387
|
+
...process.env,
|
|
388
|
+
...(typeof sshConfig.agent === "string"
|
|
389
|
+
? { SSH_AUTH_SOCK: sshConfig.agent }
|
|
390
|
+
: {}),
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
// Wait for the tunnel to be ready by attempting to connect to the local port
|
|
394
|
+
await new Promise((resolve, reject) => {
|
|
395
|
+
const sshProc = this.sshProcess;
|
|
396
|
+
let stderr = "";
|
|
397
|
+
sshProc.stderr?.on("data", (data) => {
|
|
398
|
+
stderr += data.toString();
|
|
399
|
+
});
|
|
400
|
+
sshProc.on("error", (err) => {
|
|
401
|
+
reject(new Error(`Failed to spawn ssh: ${err.message}. Is OpenSSH installed?`));
|
|
402
|
+
});
|
|
403
|
+
sshProc.on("close", (code) => {
|
|
404
|
+
if (code !== null && code !== 0) {
|
|
405
|
+
reject(new Error(`SSH tunnel exited with code ${code}: ${stderr.trim()}`));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
// Poll until the tunnel port is accepting connections
|
|
409
|
+
const maxAttempts = 50;
|
|
410
|
+
let attempt = 0;
|
|
411
|
+
const tryConnect = () => {
|
|
412
|
+
attempt++;
|
|
413
|
+
const sock = createConnection({ host: "127.0.0.1", port: localPort });
|
|
414
|
+
sock.on("connect", () => {
|
|
415
|
+
sock.destroy();
|
|
416
|
+
console.log(`^2SSH tunnel established on port ${localPort}^0`);
|
|
417
|
+
resolve();
|
|
418
|
+
});
|
|
419
|
+
sock.on("error", () => {
|
|
420
|
+
sock.destroy();
|
|
421
|
+
if (attempt >= maxAttempts) {
|
|
422
|
+
sshProc.kill();
|
|
423
|
+
reject(new Error(`SSH tunnel failed to become ready after ${maxAttempts} attempts: ${stderr.trim()}`));
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
setTimeout(tryConnect, 100);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
// Give ssh a moment to start before polling
|
|
431
|
+
setTimeout(tryConnect, 200);
|
|
432
|
+
});
|
|
433
|
+
return { localPort };
|
|
434
|
+
}
|
|
435
|
+
getQueryOption(option) {
|
|
436
|
+
return option ?? this.defaultQueryOptions;
|
|
46
437
|
}
|
|
47
438
|
async fetchServerVersion() {
|
|
48
439
|
try {
|
|
@@ -62,13 +453,13 @@ export class MySQL {
|
|
|
62
453
|
// This wrapper is meant to be usable for our Node.js code since it has way better
|
|
63
454
|
// ergonmoics, so we have to test that GetConvar exists before trying to do anything else
|
|
64
455
|
getConvar(varName) {
|
|
65
|
-
if (typeof globalThis.GetConvar ===
|
|
66
|
-
return globalThis.GetConvar(varName,
|
|
456
|
+
if (typeof globalThis.GetConvar === "function") {
|
|
457
|
+
return globalThis.GetConvar(varName, "") || null;
|
|
67
458
|
}
|
|
68
459
|
return process.env[varName] || null;
|
|
69
460
|
}
|
|
70
461
|
getConvarInt(varName, defaultValue) {
|
|
71
|
-
if (typeof globalThis.GetConvarInt ===
|
|
462
|
+
if (typeof globalThis.GetConvarInt === "function") {
|
|
72
463
|
return globalThis.GetConvarInt(varName, defaultValue);
|
|
73
464
|
}
|
|
74
465
|
const envValue = process.env[varName];
|
|
@@ -79,23 +470,23 @@ export class MySQL {
|
|
|
79
470
|
return defaultValue;
|
|
80
471
|
}
|
|
81
472
|
getConvarBool(varName, defaultValue) {
|
|
82
|
-
if (typeof globalThis.GetConvarBool ===
|
|
473
|
+
if (typeof globalThis.GetConvarBool === "function") {
|
|
83
474
|
return globalThis.GetConvarBool(varName, defaultValue);
|
|
84
475
|
}
|
|
85
476
|
const envValue = process.env[varName];
|
|
86
477
|
if (envValue !== undefined) {
|
|
87
|
-
return envValue ===
|
|
478
|
+
return envValue === "true" || envValue === "1";
|
|
88
479
|
}
|
|
89
480
|
return defaultValue;
|
|
90
481
|
}
|
|
91
482
|
getResourceName() {
|
|
92
|
-
if (typeof globalThis.GetCurrentResourceName ===
|
|
483
|
+
if (typeof globalThis.GetCurrentResourceName === "function") {
|
|
93
484
|
return globalThis.GetCurrentResourceName() || null;
|
|
94
485
|
}
|
|
95
|
-
return this.getConvar(
|
|
486
|
+
return this.getConvar("RESOURCE_NAME");
|
|
96
487
|
}
|
|
97
488
|
formatResourceConvar(resourceName) {
|
|
98
|
-
return resourceName.replace(/-/g,
|
|
489
|
+
return resourceName.replace(/-/g, "_").toLowerCase();
|
|
99
490
|
}
|
|
100
491
|
/**
|
|
101
492
|
* Gets default configuration from environment variables.
|
|
@@ -107,20 +498,31 @@ export class MySQL {
|
|
|
107
498
|
getDefaultConfig() {
|
|
108
499
|
let connectionString = null;
|
|
109
500
|
const resourceName = this.getResourceName();
|
|
110
|
-
|
|
111
|
-
|
|
501
|
+
const resourceConvar = resourceName
|
|
502
|
+
? this.formatResourceConvar(resourceName)
|
|
503
|
+
: null;
|
|
504
|
+
// Check for SSH connection string first (takes priority)
|
|
505
|
+
if (resourceConvar) {
|
|
506
|
+
connectionString = this.getConvar(`${resourceConvar}_ssh_connection_string`);
|
|
507
|
+
}
|
|
508
|
+
if (!connectionString) {
|
|
509
|
+
connectionString = this.getConvar("mysql_ssh_connection_string");
|
|
510
|
+
}
|
|
511
|
+
// Fall back to regular connection string
|
|
512
|
+
if (!connectionString && resourceConvar) {
|
|
112
513
|
connectionString = this.getConvar(`${resourceConvar}_connection_string`);
|
|
113
514
|
}
|
|
114
515
|
if (!connectionString) {
|
|
115
|
-
connectionString = this.getConvar(
|
|
516
|
+
connectionString = this.getConvar("mysql_connection_string");
|
|
116
517
|
}
|
|
117
518
|
if (!connectionString) {
|
|
118
|
-
const resourceHint =
|
|
119
|
-
? `\nTried: ${
|
|
120
|
-
:
|
|
121
|
-
throw new Error(
|
|
122
|
-
|
|
123
|
-
|
|
519
|
+
const resourceHint = resourceConvar
|
|
520
|
+
? `\nTried: ${resourceConvar}_ssh_connection_string, mysql_ssh_connection_string, ${resourceConvar}_connection_string, mysql_connection_string`
|
|
521
|
+
: "\nTried: mysql_ssh_connection_string, mysql_connection_string";
|
|
522
|
+
throw new Error("No MySQL connection string found. " +
|
|
523
|
+
"Please set a connection string: " +
|
|
524
|
+
"mysql://user:password@host:port/database or " +
|
|
525
|
+
"mysql-ssh://sshuser@sshhost/database?user=dbuser&password=dbpass" +
|
|
124
526
|
resourceHint);
|
|
125
527
|
}
|
|
126
528
|
this.validateConnectionString(connectionString);
|
|
@@ -137,25 +539,29 @@ export class MySQL {
|
|
|
137
539
|
* @throws {Error} If connection string format is invalid
|
|
138
540
|
*/
|
|
139
541
|
validateConnectionString(connectionString) {
|
|
542
|
+
// mysql-ssh:// URLs are validated separately during parsing
|
|
543
|
+
if (this.isSSHConnectionString(connectionString)) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
140
546
|
try {
|
|
141
547
|
const url = new URL(connectionString);
|
|
142
|
-
if (url.protocol !==
|
|
143
|
-
throw new Error(`Invalid protocol "${url.protocol}". Expected "mysql:". ` +
|
|
144
|
-
|
|
548
|
+
if (url.protocol !== "mysql:") {
|
|
549
|
+
throw new Error(`Invalid protocol "${url.protocol}". Expected "mysql:" or "mysql-ssh:". ` +
|
|
550
|
+
"Connection string must start with mysql:// or mysql-ssh://");
|
|
145
551
|
}
|
|
146
552
|
if (!url.hostname) {
|
|
147
|
-
throw new Error(
|
|
553
|
+
throw new Error("Connection string must include a hostname");
|
|
148
554
|
}
|
|
149
|
-
if (!url.pathname || url.pathname ===
|
|
150
|
-
throw new Error(
|
|
555
|
+
if (!url.pathname || url.pathname === "/") {
|
|
556
|
+
throw new Error("Connection string must include a database name");
|
|
151
557
|
}
|
|
152
558
|
if (!url.username) {
|
|
153
|
-
throw new Error(
|
|
559
|
+
throw new Error("Connection string must include a username");
|
|
154
560
|
}
|
|
155
561
|
}
|
|
156
562
|
catch (error) {
|
|
157
563
|
if (error instanceof TypeError) {
|
|
158
|
-
throw new Error(
|
|
564
|
+
throw new Error("Invalid connection string format. Expected: mysql://user:password@host:port/database");
|
|
159
565
|
}
|
|
160
566
|
throw error;
|
|
161
567
|
}
|
|
@@ -169,13 +575,22 @@ export class MySQL {
|
|
|
169
575
|
*/
|
|
170
576
|
parseConnectionString(connectionString) {
|
|
171
577
|
const url = new URL(connectionString);
|
|
172
|
-
|
|
578
|
+
const connectionLimit = url.searchParams.get("connectionLimit");
|
|
579
|
+
const charset = url.searchParams.get("charset");
|
|
580
|
+
const options = {
|
|
173
581
|
host: url.hostname,
|
|
174
582
|
port: url.port ? parseInt(url.port, 10) : 3306,
|
|
175
583
|
user: url.username,
|
|
176
584
|
password: url.password,
|
|
177
585
|
database: url.pathname.slice(1),
|
|
178
586
|
};
|
|
587
|
+
if (connectionLimit) {
|
|
588
|
+
options.connectionLimit = parseInt(connectionLimit, 10);
|
|
589
|
+
}
|
|
590
|
+
if (charset) {
|
|
591
|
+
options.charset = charset;
|
|
592
|
+
}
|
|
593
|
+
return options;
|
|
179
594
|
}
|
|
180
595
|
normalizeParameters(parameters) {
|
|
181
596
|
if (Array.isArray(parameters)) {
|
|
@@ -194,50 +609,76 @@ export class MySQL {
|
|
|
194
609
|
}
|
|
195
610
|
}
|
|
196
611
|
setupConvarListeners() {
|
|
197
|
-
if (typeof globalThis.AddConvarChangeListener ===
|
|
198
|
-
globalThis.AddConvarChangeListener(
|
|
199
|
-
if (convarName ===
|
|
200
|
-
this.debug = this.getConvarBool(
|
|
612
|
+
if (typeof globalThis.AddConvarChangeListener === "function") {
|
|
613
|
+
globalThis.AddConvarChangeListener("mysql_*", (convarName) => {
|
|
614
|
+
if (convarName === "mysql_debug") {
|
|
615
|
+
this.debug = this.getConvarBool("mysql_debug", false);
|
|
201
616
|
console.log(`^2Convar changed: mysql_debug = ${this.debug}^0`);
|
|
202
617
|
}
|
|
203
|
-
else if (convarName ===
|
|
204
|
-
this.slowQueryThreshold = this.getConvarInt(
|
|
618
|
+
else if (convarName === "mysql_slow_query_warning") {
|
|
619
|
+
this.slowQueryThreshold = this.getConvarInt("mysql_slow_query_warning", 150);
|
|
205
620
|
console.log(`^2Convar changed: mysql_slow_query_warning = ${this.slowQueryThreshold}^0`);
|
|
206
621
|
}
|
|
207
|
-
else if (convarName ===
|
|
208
|
-
this.collectMetrics = this.getConvarBool(
|
|
622
|
+
else if (convarName === "mysql_query_metrics") {
|
|
623
|
+
this.collectMetrics = this.getConvarBool("mysql_query_metrics", false);
|
|
209
624
|
console.log(`^2Convar changed: mysql_query_metrics = ${this.collectMetrics}^0`);
|
|
210
625
|
}
|
|
211
|
-
else if (convarName ===
|
|
212
|
-
this.metricsExportResource = this.getConvar(
|
|
626
|
+
else if (convarName === "mysql_metrics_export_resource") {
|
|
627
|
+
this.metricsExportResource = this.getConvar("mysql_metrics_export_resource");
|
|
213
628
|
console.log(`^2Convar changed: mysql_metrics_export_resource = ${this.metricsExportResource}^0`);
|
|
214
629
|
}
|
|
215
|
-
else if (convarName ===
|
|
216
|
-
this.metricsExportFunction = this.getConvar(
|
|
630
|
+
else if (convarName === "mysql_metrics_export_function") {
|
|
631
|
+
this.metricsExportFunction = this.getConvar("mysql_metrics_export_function");
|
|
217
632
|
console.log(`^2Convar changed: mysql_metrics_export_function = ${this.metricsExportFunction}^0`);
|
|
218
633
|
}
|
|
219
634
|
});
|
|
220
635
|
}
|
|
221
636
|
}
|
|
222
637
|
scheduleResourceTick() {
|
|
223
|
-
if (typeof globalThis.ScheduleResourceTick ===
|
|
638
|
+
if (typeof globalThis.ScheduleResourceTick === "function" &&
|
|
639
|
+
this.resourceName) {
|
|
224
640
|
globalThis.ScheduleResourceTick(this.resourceName);
|
|
225
641
|
}
|
|
226
642
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
this.logQuery(processedQuery, executionTime, processedParams);
|
|
233
|
-
return result;
|
|
643
|
+
isTimeoutError(error) {
|
|
644
|
+
if (error instanceof Error) {
|
|
645
|
+
const mysqlError = error;
|
|
646
|
+
return (mysqlError.code === "PROTOCOL_SEQUENCE_TIMEOUT" ||
|
|
647
|
+
mysqlError.message?.includes("timeout"));
|
|
234
648
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
async executePoolMethod(method, query, params, resultMapper, options) {
|
|
652
|
+
await this.ensureInitialized();
|
|
653
|
+
this.scheduleResourceTick();
|
|
654
|
+
const queryOptions = this.getQueryOption(options);
|
|
655
|
+
let lastError;
|
|
656
|
+
for (let attempt = 0; attempt <= queryOptions.retryCount; attempt++) {
|
|
657
|
+
const startTime = performance.now();
|
|
658
|
+
try {
|
|
659
|
+
const poolMethod = method === "execute"
|
|
660
|
+
? this.pool.execute.bind(this.pool)
|
|
661
|
+
: this.pool.query.bind(this.pool);
|
|
662
|
+
const [result] = await poolMethod({ timeout: queryOptions.timeout, sql: query }, params);
|
|
663
|
+
const executionTime = performance.now() - startTime;
|
|
664
|
+
this.logQuery(query, executionTime, params);
|
|
665
|
+
return resultMapper(result);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
lastError = error;
|
|
669
|
+
const executionTime = performance.now() - startTime;
|
|
670
|
+
if (this.isTimeoutError(error) && attempt < queryOptions.retryCount) {
|
|
671
|
+
this.logQuery(query, executionTime, params, error);
|
|
672
|
+
console.log(`^3Timeout on attempt ${attempt + 1}/${queryOptions.retryCount + 1}, retrying...^0`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
this.logQuery(query, executionTime, params, error);
|
|
676
|
+
this.handleError(error);
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
240
679
|
}
|
|
680
|
+
this.handleError(lastError);
|
|
681
|
+
return null;
|
|
241
682
|
}
|
|
242
683
|
exportMetric(metric) {
|
|
243
684
|
if (this.metricsExportResource && this.metricsExportFunction) {
|
|
@@ -248,7 +689,7 @@ export class MySQL {
|
|
|
248
689
|
}
|
|
249
690
|
}
|
|
250
691
|
replaceAtSymbols(query) {
|
|
251
|
-
return query.replace(/@/g,
|
|
692
|
+
return query.replace(/@/g, ":");
|
|
252
693
|
}
|
|
253
694
|
/**
|
|
254
695
|
* Executes a query on a specific connection with timing and logging.
|
|
@@ -259,12 +700,13 @@ export class MySQL {
|
|
|
259
700
|
* @returns Query result
|
|
260
701
|
* @throws Query execution errors (SQL errors, syntax errors, constraint violations, etc.)
|
|
261
702
|
*/
|
|
262
|
-
async executeInConnection(connection, query, parameters) {
|
|
703
|
+
async executeInConnection(connection, query, parameters, queryOptions) {
|
|
263
704
|
this.scheduleResourceTick();
|
|
264
705
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
265
706
|
const startTime = performance.now();
|
|
266
707
|
try {
|
|
267
|
-
const
|
|
708
|
+
const options = this.getQueryOption(queryOptions);
|
|
709
|
+
const [result] = await connection.execute({ ...options, sql: processedQuery }, processedParams);
|
|
268
710
|
const executionTime = performance.now() - startTime;
|
|
269
711
|
this.logQuery(processedQuery, executionTime, processedParams);
|
|
270
712
|
return result;
|
|
@@ -276,6 +718,7 @@ export class MySQL {
|
|
|
276
718
|
}
|
|
277
719
|
}
|
|
278
720
|
async executeTransaction(callback) {
|
|
721
|
+
await this.ensureInitialized();
|
|
279
722
|
this.scheduleResourceTick();
|
|
280
723
|
const connection = await this.pool.getConnection();
|
|
281
724
|
try {
|
|
@@ -317,26 +760,26 @@ export class MySQL {
|
|
|
317
760
|
}
|
|
318
761
|
const isSlowQuery = executionTime >= this.slowQueryThreshold;
|
|
319
762
|
if (this.debug || isSlowQuery || error) {
|
|
320
|
-
const resourceName = this.resourceName ||
|
|
763
|
+
const resourceName = this.resourceName || "pma-sql";
|
|
321
764
|
let color;
|
|
322
765
|
let statusText;
|
|
323
766
|
if (error) {
|
|
324
|
-
color =
|
|
767
|
+
color = "^1";
|
|
325
768
|
statusText = `${this.versionPrefix}${color}Query failed^0`;
|
|
326
769
|
}
|
|
327
770
|
else {
|
|
328
|
-
color = isSlowQuery ?
|
|
771
|
+
color = isSlowQuery ? "^3" : "^2";
|
|
329
772
|
statusText = `${this.versionPrefix}${color}${resourceName} took ${executionTime.toFixed(4)}ms to execute a query!^0`;
|
|
330
773
|
}
|
|
331
774
|
console.log(statusText);
|
|
332
|
-
console.log(`${color}${query} [${parameters && parameters.length > 0 ? parameters.join(
|
|
775
|
+
console.log(`${color}${query} [${parameters && parameters.length > 0 ? parameters.join(",") : ""}]^0`);
|
|
333
776
|
if (error) {
|
|
334
777
|
console.log(`^1Error: ${error.message}^0`);
|
|
335
778
|
}
|
|
336
779
|
}
|
|
337
780
|
}
|
|
338
781
|
handleError(error) {
|
|
339
|
-
if (typeof globalThis.printError ===
|
|
782
|
+
if (typeof globalThis.printError === "function") {
|
|
340
783
|
globalThis.printError(error);
|
|
341
784
|
}
|
|
342
785
|
else {
|
|
@@ -354,13 +797,8 @@ export class MySQL {
|
|
|
354
797
|
* @example
|
|
355
798
|
* const result = await db.rawExecute('SELECT * FROM users WHERE uid = ?', [1]);
|
|
356
799
|
*/
|
|
357
|
-
async rawExecute(query, parameters) {
|
|
358
|
-
this.
|
|
359
|
-
const params = parameters || [];
|
|
360
|
-
return this.executeWithTiming(query, params, async () => {
|
|
361
|
-
const [results] = await this.pool.execute(query, params);
|
|
362
|
-
return results;
|
|
363
|
-
});
|
|
800
|
+
async rawExecute(query, parameters, queryOptions) {
|
|
801
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result, queryOptions);
|
|
364
802
|
}
|
|
365
803
|
/**
|
|
366
804
|
* Executes a SQL query and returns the raw results.
|
|
@@ -369,13 +807,14 @@ export class MySQL {
|
|
|
369
807
|
*
|
|
370
808
|
* @param query - SQL query with :name or ? placeholders
|
|
371
809
|
* @param parameters - Parameter values as object or array
|
|
810
|
+
* @param queryOptions - Query options to use for this query
|
|
372
811
|
* @returns Query result
|
|
373
812
|
* @example
|
|
374
813
|
* const result = await db.execute('SELECT * FROM users WHERE uid = :uid', { uid: 1 });
|
|
375
814
|
*/
|
|
376
|
-
async execute(query, parameters) {
|
|
815
|
+
async execute(query, parameters, queryOptions) {
|
|
377
816
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
378
|
-
return this.rawExecute(processedQuery, processedParams);
|
|
817
|
+
return this.rawExecute(processedQuery, processedParams, queryOptions);
|
|
379
818
|
}
|
|
380
819
|
/**
|
|
381
820
|
* Executes a SELECT query with raw positional parameters.
|
|
@@ -384,17 +823,13 @@ export class MySQL {
|
|
|
384
823
|
*
|
|
385
824
|
* @param query - SELECT query with ? placeholders only
|
|
386
825
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
826
|
+
* @param queryOptions - Query options to use for this query
|
|
387
827
|
* @returns Array of rows
|
|
388
828
|
* @example
|
|
389
829
|
* const users = await db.rawQuery('SELECT * FROM users WHERE job_name = ?', ['police']);
|
|
390
830
|
*/
|
|
391
|
-
async rawQuery(query, parameters) {
|
|
392
|
-
this.
|
|
393
|
-
const params = parameters || [];
|
|
394
|
-
return this.executeWithTiming(query, params, async () => {
|
|
395
|
-
const [rows] = await this.pool.query(query, params);
|
|
396
|
-
return rows;
|
|
397
|
-
});
|
|
831
|
+
async rawQuery(query, parameters, queryOptions) {
|
|
832
|
+
return this.executePoolMethod("query", query, parameters || [], (result) => result, queryOptions);
|
|
398
833
|
}
|
|
399
834
|
/**
|
|
400
835
|
* Executes a SELECT query and returns rows.
|
|
@@ -407,9 +842,9 @@ export class MySQL {
|
|
|
407
842
|
* @example
|
|
408
843
|
* const users = await db.query('SELECT * FROM users WHERE job_name = :job', { job: 'police' });
|
|
409
844
|
*/
|
|
410
|
-
async query(query, parameters) {
|
|
845
|
+
async query(query, parameters, queryOptions) {
|
|
411
846
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
412
|
-
return this.rawQuery(processedQuery, processedParams);
|
|
847
|
+
return this.rawQuery(processedQuery, processedParams, queryOptions);
|
|
413
848
|
}
|
|
414
849
|
/**
|
|
415
850
|
* Inserts a row with raw positional parameters.
|
|
@@ -418,17 +853,13 @@ export class MySQL {
|
|
|
418
853
|
*
|
|
419
854
|
* @param query - INSERT query with ? placeholders only
|
|
420
855
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
856
|
+
* @param queryOptions - Query options to use for this query
|
|
421
857
|
* @returns Insert ID or null on error
|
|
422
858
|
* @example
|
|
423
859
|
* const insertId = await db.rawInsert('INSERT INTO users (identifier_id, char_data_id, first_name, last_name, inventory_id) VALUES (?, ?, ?, ?, ?)', [1, 1, 'John', 'Doe', 1]);
|
|
424
860
|
*/
|
|
425
|
-
async rawInsert(query, parameters) {
|
|
426
|
-
this.
|
|
427
|
-
const params = parameters || [];
|
|
428
|
-
return this.executeWithTiming(query, params, async () => {
|
|
429
|
-
const [result] = await this.pool.execute(query, params);
|
|
430
|
-
return result.insertId;
|
|
431
|
-
});
|
|
861
|
+
async rawInsert(query, parameters, queryOptions) {
|
|
862
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result.insertId, queryOptions);
|
|
432
863
|
}
|
|
433
864
|
/**
|
|
434
865
|
* Inserts a row into the database.
|
|
@@ -437,6 +868,7 @@ export class MySQL {
|
|
|
437
868
|
*
|
|
438
869
|
* @param query - INSERT query with :name or ? placeholders
|
|
439
870
|
* @param parameters - Values to insert
|
|
871
|
+
* @param queryOptions - Query options to use for this query
|
|
440
872
|
* @returns Insert ID or null on error
|
|
441
873
|
* @example
|
|
442
874
|
* const insertId = await db.insert(
|
|
@@ -444,9 +876,9 @@ export class MySQL {
|
|
|
444
876
|
* { identifier_id: 1, char_data_id: 1, first_name: 'John', last_name: 'Doe', inventory_id: 1 }
|
|
445
877
|
* );
|
|
446
878
|
*/
|
|
447
|
-
async insert(query, parameters) {
|
|
879
|
+
async insert(query, parameters, queryOptions) {
|
|
448
880
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
449
|
-
return this.rawInsert(processedQuery, processedParams);
|
|
881
|
+
return this.rawInsert(processedQuery, processedParams, queryOptions);
|
|
450
882
|
}
|
|
451
883
|
/**
|
|
452
884
|
* Updates rows with raw positional parameters.
|
|
@@ -455,17 +887,13 @@ export class MySQL {
|
|
|
455
887
|
*
|
|
456
888
|
* @param query - UPDATE query with ? placeholders only
|
|
457
889
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
890
|
+
* @param queryOptions - Query options to use for this query
|
|
458
891
|
* @returns Number of affected rows or null on error
|
|
459
892
|
* @example
|
|
460
893
|
* const affectedRows = await db.rawUpdate('UPDATE users SET job_name = ?, job_rank = ? WHERE uid = ?', ['police', 1, 123]);
|
|
461
894
|
*/
|
|
462
|
-
async rawUpdate(query, parameters) {
|
|
463
|
-
this.
|
|
464
|
-
const params = parameters || [];
|
|
465
|
-
return this.executeWithTiming(query, params, async () => {
|
|
466
|
-
const [result] = await this.pool.execute(query, params);
|
|
467
|
-
return result.affectedRows;
|
|
468
|
-
});
|
|
895
|
+
async rawUpdate(query, parameters, queryOptions) {
|
|
896
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result.affectedRows, queryOptions);
|
|
469
897
|
}
|
|
470
898
|
/**
|
|
471
899
|
* Updates rows in the database.
|
|
@@ -474,6 +902,7 @@ export class MySQL {
|
|
|
474
902
|
*
|
|
475
903
|
* @param query - UPDATE query with :name or ? placeholders
|
|
476
904
|
* @param parameters - Values to update
|
|
905
|
+
* @param queryOptions - Query options to use for this query
|
|
477
906
|
* @returns Number of affected rows or null on error
|
|
478
907
|
* @example
|
|
479
908
|
* const affectedRows = await db.update(
|
|
@@ -481,9 +910,9 @@ export class MySQL {
|
|
|
481
910
|
* { job: 'police', rank: 1, uid: 123 }
|
|
482
911
|
* );
|
|
483
912
|
*/
|
|
484
|
-
async update(query, parameters) {
|
|
913
|
+
async update(query, parameters, queryOptions) {
|
|
485
914
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
486
|
-
return this.rawUpdate(processedQuery, processedParams);
|
|
915
|
+
return this.rawUpdate(processedQuery, processedParams, queryOptions);
|
|
487
916
|
}
|
|
488
917
|
/**
|
|
489
918
|
* Returns a single value with raw positional parameters.
|
|
@@ -492,22 +921,19 @@ export class MySQL {
|
|
|
492
921
|
*
|
|
493
922
|
* @param query - SELECT query with ? placeholders only, returning one column
|
|
494
923
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
924
|
+
* @param queryOptions - Query options to use for this query
|
|
495
925
|
* @returns Value from first column of first row, or null
|
|
496
926
|
* @example
|
|
497
927
|
* const count = await db.rawScalar('SELECT COUNT(*) FROM users WHERE age > ?', [18]);
|
|
498
928
|
*/
|
|
499
|
-
async rawScalar(query, parameters) {
|
|
500
|
-
this.
|
|
501
|
-
const params = parameters || [];
|
|
502
|
-
return this.executeWithTiming(query, params, async () => {
|
|
503
|
-
const [rows] = await this.pool.query(query, params);
|
|
929
|
+
async rawScalar(query, parameters, queryOptions) {
|
|
930
|
+
return this.executePoolMethod("query", query, parameters || [], (rows) => {
|
|
504
931
|
if (!rows || rows.length === 0) {
|
|
505
932
|
return null;
|
|
506
933
|
}
|
|
507
934
|
const firstRow = rows[0];
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
});
|
|
935
|
+
return Object.values(firstRow)[0];
|
|
936
|
+
}, queryOptions);
|
|
511
937
|
}
|
|
512
938
|
/**
|
|
513
939
|
* Returns a single value from the database.
|
|
@@ -516,14 +942,15 @@ export class MySQL {
|
|
|
516
942
|
*
|
|
517
943
|
* @param query - SELECT query returning one column
|
|
518
944
|
* @param parameters - Parameter values
|
|
945
|
+
* @param queryOptions - Query options to use for this query
|
|
519
946
|
* @returns Value from first column of first row, or null
|
|
520
947
|
* @example
|
|
521
948
|
* const count = await db.scalar('SELECT COUNT(*) FROM users');
|
|
522
949
|
* const name = await db.scalar('SELECT name FROM users WHERE id = :id', { id: 1 });
|
|
523
950
|
*/
|
|
524
|
-
async scalar(query, parameters) {
|
|
951
|
+
async scalar(query, parameters, queryOptions) {
|
|
525
952
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
526
|
-
return this.rawScalar(processedQuery, processedParams);
|
|
953
|
+
return this.rawScalar(processedQuery, processedParams, queryOptions);
|
|
527
954
|
}
|
|
528
955
|
/**
|
|
529
956
|
* Returns a single row with raw positional parameters.
|
|
@@ -532,17 +959,13 @@ export class MySQL {
|
|
|
532
959
|
*
|
|
533
960
|
* @param query - SELECT query with ? placeholders only
|
|
534
961
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
962
|
+
* @param queryOptions - Query options to use for this query
|
|
535
963
|
* @returns Single row or null
|
|
536
964
|
* @example
|
|
537
965
|
* const user = await db.rawSingle('SELECT * FROM users WHERE id = ?', [1]);
|
|
538
966
|
*/
|
|
539
|
-
async rawSingle(query, parameters) {
|
|
540
|
-
this.
|
|
541
|
-
const params = parameters || [];
|
|
542
|
-
return this.executeWithTiming(query, params, async () => {
|
|
543
|
-
const [rows] = await this.pool.query(query, params);
|
|
544
|
-
return rows.length > 0 ? rows[0] : null;
|
|
545
|
-
});
|
|
967
|
+
async rawSingle(query, parameters, queryOptions) {
|
|
968
|
+
return this.executePoolMethod("query", query, parameters || [], (rows) => (rows.length > 0 ? rows[0] : null), queryOptions);
|
|
546
969
|
}
|
|
547
970
|
/**
|
|
548
971
|
* Returns a single row from the database.
|
|
@@ -551,6 +974,7 @@ export class MySQL {
|
|
|
551
974
|
*
|
|
552
975
|
* @param query - SELECT query with :name or ? placeholders
|
|
553
976
|
* @param parameters - Parameter values
|
|
977
|
+
* @param queryOptions - Query options to use for this query
|
|
554
978
|
* @returns Single row or null
|
|
555
979
|
* @example
|
|
556
980
|
* const user = await db.single('SELECT * FROM users WHERE id = :id', { id: 1 });
|
|
@@ -558,9 +982,9 @@ export class MySQL {
|
|
|
558
982
|
* console.log(user.name);
|
|
559
983
|
* }
|
|
560
984
|
*/
|
|
561
|
-
async single(query, parameters) {
|
|
985
|
+
async single(query, parameters, queryOptions) {
|
|
562
986
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
563
|
-
return this.rawSingle(processedQuery, processedParams);
|
|
987
|
+
return this.rawSingle(processedQuery, processedParams, queryOptions);
|
|
564
988
|
}
|
|
565
989
|
/**
|
|
566
990
|
* Executes multiple queries as an atomic transaction.
|
|
@@ -625,10 +1049,11 @@ export class MySQL {
|
|
|
625
1049
|
* }
|
|
626
1050
|
*/
|
|
627
1051
|
async getConnection() {
|
|
1052
|
+
await this.ensureInitialized();
|
|
628
1053
|
return this.pool.getConnection();
|
|
629
1054
|
}
|
|
630
1055
|
/**
|
|
631
|
-
* Closes all connections in the pool.
|
|
1056
|
+
* Closes all connections in the pool and SSH tunnel if active.
|
|
632
1057
|
* Call when shutting down the application.
|
|
633
1058
|
*
|
|
634
1059
|
* @throws {Error} If pool fails to close connections
|
|
@@ -636,7 +1061,12 @@ export class MySQL {
|
|
|
636
1061
|
* await db.end();
|
|
637
1062
|
*/
|
|
638
1063
|
async end() {
|
|
1064
|
+
await this.ensureInitialized();
|
|
639
1065
|
await this.pool.end();
|
|
1066
|
+
if (this.sshProcess) {
|
|
1067
|
+
this.sshProcess.kill();
|
|
1068
|
+
this.sshProcess = null;
|
|
1069
|
+
}
|
|
640
1070
|
}
|
|
641
1071
|
/**
|
|
642
1072
|
* Returns the underlying mysql2 connection pool.
|