@bytebase/dbhub 0.17.0 → 0.18.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.
|
@@ -296,7 +296,9 @@ var SSHTunnel = class {
|
|
|
296
296
|
privateKey,
|
|
297
297
|
targetConfig.passphrase,
|
|
298
298
|
previousStream,
|
|
299
|
-
`jump host ${i + 1}
|
|
299
|
+
`jump host ${i + 1}`,
|
|
300
|
+
targetConfig.keepaliveInterval,
|
|
301
|
+
targetConfig.keepaliveCountMax
|
|
300
302
|
);
|
|
301
303
|
console.error(` \u2192 Forwarding through ${jumpHost.host}:${jumpHost.port} to ${nextHost.host}:${nextHost.port}`);
|
|
302
304
|
forwardStream = await this.forwardTo(client, nextHost.host, nextHost.port);
|
|
@@ -322,7 +324,9 @@ var SSHTunnel = class {
|
|
|
322
324
|
privateKey,
|
|
323
325
|
targetConfig.passphrase,
|
|
324
326
|
previousStream,
|
|
325
|
-
jumpHosts.length > 0 ? "target host" : void 0
|
|
327
|
+
jumpHosts.length > 0 ? "target host" : void 0,
|
|
328
|
+
targetConfig.keepaliveInterval,
|
|
329
|
+
targetConfig.keepaliveCountMax
|
|
326
330
|
);
|
|
327
331
|
this.sshClients.push(finalClient);
|
|
328
332
|
return finalClient;
|
|
@@ -330,7 +334,7 @@ var SSHTunnel = class {
|
|
|
330
334
|
/**
|
|
331
335
|
* Connect to a single SSH host.
|
|
332
336
|
*/
|
|
333
|
-
connectToHost(hostInfo, password, privateKey, passphrase, sock, label) {
|
|
337
|
+
connectToHost(hostInfo, password, privateKey, passphrase, sock, label, keepaliveInterval, keepaliveCountMax) {
|
|
334
338
|
return new Promise((resolve, reject) => {
|
|
335
339
|
const client = new Client();
|
|
336
340
|
const sshConfig = {
|
|
@@ -350,6 +354,17 @@ var SSHTunnel = class {
|
|
|
350
354
|
if (sock) {
|
|
351
355
|
sshConfig.sock = sock;
|
|
352
356
|
}
|
|
357
|
+
if (keepaliveInterval !== void 0) {
|
|
358
|
+
if (Number.isNaN(keepaliveInterval) || keepaliveInterval < 0) {
|
|
359
|
+
const desc = label || `${hostInfo.host}:${hostInfo.port}`;
|
|
360
|
+
console.warn(
|
|
361
|
+
`Invalid SSH keepaliveInterval (${keepaliveInterval}) for ${desc}; keepalive configuration will be ignored.`
|
|
362
|
+
);
|
|
363
|
+
} else if (keepaliveInterval > 0) {
|
|
364
|
+
sshConfig.keepaliveInterval = keepaliveInterval * 1e3;
|
|
365
|
+
sshConfig.keepaliveCountMax = keepaliveCountMax ?? 3;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
353
368
|
const onError = (err) => {
|
|
354
369
|
client.removeListener("ready", onReady);
|
|
355
370
|
client.destroy();
|
|
@@ -1011,6 +1026,27 @@ function resolveSSHConfig() {
|
|
|
1011
1026
|
config.proxyJump = process.env.SSH_PROXY_JUMP;
|
|
1012
1027
|
sources.push("SSH_PROXY_JUMP from environment");
|
|
1013
1028
|
}
|
|
1029
|
+
const parseNonNegativeInteger = (value, name) => {
|
|
1030
|
+
const parsed = Number(value);
|
|
1031
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
1032
|
+
throw new Error(`Invalid value for ${name}: "${value}". Expected a non-negative integer.`);
|
|
1033
|
+
}
|
|
1034
|
+
return parsed;
|
|
1035
|
+
};
|
|
1036
|
+
if (args["ssh-keepalive-interval"]) {
|
|
1037
|
+
config.keepaliveInterval = parseNonNegativeInteger(args["ssh-keepalive-interval"], "ssh-keepalive-interval");
|
|
1038
|
+
sources.push("ssh-keepalive-interval from command line");
|
|
1039
|
+
} else if (process.env.SSH_KEEPALIVE_INTERVAL) {
|
|
1040
|
+
config.keepaliveInterval = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_INTERVAL, "SSH_KEEPALIVE_INTERVAL");
|
|
1041
|
+
sources.push("SSH_KEEPALIVE_INTERVAL from environment");
|
|
1042
|
+
}
|
|
1043
|
+
if (args["ssh-keepalive-count-max"]) {
|
|
1044
|
+
config.keepaliveCountMax = parseNonNegativeInteger(args["ssh-keepalive-count-max"], "ssh-keepalive-count-max");
|
|
1045
|
+
sources.push("ssh-keepalive-count-max from command line");
|
|
1046
|
+
} else if (process.env.SSH_KEEPALIVE_COUNT_MAX) {
|
|
1047
|
+
config.keepaliveCountMax = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_COUNT_MAX, "SSH_KEEPALIVE_COUNT_MAX");
|
|
1048
|
+
sources.push("SSH_KEEPALIVE_COUNT_MAX from environment");
|
|
1049
|
+
}
|
|
1014
1050
|
if (!config.host || !config.username) {
|
|
1015
1051
|
throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
|
|
1016
1052
|
}
|
|
@@ -1090,6 +1126,8 @@ async function resolveSourceConfigs() {
|
|
|
1090
1126
|
source.ssh_password = sshResult.config.password;
|
|
1091
1127
|
source.ssh_key = sshResult.config.privateKey;
|
|
1092
1128
|
source.ssh_passphrase = sshResult.config.passphrase;
|
|
1129
|
+
source.ssh_keepalive_interval = sshResult.config.keepaliveInterval;
|
|
1130
|
+
source.ssh_keepalive_count_max = sshResult.config.keepaliveCountMax;
|
|
1093
1131
|
}
|
|
1094
1132
|
if (dsnResult.isDemo) {
|
|
1095
1133
|
const { getSqliteInMemorySetupSql } = await import("./demo-loader-PSMTLZ2T.js");
|
|
@@ -1270,6 +1308,31 @@ function validateSourceConfig(source, configPath) {
|
|
|
1270
1308
|
);
|
|
1271
1309
|
}
|
|
1272
1310
|
}
|
|
1311
|
+
if (source.aws_iam_auth !== void 0 && typeof source.aws_iam_auth !== "boolean") {
|
|
1312
|
+
throw new Error(
|
|
1313
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid aws_iam_auth. Must be a boolean (true or false).`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
if (source.aws_region !== void 0) {
|
|
1317
|
+
if (typeof source.aws_region !== "string" || source.aws_region.trim().length === 0) {
|
|
1318
|
+
throw new Error(
|
|
1319
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid aws_region. Must be a non-empty string (e.g., "eu-west-1").`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (source.aws_iam_auth === true) {
|
|
1324
|
+
const validIamTypes = ["postgres", "mysql", "mariadb"];
|
|
1325
|
+
if (!source.type || !validIamTypes.includes(source.type)) {
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled, but this is only supported for postgres, mysql, and mariadb sources.`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (!source.aws_region) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled but aws_region is not specified.`
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1273
1336
|
if (source.connection_timeout !== void 0) {
|
|
1274
1337
|
if (typeof source.connection_timeout !== "number" || source.connection_timeout <= 0) {
|
|
1275
1338
|
throw new Error(
|
|
@@ -1420,7 +1483,8 @@ function buildDSNFromSource(source) {
|
|
|
1420
1483
|
}
|
|
1421
1484
|
return `sqlite:///${source.database}`;
|
|
1422
1485
|
}
|
|
1423
|
-
const
|
|
1486
|
+
const isAwsIamPasswordless = source.aws_iam_auth === true && ["postgres", "mysql", "mariadb"].includes(source.type);
|
|
1487
|
+
const passwordRequired = source.authentication !== "azure-active-directory-access-token" && !isAwsIamPasswordless;
|
|
1424
1488
|
if (!source.host || !source.user || !source.database) {
|
|
1425
1489
|
throw new Error(
|
|
1426
1490
|
`Source '${source.id}': missing required connection parameters. Required: type, host, user, database`
|
|
@@ -1428,7 +1492,7 @@ function buildDSNFromSource(source) {
|
|
|
1428
1492
|
}
|
|
1429
1493
|
if (passwordRequired && !source.password) {
|
|
1430
1494
|
throw new Error(
|
|
1431
|
-
`Source '${source.id}': password is required. (Password is optional
|
|
1495
|
+
`Source '${source.id}': password is required. (Password is optional for azure-active-directory-access-token authentication or when aws_iam_auth=true)`
|
|
1432
1496
|
);
|
|
1433
1497
|
}
|
|
1434
1498
|
const port = source.port || getDefaultPortForType(source.type);
|
|
@@ -1460,8 +1524,21 @@ function buildDSNFromSource(source) {
|
|
|
1460
1524
|
return dsn;
|
|
1461
1525
|
}
|
|
1462
1526
|
|
|
1527
|
+
// src/utils/aws-rds-signer.ts
|
|
1528
|
+
import { Signer } from "@aws-sdk/rds-signer";
|
|
1529
|
+
async function generateRdsAuthToken(params) {
|
|
1530
|
+
const signer = new Signer({
|
|
1531
|
+
hostname: params.hostname,
|
|
1532
|
+
port: params.port,
|
|
1533
|
+
username: params.username,
|
|
1534
|
+
region: params.region
|
|
1535
|
+
});
|
|
1536
|
+
return signer.getAuthToken();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1463
1539
|
// src/connectors/manager.ts
|
|
1464
1540
|
var managerInstance = null;
|
|
1541
|
+
var AWS_IAM_TOKEN_REFRESH_MS = 14 * 60 * 1e3;
|
|
1465
1542
|
var ConnectorManager = class {
|
|
1466
1543
|
// Prevent race conditions
|
|
1467
1544
|
constructor() {
|
|
@@ -1472,6 +1549,8 @@ var ConnectorManager = class {
|
|
|
1472
1549
|
// Store original source configs
|
|
1473
1550
|
this.sourceIds = [];
|
|
1474
1551
|
// Ordered list of source IDs (first is default)
|
|
1552
|
+
this.iamRefreshTimers = /* @__PURE__ */ new Map();
|
|
1553
|
+
this.isDisconnecting = false;
|
|
1475
1554
|
// Lazy connection support
|
|
1476
1555
|
this.lazySources = /* @__PURE__ */ new Map();
|
|
1477
1556
|
// Sources pending lazy connection
|
|
@@ -1561,7 +1640,7 @@ var ConnectorManager = class {
|
|
|
1561
1640
|
*/
|
|
1562
1641
|
async connectSource(source) {
|
|
1563
1642
|
const sourceId = source.id;
|
|
1564
|
-
const dsn =
|
|
1643
|
+
const dsn = await this.buildConnectionDSN(source);
|
|
1565
1644
|
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
|
|
1566
1645
|
let actualDSN = dsn;
|
|
1567
1646
|
if (source.ssh_host) {
|
|
@@ -1577,7 +1656,9 @@ var ConnectorManager = class {
|
|
|
1577
1656
|
password: source.ssh_password,
|
|
1578
1657
|
privateKey: source.ssh_key,
|
|
1579
1658
|
passphrase: source.ssh_passphrase,
|
|
1580
|
-
proxyJump: source.ssh_proxy_jump
|
|
1659
|
+
proxyJump: source.ssh_proxy_jump,
|
|
1660
|
+
keepaliveInterval: source.ssh_keepalive_interval,
|
|
1661
|
+
keepaliveCountMax: source.ssh_keepalive_count_max
|
|
1581
1662
|
};
|
|
1582
1663
|
if (!sshConfig.password && !sshConfig.privateKey) {
|
|
1583
1664
|
throw new Error(
|
|
@@ -1627,11 +1708,17 @@ var ConnectorManager = class {
|
|
|
1627
1708
|
this.sourceIds.push(sourceId);
|
|
1628
1709
|
}
|
|
1629
1710
|
this.sourceConfigs.set(sourceId, source);
|
|
1711
|
+
this.scheduleIamRefresh(source);
|
|
1630
1712
|
}
|
|
1631
1713
|
/**
|
|
1632
1714
|
* Close all database connections
|
|
1633
1715
|
*/
|
|
1634
1716
|
async disconnect() {
|
|
1717
|
+
this.isDisconnecting = true;
|
|
1718
|
+
for (const timer of this.iamRefreshTimers.values()) {
|
|
1719
|
+
clearTimeout(timer);
|
|
1720
|
+
}
|
|
1721
|
+
this.iamRefreshTimers.clear();
|
|
1635
1722
|
for (const [sourceId, connector] of this.connectors.entries()) {
|
|
1636
1723
|
try {
|
|
1637
1724
|
await connector.disconnect();
|
|
@@ -1653,6 +1740,7 @@ var ConnectorManager = class {
|
|
|
1653
1740
|
this.lazySources.clear();
|
|
1654
1741
|
this.pendingConnections.clear();
|
|
1655
1742
|
this.sourceIds = [];
|
|
1743
|
+
this.isDisconnecting = false;
|
|
1656
1744
|
}
|
|
1657
1745
|
/**
|
|
1658
1746
|
* Get a connector by source ID
|
|
@@ -1753,63 +1841,317 @@ var ConnectorManager = class {
|
|
|
1753
1841
|
}
|
|
1754
1842
|
return getDefaultPortForType(type) ?? 0;
|
|
1755
1843
|
}
|
|
1844
|
+
scheduleIamRefresh(source) {
|
|
1845
|
+
if (this.isDisconnecting) {
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const sourceId = source.id;
|
|
1849
|
+
const existingTimer = this.iamRefreshTimers.get(sourceId);
|
|
1850
|
+
if (existingTimer) {
|
|
1851
|
+
clearTimeout(existingTimer);
|
|
1852
|
+
this.iamRefreshTimers.delete(sourceId);
|
|
1853
|
+
}
|
|
1854
|
+
if (!source.aws_iam_auth) {
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const timer = setTimeout(async () => {
|
|
1858
|
+
if (this.isDisconnecting) {
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
try {
|
|
1862
|
+
await this.refreshIamSourceConnection(source);
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
console.error(
|
|
1865
|
+
`Error refreshing AWS IAM auth token for source '${sourceId}':`,
|
|
1866
|
+
error
|
|
1867
|
+
);
|
|
1868
|
+
} finally {
|
|
1869
|
+
if (!this.isDisconnecting && this.sourceConfigs.has(sourceId)) {
|
|
1870
|
+
this.scheduleIamRefresh(source);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}, AWS_IAM_TOKEN_REFRESH_MS);
|
|
1874
|
+
timer.unref?.();
|
|
1875
|
+
this.iamRefreshTimers.set(sourceId, timer);
|
|
1876
|
+
}
|
|
1877
|
+
async refreshIamSourceConnection(source) {
|
|
1878
|
+
const sourceId = source.id;
|
|
1879
|
+
if (this.isDisconnecting || !source.aws_iam_auth || !this.connectors.has(sourceId)) {
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
console.error(`Refreshing AWS IAM auth connection for source '${sourceId}'...`);
|
|
1883
|
+
const existingConnector = this.connectors.get(sourceId);
|
|
1884
|
+
if (existingConnector) {
|
|
1885
|
+
await existingConnector.disconnect();
|
|
1886
|
+
this.connectors.delete(sourceId);
|
|
1887
|
+
}
|
|
1888
|
+
const existingTunnel = this.sshTunnels.get(sourceId);
|
|
1889
|
+
if (existingTunnel) {
|
|
1890
|
+
await existingTunnel.close();
|
|
1891
|
+
this.sshTunnels.delete(sourceId);
|
|
1892
|
+
}
|
|
1893
|
+
if (this.isDisconnecting) {
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
await this.connectSource(source);
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Build a connection DSN, optionally replacing password with
|
|
1900
|
+
* an AWS RDS IAM auth token when aws_iam_auth is enabled.
|
|
1901
|
+
*/
|
|
1902
|
+
async buildConnectionDSN(source) {
|
|
1903
|
+
const dsn = buildDSNFromSource(source);
|
|
1904
|
+
if (!source.aws_iam_auth) {
|
|
1905
|
+
return dsn;
|
|
1906
|
+
}
|
|
1907
|
+
const supportedIamTypes = ["postgres", "mysql", "mariadb"];
|
|
1908
|
+
if (!source.type || !supportedIamTypes.includes(source.type)) {
|
|
1909
|
+
throw new Error(
|
|
1910
|
+
`Source '${source.id}': aws_iam_auth is only supported for postgres, mysql, and mariadb`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
if (!source.aws_region) {
|
|
1914
|
+
throw new Error(
|
|
1915
|
+
`Source '${source.id}': aws_region is required when aws_iam_auth is enabled`
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
const parsed = new SafeURL(dsn);
|
|
1919
|
+
const hostname = parsed.hostname;
|
|
1920
|
+
const username = source.user || parsed.username;
|
|
1921
|
+
const defaultPort = getDefaultPortForType(source.type);
|
|
1922
|
+
const port = parsed.port ? parseInt(parsed.port) : defaultPort;
|
|
1923
|
+
if (!hostname || !username || !port) {
|
|
1924
|
+
throw new Error(
|
|
1925
|
+
`Source '${source.id}': unable to resolve host, username, or port for AWS IAM authentication`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
const token = await generateRdsAuthToken({
|
|
1929
|
+
hostname,
|
|
1930
|
+
port,
|
|
1931
|
+
username,
|
|
1932
|
+
region: source.aws_region
|
|
1933
|
+
});
|
|
1934
|
+
const queryParams = new Map(parsed.searchParams);
|
|
1935
|
+
queryParams.set("sslmode", "require");
|
|
1936
|
+
const protocol = parsed.protocol.endsWith(":") ? parsed.protocol.slice(0, -1) : parsed.protocol;
|
|
1937
|
+
const encodedUser = encodeURIComponent(username);
|
|
1938
|
+
const encodedToken = encodeURIComponent(token);
|
|
1939
|
+
const path3 = parsed.pathname || "/";
|
|
1940
|
+
const query = Array.from(queryParams.entries()).map(
|
|
1941
|
+
([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
|
1942
|
+
).join("&");
|
|
1943
|
+
return `${protocol}://${encodedUser}:${encodedToken}@${hostname}:${port}${path3}${query ? `?${query}` : ""}`;
|
|
1944
|
+
}
|
|
1756
1945
|
};
|
|
1757
1946
|
|
|
1758
1947
|
// src/utils/sql-parser.ts
|
|
1759
|
-
|
|
1760
|
-
|
|
1948
|
+
var TokenType = { Plain: 0, Comment: 1, QuotedBlock: 2 };
|
|
1949
|
+
function plainToken(i) {
|
|
1950
|
+
return { type: TokenType.Plain, end: i + 1 };
|
|
1951
|
+
}
|
|
1952
|
+
function scanSingleLineComment(sql, i) {
|
|
1953
|
+
if (sql[i] !== "-" || sql[i + 1] !== "-") {
|
|
1954
|
+
return null;
|
|
1955
|
+
}
|
|
1956
|
+
let j = i;
|
|
1957
|
+
while (j < sql.length && sql[j] !== "\n") {
|
|
1958
|
+
j++;
|
|
1959
|
+
}
|
|
1960
|
+
return { type: TokenType.Comment, end: j };
|
|
1961
|
+
}
|
|
1962
|
+
function scanMultiLineComment(sql, i) {
|
|
1963
|
+
if (sql[i] !== "/" || sql[i + 1] !== "*") {
|
|
1964
|
+
return null;
|
|
1965
|
+
}
|
|
1966
|
+
let j = i + 2;
|
|
1967
|
+
while (j < sql.length && !(sql[j] === "*" && sql[j + 1] === "/")) {
|
|
1968
|
+
j++;
|
|
1969
|
+
}
|
|
1970
|
+
if (j < sql.length) {
|
|
1971
|
+
j += 2;
|
|
1972
|
+
}
|
|
1973
|
+
return { type: TokenType.Comment, end: j };
|
|
1974
|
+
}
|
|
1975
|
+
function scanNestedMultiLineComment(sql, i) {
|
|
1976
|
+
if (sql[i] !== "/" || sql[i + 1] !== "*") {
|
|
1977
|
+
return null;
|
|
1978
|
+
}
|
|
1979
|
+
let j = i + 2;
|
|
1980
|
+
let depth = 1;
|
|
1981
|
+
while (j < sql.length && depth > 0) {
|
|
1982
|
+
if (sql[j] === "/" && sql[j + 1] === "*") {
|
|
1983
|
+
depth++;
|
|
1984
|
+
j += 2;
|
|
1985
|
+
} else if (sql[j] === "*" && sql[j + 1] === "/") {
|
|
1986
|
+
depth--;
|
|
1987
|
+
j += 2;
|
|
1988
|
+
} else {
|
|
1989
|
+
j++;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return { type: TokenType.Comment, end: j };
|
|
1993
|
+
}
|
|
1994
|
+
function scanSingleQuotedString(sql, i) {
|
|
1995
|
+
if (sql[i] !== "'") {
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
let j = i + 1;
|
|
1999
|
+
while (j < sql.length) {
|
|
2000
|
+
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
2001
|
+
j += 2;
|
|
2002
|
+
} else if (sql[j] === "'") {
|
|
2003
|
+
j++;
|
|
2004
|
+
break;
|
|
2005
|
+
} else {
|
|
2006
|
+
j++;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return { type: TokenType.QuotedBlock, end: j };
|
|
2010
|
+
}
|
|
2011
|
+
function scanDoubleQuotedString(sql, i) {
|
|
2012
|
+
if (sql[i] !== '"') {
|
|
2013
|
+
return null;
|
|
2014
|
+
}
|
|
2015
|
+
let j = i + 1;
|
|
2016
|
+
while (j < sql.length) {
|
|
2017
|
+
if (sql[j] === '"' && sql[j + 1] === '"') {
|
|
2018
|
+
j += 2;
|
|
2019
|
+
} else if (sql[j] === '"') {
|
|
2020
|
+
j++;
|
|
2021
|
+
break;
|
|
2022
|
+
} else {
|
|
2023
|
+
j++;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { type: TokenType.QuotedBlock, end: j };
|
|
2027
|
+
}
|
|
2028
|
+
var dollarQuoteOpenRegex = /^\$([a-zA-Z_]\w*)?\$/;
|
|
2029
|
+
function scanDollarQuotedBlock(sql, i) {
|
|
2030
|
+
if (sql[i] !== "$") {
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
const next = sql[i + 1];
|
|
2034
|
+
if (next >= "0" && next <= "9") {
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
const remaining = sql.substring(i);
|
|
2038
|
+
const m = dollarQuoteOpenRegex.exec(remaining);
|
|
2039
|
+
if (!m) {
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
const tag = m[0];
|
|
2043
|
+
const bodyStart = i + tag.length;
|
|
2044
|
+
const closeIdx = sql.indexOf(tag, bodyStart);
|
|
2045
|
+
const end = closeIdx !== -1 ? closeIdx + tag.length : sql.length;
|
|
2046
|
+
return { type: TokenType.QuotedBlock, end };
|
|
2047
|
+
}
|
|
2048
|
+
function scanBacktickQuotedIdentifier(sql, i) {
|
|
2049
|
+
if (sql[i] !== "`") {
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
let j = i + 1;
|
|
2053
|
+
while (j < sql.length) {
|
|
2054
|
+
if (sql[j] === "`" && sql[j + 1] === "`") {
|
|
2055
|
+
j += 2;
|
|
2056
|
+
} else if (sql[j] === "`") {
|
|
2057
|
+
j++;
|
|
2058
|
+
break;
|
|
2059
|
+
} else {
|
|
2060
|
+
j++;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return { type: TokenType.QuotedBlock, end: j };
|
|
2064
|
+
}
|
|
2065
|
+
function scanBracketQuotedIdentifier(sql, i) {
|
|
2066
|
+
if (sql[i] !== "[") {
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
let j = i + 1;
|
|
2070
|
+
while (j < sql.length) {
|
|
2071
|
+
if (sql[j] === "]" && sql[j + 1] === "]") {
|
|
2072
|
+
j += 2;
|
|
2073
|
+
} else if (sql[j] === "]") {
|
|
2074
|
+
j++;
|
|
2075
|
+
break;
|
|
2076
|
+
} else {
|
|
2077
|
+
j++;
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
return { type: TokenType.QuotedBlock, end: j };
|
|
2081
|
+
}
|
|
2082
|
+
function scanTokenAnsi(sql, i) {
|
|
2083
|
+
return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? plainToken(i);
|
|
2084
|
+
}
|
|
2085
|
+
function scanTokenPostgres(sql, i) {
|
|
2086
|
+
return scanSingleLineComment(sql, i) ?? scanNestedMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanDollarQuotedBlock(sql, i) ?? plainToken(i);
|
|
2087
|
+
}
|
|
2088
|
+
function scanTokenMySQL(sql, i) {
|
|
2089
|
+
return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? plainToken(i);
|
|
2090
|
+
}
|
|
2091
|
+
function scanTokenSQLite(sql, i) {
|
|
2092
|
+
return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? scanBracketQuotedIdentifier(sql, i) ?? plainToken(i);
|
|
2093
|
+
}
|
|
2094
|
+
function scanTokenSQLServer(sql, i) {
|
|
2095
|
+
return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBracketQuotedIdentifier(sql, i) ?? plainToken(i);
|
|
2096
|
+
}
|
|
2097
|
+
var dialectScanners = {
|
|
2098
|
+
postgres: scanTokenPostgres,
|
|
2099
|
+
mysql: scanTokenMySQL,
|
|
2100
|
+
mariadb: scanTokenMySQL,
|
|
2101
|
+
sqlite: scanTokenSQLite,
|
|
2102
|
+
sqlserver: scanTokenSQLServer
|
|
2103
|
+
};
|
|
2104
|
+
function getScanner(dialect) {
|
|
2105
|
+
return dialect ? dialectScanners[dialect] ?? scanTokenAnsi : scanTokenAnsi;
|
|
2106
|
+
}
|
|
2107
|
+
function stripCommentsAndStrings(sql, dialect) {
|
|
2108
|
+
const scanToken = getScanner(dialect);
|
|
2109
|
+
const parts = [];
|
|
2110
|
+
let plainStart = -1;
|
|
1761
2111
|
let i = 0;
|
|
1762
2112
|
while (i < sql.length) {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2113
|
+
const token = scanToken(sql, i);
|
|
2114
|
+
if (token.type === TokenType.Plain) {
|
|
2115
|
+
if (plainStart === -1) {
|
|
2116
|
+
plainStart = i;
|
|
1766
2117
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
i += 2;
|
|
1772
|
-
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
|
|
1773
|
-
i++;
|
|
2118
|
+
} else {
|
|
2119
|
+
if (plainStart !== -1) {
|
|
2120
|
+
parts.push(sql.substring(plainStart, i));
|
|
2121
|
+
plainStart = -1;
|
|
1774
2122
|
}
|
|
1775
|
-
|
|
1776
|
-
result += " ";
|
|
1777
|
-
continue;
|
|
2123
|
+
parts.push(" ");
|
|
1778
2124
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
2125
|
+
i = token.end;
|
|
2126
|
+
}
|
|
2127
|
+
if (plainStart !== -1) {
|
|
2128
|
+
parts.push(sql.substring(plainStart));
|
|
2129
|
+
}
|
|
2130
|
+
return parts.join("");
|
|
2131
|
+
}
|
|
2132
|
+
function splitSQLStatements(sql, dialect) {
|
|
2133
|
+
const scanToken = getScanner(dialect);
|
|
2134
|
+
const statements = [];
|
|
2135
|
+
let stmtStart = 0;
|
|
2136
|
+
let i = 0;
|
|
2137
|
+
while (i < sql.length) {
|
|
2138
|
+
if (sql[i] === ";") {
|
|
2139
|
+
const trimmed2 = sql.substring(stmtStart, i).trim();
|
|
2140
|
+
if (trimmed2.length > 0) {
|
|
2141
|
+
statements.push(trimmed2);
|
|
1790
2142
|
}
|
|
1791
|
-
|
|
1792
|
-
continue;
|
|
1793
|
-
}
|
|
1794
|
-
if (sql[i] === '"') {
|
|
2143
|
+
stmtStart = i + 1;
|
|
1795
2144
|
i++;
|
|
1796
|
-
while (i < sql.length) {
|
|
1797
|
-
if (sql[i] === '"' && sql[i + 1] === '"') {
|
|
1798
|
-
i += 2;
|
|
1799
|
-
} else if (sql[i] === '"') {
|
|
1800
|
-
i++;
|
|
1801
|
-
break;
|
|
1802
|
-
} else {
|
|
1803
|
-
i++;
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
result += " ";
|
|
1807
2145
|
continue;
|
|
1808
2146
|
}
|
|
1809
|
-
|
|
1810
|
-
i
|
|
2147
|
+
const token = scanToken(sql, i);
|
|
2148
|
+
i = token.end;
|
|
2149
|
+
}
|
|
2150
|
+
const trimmed = sql.substring(stmtStart).trim();
|
|
2151
|
+
if (trimmed.length > 0) {
|
|
2152
|
+
statements.push(trimmed);
|
|
1811
2153
|
}
|
|
1812
|
-
return
|
|
2154
|
+
return statements;
|
|
1813
2155
|
}
|
|
1814
2156
|
|
|
1815
2157
|
// src/utils/parameter-mapper.ts
|
|
@@ -2145,12 +2487,15 @@ export {
|
|
|
2145
2487
|
getDatabaseTypeFromDSN,
|
|
2146
2488
|
getDefaultPortForType,
|
|
2147
2489
|
stripCommentsAndStrings,
|
|
2490
|
+
splitSQLStatements,
|
|
2148
2491
|
isDemoMode,
|
|
2149
2492
|
resolveTransport,
|
|
2150
2493
|
resolvePort,
|
|
2151
2494
|
resolveSourceConfigs,
|
|
2152
2495
|
BUILTIN_TOOL_EXECUTE_SQL,
|
|
2153
2496
|
BUILTIN_TOOL_SEARCH_OBJECTS,
|
|
2497
|
+
loadTomlConfig,
|
|
2498
|
+
resolveTomlConfigPath,
|
|
2154
2499
|
ConnectorManager,
|
|
2155
2500
|
mapArgumentsToArray,
|
|
2156
2501
|
ToolRegistry,
|
package/dist/index.js
CHANGED
|
@@ -8,15 +8,19 @@ import {
|
|
|
8
8
|
getDatabaseTypeFromDSN,
|
|
9
9
|
getDefaultPortForType,
|
|
10
10
|
getToolRegistry,
|
|
11
|
+
initializeToolRegistry,
|
|
11
12
|
isDemoMode,
|
|
13
|
+
loadTomlConfig,
|
|
12
14
|
mapArgumentsToArray,
|
|
13
15
|
obfuscateDSNPassword,
|
|
14
16
|
parseConnectionInfoFromDSN,
|
|
15
17
|
resolvePort,
|
|
16
18
|
resolveSourceConfigs,
|
|
19
|
+
resolveTomlConfigPath,
|
|
17
20
|
resolveTransport,
|
|
21
|
+
splitSQLStatements,
|
|
18
22
|
stripCommentsAndStrings
|
|
19
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-WCXOWHL3.js";
|
|
20
24
|
|
|
21
25
|
// src/connectors/postgres/index.ts
|
|
22
26
|
import pg from "pg";
|
|
@@ -585,7 +589,7 @@ var PostgresConnector = class _PostgresConnector {
|
|
|
585
589
|
}
|
|
586
590
|
const client = await this.pool.connect();
|
|
587
591
|
try {
|
|
588
|
-
const statements = sql2
|
|
592
|
+
const statements = splitSQLStatements(sql2, "postgres");
|
|
589
593
|
if (statements.length === 1) {
|
|
590
594
|
const processedStatement = SQLRowLimiter.applyMaxRows(statements[0], options.maxRows);
|
|
591
595
|
let result;
|
|
@@ -1332,7 +1336,7 @@ var SQLiteConnector = class _SQLiteConnector {
|
|
|
1332
1336
|
throw new Error("Not connected to SQLite database");
|
|
1333
1337
|
}
|
|
1334
1338
|
try {
|
|
1335
|
-
const statements = sql2
|
|
1339
|
+
const statements = splitSQLStatements(sql2, "sqlite");
|
|
1336
1340
|
if (statements.length === 1) {
|
|
1337
1341
|
let processedStatement = statements[0];
|
|
1338
1342
|
const trimmedStatement = statements[0].toLowerCase().trim();
|
|
@@ -1878,7 +1882,7 @@ var MySQLConnector = class _MySQLConnector {
|
|
|
1878
1882
|
try {
|
|
1879
1883
|
let processedSQL = sql2;
|
|
1880
1884
|
if (options.maxRows) {
|
|
1881
|
-
const statements = sql2
|
|
1885
|
+
const statements = splitSQLStatements(sql2, "mysql");
|
|
1882
1886
|
const processedStatements = statements.map(
|
|
1883
1887
|
(statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
|
|
1884
1888
|
);
|
|
@@ -2325,7 +2329,7 @@ var MariaDBConnector = class _MariaDBConnector {
|
|
|
2325
2329
|
try {
|
|
2326
2330
|
let processedSQL = sql2;
|
|
2327
2331
|
if (options.maxRows) {
|
|
2328
|
-
const statements = sql2
|
|
2332
|
+
const statements = splitSQLStatements(sql2, "mariadb");
|
|
2329
2333
|
const processedStatements = statements.map(
|
|
2330
2334
|
(statement) => SQLRowLimiter.applyMaxRows(statement, options.maxRows)
|
|
2331
2335
|
);
|
|
@@ -2428,7 +2432,7 @@ var allowedKeywords = {
|
|
|
2428
2432
|
sqlserver: ["select", "with", "explain", "showplan"]
|
|
2429
2433
|
};
|
|
2430
2434
|
function isReadOnlySQL(sql2, connectorType) {
|
|
2431
|
-
const cleanedSQL = stripCommentsAndStrings(sql2).trim().toLowerCase();
|
|
2435
|
+
const cleanedSQL = stripCommentsAndStrings(sql2, connectorType).trim().toLowerCase();
|
|
2432
2436
|
if (!cleanedSQL) {
|
|
2433
2437
|
return true;
|
|
2434
2438
|
}
|
|
@@ -2521,11 +2525,8 @@ function trackToolRequest(metadata, startTime, extra, success, error) {
|
|
|
2521
2525
|
var executeSqlSchema = {
|
|
2522
2526
|
sql: z.string().describe("SQL to execute (multiple statements separated by ;)")
|
|
2523
2527
|
};
|
|
2524
|
-
function splitSQLStatements(sql2) {
|
|
2525
|
-
return sql2.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
|
|
2526
|
-
}
|
|
2527
2528
|
function areAllStatementsReadOnly(sql2, connectorType) {
|
|
2528
|
-
const statements = splitSQLStatements(sql2);
|
|
2529
|
+
const statements = splitSQLStatements(sql2, connectorType);
|
|
2529
2530
|
return statements.every((statement) => isReadOnlySQL(statement, connectorType));
|
|
2530
2531
|
}
|
|
2531
2532
|
function createExecuteSqlToolHandler(sourceId) {
|
|
@@ -3555,6 +3556,95 @@ function buildSourceDisplayInfo(sourceConfigs, getToolsForSource2, isDemo) {
|
|
|
3555
3556
|
});
|
|
3556
3557
|
}
|
|
3557
3558
|
|
|
3559
|
+
// src/utils/config-watcher.ts
|
|
3560
|
+
import fs from "fs";
|
|
3561
|
+
var DEBOUNCE_MS = 500;
|
|
3562
|
+
function startConfigWatcher(options) {
|
|
3563
|
+
const { connectorManager, initialTools } = options;
|
|
3564
|
+
const configPath = resolveTomlConfigPath();
|
|
3565
|
+
if (!configPath) {
|
|
3566
|
+
return null;
|
|
3567
|
+
}
|
|
3568
|
+
let debounceTimer = null;
|
|
3569
|
+
let isReloading = false;
|
|
3570
|
+
let reloadPending = false;
|
|
3571
|
+
let lastGoodSources = connectorManager.getAllSourceConfigs();
|
|
3572
|
+
let lastGoodTools = initialTools;
|
|
3573
|
+
const scheduleReload = () => {
|
|
3574
|
+
if (debounceTimer) {
|
|
3575
|
+
clearTimeout(debounceTimer);
|
|
3576
|
+
}
|
|
3577
|
+
debounceTimer = setTimeout(reload, DEBOUNCE_MS);
|
|
3578
|
+
};
|
|
3579
|
+
const reload = async () => {
|
|
3580
|
+
if (isReloading) {
|
|
3581
|
+
reloadPending = true;
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
isReloading = true;
|
|
3585
|
+
reloadPending = false;
|
|
3586
|
+
try {
|
|
3587
|
+
console.error(`
|
|
3588
|
+
Detected change in ${configPath}, reloading configuration...`);
|
|
3589
|
+
const newConfig = loadTomlConfig();
|
|
3590
|
+
if (!newConfig) {
|
|
3591
|
+
console.error("Config reload: failed to load TOML config, keeping existing connections.");
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
const oldSources = lastGoodSources;
|
|
3595
|
+
const oldTools = lastGoodTools;
|
|
3596
|
+
await connectorManager.disconnect();
|
|
3597
|
+
try {
|
|
3598
|
+
await connectorManager.connectWithSources(newConfig.sources);
|
|
3599
|
+
initializeToolRegistry({
|
|
3600
|
+
sources: newConfig.sources,
|
|
3601
|
+
tools: newConfig.tools
|
|
3602
|
+
});
|
|
3603
|
+
lastGoodSources = newConfig.sources;
|
|
3604
|
+
lastGoodTools = newConfig.tools;
|
|
3605
|
+
console.error("Configuration reloaded successfully.");
|
|
3606
|
+
} catch (connectError) {
|
|
3607
|
+
console.error("Failed to connect with new config, rolling back:", connectError);
|
|
3608
|
+
try {
|
|
3609
|
+
await connectorManager.disconnect();
|
|
3610
|
+
} catch {
|
|
3611
|
+
}
|
|
3612
|
+
try {
|
|
3613
|
+
await connectorManager.connectWithSources(oldSources);
|
|
3614
|
+
initializeToolRegistry({ sources: oldSources, tools: oldTools });
|
|
3615
|
+
console.error("Rolled back to previous configuration.");
|
|
3616
|
+
} catch (rollbackError) {
|
|
3617
|
+
console.error("Rollback also failed, server has no active connections:", rollbackError);
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
} catch (error) {
|
|
3621
|
+
console.error("Config reload failed, keeping existing connections:", error);
|
|
3622
|
+
} finally {
|
|
3623
|
+
isReloading = false;
|
|
3624
|
+
if (reloadPending) {
|
|
3625
|
+
reloadPending = false;
|
|
3626
|
+
scheduleReload();
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
};
|
|
3630
|
+
const watcher = fs.watch(configPath, (eventType) => {
|
|
3631
|
+
if (eventType === "change") {
|
|
3632
|
+
scheduleReload();
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
watcher.unref?.();
|
|
3636
|
+
watcher.on("error", (err) => {
|
|
3637
|
+
console.error("Config file watcher error:", err);
|
|
3638
|
+
});
|
|
3639
|
+
console.error(`Watching ${configPath} for changes (hot reload enabled)`);
|
|
3640
|
+
return () => {
|
|
3641
|
+
if (debounceTimer) {
|
|
3642
|
+
clearTimeout(debounceTimer);
|
|
3643
|
+
}
|
|
3644
|
+
watcher.close();
|
|
3645
|
+
};
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3558
3648
|
// src/server.ts
|
|
3559
3649
|
var __filename = fileURLToPath(import.meta.url);
|
|
3560
3650
|
var __dirname = path.dirname(__filename);
|
|
@@ -3607,12 +3697,16 @@ See documentation for more details on configuring database connections.
|
|
|
3607
3697
|
const sources = sourceConfigsData.sources;
|
|
3608
3698
|
console.error(`Configuration source: ${sourceConfigsData.source}`);
|
|
3609
3699
|
await connectorManager.connectWithSources(sources);
|
|
3610
|
-
const { initializeToolRegistry } = await import("./registry-
|
|
3611
|
-
|
|
3700
|
+
const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-NTKAVQCA.js");
|
|
3701
|
+
initializeToolRegistry2({
|
|
3612
3702
|
sources: sourceConfigsData.sources,
|
|
3613
3703
|
tools: sourceConfigsData.tools
|
|
3614
3704
|
});
|
|
3615
3705
|
console.error("Tool registry initialized");
|
|
3706
|
+
const stopConfigWatcher = startConfigWatcher({
|
|
3707
|
+
connectorManager,
|
|
3708
|
+
initialTools: sourceConfigsData.tools
|
|
3709
|
+
});
|
|
3616
3710
|
const createServer = () => {
|
|
3617
3711
|
const server = new McpServer({
|
|
3618
3712
|
name: SERVER_NAME,
|
|
@@ -3640,6 +3734,9 @@ See documentation for more details on configuring database connections.
|
|
|
3640
3734
|
isDemo
|
|
3641
3735
|
);
|
|
3642
3736
|
console.error(generateStartupTable(sourceDisplayInfos));
|
|
3737
|
+
process.on("exit", () => {
|
|
3738
|
+
stopConfigWatcher?.();
|
|
3739
|
+
});
|
|
3643
3740
|
if (transportData.type === "http") {
|
|
3644
3741
|
const app = express();
|
|
3645
3742
|
app.use(express.json());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebase/dbhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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": {
|
|
@@ -32,10 +32,14 @@
|
|
|
32
32
|
"test:watch": "vitest",
|
|
33
33
|
"test:integration": "vitest run --project integration"
|
|
34
34
|
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
35
38
|
"keywords": [],
|
|
36
39
|
"author": "",
|
|
37
40
|
"license": "MIT",
|
|
38
41
|
"dependencies": {
|
|
42
|
+
"@aws-sdk/rds-signer": "^3.1001.0",
|
|
39
43
|
"@azure/identity": "^4.8.0",
|
|
40
44
|
"@iarna/toml": "^2.2.5",
|
|
41
45
|
"@modelcontextprotocol/sdk": "^1.25.1",
|