@hesed/mysql 0.5.2 → 0.6.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/README.md CHANGED
@@ -26,7 +26,7 @@ $ npm install -g @hesed/mysql
26
26
  $ mq COMMAND
27
27
  running command...
28
28
  $ mq (--version)
29
- @hesed/mysql/0.5.2 linux-x64 node-v24.17.0
29
+ @hesed/mysql/0.6.1 linux-x64 node-v24.18.0
30
30
  $ mq --help [COMMAND]
31
31
  USAGE
32
32
  $ mq COMMAND
@@ -57,16 +57,18 @@ Add MySQL authentication
57
57
  ```
58
58
  USAGE
59
59
  $ mq mysql auth add -p <value> --host <value> --port <value> -u <value> --password <value> -d <value> --ssl
60
- [--json]
60
+ --maxConcurrentQueries <value> --queryQueueTimeoutMs <value> [--json]
61
61
 
62
62
  FLAGS
63
- -d, --database=<value> (required) Database name
64
- -p, --profile=<value> (required) Profile name
65
- -u, --user=<value> (required) Username
66
- --host=<value> (required) MySQL host
67
- --password=<value> (required) Password
68
- --port=<value> (required) MySQL port
69
- --ssl (required) Use SSL
63
+ -d, --database=<value> (required) Database name
64
+ -p, --profile=<value> (required) Profile name
65
+ -u, --user=<value> (required) Username
66
+ --host=<value> (required) MySQL host
67
+ --maxConcurrentQueries=<value> (required) Max concurrent queries for this profile
68
+ --password=<value> (required) Password
69
+ --port=<value> (required) MySQL port
70
+ --queryQueueTimeoutMs=<value> (required) Milliseconds a query may wait for a free query slot before failing
71
+ --ssl (required) Use SSL
70
72
 
71
73
  GLOBAL FLAGS
72
74
  --json Format output as json.
@@ -80,7 +82,7 @@ EXAMPLES
80
82
  $ mq mysql auth add -p prod
81
83
  ```
82
84
 
83
- _See code: [src/commands/mysql/auth/add.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/add.ts)_
85
+ _See code: [src/commands/mysql/auth/add.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/add.ts)_
84
86
 
85
87
  ## `mq mysql auth delete`
86
88
 
@@ -105,7 +107,7 @@ EXAMPLES
105
107
  $ mq mysql auth delete -p prod
106
108
  ```
107
109
 
108
- _See code: [src/commands/mysql/auth/delete.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/delete.ts)_
110
+ _See code: [src/commands/mysql/auth/delete.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/delete.ts)_
109
111
 
110
112
  ## `mq mysql auth list`
111
113
 
@@ -125,7 +127,7 @@ EXAMPLES
125
127
  $ mq mysql auth list
126
128
  ```
127
129
 
128
- _See code: [src/commands/mysql/auth/list.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/list.ts)_
130
+ _See code: [src/commands/mysql/auth/list.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/list.ts)_
129
131
 
130
132
  ## `mq mysql auth profile`
131
133
 
@@ -150,7 +152,7 @@ EXAMPLES
150
152
  $ mq mysql auth profile --default test
151
153
  ```
152
154
 
153
- _See code: [src/commands/mysql/auth/profile.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/profile.ts)_
155
+ _See code: [src/commands/mysql/auth/profile.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/profile.ts)_
154
156
 
155
157
  ## `mq mysql auth test`
156
158
 
@@ -175,7 +177,7 @@ EXAMPLES
175
177
  $ mq mysql auth test -p prod
176
178
  ```
177
179
 
178
- _See code: [src/commands/mysql/auth/test.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/test.ts)_
180
+ _See code: [src/commands/mysql/auth/test.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/test.ts)_
179
181
 
180
182
  ## `mq mysql auth update`
181
183
 
