@hesed/mysql 0.5.1 → 0.6.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.
@@ -1,21 +1,21 @@
1
- import { Command, Flags } from '@oclif/core';
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base-command.js';
2
3
  import { closeConnections, listTables } from '../../mysql/index.js';
3
- export default class MySQLTables extends Command {
4
+ export default class MySQLTables extends BaseCommand {
4
5
  static description = 'List all tables in the current MySQL database';
5
6
  static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> -p local'];
6
7
  static flags = {
7
8
  profile: Flags.string({ char: 'p', description: 'Database profile name from config', required: false }),
8
9
  };
9
- jsonEnabled() {
10
- return true;
11
- }
12
10
  async run() {
13
11
  const { flags } = await this.parse(MySQLTables);
14
12
  const result = await listTables(this.config, flags.profile);
15
13
  await closeConnections();
16
14
  if (result.success) {
17
- return result.tables ?? [];
15
+ this.log(result.data?.result ?? '');
16
+ delete result.data.result;
17
+ return result;
18
18
  }
19
- this.error(result.error ?? 'Failed to list tables');
19
+ this.error(String(result.error ?? 'Failed to list tables'));
20
20
  }
21
21
  }
@@ -2,14 +2,18 @@ import type { ConnectionOptions as MySQL2ConnectionOptions } from 'mysql2/promis
2
2
  export interface 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
11
  }
10
12
  interface SafetyConfig {
11
13
  blacklistedOperations: string[];
12
14
  defaultLimit: number;
15
+ maxConcurrentQueries?: number;
16
+ queryQueueTimeoutMs?: number;
13
17
  requireConfirmationFor: string[];
14
18
  }
