@bytebase/dbhub 0.23.0 → 0.24.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.
@@ -122,6 +122,15 @@ var SQLRowLimiter = class {
122
122
  }
123
123
  };
124
124
 
125
+ // src/utils/resource-cleanup.ts
126
+ async function closeQuietly(close) {
127
+ try {
128
+ await close();
129
+ } catch {
130
+ }
131
+ }
132
+
125
133
  export {
126
- SQLRowLimiter
134
+ SQLRowLimiter,
135
+ closeQuietly
127
136
  };
@@ -1,3 +1,26 @@
1
+ import {
2
+ obfuscateDSNPassword
3
+ } from "./chunk-JEZZN2YZ.js";
4
+
5
+ // src/utils/dsn-database.ts
6
+ var MissingDatabaseError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "MissingDatabaseError";
10
+ }
11
+ };
12
+ function requireDatabaseInDSN(database, dsn, label) {
13
+ if (database) {
14
+ return;
15
+ }
16
+ throw new MissingDatabaseError(
17
+ `${label} DSN must name a database.
18
+ Provided: ${obfuscateDSNPassword(dsn)}
19
+ Add the database to the DSN, e.g. ...:3306/mydb
20
+ To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
21
+ );
22
+ }
23
+
1
24
  // src/utils/multi-statement-result-parser.ts
2
25
  function isMetadataObject(element) {
3
26
  if (!element || typeof element !== "object" || Array.isArray(element)) {
@@ -54,7 +77,37 @@ function parseQueryResults(results) {
54
77
  return results;
55
78
  }
56
79
 
80
+ // src/utils/readonly-transaction.ts
81
+ async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
82
+ if (!readonly) {
83
+ return execute();
84
+ }
85
+ try {
86
+ await conn.query(
87
+ supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
88
+ );
89
+ const result = await execute();
90
+ await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
91
+ return result;
92
+ } catch (error) {
93
+ try {
94
+ await conn.query("ROLLBACK");
95
+ } catch {
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ // src/utils/server-flavor.ts
102
+ function isTiDBVersion(version) {
103
+ return typeof version === "string" && /tidb/i.test(version);
104
+ }
105
+
57
106
  export {
107
+ MissingDatabaseError,
108
+ requireDatabaseInDSN,
58
109
  extractAffectedRows,
59
- parseQueryResults
110
+ parseQueryResults,
111
+ withReadOnlyTransaction,
112
+ isTiDBVersion
60
113
  };
@@ -0,0 +1,130 @@
1
+ import {
2
+ stripCommentsAndStrings
3
+ } from "./chunk-JEZZN2YZ.js";
4
+
5
+ // src/utils/allowed-keywords.ts
6
+ var allowedKeywords = {
7
+ postgres: ["select", "with", "explain", "show"],
8
+ mysql: ["select", "with", "explain", "show", "describe", "desc"],
9
+ mariadb: ["select", "with", "explain", "show", "describe", "desc"],
10
+ sqlite: ["select", "with", "explain", "pragma"],
11
+ // SQL Server has no native EXPLAIN statement; the connector translates a
12
+ // leading `EXPLAIN` into a SET SHOWPLAN_XML request (see SQLServerConnector).
13
+ sqlserver: ["select", "with", "explain"]
14
+ };
15
+ var mutatingKeywords = [
16
+ "insert",
17
+ "update",
18
+ "delete",
19
+ "drop",
20
+ "alter",
21
+ "create",
22
+ "truncate",
23
+ "merge",
24
+ "grant",
25
+ "revoke",
26
+ "rename"
27
+ ];
28
+ var mutatingPattern = new RegExp(
29
+ `\\b(?:${mutatingKeywords.join("|")})\\b`,
30
+ "i"
31
+ );
32
+ var mutatingPatternWithReplace = new RegExp(
33
+ `\\b(?:${mutatingKeywords.join("|")}|replace\\s+(?:(?:low_priority|delayed)\\s+)?into)\\b`,
34
+ "i"
35
+ );
36
+ var sqlServerDynamicSqlKeywords = [
37
+ "execute",
38
+ "exec",
39
+ "sp_executesql",
40
+ "xp_cmdshell"
41
+ ];
42
+ var sqlServerDynamicSqlPattern = new RegExp(
43
+ `\\b(?:${sqlServerDynamicSqlKeywords.join("|")})\\b`,
44
+ "i"
45
+ );
46
+ var sqlServerPassThroughKeywords = [
47
+ "openquery",
48
+ "openrowset",
49
+ "opendatasource"
50
+ ];
51
+ var sqlServerPassThroughPattern = new RegExp(
52
+ `\\b(?:${sqlServerPassThroughKeywords.join("|")})\\s*\\(`,
53
+ "i"
54
+ );
55
+ var mutatingPatternSqlServer = new RegExp(
56
+ `\\b(?:${[...mutatingKeywords, ...sqlServerDynamicSqlKeywords].join("|")})\\b`,
57
+ "i"
58
+ );
59
+ var mutatingPatterns = {
60
+ postgres: mutatingPattern,
61
+ mysql: mutatingPatternWithReplace,
62
+ mariadb: mutatingPatternWithReplace,
63
+ sqlite: mutatingPatternWithReplace,
64
+ sqlserver: mutatingPatternSqlServer
65
+ };
66
+ var selectIntoPattern = /\bselect\b[\s\S]+\binto\b/i;
67
+ var sqliteReadOnlyArgPragmas = /* @__PURE__ */ new Set([
68
+ "table_info",
69
+ "index_info",
70
+ "index_list",
71
+ "foreign_key_list"
72
+ ]);
73
+ var sqlitePragmaParenPattern = /^pragma\s+(?:[a-z0-9_]+\.)?([a-z0-9_]+)\s*\(/;
74
+ var explainAnalyzePattern = /^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)/i;
75
+ function isReadOnlySQL(sql, connectorType) {
76
+ return checkReadOnly(
77
+ stripCommentsAndStrings(sql, connectorType).trim().toLowerCase(),
78
+ connectorType
79
+ );
80
+ }
81
+ function checkReadOnly(cleanedSQL, connectorType) {
82
+ if (!cleanedSQL) {
83
+ return false;
84
+ }
85
+ const firstWord = cleanedSQL.match(/\S+/)?.[0] ?? "";
86
+ const keywordList = allowedKeywords[connectorType] || [];
87
+ if (!keywordList.includes(firstWord)) {
88
+ return false;
89
+ }
90
+ if (connectorType === "sqlserver" && sqlServerPassThroughPattern.test(cleanedSQL)) {
91
+ return false;
92
+ }
93
+ if (firstWord === "with") {
94
+ const pattern = mutatingPatterns[connectorType] ?? mutatingPattern;
95
+ if (pattern.test(cleanedSQL)) {
96
+ return false;
97
+ }
98
+ }
99
+ if (firstWord === "pragma" && connectorType === "sqlite") {
100
+ if (cleanedSQL.includes("=")) {
101
+ return false;
102
+ }
103
+ const parenMatch = cleanedSQL.match(sqlitePragmaParenPattern);
104
+ if (parenMatch && !sqliteReadOnlyArgPragmas.has(parenMatch[1])) {
105
+ return false;
106
+ }
107
+ }
108
+ if ((firstWord === "select" || firstWord === "with") && selectIntoPattern.test(cleanedSQL)) {
109
+ return false;
110
+ }
111
+ if (firstWord === "explain") {
112
+ const m = explainAnalyzePattern.exec(cleanedSQL);
113
+ if (m) {
114
+ const afterExplain = cleanedSQL.slice(m[0].length).trim();
115
+ if (afterExplain && !checkReadOnly(afterExplain, connectorType)) {
116
+ return false;
117
+ }
118
+ }
119
+ }
120
+ return true;
121
+ }
122
+
123
+ export {
124
+ allowedKeywords,
125
+ sqlServerDynamicSqlKeywords,
126
+ sqlServerDynamicSqlPattern,
127
+ sqlServerPassThroughKeywords,
128
+ sqlServerPassThroughPattern,
129
+ isReadOnlySQL
130
+ };
@@ -580,55 +580,59 @@ function parseCommandLineArgs() {
580
580
  }
581
581
  return parsedManually;
582
582
  }
583
- function loadEnvFiles() {
583
+ function findEnvFile() {
584
584
  const isDevelopment = process.env.NODE_ENV === "development" || process.argv[1]?.includes("tsx");
585
585
  const envFileNames = isDevelopment ? [".env.local", ".env"] : [".env"];
586
586
  const envPaths = [];
587
587
  for (const fileName of envFileNames) {
588
588
  envPaths.push(
589
- fileName,
589
+ path.join(process.cwd(), fileName),
590
590
  // Current working directory
591
- path.join(__dirname, "..", "..", fileName),
591
+ path.join(__dirname, "..", "..", fileName)
592
592
  // Two levels up (src/config -> src -> root)
593
- path.join(process.cwd(), fileName)
594
- // Explicit current working directory
595
593
  );
596
594
  }
597
595
  for (const envPath of envPaths) {
598
- console.error(`Checking for env file: ${envPath}`);
599
596
  if (fs.existsSync(envPath)) {
600
- dotenv.config({ path: envPath });
601
- if (process.env.READONLY !== void 0) {
602
- console.error("\nERROR: READONLY environment variable is no longer supported.");
603
- console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
604
- console.error(" [[sources]]");
605
- console.error(' id = "default"');
606
- console.error(' dsn = "..."\n');
607
- console.error(" [[tools]]");
608
- console.error(' name = "execute_sql"');
609
- console.error(' source = "default"');
610
- console.error(" readonly = true\n");
611
- console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
612
- process.exit(1);
613
- }
614
- if (process.env.MAX_ROWS !== void 0) {
615
- console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
616
- console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
617
- console.error(" [[sources]]");
618
- console.error(' id = "default"');
619
- console.error(' dsn = "..."\n');
620
- console.error(" [[tools]]");
621
- console.error(' name = "execute_sql"');
622
- console.error(' source = "default"');
623
- console.error(" max_rows = 1000\n");
624
- console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
625
- process.exit(1);
626
- }
627
- return path.basename(envPath);
597
+ return envPath;
628
598
  }
629
599
  }
630
600
  return null;
631
601
  }
602
+ function loadEnvFiles() {
603
+ const envPath = findEnvFile();
604
+ if (envPath) {
605
+ dotenv.config({ path: envPath });
606
+ if (process.env.READONLY !== void 0) {
607
+ console.error("\nERROR: READONLY environment variable is no longer supported.");
608
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
609
+ console.error(" [[sources]]");
610
+ console.error(' id = "default"');
611
+ console.error(' dsn = "..."\n');
612
+ console.error(" [[tools]]");
613
+ console.error(' name = "execute_sql"');
614
+ console.error(' source = "default"');
615
+ console.error(" readonly = true\n");
616
+ console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
617
+ process.exit(1);
618
+ }
619
+ if (process.env.MAX_ROWS !== void 0) {
620
+ console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
621
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
622
+ console.error(" [[sources]]");
623
+ console.error(' id = "default"');
624
+ console.error(' dsn = "..."\n');
625
+ console.error(" [[tools]]");
626
+ console.error(' name = "execute_sql"');
627
+ console.error(' source = "default"');
628
+ console.error(" max_rows = 1000\n");
629
+ console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
630
+ process.exit(1);
631
+ }
632
+ return path.basename(envPath);
633
+ }
634
+ return null;
635
+ }
632
636
  function isDemoMode() {
633
637
  const args = parseCommandLineArgs();
634
638
  return args.demo === "true";
@@ -923,6 +927,9 @@ function resolveSSHConfig() {
923
927
  };
924
928
  }
925
929
  async function resolveSourceConfigs() {
930
+ if (parseCommandLineArgs().config) {
931
+ loadEnvFiles();
932
+ }
926
933
  if (!isDemoMode()) {
927
934
  const tomlConfig = loadTomlConfig();
928
935
  if (tomlConfig) {
@@ -932,6 +939,11 @@ async function resolveSourceConfigs() {
932
939
  "The --id flag cannot be used with TOML configuration. TOML config defines source IDs directly. Either remove the --id flag or use command-line DSN configuration instead."
933
940
  );
934
941
  }
942
+ if (parseCommandLineArgs().dsn) {
943
+ throw new Error(
944
+ `The --dsn flag cannot be used with TOML configuration (${tomlConfig.source}). TOML config defines database sources directly and supports multiple databases, while a DSN configures a single database. Either remove the --dsn flag or drop the --config flag.`
945
+ );
946
+ }
935
947
  return tomlConfig;
936
948
  }
937
949
  }
@@ -1039,8 +1051,9 @@ function loadTomlConfig() {
1039
1051
  }
1040
1052
  function resolveTomlConfigPath() {
1041
1053
  const args = parseCommandLineArgs();
1042
- if (args.config) {
1043
- const configPath = expandHomeDir(args.config);
1054
+ const configValue = requireFlagValue("config", args, "./dbhub.toml");
1055
+ if (configValue) {
1056
+ const configPath = expandHomeDir(configValue);
1044
1057
  if (!fs2.existsSync(configPath)) {
1045
1058
  throw new Error(
1046
1059
  `Configuration file specified by --config flag not found: ${configPath}`
@@ -1048,10 +1061,6 @@ function resolveTomlConfigPath() {
1048
1061
  }
1049
1062
  return configPath;
1050
1063
  }
1051
- const defaultConfigPath = path2.join(process.cwd(), "dbhub.toml");
1052
- if (fs2.existsSync(defaultConfigPath)) {
1053
- return defaultConfigPath;
1054
- }
1055
1064
  return null;
1056
1065
  }
1057
1066
  function validateTomlConfig(config, configPath) {
@@ -1206,6 +1215,11 @@ function validateDSNFieldConflicts(source, configPath) {
1206
1215
  if (source.database && info.database && source.database !== info.database) {
1207
1216
  conflict("database", source.database, info.database);
1208
1217
  }
1218
+ if (source.database && !info.database) {
1219
+ throw new Error(
1220
+ `Configuration file ${configPath}: source '${source.id}' has a 'database' field but the DSN names no database. The field is ignored at connection time \u2014 add the database to the DSN, or use individual connection parameters instead of a DSN.`
1221
+ );
1222
+ }
1209
1223
  if (source.user && info.user && source.user !== info.user) {
1210
1224
  conflict("user", source.user, info.user);
1211
1225
  }
package/dist/index.js CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  resolveSourceConfigs,
16
16
  resolveTomlConfigPath,
17
17
  resolveTransport
18
- } from "./chunk-COEDRFSK.js";
18
+ } from "./chunk-MQR6VX5F.js";
19
+ import {
20
+ allowedKeywords,
21
+ isReadOnlySQL
22
+ } from "./chunk-JO7YQPBQ.js";
19
23
  import {
20
24
  loadConnectors
21
25
  } from "./chunk-WVVMH6FJ.js";
@@ -27,8 +31,7 @@ import {
27
31
  getDatabaseTypeFromDSN,
28
32
  getDefaultPortForType,
29
33
  parseConnectionInfoFromDSN,
30
- splitSQLStatements,
31
- stripCommentsAndStrings
34
+ splitSQLStatements
32
35
  } from "./chunk-JEZZN2YZ.js";
33
36
 
34
37
  // src/server.ts
@@ -95,102 +98,6 @@ function createToolSuccessResponse(data, meta = {}) {
95
98
  };
96
99
  }
97
100
 
98
- // src/utils/allowed-keywords.ts
99
- var allowedKeywords = {
100
- postgres: ["select", "with", "explain", "show"],
101
- mysql: ["select", "with", "explain", "show", "describe", "desc"],
102
- mariadb: ["select", "with", "explain", "show", "describe", "desc"],
103
- sqlite: ["select", "with", "explain", "pragma"],
104
- // SQL Server has no native EXPLAIN statement; the connector translates a
105
- // leading `EXPLAIN` into a SET SHOWPLAN_XML request (see SQLServerConnector).
106
- sqlserver: ["select", "with", "explain"]
107
- };
108
- var mutatingKeywords = [
109
- "insert",
110
- "update",
111
- "delete",
112
- "drop",
113
- "alter",
114
- "create",
115
- "truncate",
116
- "merge",
117
- "grant",
118
- "revoke",
119
- "rename"
120
- ];
121
- var mutatingPattern = new RegExp(
122
- `\\b(?:${mutatingKeywords.join("|")})\\b`,
123
- "i"
124
- );
125
- var mutatingPatternWithReplace = new RegExp(
126
- `\\b(?:${mutatingKeywords.join("|")}|replace\\s+(?:(?:low_priority|delayed)\\s+)?into)\\b`,
127
- "i"
128
- );
129
- var mutatingPatternSqlServer = new RegExp(
130
- `\\b(?:${[...mutatingKeywords, "execute", "exec", "sp_executesql", "xp_cmdshell"].join("|")})\\b`,
131
- "i"
132
- );
133
- var mutatingPatterns = {
134
- postgres: mutatingPattern,
135
- mysql: mutatingPatternWithReplace,
136
- mariadb: mutatingPatternWithReplace,
137
- sqlite: mutatingPatternWithReplace,
138
- sqlserver: mutatingPatternSqlServer
139
- };
140
- var selectIntoPattern = /\bselect\b[\s\S]+\binto\b/i;
141
- var sqliteReadOnlyArgPragmas = /* @__PURE__ */ new Set([
142
- "table_info",
143
- "index_info",
144
- "index_list",
145
- "foreign_key_list"
146
- ]);
147
- var sqlitePragmaParenPattern = /^pragma\s+(?:[a-z0-9_]+\.)?([a-z0-9_]+)\s*\(/;
148
- var explainAnalyzePattern = /^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)/i;
149
- function isReadOnlySQL(sql, connectorType) {
150
- return checkReadOnly(
151
- stripCommentsAndStrings(sql, connectorType).trim().toLowerCase(),
152
- connectorType
153
- );
154
- }
155
- function checkReadOnly(cleanedSQL, connectorType) {
156
- if (!cleanedSQL) {
157
- return false;
158
- }
159
- const firstWord = cleanedSQL.match(/\S+/)?.[0] ?? "";
160
- const keywordList = allowedKeywords[connectorType] || [];
161
- if (!keywordList.includes(firstWord)) {
162
- return false;
163
- }
164
- if (firstWord === "with") {
165
- const pattern = mutatingPatterns[connectorType] ?? mutatingPattern;
166
- if (pattern.test(cleanedSQL)) {
167
- return false;
168
- }
169
- }
170
- if (firstWord === "pragma" && connectorType === "sqlite") {
171
- if (cleanedSQL.includes("=")) {
172
- return false;
173
- }
174
- const parenMatch = cleanedSQL.match(sqlitePragmaParenPattern);
175
- if (parenMatch && !sqliteReadOnlyArgPragmas.has(parenMatch[1])) {
176
- return false;
177
- }
178
- }
179
- if ((firstWord === "select" || firstWord === "with") && selectIntoPattern.test(cleanedSQL)) {
180
- return false;
181
- }
182
- if (firstWord === "explain") {
183
- const m = explainAnalyzePattern.exec(cleanedSQL);
184
- if (m) {
185
- const afterExplain = cleanedSQL.slice(m[0].length).trim();
186
- if (afterExplain && !checkReadOnly(afterExplain, connectorType)) {
187
- return false;
188
- }
189
- }
190
- }
191
- return true;
192
- }
193
-
194
101
  // src/requests/store.ts
195
102
  var RequestStore = class {
196
103
  constructor() {
@@ -377,7 +284,7 @@ async function getTableRowCount(connector, tableName, schemaName) {
377
284
  }
378
285
  const qualifiedTable = quoteQualifiedIdentifier(tableName, schemaName, connector.id);
379
286
  const countQuery = `SELECT COUNT(*) as count FROM ${qualifiedTable}`;
380
- const result = await connector.executeSQL(countQuery, { maxRows: 1 });
287
+ const result = await connector.executeSQL(countQuery, { maxRows: 1, readonly: true });
381
288
  if (result.rows && result.rows.length > 0) {
382
289
  return Number(result.rows[0].count || result.rows[0].COUNT || 0);
383
290
  }
@@ -510,7 +417,7 @@ async function searchViews(connector, pattern, schemaFilter, detailLevel, limit)
510
417
  if (schemaFilter) {
511
418
  schemasToSearch = [schemaFilter];
512
419
  } else {
513
- schemasToSearch = await connector.getSchemas();
420
+ schemasToSearch = await resolveDefaultSchemas(connector);
514
421
  }
515
422
  for (const schemaName of schemasToSearch) {
516
423
  if (results.length >= limit) break;
@@ -1676,7 +1583,7 @@ See documentation for more details on configuring database connections.
1676
1583
  const sources = sourceConfigsData.sources;
1677
1584
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1678
1585
  await connectorManager.connectWithSources(sources);
1679
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZME64VB2.js");
1586
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZU6ZFNQ2.js");
1680
1587
  initializeToolRegistry2({
1681
1588
  sources: sourceConfigsData.sources,
1682
1589
  tools: sourceConfigsData.tools
@@ -1830,11 +1737,11 @@ See documentation for more details on configuring database connections.
1830
1737
 
1831
1738
  // src/index.ts
1832
1739
  var connectorModules = [
1833
- { load: () => import("./postgres-BKBCGDZS.js"), name: "PostgreSQL", driver: "pg" },
1834
- { load: () => import("./sqlserver-65AGCEJX.js"), name: "SQL Server", driver: "mssql" },
1835
- { load: () => import("./sqlite-CQ4LCBB6.js"), name: "SQLite", driver: "node:sqlite" },
1836
- { load: () => import("./mysql-MZWCPISB.js"), name: "MySQL", driver: "mysql2" },
1837
- { load: () => import("./mariadb-SPFBL46R.js"), name: "MariaDB", driver: "mariadb" }
1740
+ { load: () => import("./postgres-ZQFXCBYA.js"), name: "PostgreSQL", driver: "pg" },
1741
+ { load: () => import("./sqlserver-LHWZ7WOP.js"), name: "SQL Server", driver: "mssql" },
1742
+ { load: () => import("./sqlite-JH5CNV5E.js"), name: "SQLite", driver: "node:sqlite" },
1743
+ { load: () => import("./mysql-MHIZK2UY.js"), name: "MySQL", driver: "mysql2" },
1744
+ { load: () => import("./mariadb-VZMU7LYA.js"), name: "MariaDB", driver: "mariadb" }
1838
1745
  ];
1839
1746
  loadConnectors(connectorModules).then(() => main()).catch((error) => {
1840
1747
  console.error("Fatal error:", error);
@@ -1,13 +1,18 @@
1
1
  import {
2
+ MissingDatabaseError,
2
3
  extractAffectedRows,
3
- parseQueryResults
4
- } from "./chunk-RTB262PR.js";
4
+ isTiDBVersion,
5
+ parseQueryResults,
6
+ requireDatabaseInDSN,
7
+ withReadOnlyTransaction
8
+ } from "./chunk-DUCNPMNW.js";
5
9
  import {
6
10
  quoteIdentifier
7
11
  } from "./chunk-JFWX35TB.js";
8
12
  import {
9
- SQLRowLimiter
10
- } from "./chunk-BXXHWEQS.js";
13
+ SQLRowLimiter,
14
+ closeQuietly
15
+ } from "./chunk-D2BIPC7V.js";
11
16
  import {
12
17
  ConnectorRegistry,
13
18
  SafeURL,
@@ -32,11 +37,12 @@ Expected: ${expectedFormat}`
32
37
  }
33
38
  try {
34
39
  const url = new SafeURL(dsn);
40
+ const database = url.pathname ? url.pathname.substring(1) : "";
41
+ requireDatabaseInDSN(database, dsn, "MariaDB");
35
42
  const connectionConfig = {
36
43
  host: url.hostname,
37
44
  port: url.port ? parseInt(url.port) : 3306,
38
- database: url.pathname ? url.pathname.substring(1) : "",
39
- // Remove leading '/' if exists
45
+ database,
40
46
  user: url.username,
41
47
  password: url.password,
42
48
  multipleStatements: true,
@@ -77,6 +83,9 @@ Expected: ${expectedFormat}`
77
83
  }
78
84
  return connectionConfig;
79
85
  } catch (error) {
86
+ if (error instanceof MissingDatabaseError) {
87
+ throw error;
88
+ }
80
89
  throw new Error(
81
90
  `Failed to parse MariaDB DSN: ${error instanceof Error ? error.message : String(error)}`
82
91
  );
@@ -101,6 +110,9 @@ var MariaDBConnector = class _MariaDBConnector {
101
110
  this.pool = null;
102
111
  // Source ID is set by ConnectorManager after cloning
103
112
  this.sourceId = "default";
113
+ // TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY`
114
+ // unless tidb_enable_noop_functions is on. Detected once at connect time.
115
+ this.supportsReadOnlyTransaction = true;
104
116
  }
105
117
  getId() {
106
118
  return this.sourceId;
@@ -112,8 +124,14 @@ var MariaDBConnector = class _MariaDBConnector {
112
124
  try {
113
125
  const connectionConfig = await this.dsnParser.parse(dsn, config);
114
126
  this.pool = mariadb.createPool(connectionConfig);
115
- await this.pool.query("SELECT 1");
127
+ const rows = await this.pool.query("SELECT VERSION() AS version");
128
+ this.supportsReadOnlyTransaction = !isTiDBVersion(rows?.[0]?.version);
116
129
  } catch (err) {
130
+ if (this.pool) {
131
+ const pool = this.pool;
132
+ this.pool = null;
133
+ await closeQuietly(() => pool.end());
134
+ }
117
135
  console.error("Failed to connect to MariaDB database:", err);
118
136
  throw err;
119
137
  }
@@ -473,46 +491,41 @@ var MariaDBConnector = class _MariaDBConnector {
473
491
  }
474
492
  const conn = await this.pool.getConnection();
475
493
  try {
476
- if (options.readonly) {
477
- await conn.query("START TRANSACTION READ ONLY");
478
- }
479
- let processedSQL = sql;
480
- if (options.maxRows) {
481
- const statements = splitSQLStatements(sql, "mariadb");
482
- const processedStatements = statements.map(
483
- (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
484
- );
485
- processedSQL = processedStatements.join("; ");
486
- if (sql.trim().endsWith(";")) {
487
- processedSQL += ";";
488
- }
489
- }
490
- let results;
491
- if (parameters && parameters.length > 0) {
492
- try {
493
- results = await conn.query(processedSQL, parameters);
494
- } catch (error) {
495
- console.error(`[MariaDB executeSQL] ERROR: ${error.message}`);
496
- console.error(`[MariaDB executeSQL] SQL: ${processedSQL}`);
497
- console.error(`[MariaDB executeSQL] Parameters: ${JSON.stringify(parameters)}`);
498
- throw error;
494
+ return await withReadOnlyTransaction(
495
+ conn,
496
+ options.readonly,
497
+ this.supportsReadOnlyTransaction,
498
+ async () => {
499
+ let processedSQL = sql;
500
+ if (options.maxRows) {
501
+ const statements = splitSQLStatements(sql, "mariadb");
502
+ const processedStatements = statements.map(
503
+ (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
504
+ );
505
+ processedSQL = processedStatements.join("; ");
506
+ if (sql.trim().endsWith(";")) {
507
+ processedSQL += ";";
508
+ }
509
+ }
510
+ let results;
511
+ if (parameters && parameters.length > 0) {
512
+ try {
513
+ results = await conn.query(processedSQL, parameters);
514
+ } catch (error) {
515
+ console.error(`[MariaDB executeSQL] ERROR: ${error.message}`);
516
+ console.error(`[MariaDB executeSQL] SQL: ${processedSQL}`);
517
+ console.error(`[MariaDB executeSQL] Parameters: ${JSON.stringify(parameters)}`);
518
+ throw error;
519
+ }
520
+ } else {
521
+ results = await conn.query(processedSQL);
522
+ }
523
+ const rows = parseQueryResults(results);
524
+ const rowCount = extractAffectedRows(results);
525
+ return { rows, rowCount };
499
526
  }
500
- } else {
501
- results = await conn.query(processedSQL);
502
- }
503
- const rows = parseQueryResults(results);
504
- const rowCount = extractAffectedRows(results);
505
- if (options.readonly) {
506
- await conn.query("COMMIT");
507
- }
508
- return { rows, rowCount };
527
+ );
509
528
  } catch (error) {
510
- if (options.readonly) {
511
- try {
512
- await conn.query("ROLLBACK");
513
- } catch {
514
- }
515
- }
516
529
  console.error("Error executing query:", error);
517
530
  throw error;
518
531
  } finally {
@@ -1,13 +1,18 @@
1
1
  import {
2
+ MissingDatabaseError,
2
3
  extractAffectedRows,
3
- parseQueryResults
4
- } from "./chunk-RTB262PR.js";
4
+ isTiDBVersion,
5
+ parseQueryResults,
6
+ requireDatabaseInDSN,
7
+ withReadOnlyTransaction
8
+ } from "./chunk-DUCNPMNW.js";
5
9
  import {
6
10
  quoteIdentifier
7
11
  } from "./chunk-JFWX35TB.js";
8
12
  import {
9
- SQLRowLimiter
10
- } from "./chunk-BXXHWEQS.js";
13
+ SQLRowLimiter,
14
+ closeQuietly
15
+ } from "./chunk-D2BIPC7V.js";
11
16
  import {
12
17
  ConnectorRegistry,
13
18
  SafeURL,
@@ -34,11 +39,12 @@ Expected: ${expectedFormat}`
34
39
  }
35
40
  try {
36
41
  const url = new SafeURL(dsn);
42
+ const database = url.pathname ? url.pathname.substring(1) : "";
43
+ requireDatabaseInDSN(database, dsn, "MySQL");
37
44
  const config2 = {
38
45
  host: url.hostname,
39
46
  port: url.port ? parseInt(url.port) : 3306,
40
- database: url.pathname ? url.pathname.substring(1) : "",
41
- // Remove leading '/' if exists
47
+ database,
42
48
  user: url.username,
43
49
  password: url.password,
44
50
  multipleStatements: true,
@@ -79,6 +85,9 @@ Expected: ${expectedFormat}`
79
85
  }
80
86
  return config2;
81
87
  } catch (error) {
88
+ if (error instanceof MissingDatabaseError) {
89
+ throw error;
90
+ }
82
91
  throw new Error(
83
92
  `Failed to parse MySQL DSN: ${error instanceof Error ? error.message : String(error)}`
84
93
  );
@@ -103,6 +112,9 @@ var MySQLConnector = class _MySQLConnector {
103
112
  this.pool = null;
104
113
  // Source ID is set by ConnectorManager after cloning
105
114
  this.sourceId = "default";
115
+ // TiDB speaks the MySQL protocol but rejects `START TRANSACTION READ ONLY`
116
+ // unless tidb_enable_noop_functions is on. Detected once at connect time.
117
+ this.supportsReadOnlyTransaction = true;
106
118
  }
107
119
  getId() {
108
120
  return this.sourceId;
@@ -117,8 +129,14 @@ var MySQLConnector = class _MySQLConnector {
117
129
  if (config?.queryTimeoutSeconds !== void 0) {
118
130
  this.queryTimeoutMs = config.queryTimeoutSeconds * 1e3;
119
131
  }
120
- const [rows] = await this.pool.query("SELECT 1");
132
+ const [rows] = await this.pool.query("SELECT VERSION() AS version");
133
+ this.supportsReadOnlyTransaction = !isTiDBVersion(rows[0]?.version);
121
134
  } catch (err) {
135
+ if (this.pool) {
136
+ const pool = this.pool;
137
+ this.pool = null;
138
+ await closeQuietly(() => pool.end());
139
+ }
122
140
  console.error("Failed to connect to MySQL database:", err);
123
141
  throw err;
124
142
  }
@@ -478,47 +496,42 @@ var MySQLConnector = class _MySQLConnector {
478
496
  }
479
497
  const conn = await this.pool.getConnection();
480
498
  try {
481
- if (options.readonly) {
482
- await conn.query("START TRANSACTION READ ONLY");
483
- }
484
- let processedSQL = sql;
485
- if (options.maxRows) {
486
- const statements = splitSQLStatements(sql, "mysql");
487
- const processedStatements = statements.map(
488
- (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
489
- );
490
- processedSQL = processedStatements.join("; ");
491
- if (sql.trim().endsWith(";")) {
492
- processedSQL += ";";
493
- }
494
- }
495
- let results;
496
- if (parameters && parameters.length > 0) {
497
- try {
498
- results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters);
499
- } catch (error) {
500
- console.error(`[MySQL executeSQL] ERROR: ${error.message}`);
501
- console.error(`[MySQL executeSQL] SQL: ${processedSQL}`);
502
- console.error(`[MySQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
503
- throw error;
499
+ return await withReadOnlyTransaction(
500
+ conn,
501
+ options.readonly,
502
+ this.supportsReadOnlyTransaction,
503
+ async () => {
504
+ let processedSQL = sql;
505
+ if (options.maxRows) {
506
+ const statements = splitSQLStatements(sql, "mysql");
507
+ const processedStatements = statements.map(
508
+ (statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
509
+ );
510
+ processedSQL = processedStatements.join("; ");
511
+ if (sql.trim().endsWith(";")) {
512
+ processedSQL += ";";
513
+ }
514
+ }
515
+ let results;
516
+ if (parameters && parameters.length > 0) {
517
+ try {
518
+ results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters);
519
+ } catch (error) {
520
+ console.error(`[MySQL executeSQL] ERROR: ${error.message}`);
521
+ console.error(`[MySQL executeSQL] SQL: ${processedSQL}`);
522
+ console.error(`[MySQL executeSQL] Parameters: ${JSON.stringify(parameters)}`);
523
+ throw error;
524
+ }
525
+ } else {
526
+ results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs });
527
+ }
528
+ const [firstResult] = results;
529
+ const rows = parseQueryResults(firstResult);
530
+ const rowCount = extractAffectedRows(firstResult);
531
+ return { rows, rowCount };
504
532
  }
505
- } else {
506
- results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs });
507
- }
508
- const [firstResult] = results;
509
- const rows = parseQueryResults(firstResult);
510
- const rowCount = extractAffectedRows(firstResult);
511
- if (options.readonly) {
512
- await conn.query("COMMIT");
513
- }
514
- return { rows, rowCount };
533
+ );
515
534
  } catch (error) {
516
- if (options.readonly) {
517
- try {
518
- await conn.query("ROLLBACK");
519
- } catch {
520
- }
521
- }
522
535
  console.error("Error executing query:", error);
523
536
  throw error;
524
537
  } finally {
@@ -2,8 +2,9 @@ import {
2
2
  quoteIdentifier
3
3
  } from "./chunk-JFWX35TB.js";
4
4
  import {
5
- SQLRowLimiter
6
- } from "./chunk-BXXHWEQS.js";
5
+ SQLRowLimiter,
6
+ closeQuietly
7
+ } from "./chunk-D2BIPC7V.js";
7
8
  import {
8
9
  ConnectorRegistry,
9
10
  SafeURL,
@@ -148,6 +149,11 @@ var PostgresConnector = class _PostgresConnector {
148
149
  const client = await this.pool.connect();
149
150
  client.release();
150
151
  } catch (err) {
152
+ if (this.pool) {
153
+ const pool = this.pool;
154
+ this.pool = null;
155
+ await closeQuietly(() => pool.end());
156
+ }
151
157
  console.error("Failed to connect to PostgreSQL database:", err);
152
158
  throw err;
153
159
  }
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-COEDRFSK.js";
5
+ } from "./chunk-MQR6VX5F.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
7
  import "./chunk-JEZZN2YZ.js";
8
8
  export {
@@ -2,8 +2,9 @@ import {
2
2
  quoteIdentifier
3
3
  } from "./chunk-JFWX35TB.js";
4
4
  import {
5
- SQLRowLimiter
6
- } from "./chunk-BXXHWEQS.js";
5
+ SQLRowLimiter,
6
+ closeQuietly
7
+ } from "./chunk-D2BIPC7V.js";
7
8
  import {
8
9
  ConnectorRegistry,
9
10
  SafeURL,
@@ -150,6 +151,11 @@ var SQLiteConnector = class _SQLiteConnector {
150
151
  this.db.exec(initScript);
151
152
  }
152
153
  } catch (error) {
154
+ if (this.db) {
155
+ const db = this.db;
156
+ this.db = null;
157
+ await closeQuietly(() => db.close());
158
+ }
153
159
  console.error("Failed to connect to SQLite database:", error);
154
160
  throw error;
155
161
  }
@@ -1,9 +1,16 @@
1
+ import {
2
+ sqlServerDynamicSqlKeywords,
3
+ sqlServerDynamicSqlPattern,
4
+ sqlServerPassThroughKeywords,
5
+ sqlServerPassThroughPattern
6
+ } from "./chunk-JO7YQPBQ.js";
1
7
  import {
2
8
  isDriverNotInstalled
3
9
  } from "./chunk-WVVMH6FJ.js";
4
10
  import {
5
- SQLRowLimiter
6
- } from "./chunk-BXXHWEQS.js";
11
+ SQLRowLimiter,
12
+ closeQuietly
13
+ } from "./chunk-D2BIPC7V.js";
7
14
  import {
8
15
  ConnectorRegistry,
9
16
  SafeURL,
@@ -156,8 +163,14 @@ var _SQLServerConnector = class _SQLServerConnector {
156
163
  if (!this.config.options) {
157
164
  this.config.options = {};
158
165
  }
159
- this.connection = await new sql.ConnectionPool(this.config).connect();
166
+ this.connection = new sql.ConnectionPool(this.config);
167
+ await this.connection.connect();
160
168
  } catch (error) {
169
+ if (this.connection) {
170
+ const connection = this.connection;
171
+ this.connection = void 0;
172
+ await closeQuietly(() => connection.close());
173
+ }
161
174
  throw error;
162
175
  }
163
176
  }
@@ -462,7 +475,7 @@ var _SQLServerConnector = class _SQLServerConnector {
462
475
  sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
463
476
  );
464
477
  if (/^explain\b/i.test(afterNoise)) {
465
- return this.explainQuery(afterNoise.slice("explain".length).trim());
478
+ return this.explainQuery(afterNoise.slice("explain".length).trim(), options.readonly);
466
479
  }
467
480
  try {
468
481
  let processedSQL = sqlQuery;
@@ -529,30 +542,55 @@ var _SQLServerConnector = class _SQLServerConnector {
529
542
  }
530
543
  }
531
544
  /**
532
- * Execute a query inside a transaction that always rolls back, preventing
533
- * any modifications from persisting. SQL Server has no native READ ONLY
534
- * transaction mode, so this is the defense-in-depth backstop behind the
535
- * keyword classifier.
545
+ * Reject the constructs that escape SQL Server's read-only guards, for use by
546
+ * both read-only execution paths.
536
547
  *
537
- * Because the rollback guard is application-level (not engine-enforced),
538
- * we reject dangerous keywords in the stripped SQL before opening the
539
- * transaction:
540
- * - COMMIT/ROLLBACK: would end the outer transaction, letting writes persist
541
- * - EXEC/EXECUTE/sp_executesql/xp_cmdshell: dynamic SQL can carry hidden
542
- * COMMIT/ROLLBACK inside string literals that stripCommentsAndStrings removes
548
+ * - Dynamic SQL (sqlServerDynamicSqlKeywords): can carry hidden COMMIT/ROLLBACK
549
+ * inside string literals that stripCommentsAndStrings removes
550
+ * - Pass-through data sources (sqlServerPassThroughKeywords): execute on a
551
+ * remote or ad-hoc source, so a local rollback never reaches them
552
+ * - COMMIT/ROLLBACK, when `transactionControl` is set: would end the wrapping
553
+ * transaction, letting writes persist. Only meaningful for the transaction
554
+ * path; the EXPLAIN path opens no transaction of its own.
555
+ *
556
+ * Both keyword lists are imported from the read-only classifier rather than
557
+ * redeclared, so the classifier and these backstops cannot drift apart.
558
+ *
559
+ * Note the COMMIT/ROLLBACK check is SQL Server-only by design. MySQL/MariaDB
560
+ * wrap batches in a transaction too, but there `commit`, `prepare` and
561
+ * `execute` are absent from their allow-lists in allowedKeywords, and
562
+ * execute-sql.ts requires every split statement to pass the classifier — so a
563
+ * transaction-control statement can never reach their backstop.
543
564
  */
544
- async executeReadOnly(processedSQL, parameters) {
545
- const cleaned = stripCommentsAndStrings(processedSQL, "sqlserver").toLowerCase();
546
- if (/\b(?:commit|rollback)\b/.test(cleaned)) {
565
+ assertNoReadOnlyEscapes(sqlText, { transactionControl = false } = {}) {
566
+ const cleaned = stripCommentsAndStrings(sqlText, "sqlserver").toLowerCase();
567
+ if (transactionControl && /\b(?:commit|rollback)\b/.test(cleaned)) {
547
568
  throw new Error(
548
569
  "Read-only mode: transaction control statements (COMMIT, ROLLBACK) are not allowed"
549
570
  );
550
571
  }
551
- if (/\b(?:exec|execute|sp_executesql|xp_cmdshell)\b/.test(cleaned)) {
572
+ if (sqlServerDynamicSqlPattern.test(cleaned)) {
573
+ throw new Error(
574
+ `Read-only mode: dynamic SQL execution (${sqlServerDynamicSqlKeywords.map((k) => k.toUpperCase()).join(", ")}) is not allowed`
575
+ );
576
+ }
577
+ if (sqlServerPassThroughPattern.test(cleaned)) {
552
578
  throw new Error(
553
- "Read-only mode: dynamic SQL execution (EXEC, EXECUTE, sp_executesql, xp_cmdshell) is not allowed"
579
+ `Read-only mode: pass-through data sources (${sqlServerPassThroughKeywords.map((k) => k.toUpperCase()).join(", ")}) are not allowed`
554
580
  );
555
581
  }
582
+ }
583
+ /**
584
+ * Execute a query inside a transaction that always rolls back, preventing
585
+ * any modifications from persisting. SQL Server has no native READ ONLY
586
+ * transaction mode, so this is the defense-in-depth backstop behind the
587
+ * keyword classifier.
588
+ *
589
+ * Dangerous constructs are rejected before the transaction opens; see
590
+ * assertNoReadOnlyEscapes.
591
+ */
592
+ async executeReadOnly(processedSQL, parameters) {
593
+ this.assertNoReadOnlyEscapes(processedSQL, { transactionControl: true });
556
594
  const transaction = new sql.Transaction(this.connection);
557
595
  await transaction.begin();
558
596
  const request = new sql.Request(transaction);
@@ -633,11 +671,14 @@ var _SQLServerConnector = class _SQLServerConnector {
633
671
  * off the shared pool, so a concurrent query can never land on a connection
634
672
  * with SHOWPLAN enabled (which would return a plan instead of its results).
635
673
  */
636
- async explainQuery(innerQuery) {
674
+ async explainQuery(innerQuery, readonly) {
637
675
  const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
638
676
  if (!cleaned) {
639
677
  throw new Error("EXPLAIN requires a statement to analyze");
640
678
  }
679
+ if (readonly) {
680
+ this.assertNoReadOnlyEscapes(innerQuery);
681
+ }
641
682
  if (/\bset\s+showplan/i.test(cleaned)) {
642
683
  throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
643
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "mcpName": "io.github.bytebase/dbhub",
5
5
  "description": "Minimal, token-efficient Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
6
6
  "repository": {