@@ -184,16 +186,18 @@ Update MySQL authentication
184
186
  ```
185
187
  USAGE
186
188
  $ mq mysql auth update -p <value> --host <value> --port <value> -u <value> --password <value> -d <value> --ssl
187
- [--json]
189
+ --maxConcurrentQueries <value> --queryQueueTimeoutMs <value> [--json]
188
190
 
189
191
  FLAGS
190
- -d, --database=<value> (required) Database name
191
- -p, --profile=<value> (required) Profile name
192
- -u, --user=<value> (required) Username
193
- --host=<value> (required) MySQL host
194
- --password=<value> (required) Password
195
- --port=<value> (required) MySQL port
196
- --ssl (required) Use SSL
192
+ -d, --database=<value> (required) Database name
193
+ -p, --profile=<value> (required) Profile name
194
+ -u, --user=<value> (required) Username
195
+ --host=<value> (required) MySQL host
196
+ --maxConcurrentQueries=<value> (required) Max concurrent queries for this profile
197
+ --password=<value> (required) Password
198
+ --port=<value> (required) MySQL port
199
+ --queryQueueTimeoutMs=<value> (required) Milliseconds a query may wait for a free query slot before failing
200
+ --ssl (required) Use SSL
197
201
 
198
202
  GLOBAL FLAGS
199
203
  --json Format output as json.
@@ -207,7 +211,7 @@ EXAMPLES
207
211
  $ mq mysql auth update -p test
208
212
  ```
209
213
 
210
- _See code: [src/commands/mysql/auth/update.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/auth/update.ts)_
214
+ _See code: [src/commands/mysql/auth/update.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/update.ts)_
211
215
 
212
216
  ## `mq mysql databases`
213
217
 
@@ -232,7 +236,7 @@ EXAMPLES
232
236
  $ mq mysql databases -p staging
233
237
  ```
234
238
 
235
- _See code: [src/commands/mysql/databases.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/databases.ts)_
239
+ _See code: [src/commands/mysql/databases.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/databases.ts)_
236
240
 
237
241
  ## `mq mysql describe-table TABLE`
238
242
 
@@ -261,7 +265,7 @@ EXAMPLES
261
265
  $ mq mysql describe-table orders --toon -p prod
262
266
  ```
263
267
 
264
- _See code: [src/commands/mysql/describe-table.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/describe-table.ts)_
268
+ _See code: [src/commands/mysql/describe-table.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/describe-table.ts)_
265
269
 
266
270
  ## `mq mysql explain QUERY`
267
271
 
@@ -290,7 +294,7 @@ EXAMPLES
290
294
  $ mq mysql explain "SELECT * FROM orders JOIN users ON orders.user_id = users.id" --json
291
295
  ```
292
296
 
293
- _See code: [src/commands/mysql/explain.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/explain.ts)_
297
+ _See code: [src/commands/mysql/explain.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/explain.ts)_
294
298
 
295
299
  ## `mq mysql indexes TABLE`
296
300
 
@@ -319,7 +323,7 @@ EXAMPLES
319
323
  $ mq mysql indexes orders --json -p prod
320
324
  ```
321
325
 
322
- _See code: [src/commands/mysql/indexes.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/indexes.ts)_
326
+ _See code: [src/commands/mysql/indexes.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/indexes.ts)_
323
327
 
324
328
  ## `mq mysql query QUERY`
325
329
 
@@ -351,7 +355,7 @@ EXAMPLES
351
355
  $ mq mysql query "DELETE FROM sessions" -p prod --skip-confirmation
352
356
  ```
353
357
 
354
- _See code: [src/commands/mysql/query.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/query.ts)_
358
+ _See code: [src/commands/mysql/query.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/query.ts)_
355
359
 
356
360
  ## `mq mysql tables`
357
361
 
@@ -376,5 +380,5 @@ EXAMPLES
376
380
  $ mq mysql tables -p local
377
381
  ```
378
382
 
