@pma-network/sql 1.1.0 → 1.2.1
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 +118 -16
- package/dist/MySQL.d.ts.map +1 -1
- package/dist/MySQL.js +530 -104
- 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/types.d.ts +74 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/MySQL.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
1
3
|
import { performance } from "node:perf_hooks";
|
|
2
4
|
import mysql from "mysql2/promise";
|
|
3
5
|
import namedPlaceholders from "named-placeholders";
|
|
@@ -16,10 +18,21 @@ export class MySQL {
|
|
|
16
18
|
versionPrefix = "";
|
|
17
19
|
metricsExportResource;
|
|
18
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;
|
|
19
30
|
/**
|
|
20
31
|
* Creates a new MySQL instance with connection pooling.
|
|
21
32
|
* If no config is provided, it will automatically look for connection strings in environment variables.
|
|
22
33
|
*
|
|
34
|
+
* SSH tunnel connections (mysql-ssh://) are supported and initialized automatically.
|
|
35
|
+
*
|
|
23
36
|
* @param config - Optional database configuration. If omitted, uses environment variables.
|
|
24
37
|
* @throws {Error} If no connection string is found or if connection string validation fails
|
|
25
38
|
*/
|
|
@@ -31,20 +44,396 @@ export class MySQL {
|
|
|
31
44
|
this.metricsExportResource = this.getConvar("mysql_metrics_export_resource");
|
|
32
45
|
this.metricsExportFunction = this.getConvar("mysql_metrics_export_function");
|
|
33
46
|
this.setupConvarListeners();
|
|
47
|
+
this.namedPlaceholdersCompiler = namedPlaceholders();
|
|
34
48
|
const finalConfig = config || this.getDefaultConfig();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
38
307
|
this.pool = mysql.createPool({
|
|
39
308
|
waitForConnections: true,
|
|
40
309
|
connectionLimit: 10,
|
|
41
310
|
queueLimit: 0,
|
|
42
|
-
idleTimeout: 300_000,
|
|
43
311
|
typeCast: typeCast,
|
|
44
312
|
...poolConfig,
|
|
45
313
|
});
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
48
437
|
}
|
|
49
438
|
async fetchServerVersion() {
|
|
50
439
|
try {
|
|
@@ -109,20 +498,31 @@ export class MySQL {
|
|
|
109
498
|
getDefaultConfig() {
|
|
110
499
|
let connectionString = null;
|
|
111
500
|
const resourceName = this.getResourceName();
|
|
112
|
-
|
|
113
|
-
|
|
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) {
|
|
114
513
|
connectionString = this.getConvar(`${resourceConvar}_connection_string`);
|
|
115
514
|
}
|
|
116
515
|
if (!connectionString) {
|
|
117
516
|
connectionString = this.getConvar("mysql_connection_string");
|
|
118
517
|
}
|
|
119
518
|
if (!connectionString) {
|
|
120
|
-
const resourceHint =
|
|
121
|
-
? `\nTried: ${
|
|
122
|
-
: "\nTried: mysql_connection_string";
|
|
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";
|
|
123
522
|
throw new Error("No MySQL connection string found. " +
|
|
124
523
|
"Please set a connection string: " +
|
|
125
|
-
"mysql://user:password@host:port/database" +
|
|
524
|
+
"mysql://user:password@host:port/database or " +
|
|
525
|
+
"mysql-ssh://sshuser@sshhost/database?user=dbuser&password=dbpass" +
|
|
126
526
|
resourceHint);
|
|
127
527
|
}
|
|
128
528
|
this.validateConnectionString(connectionString);
|
|
@@ -139,11 +539,15 @@ export class MySQL {
|
|
|
139
539
|
* @throws {Error} If connection string format is invalid
|
|
140
540
|
*/
|
|
141
541
|
validateConnectionString(connectionString) {
|
|
542
|
+
// mysql-ssh:// URLs are validated separately during parsing
|
|
543
|
+
if (this.isSSHConnectionString(connectionString)) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
142
546
|
try {
|
|
143
547
|
const url = new URL(connectionString);
|
|
144
548
|
if (url.protocol !== "mysql:") {
|
|
145
|
-
throw new Error(`Invalid protocol "${url.protocol}". Expected "mysql:". ` +
|
|
146
|
-
"Connection string must start with mysql://");
|
|
549
|
+
throw new Error(`Invalid protocol "${url.protocol}". Expected "mysql:" or "mysql-ssh:". ` +
|
|
550
|
+
"Connection string must start with mysql:// or mysql-ssh://");
|
|
147
551
|
}
|
|
148
552
|
if (!url.hostname) {
|
|
149
553
|
throw new Error("Connection string must include a hostname");
|
|
@@ -171,29 +575,22 @@ export class MySQL {
|
|
|
171
575
|
*/
|
|
172
576
|
parseConnectionString(connectionString) {
|
|
173
577
|
const url = new URL(connectionString);
|
|
174
|
-
|
|
578
|
+
const connectionLimit = url.searchParams.get("connectionLimit");
|
|
579
|
+
const charset = url.searchParams.get("charset");
|
|
580
|
+
const options = {
|
|
175
581
|
host: url.hostname,
|
|
176
582
|
port: url.port ? parseInt(url.port, 10) : 3306,
|
|
177
583
|
user: url.username,
|
|
178
584
|
password: url.password,
|
|
179
585
|
database: url.pathname.slice(1),
|
|
180
586
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (Array.isArray(parameters)) {
|
|
184
|
-
for (let i = 0; i < parameters.length; i++) {
|
|
185
|
-
if (parameters[i] === undefined) {
|
|
186
|
-
parameters[i] = null;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
587
|
+
if (connectionLimit) {
|
|
588
|
+
options.connectionLimit = parseInt(connectionLimit, 10);
|
|
189
589
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (parameters[key] === undefined) {
|
|
193
|
-
parameters[key] = null;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
590
|
+
if (charset) {
|
|
591
|
+
options.charset = charset;
|
|
196
592
|
}
|
|
593
|
+
return options;
|
|
197
594
|
}
|
|
198
595
|
setupConvarListeners() {
|
|
199
596
|
if (typeof globalThis.AddConvarChangeListener === "function") {
|
|
@@ -227,20 +624,45 @@ export class MySQL {
|
|
|
227
624
|
globalThis.ScheduleResourceTick(this.resourceName);
|
|
228
625
|
}
|
|
229
626
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.logQuery(processedQuery, executionTime, processedParams);
|
|
236
|
-
return result;
|
|
627
|
+
isTimeoutError(error) {
|
|
628
|
+
if (error instanceof Error) {
|
|
629
|
+
const mysqlError = error;
|
|
630
|
+
return (mysqlError.code === "PROTOCOL_SEQUENCE_TIMEOUT" ||
|
|
631
|
+
mysqlError.message?.includes("timeout"));
|
|
237
632
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
async executePoolMethod(method, query, params, resultMapper, options) {
|
|
636
|
+
await this.ensureInitialized();
|
|
637
|
+
this.scheduleResourceTick();
|
|
638
|
+
const queryOptions = this.getQueryOption(options);
|
|
639
|
+
let lastError;
|
|
640
|
+
for (let attempt = 0; attempt <= queryOptions.retryCount; attempt++) {
|
|
641
|
+
const startTime = performance.now();
|
|
642
|
+
try {
|
|
643
|
+
const poolMethod = method === "execute"
|
|
644
|
+
? this.pool.execute.bind(this.pool)
|
|
645
|
+
: this.pool.query.bind(this.pool);
|
|
646
|
+
const [result] = await poolMethod({ timeout: queryOptions.timeout, sql: query }, params);
|
|
647
|
+
const executionTime = performance.now() - startTime;
|
|
648
|
+
this.logQuery(query, executionTime, params);
|
|
649
|
+
return resultMapper(result);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
lastError = error;
|
|
653
|
+
const executionTime = performance.now() - startTime;
|
|
654
|
+
if (this.isTimeoutError(error) && attempt < queryOptions.retryCount) {
|
|
655
|
+
this.logQuery(query, executionTime, params, error);
|
|
656
|
+
console.log(`^3Timeout on attempt ${attempt + 1}/${queryOptions.retryCount + 1}, retrying...^0`);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
this.logQuery(query, executionTime, params, error);
|
|
660
|
+
this.handleError(error);
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
243
663
|
}
|
|
664
|
+
this.handleError(lastError);
|
|
665
|
+
return null;
|
|
244
666
|
}
|
|
245
667
|
exportMetric(metric) {
|
|
246
668
|
if (this.metricsExportResource && this.metricsExportFunction) {
|
|
@@ -262,12 +684,13 @@ export class MySQL {
|
|
|
262
684
|
* @returns Query result
|
|
263
685
|
* @throws Query execution errors (SQL errors, syntax errors, constraint violations, etc.)
|
|
264
686
|
*/
|
|
265
|
-
async executeInConnection(connection, query, parameters) {
|
|
687
|
+
async executeInConnection(connection, query, parameters, queryOptions) {
|
|
266
688
|
this.scheduleResourceTick();
|
|
267
689
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
268
690
|
const startTime = performance.now();
|
|
269
691
|
try {
|
|
270
|
-
const
|
|
692
|
+
const options = this.getQueryOption(queryOptions);
|
|
693
|
+
const [result] = await connection.execute({ ...options, sql: processedQuery }, processedParams);
|
|
271
694
|
const executionTime = performance.now() - startTime;
|
|
272
695
|
this.logQuery(processedQuery, executionTime, processedParams);
|
|
273
696
|
return result;
|
|
@@ -279,6 +702,7 @@ export class MySQL {
|
|
|
279
702
|
}
|
|
280
703
|
}
|
|
281
704
|
async executeTransaction(callback) {
|
|
705
|
+
await this.ensureInitialized();
|
|
282
706
|
this.scheduleResourceTick();
|
|
283
707
|
const connection = await this.pool.getConnection();
|
|
284
708
|
try {
|
|
@@ -301,12 +725,22 @@ export class MySQL {
|
|
|
301
725
|
if (!parameters) {
|
|
302
726
|
return [processedQuery, []];
|
|
303
727
|
}
|
|
304
|
-
|
|
728
|
+
let finalQuery;
|
|
729
|
+
let finalParams;
|
|
305
730
|
if (Array.isArray(parameters)) {
|
|
306
|
-
|
|
731
|
+
finalQuery = processedQuery;
|
|
732
|
+
finalParams = parameters;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
[finalQuery, finalParams] = this.namedPlaceholdersCompiler(processedQuery, parameters);
|
|
307
736
|
}
|
|
308
|
-
|
|
309
|
-
|
|
737
|
+
// Normalize undefined to null in the final parameter array
|
|
738
|
+
for (let i = 0; i < finalParams.length; i++) {
|
|
739
|
+
if (finalParams[i] === undefined) {
|
|
740
|
+
finalParams[i] = null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return [finalQuery, finalParams];
|
|
310
744
|
}
|
|
311
745
|
logQuery(query, executionTime, parameters, error) {
|
|
312
746
|
if (this.collectMetrics) {
|
|
@@ -340,7 +774,12 @@ export class MySQL {
|
|
|
340
774
|
}
|
|
341
775
|
handleError(error) {
|
|
342
776
|
if (typeof globalThis.printError === "function") {
|
|
343
|
-
|
|
777
|
+
if (error instanceof Error) {
|
|
778
|
+
globalThis.printError(error);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
globalThis.printError(new Error(String(error)));
|
|
782
|
+
}
|
|
344
783
|
}
|
|
345
784
|
else {
|
|
346
785
|
throw error;
|
|
@@ -357,13 +796,8 @@ export class MySQL {
|
|
|
357
796
|
* @example
|
|
358
797
|
* const result = await db.rawExecute('SELECT * FROM users WHERE uid = ?', [1]);
|
|
359
798
|
*/
|
|
360
|
-
async rawExecute(query, parameters) {
|
|
361
|
-
this.
|
|
362
|
-
const params = parameters || [];
|
|
363
|
-
return this.executeWithTiming(query, params, async () => {
|
|
364
|
-
const [results] = await this.pool.execute(query, params);
|
|
365
|
-
return results;
|
|
366
|
-
});
|
|
799
|
+
async rawExecute(query, parameters, queryOptions) {
|
|
800
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result, queryOptions);
|
|
367
801
|
}
|
|
368
802
|
/**
|
|
369
803
|
* Executes a SQL query and returns the raw results.
|
|
@@ -372,13 +806,14 @@ export class MySQL {
|
|
|
372
806
|
*
|
|
373
807
|
* @param query - SQL query with :name or ? placeholders
|
|
374
808
|
* @param parameters - Parameter values as object or array
|
|
809
|
+
* @param queryOptions - Query options to use for this query
|
|
375
810
|
* @returns Query result
|
|
376
811
|
* @example
|
|
377
812
|
* const result = await db.execute('SELECT * FROM users WHERE uid = :uid', { uid: 1 });
|
|
378
813
|
*/
|
|
379
|
-
async execute(query, parameters) {
|
|
814
|
+
async execute(query, parameters, queryOptions) {
|
|
380
815
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
381
|
-
return this.rawExecute(processedQuery, processedParams);
|
|
816
|
+
return this.rawExecute(processedQuery, processedParams, queryOptions);
|
|
382
817
|
}
|
|
383
818
|
/**
|
|
384
819
|
* Executes a SELECT query with raw positional parameters.
|
|
@@ -387,17 +822,13 @@ export class MySQL {
|
|
|
387
822
|
*
|
|
388
823
|
* @param query - SELECT query with ? placeholders only
|
|
389
824
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
825
|
+
* @param queryOptions - Query options to use for this query
|
|
390
826
|
* @returns Array of rows
|
|
391
827
|
* @example
|
|
392
828
|
* const users = await db.rawQuery('SELECT * FROM users WHERE job_name = ?', ['police']);
|
|
393
829
|
*/
|
|
394
|
-
async rawQuery(query, parameters) {
|
|
395
|
-
this.
|
|
396
|
-
const params = parameters || [];
|
|
397
|
-
return this.executeWithTiming(query, params, async () => {
|
|
398
|
-
const [rows] = await this.pool.query(query, params);
|
|
399
|
-
return rows;
|
|
400
|
-
});
|
|
830
|
+
async rawQuery(query, parameters, queryOptions) {
|
|
831
|
+
return this.executePoolMethod("query", query, parameters || [], (result) => result, queryOptions);
|
|
401
832
|
}
|
|
402
833
|
/**
|
|
403
834
|
* Executes a SELECT query and returns rows.
|
|
@@ -410,9 +841,9 @@ export class MySQL {
|
|
|
410
841
|
* @example
|
|
411
842
|
* const users = await db.query('SELECT * FROM users WHERE job_name = :job', { job: 'police' });
|
|
412
843
|
*/
|
|
413
|
-
async query(query, parameters) {
|
|
844
|
+
async query(query, parameters, queryOptions) {
|
|
414
845
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
415
|
-
return this.rawQuery(processedQuery, processedParams);
|
|
846
|
+
return this.rawQuery(processedQuery, processedParams, queryOptions);
|
|
416
847
|
}
|
|
417
848
|
/**
|
|
418
849
|
* Inserts a row with raw positional parameters.
|
|
@@ -421,17 +852,13 @@ export class MySQL {
|
|
|
421
852
|
*
|
|
422
853
|
* @param query - INSERT query with ? placeholders only
|
|
423
854
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
855
|
+
* @param queryOptions - Query options to use for this query
|
|
424
856
|
* @returns Insert ID or null on error
|
|
425
857
|
* @example
|
|
426
858
|
* 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]);
|
|
427
859
|
*/
|
|
428
|
-
async rawInsert(query, parameters) {
|
|
429
|
-
this.
|
|
430
|
-
const params = parameters || [];
|
|
431
|
-
return this.executeWithTiming(query, params, async () => {
|
|
432
|
-
const [result] = await this.pool.execute(query, params);
|
|
433
|
-
return result.insertId;
|
|
434
|
-
});
|
|
860
|
+
async rawInsert(query, parameters, queryOptions) {
|
|
861
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result.insertId, queryOptions);
|
|
435
862
|
}
|
|
436
863
|
/**
|
|
437
864
|
* Inserts a row into the database.
|
|
@@ -440,6 +867,7 @@ export class MySQL {
|
|
|
440
867
|
*
|
|
441
868
|
* @param query - INSERT query with :name or ? placeholders
|
|
442
869
|
* @param parameters - Values to insert
|
|
870
|
+
* @param queryOptions - Query options to use for this query
|
|
443
871
|
* @returns Insert ID or null on error
|
|
444
872
|
* @example
|
|
445
873
|
* const insertId = await db.insert(
|
|
@@ -447,9 +875,9 @@ export class MySQL {
|
|
|
447
875
|
* { identifier_id: 1, char_data_id: 1, first_name: 'John', last_name: 'Doe', inventory_id: 1 }
|
|
448
876
|
* );
|
|
449
877
|
*/
|
|
450
|
-
async insert(query, parameters) {
|
|
878
|
+
async insert(query, parameters, queryOptions) {
|
|
451
879
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
452
|
-
return this.rawInsert(processedQuery, processedParams);
|
|
880
|
+
return this.rawInsert(processedQuery, processedParams, queryOptions);
|
|
453
881
|
}
|
|
454
882
|
/**
|
|
455
883
|
* Updates rows with raw positional parameters.
|
|
@@ -458,17 +886,13 @@ export class MySQL {
|
|
|
458
886
|
*
|
|
459
887
|
* @param query - UPDATE query with ? placeholders only
|
|
460
888
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
889
|
+
* @param queryOptions - Query options to use for this query
|
|
461
890
|
* @returns Number of affected rows or null on error
|
|
462
891
|
* @example
|
|
463
892
|
* const affectedRows = await db.rawUpdate('UPDATE users SET job_name = ?, job_rank = ? WHERE uid = ?', ['police', 1, 123]);
|
|
464
893
|
*/
|
|
465
|
-
async rawUpdate(query, parameters) {
|
|
466
|
-
this.
|
|
467
|
-
const params = parameters || [];
|
|
468
|
-
return this.executeWithTiming(query, params, async () => {
|
|
469
|
-
const [result] = await this.pool.execute(query, params);
|
|
470
|
-
return result.affectedRows;
|
|
471
|
-
});
|
|
894
|
+
async rawUpdate(query, parameters, queryOptions) {
|
|
895
|
+
return this.executePoolMethod("execute", query, parameters || [], (result) => result.affectedRows, queryOptions);
|
|
472
896
|
}
|
|
473
897
|
/**
|
|
474
898
|
* Updates rows in the database.
|
|
@@ -477,6 +901,7 @@ export class MySQL {
|
|
|
477
901
|
*
|
|
478
902
|
* @param query - UPDATE query with :name or ? placeholders
|
|
479
903
|
* @param parameters - Values to update
|
|
904
|
+
* @param queryOptions - Query options to use for this query
|
|
480
905
|
* @returns Number of affected rows or null on error
|
|
481
906
|
* @example
|
|
482
907
|
* const affectedRows = await db.update(
|
|
@@ -484,9 +909,9 @@ export class MySQL {
|
|
|
484
909
|
* { job: 'police', rank: 1, uid: 123 }
|
|
485
910
|
* );
|
|
486
911
|
*/
|
|
487
|
-
async update(query, parameters) {
|
|
912
|
+
async update(query, parameters, queryOptions) {
|
|
488
913
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
489
|
-
return this.rawUpdate(processedQuery, processedParams);
|
|
914
|
+
return this.rawUpdate(processedQuery, processedParams, queryOptions);
|
|
490
915
|
}
|
|
491
916
|
/**
|
|
492
917
|
* Returns a single value with raw positional parameters.
|
|
@@ -495,22 +920,19 @@ export class MySQL {
|
|
|
495
920
|
*
|
|
496
921
|
* @param query - SELECT query with ? placeholders only, returning one column
|
|
497
922
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
923
|
+
* @param queryOptions - Query options to use for this query
|
|
498
924
|
* @returns Value from first column of first row, or null
|
|
499
925
|
* @example
|
|
500
926
|
* const count = await db.rawScalar('SELECT COUNT(*) FROM users WHERE age > ?', [18]);
|
|
501
927
|
*/
|
|
502
|
-
async rawScalar(query, parameters) {
|
|
503
|
-
this.
|
|
504
|
-
const params = parameters || [];
|
|
505
|
-
return this.executeWithTiming(query, params, async () => {
|
|
506
|
-
const [rows] = await this.pool.query(query, params);
|
|
928
|
+
async rawScalar(query, parameters, queryOptions) {
|
|
929
|
+
return this.executePoolMethod("query", query, parameters || [], (rows) => {
|
|
507
930
|
if (!rows || rows.length === 0) {
|
|
508
931
|
return null;
|
|
509
932
|
}
|
|
510
933
|
const firstRow = rows[0];
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
});
|
|
934
|
+
return Object.values(firstRow)[0];
|
|
935
|
+
}, queryOptions);
|
|
514
936
|
}
|
|
515
937
|
/**
|
|
516
938
|
* Returns a single value from the database.
|
|
@@ -519,14 +941,15 @@ export class MySQL {
|
|
|
519
941
|
*
|
|
520
942
|
* @param query - SELECT query returning one column
|
|
521
943
|
* @param parameters - Parameter values
|
|
944
|
+
* @param queryOptions - Query options to use for this query
|
|
522
945
|
* @returns Value from first column of first row, or null
|
|
523
946
|
* @example
|
|
524
947
|
* const count = await db.scalar('SELECT COUNT(*) FROM users');
|
|
525
948
|
* const name = await db.scalar('SELECT name FROM users WHERE id = :id', { id: 1 });
|
|
526
949
|
*/
|
|
527
|
-
async scalar(query, parameters) {
|
|
950
|
+
async scalar(query, parameters, queryOptions) {
|
|
528
951
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
529
|
-
return this.rawScalar(processedQuery, processedParams);
|
|
952
|
+
return this.rawScalar(processedQuery, processedParams, queryOptions);
|
|
530
953
|
}
|
|
531
954
|
/**
|
|
532
955
|
* Returns a single row with raw positional parameters.
|
|
@@ -535,17 +958,13 @@ export class MySQL {
|
|
|
535
958
|
*
|
|
536
959
|
* @param query - SELECT query with ? placeholders only
|
|
537
960
|
* @param parameters - Array of values (undefined values are NOT converted to null)
|
|
961
|
+
* @param queryOptions - Query options to use for this query
|
|
538
962
|
* @returns Single row or null
|
|
539
963
|
* @example
|
|
540
964
|
* const user = await db.rawSingle('SELECT * FROM users WHERE id = ?', [1]);
|
|
541
965
|
*/
|
|
542
|
-
async rawSingle(query, parameters) {
|
|
543
|
-
this.
|
|
544
|
-
const params = parameters || [];
|
|
545
|
-
return this.executeWithTiming(query, params, async () => {
|
|
546
|
-
const [rows] = await this.pool.query(query, params);
|
|
547
|
-
return rows.length > 0 ? rows[0] : null;
|
|
548
|
-
});
|
|
966
|
+
async rawSingle(query, parameters, queryOptions) {
|
|
967
|
+
return this.executePoolMethod("query", query, parameters || [], (rows) => (rows.length > 0 ? rows[0] : null), queryOptions);
|
|
549
968
|
}
|
|
550
969
|
/**
|
|
551
970
|
* Returns a single row from the database.
|
|
@@ -554,6 +973,7 @@ export class MySQL {
|
|
|
554
973
|
*
|
|
555
974
|
* @param query - SELECT query with :name or ? placeholders
|
|
556
975
|
* @param parameters - Parameter values
|
|
976
|
+
* @param queryOptions - Query options to use for this query
|
|
557
977
|
* @returns Single row or null
|
|
558
978
|
* @example
|
|
559
979
|
* const user = await db.single('SELECT * FROM users WHERE id = :id', { id: 1 });
|
|
@@ -561,9 +981,9 @@ export class MySQL {
|
|
|
561
981
|
* console.log(user.name);
|
|
562
982
|
* }
|
|
563
983
|
*/
|
|
564
|
-
async single(query, parameters) {
|
|
984
|
+
async single(query, parameters, queryOptions) {
|
|
565
985
|
const [processedQuery, processedParams] = this.processParameters(query, parameters);
|
|
566
|
-
return this.rawSingle(processedQuery, processedParams);
|
|
986
|
+
return this.rawSingle(processedQuery, processedParams, queryOptions);
|
|
567
987
|
}
|
|
568
988
|
/**
|
|
569
989
|
* Executes multiple queries as an atomic transaction.
|
|
@@ -628,10 +1048,11 @@ export class MySQL {
|
|
|
628
1048
|
* }
|
|
629
1049
|
*/
|
|
630
1050
|
async getConnection() {
|
|
1051
|
+
await this.ensureInitialized();
|
|
631
1052
|
return this.pool.getConnection();
|
|
632
1053
|
}
|
|
633
1054
|
/**
|
|
634
|
-
* Closes all connections in the pool.
|
|
1055
|
+
* Closes all connections in the pool and SSH tunnel if active.
|
|
635
1056
|
* Call when shutting down the application.
|
|
636
1057
|
*
|
|
637
1058
|
* @throws {Error} If pool fails to close connections
|
|
@@ -639,7 +1060,12 @@ export class MySQL {
|
|
|
639
1060
|
* await db.end();
|
|
640
1061
|
*/
|
|
641
1062
|
async end() {
|
|
1063
|
+
await this.ensureInitialized();
|
|
642
1064
|
await this.pool.end();
|
|
1065
|
+
if (this.sshProcess) {
|
|
1066
|
+
this.sshProcess.kill();
|
|
1067
|
+
this.sshProcess = null;
|
|
1068
|
+
}
|
|
643
1069
|
}
|
|
644
1070
|
/**
|
|
645
1071
|
* Returns the underlying mysql2 connection pool.
|