15
19
  export interface MySQLConfig {
@@ -1,49 +1,57 @@
1
- export interface QueryResult {
2
- error?: string;
1
+ import type { ApiResult } from '@hesed/plugin-lib';
2
+ export type OutputFormat = 'csv' | 'json' | 'table' | 'toon';
3
+ export interface QueryData {
3
4
  message?: string;
4
5
  notices?: string;
5
6
  requiresConfirmation?: boolean;
6
- result?: string;
7
- success: boolean;
7
+ result?: unknown;
8
8
  }
9
- export interface DatabaseListResult {
10
- databases?: string[];
11
- error?: string;
9
+ export interface DatabaseListData {
10
+ databases: string[];
12
11
  result?: string;
13
- success: boolean;
14
12
  }
15
- export interface TableListResult {
16
- error?: string;
13
+ export interface TableListData {
17
14
  result?: string;
18
- success: boolean;
19
- tables?: string[];
15
+ tables: string[];
20
16
  }
21
- export interface TableStructureResult {
22
- error?: string;
17
+ export interface TableStructureData {
23
18
  result?: string;
24
- structure?: Record<string, unknown>[];
25
- success: boolean;
19
+ structure: Record<string, unknown>[];
26
20
  }
27
- export interface IndexResult {
28
- error?: string;
29
- indexes?: Record<string, unknown>[];
21
+ export interface IndexData {
22
+ indexes: Record<string, unknown>[];
30
23
  result?: string;
31
- success: boolean;
32
24
  }
33
- export interface ExplainResult {
34
- error?: string;
35
- plan?: Record<string, unknown>[];
25
+ export interface ExplainData {
26
+ plan: Record<string, unknown>[];
36
27
  result?: string;
37
- success: boolean;
38
28
  }
39
- export interface ConnectionTestResult {
40
- database?: string;
41
- error?: string;
29
+ interface ConnectionTestData {
30
+ database: string;
42
31
  result?: string;
43
- success: boolean;
44
- version?: string;
32
+ version: string;
45
33
  }
46
- export type OutputFormat = 'csv' | 'json' | 'table' | 'toon';
34
+ export type QueryResult = ApiResult & {
35
+ data?: QueryData;
36
+ };
37
+ export type DatabaseListResult = ApiResult & {
38
+ data?: DatabaseListData;
39
+ };
40
+ export type TableListResult = ApiResult & {
41
+ data?: TableListData;
42
+ };
43
+ export type TableStructureResult = ApiResult & {
44
+ data?: TableStructureData;
45
+ };
46
+ export type IndexResult = ApiResult & {
47
+ data?: IndexData;
48
+ };
49
+ export type ExplainResult = ApiResult & {
50
+ data?: ExplainData;
51
+ };
52
+ export type ConnectionTestResult = ApiResult & {
53
+ data?: ConnectionTestData;
54
+ };
47
55
  export interface DatabaseUtil {
48
56
  closeAll(): Promise<void>;
49
57
  describeTable(profileName: string, table: string, format?: OutputFormat): Promise<TableStructureResult>;
@@ -53,3 +61,4 @@ export interface DatabaseUtil {
53
61
  listTables(profileName: string): Promise<TableListResult>;
54
62
  showIndexes(profileName: string, table: string, format?: OutputFormat): Promise<IndexResult>;
55
63
  }
64
+ export {};
@@ -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) {
@@ -2,7 +2,8 @@ import type { MySQLConfig } from './config-loader.js';
2
2
  import type { ConnectionTestResult, DatabaseListResult, DatabaseUtil, ExplainResult, IndexResult, OutputFormat, QueryResult, TableListResult, TableStructureResult } from './database.js';
3
3
  export declare class MySQLUtil implements DatabaseUtil {
4
4
  private config;
5
- private connections;
5
+ private pools;
6
+ private 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,25 +2,37 @@ 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;
5
7
  export class MySQLUtil {
6
8
  config;
7
- connections;
9
+ pools;
10
+ querySlots;
8
11
  constructor(config) {
9
12
  this.config = config;
10
- this.connections = new Map();
13
+ this.pools = new Map();
14
+ this.querySlots = new Map();
11
15
  }
12
16
  async closeAll() {
13
- const entries = [...this.connections.values()];
14
- this.connections.clear();
15
- await Promise.allSettled(entries.map(async (connPromise) => (await connPromise).end()));
17
+ // Reject queued queries first so nothing waits forever on a closed util.
18
+ for (const slot of this.querySlots.values()) {
19
+ for (const waiter of slot.waiting.splice(0)) {
20
+ waiter.reject(new Error('Connections were closed while the query was waiting for a free slot'));
21
+ }
22
+ }
23
+ this.querySlots.clear();
24
+ const pools = [...this.pools.values()];
25
+ this.pools.clear();
26
+ await Promise.allSettled(pools.map((pool) => pool.end()));
16
27
  }
17
28
  async describeTable(profileName, table, format = 'table') {
18
29
  try {
19
- const connection = await this.getConnection(profileName);
20
- const [rows, fields] = await connection.query(`DESCRIBE ${table}`);
30
+ const [rows, fields] = await this.runQuery(profileName, `DESCRIBE ${table}`);
21
31
  return {
22
- result: this.formatRows(rows, fields, format),
23
- structure: rows,
32
+ data: {
33
+ result: this.formatRows(rows, fields, format),
34
+ structure: rows,
35
+ },
24
36
  success: true,
25
37
  };
26
38
  }
@@ -44,8 +56,10 @@ export class MySQLUtil {
44
56
  const confirmationCheck = requiresConfirmation(query, this.config.safety.requireConfirmationFor);
45
57
  if (confirmationCheck.required) {
46
58
  return {
47
- message: `${confirmationCheck.message}\nQuery: ${query}`,
48
- requiresConfirmation: true,
59
+ data: {
60
+ message: `${confirmationCheck.message}\nQuery: ${query}`,
61
+ requiresConfirmation: true,
62
+ },
49
63
  success: false,
50
64
  };
51
65
  }
@@ -68,18 +82,22 @@ export class MySQLUtil {
68
82
  }
69
83
  }
70
84
  try {
71
- const connection = await this.getConnection(profileName);
72
- const [rows, fields] = await connection.query(finalQuery);
85
+ const [rows, fields] = await this.runQuery(profileName, finalQuery);
73
86
  const isRead = queryType === 'SELECT' || queryType === 'SHOW' || queryType === 'DESCRIBE' || queryType === 'EXPLAIN';
74
- const data = isRead
87
+ let data = isRead
75
88
  ? this.formatReadResult(rows, fields, format, notices)
76
- : this.formatWriteResult(rows, notices);
89
+ : this.formatWriteResult(rows, notices, format);
90
+ if (format === 'json') {
91
+ data = JSON.parse(data);
92
+ }
77
93
  const notice = notices.join('\n\n');
78
94
  // For human (table) output everything stays on stdout, exactly as before.
79
95
  // For machine formats the data is returned alone and notices go to stderr.
80
96
  return {
81
- notices: machineFormat ? notice : undefined,
82
- result: machineFormat ? data : `${notice}\n\n${data}`,
97
+ data: {
98
+ notices: machineFormat ? notice : undefined,
99
+ result: machineFormat ? data : `${notice}\n\n${data}`,
100
+ },
83
101
  success: true,
84
102
  };
85
103
  }
@@ -93,11 +111,12 @@ export class MySQLUtil {
93
111
  }
94
112
  async explainQuery(profileName, query, format = 'table') {
95
113
  try {
96
- const connection = await this.getConnection(profileName);
97
- const [rows, fields] = await connection.query(`EXPLAIN ${query}`);
114
+ const [rows, fields] = await this.runQuery(profileName, `EXPLAIN ${query}`);
98
115
  return {
99
- plan: rows,
100
- result: this.formatRows(rows, fields, format),
116
+ data: {
117
+ plan: rows,
118
+ result: this.formatRows(rows, fields, format),
119
+ },
101
120
  success: true,
102
121
  };
103
122
  }
@@ -111,12 +130,13 @@ export class MySQLUtil {
111
130
  }
112
131
  async listDatabases(profileName) {
113
132
  try {
114
- const connection = await this.getConnection(profileName);
115
- const [rows] = await connection.query('SHOW DATABASES');
133
+ const [rows] = await this.runQuery(profileName, 'SHOW DATABASES');
116
134
  const databases = rows.map((row) => row.Database);
117
135
  return {
118
- databases,
119
- result: `Databases:\n${databases.map((db) => ` • ${db}`).join('\n')}`,
136
+ data: {
137
+ databases,
138
+ result: `Databases:\n${databases.map((db) => ` • ${db}`).join('\n')}`,
139
+ },
120
140
  success: true,
121
141
  };
122
142
  }
@@ -130,15 +150,16 @@ export class MySQLUtil {
130
150
  }
131
151
  async listTables(profileName) {
132
152
  try {
133
- const connection = await this.getConnection(profileName);
134
- const [rows] = await connection.query('SHOW TABLES');
153
+ const [rows] = await this.runQuery(profileName, 'SHOW TABLES');
135
154
  const rowsArray = rows;
136
155
  const tableKey = Object.keys(rowsArray[0])[0];
137
156
  const tables = rowsArray.map((row) => row[tableKey]);
138
157
  return {
139
- result: `Tables in database:\n${tables.map((table) => ` • ${table}`).join('\n')}`,
158
+ data: {
159
+ result: `Tables in database:\n${tables.map((table) => ` • ${table}`).join('\n')}`,
160
+ tables,
161
+ },
140
162
  success: true,
141
- tables,
142
163
  };
143
164
  }
144
165
  catch (error) {
@@ -151,11 +172,12 @@ export class MySQLUtil {
151
172
  }
152
173
  async showIndexes(profileName, table, format = 'table') {
153
174
  try {
154
- const connection = await this.getConnection(profileName);
155
- const [rows, fields] = await connection.query(`SHOW INDEXES FROM ${table}`);
175
+ const [rows, fields] = await this.runQuery(profileName, `SHOW INDEXES FROM ${table}`);
156
176
  return {
157
- indexes: rows,
158
- result: this.formatRows(rows, fields, format),
177
+ data: {
178
+ indexes: rows,
179
+ result: this.formatRows(rows, fields, format),
180
+ },
159
181
  success: true,
160
182
  };
161
183
  }
@@ -169,14 +191,15 @@ export class MySQLUtil {
169
191
  }
170
192
  async testConnection(profileName) {
171
193
  try {
172
- const connection = await this.getConnection(profileName);
173
- const [rows] = await connection.query('SELECT VERSION() as version, DATABASE() as current_database');
194
+ const [rows] = await this.runQuery(profileName, 'SELECT VERSION() as version, DATABASE() as current_database');
174
195
  const info = rows[0];
175
196
  return {
176
- database: info.current_database,
177
- result: `Connection successful!\n\nProfile: ${profileName}\nMySQL Version: ${info.version}\nCurrent Database: ${info.current_database}`,
197
+ data: {
198
+ database: info.current_database,
199
+ result: `Connection successful!\n\nProfile: ${profileName}\nMySQL Version: ${info.version}\nCurrent Database: ${info.current_database}`,
200
+ version: info.version,
201
+ },
178
202
  success: true,
179
- version: info.version,
180
203
  };
181
204
  }
182
205
  catch (error) {
@@ -187,6 +210,56 @@ export class MySQLUtil {
187
210
  };
188
211
  }
189
212
  }
213
+ // Grants a query slot for the profile, or waits until one frees up. The
214
+ // returned release callback must be invoked exactly once per acquisition.
215
+ acquireQuerySlot(profileName) {
216
+ const limit = this.getQueryLimit(profileName);
217
+ let slot = this.querySlots.get(profileName);
218
+ if (!slot) {
219
+ slot = { active: 0, waiting: [] };
220
+ this.querySlots.set(profileName, slot);
221
+ }
222
+ const state = slot;
223
+ const release = () => {
224
+ const next = state.waiting.shift();
225
+ if (next) {
226
+ next.grant();
227
+ }
228
+ else {
229
+ state.active -= 1;
230
+ }
231
+ };
232
+ if (state.active < limit) {
233
+ state.active += 1;
234
+ return Promise.resolve(release);
235
+ }
236
+ const timeoutMs = this.config.profiles[profileName]?.queryQueueTimeoutMs ??
237
+ this.config.safety.queryQueueTimeoutMs ??
238
+ DEFAULT_QUEUE_TIMEOUT_MS;
239
+ process.stderr.write(`Waiting for a free query slot (${limit}/${limit} in use for profile "${profileName}")...\n`);
240
+ return new Promise((resolve, reject) => {
241
+ const waiter = {
242
+ grant() {
243
+ clearTimeout(timer);
244
+ resolve(release);
245
+ },
246
+ reject(error) {
247
+ clearTimeout(timer);
248
+ reject(error);
249
+ },
250
+ };
251
+ const timer = setTimeout(() => {
252
+ const index = state.waiting.indexOf(waiter);
253
+ if (index !== -1)
254
+ state.waiting.splice(index, 1);
255
+ reject(new Error(`Timed out after ${timeoutMs / 1000}s waiting for a free query slot ` +
256
+ `(limit: ${limit} concurrent queries for profile "${profileName}")`));
257
+ }, timeoutMs);
258
+ // Don't let a pending queue timer keep the CLI process alive.
259
+ timer.unref?.();
260
+ state.waiting.push(waiter);
261
+ });
262
+ }
190
263
  formatReadResult(rows, fields, format, notices) {
191
264
  const rowCount = Array.isArray(rows) ? rows.length : 0;
192
265
  notices.push(`Query executed successfully. Rows returned: ${rowCount}`);
@@ -195,35 +268,54 @@ export class MySQLUtil {
195
268
  formatRows(rows, fields, format) {
196
269
  return FORMATTERS[format](rows, fields);
197
270
  }
198
- formatWriteResult(rows, notices) {
271
+ formatWriteResult(rows, notices, format) {
199
272
  const affectedRows = rows.affectedRows ?? 0;
200
273
  const insertId = rows.insertId ?? null;
201
274
  notices.push('Query executed successfully.');
275
+ // The caller JSON.parses the result for json output, so emit valid JSON
276
+ // here rather than the human-readable string (which would throw on parse).
277
+ if (format === 'json') {
278
+ const payload = { affectedRows };
279
+ if (insertId)
280
+ payload.insertId = insertId;
281
+ return JSON.stringify(payload, null, 2);
282
+ }
202
283
  let data = `Affected rows: ${affectedRows}\n`;
203
284
  if (insertId)
204
285
  data += `Insert ID: ${insertId}\n`;
205
286
  return data;
206
287
  }
207
- async getConnection(profileName) {
208
- const existing = this.connections.get(profileName);
209
- if (existing) {
210
- try {
211
- const conn = await existing;
212
- await conn.ping();
213
- return conn;
214
- }
215
- catch {
216
- this.connections.delete(profileName);
217
- }
218
- }
219
- const connPromise = mysql.createConnection(getMySQLConnectionOptions(this.config, profileName));
220
- this.connections.set(profileName, connPromise);
288
+ // The pool is sized to the profile's query limit so slot holders each get a
289
+ // real physical connection — a single Connection would serialize commands on
290
+ // the wire and make the concurrency limit meaningless.
291
+ getPool(profileName) {
292
+ const existing = this.pools.get(profileName);
293
+ if (existing)
294
+ return existing;
295
+ const pool = mysql.createPool({
296
+ ...getMySQLConnectionOptions(this.config, profileName),
297
+ connectionLimit: this.getQueryLimit(profileName),
298
+ waitForConnections: true,
299
+ });
300
+ this.pools.set(profileName, pool);
301
+ return pool;
302
+ }
303
+ getQueryLimit(profileName) {
304
+ const configuredLimit = this.config.profiles[profileName]?.maxConcurrentQueries ??
305
+ this.config.safety.maxConcurrentQueries ??
306
+ DEFAULT_MAX_CONCURRENT_QUERIES;
307
+ // A limit below 1 would leave every query waiting forever.
308
+ return Math.max(1, configuredLimit);
309
+ }
310
+ // All queries go through here so concurrent load on the same profile is
311
+ // capped at maxConcurrentQueries; excess queries wait for a free slot.
312
+ async runQuery(profileName, sql) {
313
+ const release = await this.acquireQuerySlot(profileName);
221
314
  try {
222
- return await connPromise;
315
+ return (await this.getPool(profileName).query(sql));
223
316
  }
224
- catch (error) {
225
- this.connections.delete(profileName);
226
- throw error;
317
+ finally {
318
+ release();
227
319
  }
228
320
  }
229
321
  }