379
- _See code: [src/commands/mysql/tables.ts](https://github.com/hesedcasa/mysql/blob/v0.5.2/src/commands/mysql/tables.ts)_
383
+ _See code: [src/commands/mysql/tables.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/tables.ts)_
380
384
  <!-- commandsstop -->
@@ -7,6 +7,20 @@ const fields = [
7
7
  { description: 'Password', name: 'password', type: 'string' },
8
8
  { char: 'd', description: 'Database name', name: 'database', type: 'string' },
9
9
  { default: false, description: 'Use SSL', name: 'ssl', required: false, type: 'boolean' },
10
+ {
11
+ default: 5,
12
+ description: 'Max concurrent queries for this profile',
13
+ name: 'maxConcurrentQueries',
14
+ required: false,
15
+ type: 'number',
16
+ },
17
+ {
18
+ default: 60_000,
19
+ description: 'Milliseconds a query may wait for a free query slot before failing',
20
+ name: 'queryQueueTimeoutMs',
21
+ required: false,
22
+ type: 'number',
23
+ },
10
24
  ];
11
25
  export default createAuthAddCommand({
12
26
  clearClients: closeConnections,
@@ -7,6 +7,20 @@ const fields = [
7
7
  { description: 'Password', name: 'password', type: 'string' },
8
8
  { char: 'd', description: 'Database name', name: 'database', type: 'string' },
9
9
  { default: false, description: 'Use SSL', name: 'ssl', required: false, type: 'boolean' },
10
+ {
11
+ default: 5,
12
+ description: 'Max concurrent queries for this profile',
13
+ name: 'maxConcurrentQueries',
14
+ required: false,
15
+ type: 'number',
16
+ },
17
+ {
18
+ default: 60_000,
19
+ description: 'Milliseconds a query may wait for a free query slot before failing',
20
+ name: 'queryQueueTimeoutMs',
21
+ required: false,
22
+ type: 'number',
23
+ },
10
24
  ];
11
25
  export default createAuthUpdateCommand({
12
26
  clearClients: closeConnections,
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLDescribeTable extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLExplain extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLIndexes extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLQuery extends BaseCommand {
4
4
  static args: {
@@ -1,23 +1,27 @@
1
1
  import type { ConnectionOptions as MySQL2ConnectionOptions } from 'mysql2/promise';
2
- export interface DatabaseProfile {
2
+ export type DatabaseProfile = {
3
3
  database: string;
4
4
  host: string;
5
+ maxConcurrentQueries?: number;
5
6
  password: string;
6
7
  port: number;
8
+ queryQueueTimeoutMs?: number;
7
9
  ssl?: boolean;
8
10
  user: string;
9
- }
10
- interface SafetyConfig {
11
+ };
12
+ type SafetyConfig = {
11
13
  blacklistedOperations: string[];
12
14
  defaultLimit: number;
15
+ maxConcurrentQueries?: number;
16
+ queryQueueTimeoutMs?: number;
13
17
  requireConfirmationFor: string[];
14
- }
15
- export interface MySQLConfig {
18
+ };
19
+ export type MySQLConfig = {
16
20
  defaultFormat: 'csv' | 'json' | 'table' | 'toon';
17
21
  defaultProfile: string;
18
22
  profiles: Record<string, DatabaseProfile>;
19
23
  safety: SafetyConfig;
20
- }
24
+ };
21
25
  type MySQLConnectionOptions = Pick<MySQL2ConnectionOptions, 'connectTimeout' | 'database' | 'host' | 'multipleStatements' | 'password' | 'port' | 'ssl' | 'user'>;
22
26
  export declare function getMySQLConnectionOptions(config: MySQLConfig, profileName: string): MySQLConnectionOptions;
23
27
  export {};
@@ -1,36 +1,36 @@
1
1
  import type { ApiResult } from '@hesed/plugin-lib';
2
2
  export type OutputFormat = 'csv' | 'json' | 'table' | 'toon';
3
- export interface QueryData {
3
+ export type QueryData = {
4
4
  message?: string;
5
5
  notices?: string;
6
6
  requiresConfirmation?: boolean;
7
7
  result?: unknown;
8
- }
9
- export interface DatabaseListData {
8
+ };
9
+ export type DatabaseListData = {
10
10
  databases: string[];
11
11
  result?: string;
12
- }
13
- export interface TableListData {
12
+ };
13
+ export type TableListData = {
14
14
  result?: string;
15
15
  tables: string[];
16
- }
17
- export interface TableStructureData {
16
+ };
17
+ export type TableStructureData = {
18
18
  result?: string;
19
- structure: Record<string, unknown>[];
20
- }
21
- export interface IndexData {
22
- indexes: Record<string, unknown>[];
19
+ structure: Array<Record<string, unknown>>;
20
+ };
21
+ export type IndexData = {
22
+ indexes: Array<Record<string, unknown>>;
23
23
  result?: string;
24
- }
25
- export interface ExplainData {
26
- plan: Record<string, unknown>[];
24
+ };
25
+ export type ExplainData = {
26
+ plan: Array<Record<string, unknown>>;
27
27
  result?: string;
28
- }
29
- interface ConnectionTestData {
28
+ };
29
+ type ConnectionTestData = {
30
30
  database: string;
31
31
  result?: string;
32
32
  version: string;
33
- }
33
+ };
34
34
  export type QueryResult = ApiResult & {
35
35
  data?: QueryData;
36
36
  };
@@ -52,7 +52,7 @@ export type ExplainResult = ApiResult & {
52
52
  export type ConnectionTestResult = ApiResult & {
53
53
  data?: ConnectionTestData;
54
54
  };
55
- export interface DatabaseUtil {
55
+ export type DatabaseUtil = {
56
56
  closeAll(): Promise<void>;
57
57
  describeTable(profileName: string, table: string, format?: OutputFormat): Promise<TableStructureResult>;
58
58
  executeQuery(profileName: string, query: string, format?: OutputFormat, skipConfirmation?: boolean): Promise<QueryResult>;
@@ -60,5 +60,5 @@ export interface DatabaseUtil {
60
60
  listDatabases(profileName: string): Promise<DatabaseListResult>;
61
61
  listTables(profileName: string): Promise<TableListResult>;
62
62
  showIndexes(profileName: string, table: string, format?: OutputFormat): Promise<IndexResult>;
63
- }
63
+ };
64
64
  export {};
@@ -1,4 +1,5 @@
1
1
  import { encode } from '@toon-format/toon';
2
+ import { Buffer } from 'node:buffer';
2
3
  function formatAsCsv(rows, fields) {
3
4
  if (!rows || rows.length === 0) {
4
5
  return '';
@@ -5,6 +5,8 @@ let cachedConfig = null;
5
5
  const DEFAULT_SAFETY_CONFIG = {
6
6
  blacklistedOperations: ['DROP DATABASE'],
7
7
  defaultLimit: 100,
8
+ maxConcurrentQueries: 5,
9
+ queryQueueTimeoutMs: 60_000,
8
10
  requireConfirmationFor: ['DELETE', 'UPDATE', 'DROP', 'TRUNCATE', 'ALTER'],
9
11
  };
10
12
  async function initMySQL(config) {
@@ -66,9 +68,10 @@ export async function testDirectConnection(profile) {
66
68
  return result;
67
69
  }
68
70
  export async function closeConnections() {
69
- if (mysqlUtil) {
70
- await mysqlUtil.closeAll();
71
- mysqlUtil = null;
72
- cachedConfig = null;
71
+ if (!mysqlUtil) {
72
+ return;
73
73
  }
74
+ await mysqlUtil.closeAll();
75
+ mysqlUtil = null;
76
+ cachedConfig = null;
74
77
  }
@@ -1,8 +1,9 @@
1
- import type { MySQLConfig } from './config-loader.js';
2
1
  import type { ConnectionTestResult, DatabaseListResult, DatabaseUtil, ExplainResult, IndexResult, OutputFormat, QueryResult, TableListResult, TableStructureResult } from './database.js';
2
+ import { type MySQLConfig } from './config-loader.js';
3
3
  export declare class MySQLUtil implements DatabaseUtil {
4
- private config;
5
- private connections;
4
+ private readonly config;
5
+ private readonly pools;
6
+ private readonly querySlots;
6
7
  constructor(config: MySQLConfig);
7
8
  closeAll(): Promise<void>;
8
9
  describeTable(profileName: string, table: string, format?: 'json' | 'table' | 'toon'): Promise<TableStructureResult>;
@@ -12,8 +13,11 @@ export declare class MySQLUtil implements DatabaseUtil {
12
13
  listTables(profileName: string): Promise<TableListResult>;
13
14
  showIndexes(profileName: string, table: string, format?: 'json' | 'table' | 'toon'): Promise<IndexResult>;
14
15
  testConnection(profileName: string): Promise<ConnectionTestResult>;
16
+ private acquireQuerySlot;
15
17
  private formatReadResult;
16
18
  private formatRows;
17
19
  private formatWriteResult;
18
- private getConnection;
20
+ private getPool;
21
+ private getQueryLimit;
22
+ private runQuery;
19
23
  }
@@ -2,22 +2,38 @@ import mysql from 'mysql2/promise';
2
2
  import { getMySQLConnectionOptions } from './config-loader.js';
3
3
  import { FORMATTERS } from './formatters.js';
4
4
  import { analyzeQuery, applyDefaultLimit, checkBlacklist, getQueryType, requiresConfirmation } from './query-validator.js';
5
+ const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
6
+ const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
7
+ // Formats that must emit only the data payload on stdout.
8
+ const MACHINE_FORMATS = new Set(['csv', 'json', 'toon']);
9
+ // Query types whose result set is rows rather than an OkPacket.
10
+ const READ_QUERY_TYPES = new Set(['DESCRIBE', 'EXPLAIN', 'SELECT', 'SHOW']);
5
11
  export class MySQLUtil {
6
12
  config;
7
- connections;
13
+ pools;
14
+ querySlots;
8
15
  constructor(config) {
9
16
  this.config = config;
10
- this.connections = new Map();
17
+ this.pools = new Map();
18
+ this.querySlots = new Map();
11
19
  }
12
20
  async closeAll() {
13
- const entries = [...this.connections.values()];
14
- this.connections.clear();
15
- await Promise.allSettled(entries.map(async (connPromise) => (await connPromise).end()));
21
+ // Reject queued queries first so nothing waits forever on a closed util.
22
+ for (const slot of this.querySlots.values()) {
23
+ const { waiting } = slot;
24
+ slot.waiting = [];
25
+ for (const waiter of waiting) {
26
+ waiter.reject(new Error('Connections were closed while the query was waiting for a free slot'));
27
+ }
28
+ }
29
+ this.querySlots.clear();
30
+ const pools = [...this.pools.values()];
31
+ this.pools.clear();
32
+ await Promise.allSettled(pools.map(async (pool) => pool.end()));
16
33
  }
17
34
  async describeTable(profileName, table, format = 'table') {
18
35
  try {
19
- const connection = await this.getConnection(profileName);
20
- const [rows, fields] = await connection.query(`DESCRIBE ${table}`);
36
+ const [rows, fields] = await this.runQuery(profileName, `DESCRIBE ${table}`);
21
37
  return {
22
38
  data: {
23
39
  result: this.formatRows(rows, fields, format),
@@ -56,7 +72,7 @@ export class MySQLUtil {
56
72
  }
57
73
  // Machine-readable formats must emit only the data payload on stdout, so
58
74
  // analysis warnings and status lines are collected as notices instead.
59
- const machineFormat = format === 'json' || format === 'csv' || format === 'toon';
75
+ const isMachineFormat = MACHINE_FORMATS.has(format);
60
76
  const notices = [];
61
77
  const warnings = analyzeQuery(query);
62
78
  if (warnings.length > 0) {
@@ -72,9 +88,8 @@ export class MySQLUtil {
72
88
  }
73
89
  }
74
90
  try {
75
- const connection = await this.getConnection(profileName);
76
- const [rows, fields] = await connection.query(finalQuery);
77
- const isRead = queryType === 'SELECT' || queryType === 'SHOW' || queryType === 'DESCRIBE' || queryType === 'EXPLAIN';
91
+ const [rows, fields] = await this.runQuery(profileName, finalQuery);
92
+ const isRead = READ_QUERY_TYPES.has(queryType);
78
93
  let data = isRead
79
94
  ? this.formatReadResult(rows, fields, format, notices)
80
95
  : this.formatWriteResult(rows, notices, format);
@@ -86,8 +101,8 @@ export class MySQLUtil {
86
101
  // For machine formats the data is returned alone and notices go to stderr.
87
102
  return {
88
103
  data: {
89
- notices: machineFormat ? notice : undefined,
90
- result: machineFormat ? data : `${notice}\n\n${data}`,
104
+ notices: isMachineFormat ? notice : undefined,
105
+ result: isMachineFormat ? data : `${notice}\n\n${data}`,
91
106
  },
92
107
  success: true,
93
108
  };
@@ -102,8 +117,7 @@ export class MySQLUtil {
102
117
  }
103
118
  async explainQuery(profileName, query, format = 'table') {
104
119
  try {
105
- const connection = await this.getConnection(profileName);
106
- const [rows, fields] = await connection.query(`EXPLAIN ${query}`);
120
+ const [rows, fields] = await this.runQuery(profileName, `EXPLAIN ${query}`);
107
121
  return {
108
122
  data: {
109
123
  plan: rows,
@@ -122,8 +136,7 @@ export class MySQLUtil {
122
136
  }
123
137
  async listDatabases(profileName) {
124
138
  try {
125
- const connection = await this.getConnection(profileName);
126
- const [rows] = await connection.query('SHOW DATABASES');
139
+ const [rows] = await this.runQuery(profileName, 'SHOW DATABASES');
127
140
  const databases = rows.map((row) => row.Database);
128
141
  return {
129
142
  data: {
@@ -143,8 +156,7 @@ export class MySQLUtil {
143
156
  }
144
157
  async listTables(profileName) {
145
158
  try {
146
- const connection = await this.getConnection(profileName);
147
- const [rows] = await connection.query('SHOW TABLES');
159
+ const [rows] = await this.runQuery(profileName, 'SHOW TABLES');
148
160
  const rowsArray = rows;
149
161
  const tableKey = Object.keys(rowsArray[0])[0];
150
162
  const tables = rowsArray.map((row) => row[tableKey]);
@@ -166,8 +178,7 @@ export class MySQLUtil {
166
178
  }
167
179
  async showIndexes(profileName, table, format = 'table') {
168
180
  try {
169
- const connection = await this.getConnection(profileName);
170
- const [rows, fields] = await connection.query(`SHOW INDEXES FROM ${table}`);
181
+ const [rows, fields] = await this.runQuery(profileName, `SHOW INDEXES FROM ${table}`);
171
182
  return {
172
183
  data: {
173
184
  indexes: rows,
@@ -186,8 +197,7 @@ export class MySQLUtil {
186
197
  }
187
198
  async testConnection(profileName) {
188
199
  try {
189
- const connection = await this.getConnection(profileName);
190
- const [rows] = await connection.query('SELECT VERSION() as version, DATABASE() as current_database');
200
+ const [rows] = await this.runQuery(profileName, 'SELECT VERSION() as version, DATABASE() as current_database');
191
201
  const info = rows[0];
192
202
  return {
193
203
  data: {
@@ -206,6 +216,56 @@ export class MySQLUtil {
206
216
  };
207
217
  }
208
218
  }
219
+ // Grants a query slot for the profile, or waits until one frees up. The
220
+ // returned release callback must be invoked exactly once per acquisition.
221
+ async acquireQuerySlot(profileName) {
222
+ const limit = this.getQueryLimit(profileName);
223
+ let slot = this.querySlots.get(profileName);
224
+ if (!slot) {
225
+ slot = { active: 0, waiting: [] };
226
+ this.querySlots.set(profileName, slot);
227
+ }
228
+ const state = slot;
229
+ const release = () => {
230
+ const next = state.waiting.shift();
231
+ if (next) {
232
+ next.grant();
233
+ }
234
+ else {
235
+ state.active -= 1;
236
+ }
237
+ };
238
+ if (state.active < limit) {
239
+ state.active += 1;
240
+ return release;
241
+ }
242
+ const timeoutMs = this.config.profiles[profileName]?.queryQueueTimeoutMs ??
243
+ this.config.safety.queryQueueTimeoutMs ??
244
+ DEFAULT_QUEUE_TIMEOUT_MS;
245
+ process.stderr.write(`Waiting for a free query slot (${limit}/${limit} in use for profile "${profileName}")...\n`);
246
+ return new Promise((resolve, reject) => {
247
+ const waiter = {
248
+ grant() {
249
+ clearTimeout(timer);
250
+ resolve(release);
251
+ },
252
+ reject(error) {
253
+ clearTimeout(timer);
254
+ reject(error);
255
+ },
256
+ };
257
+ const timer = setTimeout(() => {
258
+ const index = state.waiting.indexOf(waiter);
259
+ if (index !== -1)
260
+ state.waiting.splice(index, 1);
261
+ reject(new Error(`Timed out after ${timeoutMs / 1000}s waiting for a free query slot ` +
262
+ `(limit: ${limit} concurrent queries for profile "${profileName}")`));
263
+ }, timeoutMs);
264
+ // Don't let a pending queue timer keep the CLI process alive.
265
+ timer.unref?.();
266
+ state.waiting.push(waiter);
267
+ });
268
+ }
209
269
  formatReadResult(rows, fields, format, notices) {
210
270
  const rowCount = Array.isArray(rows) ? rows.length : 0;
211
271
  notices.push(`Query executed successfully. Rows returned: ${rowCount}`);
@@ -231,26 +291,37 @@ export class MySQLUtil {
231
291
  data += `Insert ID: ${insertId}\n`;
232
292
  return data;
233
293
  }
234
- async getConnection(profileName) {
235
- const existing = this.connections.get(profileName);
236
- if (existing) {
237
- try {
238
- const conn = await existing;
239
- await conn.ping();
240
- return conn;
241
- }
242
- catch {
243
- this.connections.delete(profileName);
244
- }
245
- }
246
- const connPromise = mysql.createConnection(getMySQLConnectionOptions(this.config, profileName));
247
- this.connections.set(profileName, connPromise);
294
+ // The pool is sized to the profile's query limit so slot holders each get a
295
+ // real physical connection — a single Connection would serialize commands on
296
+ // the wire and make the concurrency limit meaningless.
297
+ getPool(profileName) {
298
+ const existing = this.pools.get(profileName);
299
+ if (existing)
300
+ return existing;
301
+ const pool = mysql.createPool({
302
+ ...getMySQLConnectionOptions(this.config, profileName),
303
+ connectionLimit: this.getQueryLimit(profileName),
304
+ waitForConnections: true,
305
+ });
306
+ this.pools.set(profileName, pool);
307
+ return pool;
308
+ }
309
+ getQueryLimit(profileName) {
310
+ const configuredLimit = this.config.profiles[profileName]?.maxConcurrentQueries ??
311
+ this.config.safety.maxConcurrentQueries ??
312
+ DEFAULT_MAX_CONCURRENT_QUERIES;
313
+ // A limit below 1 would leave every query waiting forever.
314
+ return Math.max(1, configuredLimit);
315
+ }
316
+ // All queries go through here so concurrent load on the same profile is
317
+ // capped at maxConcurrentQueries; excess queries wait for a free slot.
318
+ async runQuery(profileName, sql) {
319
+ const release = await this.acquireQuerySlot(profileName);
248
320
  try {
249
- return await connPromise;
321
+ return await this.getPool(profileName).query(sql);
250
322
  }
251
- catch (error) {
252
- this.connections.delete(profileName);
253
- throw error;
323
+ finally {
324
+ release();
254
325
  }
255
326
  }
256
327
  }
@@ -1,16 +1,16 @@
1
- interface BlacklistCheckResult {
1
+ type BlacklistCheckResult = {
2
2
  allowed: boolean;
3
3
  reason?: string;
4
- }
5
- interface ConfirmationCheckResult {
4
+ };
5
+ type ConfirmationCheckResult = {
6
6
  message?: string;
7
7
  required: boolean;
8
- }
9
- interface QueryWarning {
8
+ };
9
+ type QueryWarning = {
10
10
  level: 'info' | 'warning';
11
11
  message: string;
12
12
  suggestion: string;
13
- }
13
+ };
14
14
  export declare function checkBlacklist(query: string, blacklistedOperations: string[]): BlacklistCheckResult;
15
15
  export declare function requiresConfirmation(query: string, confirmationOperations: string[]): ConfirmationCheckResult;
16
16
  export declare function getQueryType(query: string): string;
@@ -26,7 +26,7 @@ export function requiresConfirmation(query, confirmationOperations) {
26
26
  }
27
27
  export function getQueryType(query) {
28
28
  const normalizedQuery = query.trim().toUpperCase();
29
- const firstWord = normalizedQuery.split(/\s+/)[0];
29
+ const firstWord = normalizedQuery.split(/\s+/, 1)[0];
30
30
  const knownTypes = [
31
31
  'SELECT',
32
32
  'INSERT',
@@ -384,6 +384,22 @@
384
384
  "required": true,
385
385
  "allowNo": false,
386
386
  "type": "boolean"
387
+ },
388
+ "maxConcurrentQueries": {
389
+ "description": "Max concurrent queries for this profile",
390
+ "name": "maxConcurrentQueries",
391
+ "required": true,
392
+ "hasDynamicHelp": false,
393
+ "multiple": false,
394
+ "type": "option"
395
+ },
396
+ "queryQueueTimeoutMs": {
397
+ "description": "Milliseconds a query may wait for a free query slot before failing",
398
+ "name": "queryQueueTimeoutMs",
399
+ "required": true,
400
+ "hasDynamicHelp": false,
401
+ "multiple": false,
402
+ "type": "option"
387
403
  }
388
404
  },
389
405
  "hasDynamicHelp": false,
@@ -637,6 +653,22 @@
637
653
  "required": true,
638
654
  "allowNo": false,
639
655
  "type": "boolean"
656
+ },
657
+ "maxConcurrentQueries": {
658
+ "description": "Max concurrent queries for this profile",
659
+ "name": "maxConcurrentQueries",
660
+ "required": true,
661
+ "hasDynamicHelp": false,
662
+ "multiple": false,
663
+ "type": "option"
664
+ },
665
+ "queryQueueTimeoutMs": {
666
+ "description": "Milliseconds a query may wait for a free query slot before failing",
667
+ "name": "queryQueueTimeoutMs",
668
+ "required": true,
669
+ "hasDynamicHelp": false,
670
+ "multiple": false,
671
+ "type": "option"
640
672
  }
641
673
  },
642
674
  "hasDynamicHelp": false,
@@ -657,5 +689,5 @@
657
689
  ]
658
690
  }
659
691
  },
660
- "version": "0.5.2"
692
+ "version": "0.6.1"
661
693
  }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@hesed/mysql",
3
3
  "description": "CLI for MySQL database interaction",
4
- "version": "0.5.2",
4
+ "version": "0.6.1",
5
5
  "author": "Hesed",
6
6
  "bin": {
7
7
  "mq": "./bin/run.js"
8
8
  },
9
9
  "bugs": "https://github.com/hesedcasa/mysql/issues",
10
10
  "dependencies": {
11
- "@hesed/plugin-lib": "^0.9.0",
11
+ "@hesed/plugin-lib": "^0.12.1",
12
12
  "@inquirer/prompts": "^8.3.0",
13
13
  "@oclif/core": "^4",
14
14
  "@toon-format/toon": "^2.1.0",
@@ -23,11 +23,11 @@
23
23
  "@types/fs-extra": "^11.0.4",
24
24
  "@types/mocha": "^10",
25
25
  "@types/node": "^26",
26
- "@types/sinon": "^21.0.0",
26
+ "@types/sinon": "^22.0.0",
27
27
  "c8": "^11.0.0",
28
28
  "chai": "^6",
29
- "eslint": "^9",
30
- "eslint-config-oclif": "^6",
29
+ "eslint": "^10",
30
+ "eslint-config-oclif": "^7",
31
31
  "eslint-config-prettier": "^10",
32
32
  "esmock": "^2.7.3",
33
33
  "mocha": "^11",
@@ -37,10 +37,11 @@
37
37
  "sinon": "^22.0.0",
38
38
  "ts-node": "^10",
39
39
  "ts-prune": "^0.10.3",
40
- "typescript": "^5"
40
+ "typescript": "^5",
41
+ "typescript-eslint": "^8.67.0"
41
42
  },
42
43
  "engines": {
43
- "node": ">=18.0.0"
44
+ "node": ">=22.13.0"
44
45
  },
45
46
  "files": [
46
47
  "./bin",