Skip to content

Commit 1eefec4

Browse files
tianzhouclaude
andauthored
feat: support optional database driver packages (#286)
* feat: support optional database driver packages Move database driver packages (pg, mysql2, mariadb, mssql, better-sqlite3) from dependencies to optionalDependencies and load connectors dynamically at startup. Missing drivers are gracefully skipped instead of crashing the server, allowing users who only need specific databases to exclude the rest. Closes #281 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback on optional driver loading - Narrow ERR_MODULE_NOT_FOUND check to match only the expected driver package name, so unrelated module errors still surface immediately - Extract isDriverNotInstalled into src/utils/module-loader.ts for testability - Add unit tests for the driver detection logic - Improve docs: clarify --omit=optional applies to npm install (not npx), note that --demo mode requires better-sqlite3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use exact package name matching for missing driver detection - Parse the missing module specifier from the Node.js error message instead of using substring includes(), preventing false positives (e.g. missing 'pg-connection-string' no longer matches driver 'pg') - Support subpath imports (e.g. 'mysql2/promise' matches driver 'mysql2') - Add tests for substring false-positive and subpath import cases - Soften docs wording: optional deps may be skipped on some platforms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use string literal imports and exact package name matching - Use load functions with string literal import() paths so the bundler can statically analyze and code-split connector modules - Drop subpath matching — only exact driver package name triggers skip - Add loadConnectors behavior tests: missing driver logs and continues, non-driver errors are rethrown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract loadConnectors into module-loader, restore subpath matching - Move loadConnectors() into src/utils/module-loader.ts so tests exercise the real implementation instead of inlined copies - Restore subpath matching (e.g. 'mysql2/promise' matches driver 'mysql2') since the MySQL connector imports mysql2/promise - Tests now import and call the real loadConnectors function directly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 409394b commit 1eefec4

5 files changed

Lines changed: 206 additions & 21 deletions

File tree

docs/installation.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ title: "Installation"
3232
</Tab>
3333
</Tabs>
3434

35+
### Minimal Installation
36+
37+
By default, DBHub attempts to install drivers for all supported databases (PostgreSQL, MySQL, MariaDB, SQL Server, SQLite). If you only need specific databases, you can skip the unnecessary drivers to reduce installation size.
38+
39+
This applies to `npm install` (global or local). When using `npx`, npm will attempt to install all optional dependencies, but some drivers may be skipped if installation fails or is not supported on your platform.
40+
41+
```bash
42+
# Install with only the PostgreSQL driver
43+
npm install -g @bytebase/dbhub@latest --omit=optional && npm install -g pg
44+
45+
# Install with only PostgreSQL and MySQL drivers
46+
npm install -g @bytebase/dbhub@latest --omit=optional && npm install -g pg mysql2
47+
```
48+
49+
Available driver packages:
50+
- `pg` — PostgreSQL
51+
- `mysql2` — MySQL
52+
- `mariadb` — MariaDB
53+
- `mssql` — SQL Server
54+
- `better-sqlite3` — SQLite
55+
56+
<Note>
57+
When a driver is not installed, DBHub will skip that connector at startup and log a message. All other connectors will work normally. Note that `--demo` mode requires the `better-sqlite3` driver.
58+
</Note>
59+
3560
## Docker
3661

3762
<Tabs>

package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,19 @@
4343
"@azure/identity": "^4.8.0",
4444
"@iarna/toml": "^2.2.5",
4545
"@modelcontextprotocol/sdk": "^1.25.1",
46-
"better-sqlite3": "^11.9.0",
4746
"dotenv": "^16.4.7",
4847
"express": "^4.18.2",
49-
"mariadb": "^3.4.0",
50-
"mssql": "^11.0.1",
51-
"mysql2": "^3.13.0",
52-
"pg": "^8.13.3",
5348
"ssh-config": "^5.0.3",
5449
"ssh2": "^1.16.0",
5550
"zod": "^3.24.2"
5651
},
52+
"optionalDependencies": {
53+
"better-sqlite3": "^11.9.0",
54+
"mariadb": "^3.4.0",
55+
"mssql": "^11.0.1",
56+
"mysql2": "^3.13.0",
57+
"pg": "^8.13.3"
58+
},
5759
"devDependencies": {
5860
"@testcontainers/mariadb": "^11.0.3",
5961
"@testcontainers/mssqlserver": "^11.0.3",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { isDriverNotInstalled, loadConnectors } from "../utils/module-loader.js";
3+
4+
describe("isDriverNotInstalled", () => {
5+
it("should return true when the driver package is missing", () => {
6+
const err = new Error(
7+
"Cannot find package 'pg' imported from /fake/path"
8+
);
9+
(err as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
10+
11+
expect(isDriverNotInstalled(err, "pg")).toBe(true);
12+
});
13+
14+
it("should return false when a different driver is missing", () => {
15+
const err = new Error(
16+
"Cannot find package 'pg' imported from /fake/path"
17+
);
18+
(err as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
19+
20+
expect(isDriverNotInstalled(err, "mysql2")).toBe(false);
21+
});
22+
23+
it("should return true for driver subpath imports", () => {
24+
const err = new Error(
25+
"Cannot find package 'mysql2/promise' imported from /fake/path"
26+
);
27+
(err as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
28+
29+
expect(isDriverNotInstalled(err, "mysql2")).toBe(true);
30+
});
31+
32+
it("should return false when missing module name only contains driver as a substring", () => {
33+
const err = new Error(
34+
"Cannot find package 'pg-connection-string' imported from /fake/path"
35+
);
36+
(err as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
37+
38+
expect(isDriverNotInstalled(err, "pg")).toBe(false);
39+
});
40+
41+
it("should return false for unrelated ERR_MODULE_NOT_FOUND errors", () => {
42+
const err = new Error(
43+
"Cannot find package 'some-internal-dep' imported from /fake/path"
44+
);
45+
(err as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
46+
47+
expect(isDriverNotInstalled(err, "pg")).toBe(false);
48+
expect(isDriverNotInstalled(err, "mysql2")).toBe(false);
49+
});
50+
51+
it("should return false for non-module errors", () => {
52+
const err = new Error("Some other error");
53+
expect(isDriverNotInstalled(err, "pg")).toBe(false);
54+
});
55+
56+
it("should return false for non-Error values", () => {
57+
expect(isDriverNotInstalled("string error", "pg")).toBe(false);
58+
expect(isDriverNotInstalled(null, "pg")).toBe(false);
59+
expect(isDriverNotInstalled(undefined, "pg")).toBe(false);
60+
});
61+
});
62+
63+
describe("loadConnectors", () => {
64+
it("should log and continue when a driver is missing", async () => {
65+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66+
67+
const driverErr = new Error("Cannot find package 'pg' imported from /fake/path");
68+
(driverErr as NodeJS.ErrnoException).code = "ERR_MODULE_NOT_FOUND";
69+
70+
await loadConnectors([
71+
{ load: () => Promise.reject(driverErr), name: "PostgreSQL", driver: "pg" },
72+
]);
73+
74+
expect(errorSpy).toHaveBeenCalledWith(
75+
'Skipping PostgreSQL connector: driver package "pg" not installed.'
76+
);
77+
errorSpy.mockRestore();
78+
});
79+
80+
it("should rethrow non-driver errors", async () => {
81+
const runtimeErr = new Error("Unexpected syntax error");
82+
83+
await expect(
84+
loadConnectors([
85+
{ load: () => Promise.reject(runtimeErr), name: "PostgreSQL", driver: "pg" },
86+
])
87+
).rejects.toThrow("Unexpected syntax error");
88+
});
89+
90+
it("should load all connectors when drivers are available", async () => {
91+
let loaded = 0;
92+
await loadConnectors([
93+
{ load: async () => { loaded++; }, name: "TestDB", driver: "test-driver" },
94+
]);
95+
expect(loaded).toBe(1);
96+
});
97+
});

src/index.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
#!/usr/bin/env node
22

3-
// Import connector modules to register them
4-
import "./connectors/postgres/index.js"; // Register PostgreSQL connector
5-
import "./connectors/sqlserver/index.js"; // Register SQL Server connector
6-
import "./connectors/sqlite/index.js"; // SQLite connector
7-
import "./connectors/mysql/index.js"; // MySQL connector
8-
import "./connectors/mariadb/index.js"; // MariaDB connector
9-
10-
// Import main function from server.ts
113
import { main } from "./server.js";
4+
import { loadConnectors } from "./utils/module-loader.js";
5+
6+
// Each load function uses a string literal so the bundler can resolve it.
7+
const connectorModules = [
8+
{ load: () => import("./connectors/postgres/index.js"), name: "PostgreSQL", driver: "pg" },
9+
{ load: () => import("./connectors/sqlserver/index.js"), name: "SQL Server", driver: "mssql" },
10+
{ load: () => import("./connectors/sqlite/index.js"), name: "SQLite", driver: "better-sqlite3" },
11+
{ load: () => import("./connectors/mysql/index.js"), name: "MySQL", driver: "mysql2" },
12+
{ load: () => import("./connectors/mariadb/index.js"), name: "MariaDB", driver: "mariadb" },
13+
];
1214

13-
/**
14-
* Entry point for the DBHub MCP Server
15-
* Handles top-level exceptions and starts the server
16-
*/
17-
main().catch((error) => {
18-
console.error("Fatal error:", error);
19-
process.exit(1);
20-
});
15+
loadConnectors(connectorModules)
16+
.then(() => main())
17+
.catch((error) => {
18+
console.error("Fatal error:", error);
19+
process.exit(1);
20+
});

src/utils/module-loader.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Matches the package name from Node.js ERR_MODULE_NOT_FOUND messages:
2+
// Cannot find package 'pg' imported from ...
3+
// Cannot find module 'mysql2/promise' ...
4+
const MISSING_MODULE_RE = /Cannot find (?:package|module) '([^']+)'/;
5+
6+
/**
7+
* Check if an error is an ERR_MODULE_NOT_FOUND for a specific driver package.
8+
* Matches the exact package name or a subpath import (e.g. "mysql2/promise"
9+
* matches driver "mysql2"), but not unrelated packages that happen to contain
10+
* the driver name as a substring (e.g. "pg-connection-string" does not match "pg").
11+
*/
12+
export function isDriverNotInstalled(err: unknown, driver: string): boolean {
13+
if (
14+
!(err instanceof Error) ||
15+
!("code" in err) ||
16+
(err as NodeJS.ErrnoException).code !== "ERR_MODULE_NOT_FOUND"
17+
) {
18+
return false;
19+
}
20+
21+
const match = err.message.match(MISSING_MODULE_RE);
22+
if (!match) {
23+
return false;
24+
}
25+
26+
const missingSpecifier = match[1];
27+
return (
28+
missingSpecifier === driver ||
29+
missingSpecifier.startsWith(`${driver}/`)
30+
);
31+
}
32+
33+
export interface ConnectorModule {
34+
load: () => Promise<unknown>;
35+
name: string;
36+
driver: string;
37+
}
38+
39+
/**
40+
* Load connector modules, gracefully skipping any whose driver package
41+
* is not installed.
42+
*/
43+
export async function loadConnectors(
44+
connectorModules: ConnectorModule[]
45+
): Promise<void> {
46+
await Promise.all(
47+
connectorModules.map(async ({ load, name, driver }) => {
48+
try {
49+
await load();
50+
} catch (err) {
51+
if (isDriverNotInstalled(err, driver)) {
52+
console.error(
53+
`Skipping ${name} connector: driver package "${driver}" not installed.`
54+
);
55+
} else {
56+
throw err;
57+
}
58+
}
59+
})
60+
);
61+
}

0 commit comments

Comments
 (0)