@bytebase/dbhub 1.0.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/README.md +10 -52
- package/dist/{chunk-MQR6VX5F.js → chunk-CUBIR72I.js} +30 -5
- package/dist/chunk-FU2ZJE4E.js +16 -0
- package/dist/{chunk-JO7YQPBQ.js → chunk-PBN2UXB5.js} +25 -3
- package/dist/chunk-QEFMUQJ2.js +179 -0
- package/dist/{chunk-S2V6RZ3C.js → chunk-RSB7DPO2.js} +64 -3
- package/dist/{chunk-JEZZN2YZ.js → chunk-WIQ4DWGY.js} +16 -0
- package/dist/index.js +341 -57
- package/dist/{mariadb-72C2BNCG.js → mariadb-E33EXGJZ.js} +16 -9
- package/dist/{mysql-CDUKJMNN.js → mysql-RLDQI3SJ.js} +68 -11
- package/dist/{postgres-YAYHDXVD.js → postgres-6XY2GPQV.js} +80 -13
- package/dist/public/assets/{index-DBYlgGks.js → index-DOZ5HOZ9.js} +21 -20
- package/dist/public/index.html +1 -1
- package/dist/{registry-ZU6ZFNQ2.js → registry-XMEJF7WW.js} +2 -2
- package/dist/{sqlite-5M7SDAQZ.js → sqlite-CZE6Z36Y.js} +14 -13
- package/dist/{sqlserver-COKW7RIK.js → sqlserver-IIGRXGYK.js} +148 -13
- package/package.json +4 -2
- package/dist/chunk-DUCNPMNW.js +0 -113
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
> [!NOTE]
|
|
2
|
-
> Brought to you by [Bytebase](https://www.bytebase.com/), open-source database
|
|
2
|
+
> Brought to you by [Bytebase](https://www.bytebase.com/), open-source database governance platform.
|
|
3
3
|
|
|
4
4
|
<p align="center">
|
|
5
5
|
<a href="https://dbhub.ai/" target="_blank">
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
MCP Clients MCP Server Databases
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
DBHub is a zero-dependency
|
|
33
|
+
DBHub is a token efficient, zero-dependency MCP server implementing the Model Context Protocol (MCP) server interface. This lightweight gateway allows MCP-compatible clients to connect to and explore different databases:
|
|
34
34
|
|
|
35
|
-
- **Local Development First**: Zero dependency, token efficient with
|
|
35
|
+
- **Local Development First**: Zero dependency, token efficient with a minimal set of MCP tools to maximize context window
|
|
36
36
|
- **Multi-Database**: PostgreSQL, MySQL, MariaDB, SQL Server, and SQLite through a single interface
|
|
37
37
|
- **Multi-Connection**: Connect to multiple databases simultaneously with TOML configuration
|
|
38
38
|
- **Guardrails**: Read-only mode, row limiting, and query timeout to prevent runaway operations
|
|
@@ -48,6 +48,8 @@ DBHub implements MCP tools for database operations:
|
|
|
48
48
|
|
|
49
49
|
- **[execute_sql](https://dbhub.ai/tools/execute-sql)**: Execute SQL queries with transaction support and safety controls
|
|
50
50
|
- **[search_objects](https://dbhub.ai/tools/search-objects)**: Search and explore database schemas, tables, columns, indexes, and procedures with progressive disclosure
|
|
51
|
+
- **[explain_sql](https://dbhub.ai/tools/explain-sql)** (opt-in): Show a query's execution plan without running it
|
|
52
|
+
- **[health_check](https://dbhub.ai/tools/health-check)** (opt-in): Report connection pool state and buffer cache hit ratio
|
|
51
53
|
- **[Custom Tools](https://dbhub.ai/tools/custom-tools)**: Define reusable, parameterized SQL operations in your `dbhub.toml` configuration file
|
|
52
54
|
|
|
53
55
|
## Workbench
|
|
@@ -58,61 +60,17 @@ DBHub includes a [built-in web interface](https://dbhub.ai/workbench/overview) f
|
|
|
58
60
|
|
|
59
61
|
## Installation
|
|
60
62
|
|
|
61
|
-
See the full [Installation Guide](https://dbhub.ai/installation) for detailed instructions.
|
|
62
|
-
|
|
63
|
-
### Quick Start
|
|
64
|
-
|
|
65
|
-
**Docker:**
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
docker run --rm --init \
|
|
69
|
-
--name dbhub \
|
|
70
|
-
--publish 8080:8080 \
|
|
71
|
-
bytebase/dbhub \
|
|
72
|
-
--transport http \
|
|
73
|
-
--port 8080 \
|
|
74
|
-
--dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
**NPM:** (requires Node.js >= 22.5.0)
|
|
78
|
-
|
|
79
63
|
```bash
|
|
80
64
|
npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
|
81
65
|
```
|
|
82
66
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Download `dbhub-<version>.mcpb` from the [latest release](https://github.com/bytebase/dbhub/releases/latest) and install it in any [MCPB-compatible client](https://github.com/modelcontextprotocol/mcpb) — Claude Desktop (double-click, or drag into Settings → Extensions), Claude Code, or MCP for Windows — then enter your database connection string. The bundle runs locally over stdio, is **read-only by design** (writes are rejected and the database session is set to read-only at the engine level), and needs no remote endpoint or OAuth setup — ideal for giving non-technical teammates curated, read-only database access. Pair it with a least-privilege, read-only database account. See the [MCP Bundle guide](https://dbhub.ai/mcpb) for details and for packaging your own bundle.
|
|
86
|
-
|
|
87
|
-
**Claude Code Plugin:**
|
|
88
|
-
|
|
89
|
-
For Claude Code, the [DBHub plugin](https://dbhub.ai/claude-code-plugin) registers the MCP server with one install command — Claude Code prompts for your connection string and stores it in secure storage — and adds `/dbhub:setup` and `/dbhub:explore` skills. Read-only by design, like the MCP Bundle.
|
|
90
|
-
|
|
91
|
-
**Demo Mode:**
|
|
92
|
-
|
|
93
|
-
```bash
|
|
94
|
-
npx @bytebase/dbhub@latest --transport http --port 8080 --demo
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
**Restrict to loopback (recommended for production):**
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
> The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients.
|
|
104
|
-
>
|
|
105
|
-
> The HTTP transport also has built-in DNS-rebinding protection: it only accepts requests whose `Host` is loopback, this machine's own hostname/IPs, or a name you allow via [`--allowed-hosts`](https://dbhub.ai/config/command-line#allowed-hosts). If a client behind a reverse proxy or custom DNS name gets a `403`, add that hostname with `--allowed-hosts`.
|
|
106
|
-
>
|
|
107
|
-
> DBHub serves both MCP protocol eras on the same `/mcp` endpoint: the stateless **2026-07-28** revision (cacheable `tools/list`, no session state — safe behind round-robin load balancers) and the 2025-era Streamable HTTP protocol for older clients. 2026-era clients send the standard `Mcp-Method` / `Mcp-Name` headers on every request, so a fronting gateway or WAF can route and rate-limit `execute_sql` separately from `search_objects` without parsing JSON bodies.
|
|
108
|
-
|
|
109
|
-
See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
|
|
110
|
-
|
|
111
|
-
### Multi-Database Setup
|
|
67
|
+
Also available as:
|
|
112
68
|
|
|
113
|
-
|
|
69
|
+
- [Docker image](https://dbhub.ai/installation#docker)
|
|
70
|
+
- [MCP Bundle](https://dbhub.ai/mcpb) (one-click install, read-only)
|
|
71
|
+
- [Claude Code plugin](https://dbhub.ai/claude-code-plugin)
|
|
114
72
|
|
|
115
|
-
See [Multi-Database Configuration](https://dbhub.ai/config/toml) for
|
|
73
|
+
See the [Installation Guide](https://dbhub.ai/installation) for all options, [Command-Line Options](https://dbhub.ai/config/command-line) for parameters, and [Multi-Database Configuration](https://dbhub.ai/config/toml) for connecting several databases at once.
|
|
116
74
|
|
|
117
75
|
## Development
|
|
118
76
|
|
|
@@ -8,15 +8,22 @@ import {
|
|
|
8
8
|
getDefaultPortForType,
|
|
9
9
|
parseConnectionInfoFromDSN,
|
|
10
10
|
stripCommentsAndStrings
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-WIQ4DWGY.js";
|
|
12
12
|
|
|
13
13
|
// src/tools/builtin-tools.ts
|
|
14
14
|
var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
|
|
15
15
|
var BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects";
|
|
16
|
+
var BUILTIN_TOOL_EXPLAIN_SQL = "explain_sql";
|
|
17
|
+
var BUILTIN_TOOL_HEALTH_CHECK = "health_check";
|
|
16
18
|
var BUILTIN_TOOLS = [
|
|
17
19
|
BUILTIN_TOOL_EXECUTE_SQL,
|
|
18
20
|
BUILTIN_TOOL_SEARCH_OBJECTS
|
|
19
21
|
];
|
|
22
|
+
var ALL_BUILTIN_TOOL_NAMES = [
|
|
23
|
+
...BUILTIN_TOOLS,
|
|
24
|
+
BUILTIN_TOOL_EXPLAIN_SQL,
|
|
25
|
+
BUILTIN_TOOL_HEALTH_CHECK
|
|
26
|
+
];
|
|
20
27
|
|
|
21
28
|
// src/utils/ssh-tunnel.ts
|
|
22
29
|
import { Client } from "ssh2";
|
|
@@ -798,6 +805,21 @@ function resolveAllowedHosts() {
|
|
|
798
805
|
function splitHostList(value) {
|
|
799
806
|
return value.split(",").map((h) => h.trim()).filter((h) => h.length > 0);
|
|
800
807
|
}
|
|
808
|
+
function resolveAuthTokens() {
|
|
809
|
+
const args = parseCommandLineArgs();
|
|
810
|
+
const cliValue = requireFlagValue("auth-token", args, "secret1,secret2");
|
|
811
|
+
if (cliValue !== void 0) {
|
|
812
|
+
return { tokens: splitTokenList(cliValue), source: "command line argument" };
|
|
813
|
+
}
|
|
814
|
+
const envValue = process.env.DBHUB_AUTH_TOKEN?.trim();
|
|
815
|
+
if (envValue) {
|
|
816
|
+
return { tokens: splitTokenList(envValue), source: "environment variable" };
|
|
817
|
+
}
|
|
818
|
+
return { tokens: [], source: "default" };
|
|
819
|
+
}
|
|
820
|
+
function splitTokenList(value) {
|
|
821
|
+
return value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
822
|
+
}
|
|
801
823
|
function redactDSN(dsn) {
|
|
802
824
|
try {
|
|
803
825
|
const url = new URL(dsn);
|
|
@@ -1130,7 +1152,7 @@ function validateToolsConfig(tools, sources, configPath) {
|
|
|
1130
1152
|
`Configuration file ${configPath}: tool '${tool.name}' references unknown source '${tool.source}'`
|
|
1131
1153
|
);
|
|
1132
1154
|
}
|
|
1133
|
-
const isBuiltin =
|
|
1155
|
+
const isBuiltin = ALL_BUILTIN_TOOL_NAMES.includes(tool.name);
|
|
1134
1156
|
const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL;
|
|
1135
1157
|
if (isBuiltin) {
|
|
1136
1158
|
if (tool.description || tool.statement || tool.parameters) {
|
|
@@ -2278,7 +2300,7 @@ var ToolRegistry = class {
|
|
|
2278
2300
|
* Check if a tool name is a built-in tool
|
|
2279
2301
|
*/
|
|
2280
2302
|
isBuiltinTool(toolName) {
|
|
2281
|
-
return
|
|
2303
|
+
return ALL_BUILTIN_TOOL_NAMES.includes(toolName);
|
|
2282
2304
|
}
|
|
2283
2305
|
/**
|
|
2284
2306
|
* Validate a custom tool parameter definition
|
|
@@ -2350,10 +2372,10 @@ var ToolRegistry = class {
|
|
|
2350
2372
|
`Tool '${toolConfig.name}' references unknown source '${toolConfig.source}'. Available sources: ${availableSources.join(", ")}`
|
|
2351
2373
|
);
|
|
2352
2374
|
}
|
|
2353
|
-
for (const builtinName of
|
|
2375
|
+
for (const builtinName of ALL_BUILTIN_TOOL_NAMES) {
|
|
2354
2376
|
if (toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`)) {
|
|
2355
2377
|
throw new Error(
|
|
2356
|
-
`Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${
|
|
2378
|
+
`Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${ALL_BUILTIN_TOOL_NAMES.join(", ")}`
|
|
2357
2379
|
);
|
|
2358
2380
|
}
|
|
2359
2381
|
}
|
|
@@ -2485,9 +2507,12 @@ export {
|
|
|
2485
2507
|
resolvePort,
|
|
2486
2508
|
resolveHost,
|
|
2487
2509
|
resolveAllowedHosts,
|
|
2510
|
+
resolveAuthTokens,
|
|
2488
2511
|
resolveSourceConfigs,
|
|
2489
2512
|
BUILTIN_TOOL_EXECUTE_SQL,
|
|
2490
2513
|
BUILTIN_TOOL_SEARCH_OBJECTS,
|
|
2514
|
+
BUILTIN_TOOL_EXPLAIN_SQL,
|
|
2515
|
+
BUILTIN_TOOL_HEALTH_CHECK,
|
|
2491
2516
|
loadTomlConfig,
|
|
2492
2517
|
resolveTomlConfigPath,
|
|
2493
2518
|
classifyConnectionError,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/connectors/health-check-utils.ts
|
|
2
|
+
function toNullableNumber(value) {
|
|
3
|
+
return value === null || value === void 0 ? null : Number(value);
|
|
4
|
+
}
|
|
5
|
+
function computeHitRatioPct(logicalReads, physicalReads) {
|
|
6
|
+
if (!Number.isFinite(logicalReads) || !Number.isFinite(physicalReads) || logicalReads === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const pct = Math.round((logicalReads - physicalReads) / logicalReads * 1e4) / 100;
|
|
10
|
+
return Math.min(100, Math.max(0, pct));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
toNullableNumber,
|
|
15
|
+
computeHitRatioPct
|
|
16
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
stripCommentsAndStrings
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-WIQ4DWGY.js";
|
|
4
4
|
|
|
5
5
|
// src/utils/allowed-keywords.ts
|
|
6
6
|
var allowedKeywords = {
|
|
@@ -52,6 +52,22 @@ var sqlServerPassThroughPattern = new RegExp(
|
|
|
52
52
|
`\\b(?:${sqlServerPassThroughKeywords.join("|")})\\s*\\(`,
|
|
53
53
|
"i"
|
|
54
54
|
);
|
|
55
|
+
var escapeHatchFunctionKeywords = {
|
|
56
|
+
mysql: ["load_file", "get_lock", "release_lock", "release_all_locks"],
|
|
57
|
+
mariadb: ["load_file", "get_lock", "release_lock", "release_all_locks"],
|
|
58
|
+
postgres: ["pg_read_file", "pg_read_binary_file", "pg_ls_dir"],
|
|
59
|
+
sqlserver: sqlServerPassThroughKeywords
|
|
60
|
+
};
|
|
61
|
+
var escapeHatchFunctionPatterns = Object.fromEntries(
|
|
62
|
+
Object.entries(escapeHatchFunctionKeywords).map(([type, keywords]) => [
|
|
63
|
+
type,
|
|
64
|
+
new RegExp(`\\b(?:${keywords.join("|")})\\s*\\(`, "i")
|
|
65
|
+
])
|
|
66
|
+
);
|
|
67
|
+
function hasEscapeHatchFunction(strippedSQL, connectorType) {
|
|
68
|
+
const pattern = escapeHatchFunctionPatterns[connectorType];
|
|
69
|
+
return pattern !== void 0 && pattern.test(strippedSQL);
|
|
70
|
+
}
|
|
55
71
|
var mutatingPatternSqlServer = new RegExp(
|
|
56
72
|
`\\b(?:${[...mutatingKeywords, ...sqlServerDynamicSqlKeywords].join("|")})\\b`,
|
|
57
73
|
"i"
|
|
@@ -78,6 +94,10 @@ function isReadOnlySQL(sql, connectorType) {
|
|
|
78
94
|
connectorType
|
|
79
95
|
);
|
|
80
96
|
}
|
|
97
|
+
function getFirstKeyword(sql, connectorType) {
|
|
98
|
+
const cleaned = stripCommentsAndStrings(sql, connectorType).trim();
|
|
99
|
+
return cleaned.match(/\S+/)?.[0]?.toLowerCase() ?? "";
|
|
100
|
+
}
|
|
81
101
|
function checkReadOnly(cleanedSQL, connectorType) {
|
|
82
102
|
if (!cleanedSQL) {
|
|
83
103
|
return false;
|
|
@@ -87,7 +107,7 @@ function checkReadOnly(cleanedSQL, connectorType) {
|
|
|
87
107
|
if (!keywordList.includes(firstWord)) {
|
|
88
108
|
return false;
|
|
89
109
|
}
|
|
90
|
-
if (
|
|
110
|
+
if (hasEscapeHatchFunction(cleanedSQL, connectorType)) {
|
|
91
111
|
return false;
|
|
92
112
|
}
|
|
93
113
|
if (firstWord === "with") {
|
|
@@ -126,5 +146,7 @@ export {
|
|
|
126
146
|
sqlServerDynamicSqlPattern,
|
|
127
147
|
sqlServerPassThroughKeywords,
|
|
128
148
|
sqlServerPassThroughPattern,
|
|
129
|
-
|
|
149
|
+
hasEscapeHatchFunction,
|
|
150
|
+
isReadOnlySQL,
|
|
151
|
+
getFirstKeyword
|
|
130
152
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeHitRatioPct,
|
|
3
|
+
toNullableNumber
|
|
4
|
+
} from "./chunk-FU2ZJE4E.js";
|
|
5
|
+
import {
|
|
6
|
+
obfuscateDSNPassword
|
|
7
|
+
} from "./chunk-WIQ4DWGY.js";
|
|
8
|
+
|
|
9
|
+
// src/connectors/mysql-family-health-check.ts
|
|
10
|
+
async function getMySQLFamilyHealthCheck(query) {
|
|
11
|
+
const [processlistRows, maxConnRows, bufferPoolRows, trxOutcome] = await Promise.all([
|
|
12
|
+
query(`
|
|
13
|
+
SELECT
|
|
14
|
+
COUNT(*) AS total,
|
|
15
|
+
SUM(CASE WHEN COMMAND != 'Sleep' THEN 1 ELSE 0 END) AS active,
|
|
16
|
+
SUM(CASE WHEN COMMAND = 'Sleep' THEN 1 ELSE 0 END) AS idle,
|
|
17
|
+
MAX(CASE WHEN COMMAND != 'Sleep' THEN TIME ELSE NULL END) AS longest_active_query_seconds
|
|
18
|
+
FROM information_schema.PROCESSLIST
|
|
19
|
+
WHERE ID <> CONNECTION_ID()
|
|
20
|
+
`),
|
|
21
|
+
query(`SHOW VARIABLES LIKE 'max_connections'`),
|
|
22
|
+
query(`
|
|
23
|
+
SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads')
|
|
24
|
+
`),
|
|
25
|
+
// Joining PROCESSLIST against INNODB_TRX to detect idle-in-transaction
|
|
26
|
+
// sessions requires the PROCESS privilege - unlike a plain PROCESSLIST
|
|
27
|
+
// select, which silently narrows to the caller's own session instead of
|
|
28
|
+
// erroring. Run concurrently with the other queries above, but resolve
|
|
29
|
+
// rather than reject on failure so it degrades instead of failing the
|
|
30
|
+
// whole health check.
|
|
31
|
+
query(`
|
|
32
|
+
SELECT
|
|
33
|
+
COUNT(*) AS idle_in_transaction,
|
|
34
|
+
MAX(TIMESTAMPDIFF(SECOND, t.trx_started, NOW())) AS longest_idle_in_transaction_seconds
|
|
35
|
+
FROM information_schema.INNODB_TRX t
|
|
36
|
+
JOIN information_schema.PROCESSLIST p ON p.ID = t.trx_mysql_thread_id
|
|
37
|
+
WHERE p.COMMAND = 'Sleep'
|
|
38
|
+
`).then(
|
|
39
|
+
(rows) => ({ ok: true, rows }),
|
|
40
|
+
() => ({ ok: false })
|
|
41
|
+
)
|
|
42
|
+
]);
|
|
43
|
+
const notes = [];
|
|
44
|
+
let idleInTransaction = 0;
|
|
45
|
+
let longestIdleInTransactionSeconds = null;
|
|
46
|
+
if (trxOutcome.ok) {
|
|
47
|
+
idleInTransaction = Number(trxOutcome.rows[0].idle_in_transaction);
|
|
48
|
+
longestIdleInTransactionSeconds = toNullableNumber(trxOutcome.rows[0].longest_idle_in_transaction_seconds);
|
|
49
|
+
} else {
|
|
50
|
+
notes.push(
|
|
51
|
+
"Connected user lacks the PROCESS privilege: PROCESSLIST visibility is restricted to the connecting user's own sessions, and this diagnostic session is excluded from the count, so connection counts and active-query duration may under-report (down to 0). Idle-in-transaction detection is unavailable."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const proc = processlistRows[0];
|
|
55
|
+
const bufferPoolStats = Object.fromEntries(bufferPoolRows.map((row) => [row.Variable_name, row.Value]));
|
|
56
|
+
const readRequests = Number(bufferPoolStats.Innodb_buffer_pool_read_requests ?? 0);
|
|
57
|
+
const reads = Number(bufferPoolStats.Innodb_buffer_pool_reads ?? 0);
|
|
58
|
+
return {
|
|
59
|
+
connections: {
|
|
60
|
+
total: Number(proc.total),
|
|
61
|
+
active: Number(proc.active),
|
|
62
|
+
idle: Number(proc.idle),
|
|
63
|
+
idleInTransaction,
|
|
64
|
+
// MySQL/MariaDB's InnoDB has no equivalent of Postgres's "idle in
|
|
65
|
+
// transaction (aborted)" state - a failed statement doesn't leave the
|
|
66
|
+
// session's transaction in a distinct aborted-but-open state the way
|
|
67
|
+
// Postgres does - so idleInTransactionAborted is left undefined.
|
|
68
|
+
maxConnections: maxConnRows.length > 0 ? Number(maxConnRows[0].Value) : null,
|
|
69
|
+
longestIdleInTransactionSeconds,
|
|
70
|
+
longestActiveQuerySeconds: toNullableNumber(proc.longest_active_query_seconds)
|
|
71
|
+
},
|
|
72
|
+
bufferCache: {
|
|
73
|
+
hitRatioPct: computeHitRatioPct(readRequests, reads),
|
|
74
|
+
blocksHit: readRequests - reads,
|
|
75
|
+
blocksRead: reads
|
|
76
|
+
},
|
|
77
|
+
...notes.length > 0 ? { notes } : {}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/utils/dsn-database.ts
|
|
82
|
+
var MissingDatabaseError = class extends Error {
|
|
83
|
+
constructor(message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "MissingDatabaseError";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
function requireDatabaseInDSN(database, dsn, label) {
|
|
89
|
+
if (database) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
throw new MissingDatabaseError(
|
|
93
|
+
`${label} DSN must name a database.
|
|
94
|
+
Provided: ${obfuscateDSNPassword(dsn)}
|
|
95
|
+
Add the database to the DSN, e.g. ...:3306/mydb
|
|
96
|
+
To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/utils/multi-statement-result-parser.ts
|
|
101
|
+
function isMetadataObject(element) {
|
|
102
|
+
if (!element || typeof element !== "object" || Array.isArray(element)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return "affectedRows" in element || "insertId" in element || "fieldCount" in element || "warningStatus" in element;
|
|
106
|
+
}
|
|
107
|
+
function isMultiStatementResult(results) {
|
|
108
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const firstElement = results[0];
|
|
112
|
+
return isMetadataObject(firstElement) || Array.isArray(firstElement);
|
|
113
|
+
}
|
|
114
|
+
function buildResultSetsFromMultiStatement(results, statementTexts) {
|
|
115
|
+
const sql = statementTexts?.length === results.length ? statementTexts : void 0;
|
|
116
|
+
return results.map((result, index) => {
|
|
117
|
+
if (Array.isArray(result)) {
|
|
118
|
+
return { sql: sql?.[index], rows: result, rowCount: result.length };
|
|
119
|
+
}
|
|
120
|
+
if (isMetadataObject(result)) {
|
|
121
|
+
return { sql: sql?.[index], rows: [], rowCount: result.affectedRows || 0 };
|
|
122
|
+
}
|
|
123
|
+
return { sql: sql?.[index], rows: [], rowCount: 0 };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function parseQueryResultSets(results, statementTexts) {
|
|
127
|
+
const singleStatementSql = statementTexts?.length === 1 ? statementTexts[0] : void 0;
|
|
128
|
+
if (isMetadataObject(results)) {
|
|
129
|
+
return [{ sql: singleStatementSql, rows: [], rowCount: results.affectedRows || 0 }];
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(results)) {
|
|
132
|
+
return [{ sql: singleStatementSql, rows: [], rowCount: 0 }];
|
|
133
|
+
}
|
|
134
|
+
if (isMultiStatementResult(results)) {
|
|
135
|
+
return buildResultSetsFromMultiStatement(results, statementTexts);
|
|
136
|
+
}
|
|
137
|
+
return [{ sql: singleStatementSql, rows: results, rowCount: results.length }];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/utils/readonly-transaction.ts
|
|
141
|
+
function isClientSideTimeout(error) {
|
|
142
|
+
return typeof error === "object" && error !== null && error.code === "PROTOCOL_SEQUENCE_TIMEOUT";
|
|
143
|
+
}
|
|
144
|
+
async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
|
|
145
|
+
if (!readonly) {
|
|
146
|
+
return execute();
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await conn.query(
|
|
150
|
+
supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
|
|
151
|
+
);
|
|
152
|
+
const result = await execute();
|
|
153
|
+
await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
|
|
154
|
+
return result;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!isClientSideTimeout(error)) {
|
|
157
|
+
try {
|
|
158
|
+
await conn.query("ROLLBACK");
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/utils/server-flavor.ts
|
|
167
|
+
function isTiDBVersion(version) {
|
|
168
|
+
return typeof version === "string" && /tidb/i.test(version);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export {
|
|
172
|
+
getMySQLFamilyHealthCheck,
|
|
173
|
+
MissingDatabaseError,
|
|
174
|
+
requireDatabaseInDSN,
|
|
175
|
+
parseQueryResultSets,
|
|
176
|
+
isClientSideTimeout,
|
|
177
|
+
withReadOnlyTransaction,
|
|
178
|
+
isTiDBVersion
|
|
179
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
blankCommentsAndStrings,
|
|
2
3
|
stripCommentsAndStrings
|
|
3
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-WIQ4DWGY.js";
|
|
4
5
|
|
|
5
6
|
// src/utils/sql-row-limiter.ts
|
|
6
7
|
var SQLRowLimiter = class {
|
|
@@ -70,17 +71,77 @@ var SQLRowLimiter = class {
|
|
|
70
71
|
LIMIT ${maxRows}${hasSemicolon ? ";" : ""}`;
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Scan blanked (comment/string-free, length-preserving) SQL for parenthesis
|
|
76
|
+
* depth, invoking onMatch for every regex hit at depth 0 (i.e. not nested
|
|
77
|
+
* inside a subquery, function call, or window OVER clause).
|
|
78
|
+
*/
|
|
79
|
+
static scanTopLevel(blankedSQL, regex, onMatch) {
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = regex.exec(blankedSQL)) !== null) {
|
|
83
|
+
const token = m[0];
|
|
84
|
+
if (token === "(") {
|
|
85
|
+
depth++;
|
|
86
|
+
} else if (token === ")") {
|
|
87
|
+
depth--;
|
|
88
|
+
} else if (depth === 0) {
|
|
89
|
+
onMatch(m);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Check if a SQL statement combines multiple SELECTs with a set operator
|
|
95
|
+
* (UNION [ALL], INTERSECT, EXCEPT) at the top level — i.e. not nested
|
|
96
|
+
* inside a subquery already wrapped in parentheses. Strips comments and
|
|
97
|
+
* string literals first to avoid false positives.
|
|
98
|
+
*/
|
|
99
|
+
static hasSetOperator(sql) {
|
|
100
|
+
const blankedSQL = blankCommentsAndStrings(sql, "sqlserver");
|
|
101
|
+
let found = false;
|
|
102
|
+
this.scanTopLevel(blankedSQL, /\(|\)|\bunion\b|\bintersect\b|\bexcept\b/gi, () => {
|
|
103
|
+
found = true;
|
|
104
|
+
});
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Find the start index of a top-level trailing ORDER BY clause (not one
|
|
109
|
+
* nested inside a subquery or a window function's OVER (...) clause).
|
|
110
|
+
* Returns -1 if none exists. Operates on the original (non-blanked) SQL
|
|
111
|
+
* length, since blankCommentsAndStrings preserves length/position.
|
|
112
|
+
*/
|
|
113
|
+
static findTopLevelOrderByIndex(sql) {
|
|
114
|
+
const blankedSQL = blankCommentsAndStrings(sql, "sqlserver");
|
|
115
|
+
let lastIndex = -1;
|
|
116
|
+
this.scanTopLevel(blankedSQL, /\(|\)|\border\s+by\b/gi, (m) => {
|
|
117
|
+
lastIndex = m.index;
|
|
118
|
+
});
|
|
119
|
+
return lastIndex;
|
|
120
|
+
}
|
|
73
121
|
/**
|
|
74
122
|
* Add or modify TOP clause in a SQL statement (SQL Server)
|
|
75
123
|
*/
|
|
76
124
|
static applyTopToQuery(sql, maxRows) {
|
|
125
|
+
if (this.hasSetOperator(sql)) {
|
|
126
|
+
const trimmed = sql.trim();
|
|
127
|
+
const hasSemicolon = trimmed.endsWith(";");
|
|
128
|
+
const sqlWithoutSemicolon = hasSemicolon ? trimmed.slice(0, -1) : trimmed;
|
|
129
|
+
const orderByIndex = this.findTopLevelOrderByIndex(sqlWithoutSemicolon);
|
|
130
|
+
if (orderByIndex !== -1) {
|
|
131
|
+
const innerSql = sqlWithoutSemicolon.slice(0, orderByIndex).trimEnd();
|
|
132
|
+
const orderByClause = sqlWithoutSemicolon.slice(orderByIndex).trim();
|
|
133
|
+
return `SELECT TOP ${maxRows} * FROM (${innerSql}
|
|
134
|
+
) AS subq ${orderByClause}${hasSemicolon ? ";" : ""}`;
|
|
135
|
+
}
|
|
136
|
+
return `SELECT TOP ${maxRows} * FROM (${sqlWithoutSemicolon}
|
|
137
|
+
) AS subq${hasSemicolon ? ";" : ""}`;
|
|
138
|
+
}
|
|
77
139
|
const existingTop = this.extractTopValue(sql);
|
|
78
140
|
if (existingTop !== null) {
|
|
79
141
|
const effectiveTop = Math.min(existingTop, maxRows);
|
|
80
142
|
return sql.replace(/\bselect\s+top\s+\d+/i, `SELECT TOP ${effectiveTop}`);
|
|
81
|
-
} else {
|
|
82
|
-
return sql.replace(/\bselect\s+/i, `SELECT TOP ${maxRows} `);
|
|
83
143
|
}
|
|
144
|
+
return sql.replace(/\bselect\s+/i, `SELECT TOP ${maxRows} `);
|
|
84
145
|
}
|
|
85
146
|
/**
|
|
86
147
|
* Check if a LIMIT clause uses a parameter placeholder (not a literal number).
|
|
@@ -462,6 +462,21 @@ function stripCommentsAndStrings(sql, dialect) {
|
|
|
462
462
|
}
|
|
463
463
|
return parts.join("");
|
|
464
464
|
}
|
|
465
|
+
function blankCommentsAndStrings(sql, dialect) {
|
|
466
|
+
const scanToken = getScanner(dialect);
|
|
467
|
+
let result = "";
|
|
468
|
+
let i = 0;
|
|
469
|
+
while (i < sql.length) {
|
|
470
|
+
const token = scanToken(sql, i);
|
|
471
|
+
if (token.type === TokenType.Plain) {
|
|
472
|
+
result += sql[i];
|
|
473
|
+
} else {
|
|
474
|
+
result += " ".repeat(token.end - i);
|
|
475
|
+
}
|
|
476
|
+
i = token.end;
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
465
480
|
function splitSQLStatements(sql, dialect) {
|
|
466
481
|
const scanToken = getScanner(dialect);
|
|
467
482
|
const statements = [];
|
|
@@ -495,5 +510,6 @@ export {
|
|
|
495
510
|
getDatabaseTypeFromDSN,
|
|
496
511
|
getDefaultPortForType,
|
|
497
512
|
stripCommentsAndStrings,
|
|
513
|
+
blankCommentsAndStrings,
|
|
498
514
|
splitSQLStatements
|
|
499
515
|
};
|