@pma-network/sql 1.1.0 → 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.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
- const poolConfig = finalConfig.connectionString
36
- ? this.parseConnectionString(finalConfig.connectionString)
37
- : finalConfig;
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
- this.namedPlaceholdersCompiler = namedPlaceholders();
47
- this.fetchServerVersion();
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
- if (resourceName) {
113
- const resourceConvar = this.formatResourceConvar(resourceName);
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 = resourceName
121
- ? `\nTried: ${this.formatResourceConvar(resourceName)}_connection_string and mysql_connection_string`
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,13 +575,22 @@ export class MySQL {
171
575
  */
172
576
  parseConnectionString(connectionString) {
173
577
  const url = new URL(connectionString);
174
- return {
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
  };
587
+ if (connectionLimit) {
588
+ options.connectionLimit = parseInt(connectionLimit, 10);
589
+ }
590
+ if (charset) {
591
+ options.charset = charset;
592
+ }
593
+ return options;
181
594
  }
182
595
  normalizeParameters(parameters) {
183
596
  if (Array.isArray(parameters)) {
@@ -227,20 +640,45 @@ export class MySQL {
227
640
  globalThis.ScheduleResourceTick(this.resourceName);
228
641
  }
229
642
  }
230
- async executeWithTiming(processedQuery, processedParams, executor) {
231
- const startTime = performance.now();
232
- try {
233
- const result = await executor();
234
- const executionTime = performance.now() - startTime;
235
- this.logQuery(processedQuery, executionTime, processedParams);
236
- 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"));
237
648
  }
238
- catch (error) {
239
- const executionTime = performance.now() - startTime;
240
- this.logQuery(processedQuery, executionTime, processedParams, error);
241
- this.handleError(error);
242
- return null;
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
+ }
243
679
  }
680
+ this.handleError(lastError);
681
+ return null;
244
682
  }
245
683
  exportMetric(metric) {
246
684
  if (this.metricsExportResource && this.metricsExportFunction) {
@@ -262,12 +700,13 @@ export class MySQL {
262
700
  * @returns Query result
263
701
  * @throws Query execution errors (SQL errors, syntax errors, constraint violations, etc.)
264
702
  */
265
- async executeInConnection(connection, query, parameters) {
703
+ async executeInConnection(connection, query, parameters, queryOptions) {
266
704
  this.scheduleResourceTick();
267
705
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
268
706
  const startTime = performance.now();
269
707
  try {
270
- const [result] = await connection.execute(processedQuery, processedParams);
708
+ const options = this.getQueryOption(queryOptions);
709
+ const [result] = await connection.execute({ ...options, sql: processedQuery }, processedParams);
271
710
  const executionTime = performance.now() - startTime;
272
711
  this.logQuery(processedQuery, executionTime, processedParams);
273
712
  return result;
@@ -279,6 +718,7 @@ export class MySQL {
279
718
  }
280
719
  }
281
720
  async executeTransaction(callback) {
721
+ await this.ensureInitialized();
282
722
  this.scheduleResourceTick();
283
723
  const connection = await this.pool.getConnection();
284
724
  try {
@@ -357,13 +797,8 @@ export class MySQL {
357
797
  * @example
358
798
  * const result = await db.rawExecute('SELECT * FROM users WHERE uid = ?', [1]);
359
799
  */
360
- async rawExecute(query, parameters) {
361
- this.scheduleResourceTick();
362
- const params = parameters || [];
363
- return this.executeWithTiming(query, params, async () => {
364
- const [results] = await this.pool.execute(query, params);
365
- return results;
366
- });
800
+ async rawExecute(query, parameters, queryOptions) {
801
+ return this.executePoolMethod("execute", query, parameters || [], (result) => result, queryOptions);
367
802
  }
368
803
  /**
369
804
  * Executes a SQL query and returns the raw results.
@@ -372,13 +807,14 @@ export class MySQL {
372
807
  *
373
808
  * @param query - SQL query with :name or ? placeholders
374
809
  * @param parameters - Parameter values as object or array
810
+ * @param queryOptions - Query options to use for this query
375
811
  * @returns Query result
376
812
  * @example
377
813
  * const result = await db.execute('SELECT * FROM users WHERE uid = :uid', { uid: 1 });
378
814
  */
379
- async execute(query, parameters) {
815
+ async execute(query, parameters, queryOptions) {
380
816
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
381
- return this.rawExecute(processedQuery, processedParams);
817
+ return this.rawExecute(processedQuery, processedParams, queryOptions);
382
818
  }
383
819
  /**
384
820
  * Executes a SELECT query with raw positional parameters.
@@ -387,17 +823,13 @@ export class MySQL {
387
823
  *
388
824
  * @param query - SELECT query with ? placeholders only
389
825
  * @param parameters - Array of values (undefined values are NOT converted to null)
826
+ * @param queryOptions - Query options to use for this query
390
827
  * @returns Array of rows
391
828
  * @example
392
829
  * const users = await db.rawQuery('SELECT * FROM users WHERE job_name = ?', ['police']);
393
830
  */
394
- async rawQuery(query, parameters) {
395
- this.scheduleResourceTick();
396
- const params = parameters || [];
397
- return this.executeWithTiming(query, params, async () => {
398
- const [rows] = await this.pool.query(query, params);
399
- return rows;
400
- });
831
+ async rawQuery(query, parameters, queryOptions) {
832
+ return this.executePoolMethod("query", query, parameters || [], (result) => result, queryOptions);
401
833
  }
402
834
  /**
403
835
  * Executes a SELECT query and returns rows.
@@ -410,9 +842,9 @@ export class MySQL {
410
842
  * @example
411
843
  * const users = await db.query('SELECT * FROM users WHERE job_name = :job', { job: 'police' });
412
844
  */
413
- async query(query, parameters) {
845
+ async query(query, parameters, queryOptions) {
414
846
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
415
- return this.rawQuery(processedQuery, processedParams);
847
+ return this.rawQuery(processedQuery, processedParams, queryOptions);
416
848
  }
417
849
  /**
418
850
  * Inserts a row with raw positional parameters.
@@ -421,17 +853,13 @@ export class MySQL {
421
853
  *
422
854
  * @param query - INSERT query with ? placeholders only
423
855
  * @param parameters - Array of values (undefined values are NOT converted to null)
856
+ * @param queryOptions - Query options to use for this query
424
857
  * @returns Insert ID or null on error
425
858
  * @example
426
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]);
427
860
  */
428
- async rawInsert(query, parameters) {
429
- this.scheduleResourceTick();
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
- });
861
+ async rawInsert(query, parameters, queryOptions) {
862
+ return this.executePoolMethod("execute", query, parameters || [], (result) => result.insertId, queryOptions);
435
863
  }
436
864
  /**
437
865
  * Inserts a row into the database.
@@ -440,6 +868,7 @@ export class MySQL {
440
868
  *
441
869
  * @param query - INSERT query with :name or ? placeholders
442
870
  * @param parameters - Values to insert
871
+ * @param queryOptions - Query options to use for this query
443
872
  * @returns Insert ID or null on error
444
873
  * @example
445
874
  * const insertId = await db.insert(
@@ -447,9 +876,9 @@ export class MySQL {
447
876
  * { identifier_id: 1, char_data_id: 1, first_name: 'John', last_name: 'Doe', inventory_id: 1 }
448
877
  * );
449
878
  */
450
- async insert(query, parameters) {
879
+ async insert(query, parameters, queryOptions) {
451
880
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
452
- return this.rawInsert(processedQuery, processedParams);
881
+ return this.rawInsert(processedQuery, processedParams, queryOptions);
453
882
  }
454
883
  /**
455
884
  * Updates rows with raw positional parameters.
@@ -458,17 +887,13 @@ export class MySQL {
458
887
  *
459
888
  * @param query - UPDATE query with ? placeholders only
460
889
  * @param parameters - Array of values (undefined values are NOT converted to null)
890
+ * @param queryOptions - Query options to use for this query
461
891
  * @returns Number of affected rows or null on error
462
892
  * @example
463
893
  * const affectedRows = await db.rawUpdate('UPDATE users SET job_name = ?, job_rank = ? WHERE uid = ?', ['police', 1, 123]);
464
894
  */
465
- async rawUpdate(query, parameters) {
466
- this.scheduleResourceTick();
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
- });
895
+ async rawUpdate(query, parameters, queryOptions) {
896
+ return this.executePoolMethod("execute", query, parameters || [], (result) => result.affectedRows, queryOptions);
472
897
  }
473
898
  /**
474
899
  * Updates rows in the database.
@@ -477,6 +902,7 @@ export class MySQL {
477
902
  *
478
903
  * @param query - UPDATE query with :name or ? placeholders
479
904
  * @param parameters - Values to update
905
+ * @param queryOptions - Query options to use for this query
480
906
  * @returns Number of affected rows or null on error
481
907
  * @example
482
908
  * const affectedRows = await db.update(
@@ -484,9 +910,9 @@ export class MySQL {
484
910
  * { job: 'police', rank: 1, uid: 123 }
485
911
  * );
486
912
  */
487
- async update(query, parameters) {
913
+ async update(query, parameters, queryOptions) {
488
914
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
489
- return this.rawUpdate(processedQuery, processedParams);
915
+ return this.rawUpdate(processedQuery, processedParams, queryOptions);
490
916
  }
491
917
  /**
492
918
  * Returns a single value with raw positional parameters.
@@ -495,22 +921,19 @@ export class MySQL {
495
921
  *
496
922
  * @param query - SELECT query with ? placeholders only, returning one column
497
923
  * @param parameters - Array of values (undefined values are NOT converted to null)
924
+ * @param queryOptions - Query options to use for this query
498
925
  * @returns Value from first column of first row, or null
499
926
  * @example
500
927
  * const count = await db.rawScalar('SELECT COUNT(*) FROM users WHERE age > ?', [18]);
501
928
  */
502
- async rawScalar(query, parameters) {
503
- this.scheduleResourceTick();
504
- const params = parameters || [];
505
- return this.executeWithTiming(query, params, async () => {
506
- const [rows] = await this.pool.query(query, params);
929
+ async rawScalar(query, parameters, queryOptions) {
930
+ return this.executePoolMethod("query", query, parameters || [], (rows) => {
507
931
  if (!rows || rows.length === 0) {
508
932
  return null;
509
933
  }
510
934
  const firstRow = rows[0];
511
- const firstColumn = Object.values(firstRow)[0];
512
- return firstColumn;
513
- });
935
+ return Object.values(firstRow)[0];
936
+ }, queryOptions);
514
937
  }
515
938
  /**
516
939
  * Returns a single value from the database.
@@ -519,14 +942,15 @@ export class MySQL {
519
942
  *
520
943
  * @param query - SELECT query returning one column
521
944
  * @param parameters - Parameter values
945
+ * @param queryOptions - Query options to use for this query
522
946
  * @returns Value from first column of first row, or null
523
947
  * @example
524
948
  * const count = await db.scalar('SELECT COUNT(*) FROM users');
525
949
  * const name = await db.scalar('SELECT name FROM users WHERE id = :id', { id: 1 });
526
950
  */
527
- async scalar(query, parameters) {
951
+ async scalar(query, parameters, queryOptions) {
528
952
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
529
- return this.rawScalar(processedQuery, processedParams);
953
+ return this.rawScalar(processedQuery, processedParams, queryOptions);
530
954
  }
531
955
  /**
532
956
  * Returns a single row with raw positional parameters.
@@ -535,17 +959,13 @@ export class MySQL {
535
959
  *
536
960
  * @param query - SELECT query with ? placeholders only
537
961
  * @param parameters - Array of values (undefined values are NOT converted to null)
962
+ * @param queryOptions - Query options to use for this query
538
963
  * @returns Single row or null
539
964
  * @example
540
965
  * const user = await db.rawSingle('SELECT * FROM users WHERE id = ?', [1]);
541
966
  */
542
- async rawSingle(query, parameters) {
543
- this.scheduleResourceTick();
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
- });
967
+ async rawSingle(query, parameters, queryOptions) {
968
+ return this.executePoolMethod("query", query, parameters || [], (rows) => (rows.length > 0 ? rows[0] : null), queryOptions);
549
969
  }
550
970
  /**
551
971
  * Returns a single row from the database.
@@ -554,6 +974,7 @@ export class MySQL {
554
974
  *
555
975
  * @param query - SELECT query with :name or ? placeholders
556
976
  * @param parameters - Parameter values
977
+ * @param queryOptions - Query options to use for this query
557
978
  * @returns Single row or null
558
979
  * @example
559
980
  * const user = await db.single('SELECT * FROM users WHERE id = :id', { id: 1 });
@@ -561,9 +982,9 @@ export class MySQL {
561
982
  * console.log(user.name);
562
983
  * }
563
984
  */
564
- async single(query, parameters) {
985
+ async single(query, parameters, queryOptions) {
565
986
  const [processedQuery, processedParams] = this.processParameters(query, parameters);
566
- return this.rawSingle(processedQuery, processedParams);
987
+ return this.rawSingle(processedQuery, processedParams, queryOptions);
567
988
  }
568
989
  /**
569
990
  * Executes multiple queries as an atomic transaction.
@@ -628,10 +1049,11 @@ export class MySQL {
628
1049
  * }
629
1050
  */
630
1051
  async getConnection() {
1052
+ await this.ensureInitialized();
631
1053
  return this.pool.getConnection();
632
1054
  }
633
1055
  /**
634
- * Closes all connections in the pool.
1056
+ * Closes all connections in the pool and SSH tunnel if active.
635
1057
  * Call when shutting down the application.
636
1058
  *
637
1059
  * @throws {Error} If pool fails to close connections
@@ -639,7 +1061,12 @@ export class MySQL {
639
1061
  * await db.end();
640
1062
  */
641
1063
  async end() {
1064
+ await this.ensureInitialized();
642
1065
  await this.pool.end();
1066
+ if (this.sshProcess) {
1067
+ this.sshProcess.kill();
1068
+ this.sshProcess = null;
1069
+ }
643
1070
  }
644
1071
  /**
645
1072
  * Returns the underlying mysql2 connection pool.