@bytebase/dbhub 0.17.0 → 0.19.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.
@@ -70,6 +70,9 @@ import { readFileSync, realpathSync, statSync } from "fs";
70
70
  import { homedir } from "os";
71
71
  import { join } from "path";
72
72
  import SSHConfig from "ssh-config";
73
+ function getDefaultSSHConfigPath() {
74
+ return join(homedir(), ".ssh", "config");
75
+ }
73
76
  var DEFAULT_SSH_KEYS = [
74
77
  "~/.ssh/id_rsa",
75
78
  "~/.ssh/id_ed25519",
@@ -296,7 +299,9 @@ var SSHTunnel = class {
296
299
  privateKey,
297
300
  targetConfig.passphrase,
298
301
  previousStream,
299
- `jump host ${i + 1}`
302
+ `jump host ${i + 1}`,
303
+ targetConfig.keepaliveInterval,
304
+ targetConfig.keepaliveCountMax
300
305
  );
301
306
  console.error(` \u2192 Forwarding through ${jumpHost.host}:${jumpHost.port} to ${nextHost.host}:${nextHost.port}`);
302
307
  forwardStream = await this.forwardTo(client, nextHost.host, nextHost.port);
@@ -322,7 +327,9 @@ var SSHTunnel = class {
322
327
  privateKey,
323
328
  targetConfig.passphrase,
324
329
  previousStream,
325
- jumpHosts.length > 0 ? "target host" : void 0
330
+ jumpHosts.length > 0 ? "target host" : void 0,
331
+ targetConfig.keepaliveInterval,
332
+ targetConfig.keepaliveCountMax
326
333
  );
327
334
  this.sshClients.push(finalClient);
328
335
  return finalClient;
@@ -330,7 +337,7 @@ var SSHTunnel = class {
330
337
  /**
331
338
  * Connect to a single SSH host.
332
339
  */
333
- connectToHost(hostInfo, password, privateKey, passphrase, sock, label) {
340
+ connectToHost(hostInfo, password, privateKey, passphrase, sock, label, keepaliveInterval, keepaliveCountMax) {
334
341
  return new Promise((resolve, reject) => {
335
342
  const client = new Client();
336
343
  const sshConfig = {
@@ -350,6 +357,17 @@ var SSHTunnel = class {
350
357
  if (sock) {
351
358
  sshConfig.sock = sock;
352
359
  }
360
+ if (keepaliveInterval !== void 0) {
361
+ if (Number.isNaN(keepaliveInterval) || keepaliveInterval < 0) {
362
+ const desc = label || `${hostInfo.host}:${hostInfo.port}`;
363
+ console.warn(
364
+ `Invalid SSH keepaliveInterval (${keepaliveInterval}) for ${desc}; keepalive configuration will be ignored.`
365
+ );
366
+ } else if (keepaliveInterval > 0) {
367
+ sshConfig.keepaliveInterval = keepaliveInterval * 1e3;
368
+ sshConfig.keepaliveCountMax = keepaliveCountMax ?? 3;
369
+ }
370
+ }
353
371
  const onError = (err) => {
354
372
  client.removeListener("ready", onReady);
355
373
  client.destroy();
@@ -488,7 +506,7 @@ var SSHTunnel = class {
488
506
  // src/config/toml-loader.ts
489
507
  import fs2 from "fs";
490
508
  import path2 from "path";
491
- import { homedir as homedir3 } from "os";
509
+ import { homedir as homedir2 } from "os";
492
510
  import toml from "@iarna/toml";
493
511
 
494
512
  // src/config/env.ts
@@ -496,7 +514,6 @@ import dotenv from "dotenv";
496
514
  import path from "path";
497
515
  import fs from "fs";
498
516
  import { fileURLToPath } from "url";
499
- import { homedir as homedir2 } from "os";
500
517
 
501
518
  // src/utils/safe-url.ts
502
519
  var SafeURL = class {
@@ -955,7 +972,7 @@ function resolveSSHConfig() {
955
972
  sources.push("SSH_HOST from environment");
956
973
  }
957
974
  if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
958
- const sshConfigPath = path.join(homedir2(), ".ssh", "config");
975
+ const sshConfigPath = getDefaultSSHConfigPath();
959
976
  console.error(`Attempting to parse SSH config for host '${sshConfigHost}' from: ${sshConfigPath}`);
960
977
  const sshConfigData = parseSSHConfig(sshConfigHost, sshConfigPath);
961
978
  if (sshConfigData) {
@@ -1011,6 +1028,27 @@ function resolveSSHConfig() {
1011
1028
  config.proxyJump = process.env.SSH_PROXY_JUMP;
1012
1029
  sources.push("SSH_PROXY_JUMP from environment");
1013
1030
  }
1031
+ const parseNonNegativeInteger = (value, name) => {
1032
+ const parsed = Number(value);
1033
+ if (!Number.isInteger(parsed) || parsed < 0) {
1034
+ throw new Error(`Invalid value for ${name}: "${value}". Expected a non-negative integer.`);
1035
+ }
1036
+ return parsed;
1037
+ };
1038
+ if (args["ssh-keepalive-interval"]) {
1039
+ config.keepaliveInterval = parseNonNegativeInteger(args["ssh-keepalive-interval"], "ssh-keepalive-interval");
1040
+ sources.push("ssh-keepalive-interval from command line");
1041
+ } else if (process.env.SSH_KEEPALIVE_INTERVAL) {
1042
+ config.keepaliveInterval = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_INTERVAL, "SSH_KEEPALIVE_INTERVAL");
1043
+ sources.push("SSH_KEEPALIVE_INTERVAL from environment");
1044
+ }
1045
+ if (args["ssh-keepalive-count-max"]) {
1046
+ config.keepaliveCountMax = parseNonNegativeInteger(args["ssh-keepalive-count-max"], "ssh-keepalive-count-max");
1047
+ sources.push("ssh-keepalive-count-max from command line");
1048
+ } else if (process.env.SSH_KEEPALIVE_COUNT_MAX) {
1049
+ config.keepaliveCountMax = parseNonNegativeInteger(process.env.SSH_KEEPALIVE_COUNT_MAX, "SSH_KEEPALIVE_COUNT_MAX");
1050
+ sources.push("SSH_KEEPALIVE_COUNT_MAX from environment");
1051
+ }
1014
1052
  if (!config.host || !config.username) {
1015
1053
  throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
1016
1054
  }
@@ -1090,6 +1128,8 @@ async function resolveSourceConfigs() {
1090
1128
  source.ssh_password = sshResult.config.password;
1091
1129
  source.ssh_key = sshResult.config.privateKey;
1092
1130
  source.ssh_passphrase = sshResult.config.passphrase;
1131
+ source.ssh_keepalive_interval = sshResult.config.keepaliveInterval;
1132
+ source.ssh_keepalive_count_max = sshResult.config.keepaliveCountMax;
1093
1133
  }
1094
1134
  if (dsnResult.isDemo) {
1095
1135
  const { getSqliteInMemorySetupSql } = await import("./demo-loader-PSMTLZ2T.js");
@@ -1112,7 +1152,8 @@ function loadTomlConfig() {
1112
1152
  }
1113
1153
  try {
1114
1154
  const fileContent = fs2.readFileSync(configPath, "utf-8");
1115
- const parsedToml = toml.parse(fileContent);
1155
+ const rawToml = toml.parse(fileContent);
1156
+ const parsedToml = interpolateEnvVars(rawToml);
1116
1157
  if (!Array.isArray(parsedToml.sources)) {
1117
1158
  throw new Error(
1118
1159
  `Configuration file ${configPath}: must contain a [[sources]] array. Use [[sources]] syntax for array of tables in TOML.`
@@ -1270,6 +1311,31 @@ function validateSourceConfig(source, configPath) {
1270
1311
  );
1271
1312
  }
1272
1313
  }
1314
+ if (source.aws_iam_auth !== void 0 && typeof source.aws_iam_auth !== "boolean") {
1315
+ throw new Error(
1316
+ `Configuration file ${configPath}: source '${source.id}' has invalid aws_iam_auth. Must be a boolean (true or false).`
1317
+ );
1318
+ }
1319
+ if (source.aws_region !== void 0) {
1320
+ if (typeof source.aws_region !== "string" || source.aws_region.trim().length === 0) {
1321
+ throw new Error(
1322
+ `Configuration file ${configPath}: source '${source.id}' has invalid aws_region. Must be a non-empty string (e.g., "eu-west-1").`
1323
+ );
1324
+ }
1325
+ }
1326
+ if (source.aws_iam_auth === true) {
1327
+ const validIamTypes = ["postgres", "mysql", "mariadb"];
1328
+ if (!source.type || !validIamTypes.includes(source.type)) {
1329
+ throw new Error(
1330
+ `Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled, but this is only supported for postgres, mysql, and mariadb sources.`
1331
+ );
1332
+ }
1333
+ if (!source.aws_region) {
1334
+ throw new Error(
1335
+ `Configuration file ${configPath}: source '${source.id}' has aws_iam_auth enabled but aws_region is not specified.`
1336
+ );
1337
+ }
1338
+ }
1273
1339
  if (source.connection_timeout !== void 0) {
1274
1340
  if (typeof source.connection_timeout !== "number" || source.connection_timeout <= 0) {
1275
1341
  throw new Error(
@@ -1397,9 +1463,29 @@ function processSourceConfigs(sources, configPath) {
1397
1463
  return processed;
1398
1464
  });
1399
1465
  }
1466
+ var ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
1467
+ function interpolateEnvVars(value) {
1468
+ if (typeof value === "string") {
1469
+ return value.replace(ENV_VAR_PATTERN, (match, varName) => {
1470
+ const envValue = process.env[varName];
1471
+ return envValue !== void 0 ? envValue : match;
1472
+ });
1473
+ }
1474
+ if (Array.isArray(value)) {
1475
+ return value.map((item) => interpolateEnvVars(item));
1476
+ }
1477
+ if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
1478
+ const result = {};
1479
+ for (const [key, val] of Object.entries(value)) {
1480
+ result[key] = interpolateEnvVars(val);
1481
+ }
1482
+ return result;
1483
+ }
1484
+ return value;
1485
+ }
1400
1486
  function expandHomeDir(filePath) {
1401
1487
  if (filePath.startsWith("~/")) {
1402
- return path2.join(homedir3(), filePath.substring(2));
1488
+ return path2.join(homedir2(), filePath.substring(2));
1403
1489
  }
1404
1490
  return filePath;
1405
1491
  }
@@ -1420,7 +1506,8 @@ function buildDSNFromSource(source) {
1420
1506
  }
1421
1507
  return `sqlite:///${source.database}`;
1422
1508
  }
1423
- const passwordRequired = source.authentication !== "azure-active-directory-access-token";
1509
+ const isAwsIamPasswordless = source.aws_iam_auth === true && ["postgres", "mysql", "mariadb"].includes(source.type);
1510
+ const passwordRequired = source.authentication !== "azure-active-directory-access-token" && !isAwsIamPasswordless;
1424
1511
  if (!source.host || !source.user || !source.database) {
1425
1512
  throw new Error(
1426
1513
  `Source '${source.id}': missing required connection parameters. Required: type, host, user, database`
@@ -1428,7 +1515,7 @@ function buildDSNFromSource(source) {
1428
1515
  }
1429
1516
  if (passwordRequired && !source.password) {
1430
1517
  throw new Error(
1431
- `Source '${source.id}': password is required. (Password is optional only for azure-active-directory-access-token authentication)`
1518
+ `Source '${source.id}': password is required. (Password is optional for azure-active-directory-access-token authentication or when aws_iam_auth=true)`
1432
1519
  );
1433
1520
  }
1434
1521
  const port = source.port || getDefaultPortForType(source.type);
@@ -1460,8 +1547,21 @@ function buildDSNFromSource(source) {
1460
1547
  return dsn;
1461
1548
  }
1462
1549
 
1550
+ // src/utils/aws-rds-signer.ts
1551
+ import { Signer } from "@aws-sdk/rds-signer";
1552
+ async function generateRdsAuthToken(params) {
1553
+ const signer = new Signer({
1554
+ hostname: params.hostname,
1555
+ port: params.port,
1556
+ username: params.username,
1557
+ region: params.region
1558
+ });
1559
+ return signer.getAuthToken();
1560
+ }
1561
+
1463
1562
  // src/connectors/manager.ts
1464
1563
  var managerInstance = null;
1564
+ var AWS_IAM_TOKEN_REFRESH_MS = 14 * 60 * 1e3;
1465
1565
  var ConnectorManager = class {
1466
1566
  // Prevent race conditions
1467
1567
  constructor() {
@@ -1472,6 +1572,8 @@ var ConnectorManager = class {
1472
1572
  // Store original source configs
1473
1573
  this.sourceIds = [];
1474
1574
  // Ordered list of source IDs (first is default)
1575
+ this.iamRefreshTimers = /* @__PURE__ */ new Map();
1576
+ this.isDisconnecting = false;
1475
1577
  // Lazy connection support
1476
1578
  this.lazySources = /* @__PURE__ */ new Map();
1477
1579
  // Sources pending lazy connection
@@ -1561,27 +1663,36 @@ var ConnectorManager = class {
1561
1663
  */
1562
1664
  async connectSource(source) {
1563
1665
  const sourceId = source.id;
1564
- const dsn = buildDSNFromSource(source);
1666
+ const dsn = await this.buildConnectionDSN(source);
1565
1667
  console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
1566
1668
  let actualDSN = dsn;
1567
1669
  if (source.ssh_host) {
1568
- if (!source.ssh_user) {
1569
- throw new Error(
1570
- `Source '${sourceId}': SSH tunnel requires ssh_user`
1571
- );
1670
+ let resolvedSSHConfig = null;
1671
+ if (looksLikeSSHAlias(source.ssh_host)) {
1672
+ const sshConfigPath = getDefaultSSHConfigPath();
1673
+ console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
1674
+ resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
1572
1675
  }
1676
+ const username = source.ssh_user || resolvedSSHConfig?.username;
1573
1677
  const sshConfig = {
1574
- host: source.ssh_host,
1575
- port: source.ssh_port || 22,
1576
- username: source.ssh_user,
1678
+ host: resolvedSSHConfig?.host || source.ssh_host,
1679
+ port: source.ssh_port || resolvedSSHConfig?.port || 22,
1680
+ username: username || "",
1577
1681
  password: source.ssh_password,
1578
- privateKey: source.ssh_key,
1682
+ privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
1579
1683
  passphrase: source.ssh_passphrase,
1580
- proxyJump: source.ssh_proxy_jump
1684
+ proxyJump: source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump,
1685
+ keepaliveInterval: source.ssh_keepalive_interval,
1686
+ keepaliveCountMax: source.ssh_keepalive_count_max
1581
1687
  };
1688
+ if (!username) {
1689
+ throw new Error(
1690
+ `Source '${sourceId}': SSH tunnel requires ssh_user (or a matching Host entry in ~/.ssh/config with User)`
1691
+ );
1692
+ }
1582
1693
  if (!sshConfig.password && !sshConfig.privateKey) {
1583
1694
  throw new Error(
1584
- `Source '${sourceId}': SSH tunnel requires either ssh_password or ssh_key`
1695
+ `Source '${sourceId}': SSH tunnel requires either ssh_password or ssh_key (or a matching Host entry in ~/.ssh/config with IdentityFile)`
1585
1696
  );
1586
1697
  }
1587
1698
  const url = new URL(dsn);
@@ -1627,11 +1738,17 @@ var ConnectorManager = class {
1627
1738
  this.sourceIds.push(sourceId);
1628
1739
  }
1629
1740
  this.sourceConfigs.set(sourceId, source);
1741
+ this.scheduleIamRefresh(source);
1630
1742
  }
1631
1743
  /**
1632
1744
  * Close all database connections
1633
1745
  */
1634
1746
  async disconnect() {
1747
+ this.isDisconnecting = true;
1748
+ for (const timer of this.iamRefreshTimers.values()) {
1749
+ clearTimeout(timer);
1750
+ }
1751
+ this.iamRefreshTimers.clear();
1635
1752
  for (const [sourceId, connector] of this.connectors.entries()) {
1636
1753
  try {
1637
1754
  await connector.disconnect();
@@ -1653,6 +1770,7 @@ var ConnectorManager = class {
1653
1770
  this.lazySources.clear();
1654
1771
  this.pendingConnections.clear();
1655
1772
  this.sourceIds = [];
1773
+ this.isDisconnecting = false;
1656
1774
  }
1657
1775
  /**
1658
1776
  * Get a connector by source ID
@@ -1753,63 +1871,317 @@ var ConnectorManager = class {
1753
1871
  }
1754
1872
  return getDefaultPortForType(type) ?? 0;
1755
1873
  }
1874
+ scheduleIamRefresh(source) {
1875
+ if (this.isDisconnecting) {
1876
+ return;
1877
+ }
1878
+ const sourceId = source.id;
1879
+ const existingTimer = this.iamRefreshTimers.get(sourceId);
1880
+ if (existingTimer) {
1881
+ clearTimeout(existingTimer);
1882
+ this.iamRefreshTimers.delete(sourceId);
1883
+ }
1884
+ if (!source.aws_iam_auth) {
1885
+ return;
1886
+ }
1887
+ const timer = setTimeout(async () => {
1888
+ if (this.isDisconnecting) {
1889
+ return;
1890
+ }
1891
+ try {
1892
+ await this.refreshIamSourceConnection(source);
1893
+ } catch (error) {
1894
+ console.error(
1895
+ `Error refreshing AWS IAM auth token for source '${sourceId}':`,
1896
+ error
1897
+ );
1898
+ } finally {
1899
+ if (!this.isDisconnecting && this.sourceConfigs.has(sourceId)) {
1900
+ this.scheduleIamRefresh(source);
1901
+ }
1902
+ }
1903
+ }, AWS_IAM_TOKEN_REFRESH_MS);
1904
+ timer.unref?.();
1905
+ this.iamRefreshTimers.set(sourceId, timer);
1906
+ }
1907
+ async refreshIamSourceConnection(source) {
1908
+ const sourceId = source.id;
1909
+ if (this.isDisconnecting || !source.aws_iam_auth || !this.connectors.has(sourceId)) {
1910
+ return;
1911
+ }
1912
+ console.error(`Refreshing AWS IAM auth connection for source '${sourceId}'...`);
1913
+ const existingConnector = this.connectors.get(sourceId);
1914
+ if (existingConnector) {
1915
+ await existingConnector.disconnect();
1916
+ this.connectors.delete(sourceId);
1917
+ }
1918
+ const existingTunnel = this.sshTunnels.get(sourceId);
1919
+ if (existingTunnel) {
1920
+ await existingTunnel.close();
1921
+ this.sshTunnels.delete(sourceId);
1922
+ }
1923
+ if (this.isDisconnecting) {
1924
+ return;
1925
+ }
1926
+ await this.connectSource(source);
1927
+ }
1928
+ /**
1929
+ * Build a connection DSN, optionally replacing password with
1930
+ * an AWS RDS IAM auth token when aws_iam_auth is enabled.
1931
+ */
1932
+ async buildConnectionDSN(source) {
1933
+ const dsn = buildDSNFromSource(source);
1934
+ if (!source.aws_iam_auth) {
1935
+ return dsn;
1936
+ }
1937
+ const supportedIamTypes = ["postgres", "mysql", "mariadb"];
1938
+ if (!source.type || !supportedIamTypes.includes(source.type)) {
1939
+ throw new Error(
1940
+ `Source '${source.id}': aws_iam_auth is only supported for postgres, mysql, and mariadb`
1941
+ );
1942
+ }
1943
+ if (!source.aws_region) {
1944
+ throw new Error(
1945
+ `Source '${source.id}': aws_region is required when aws_iam_auth is enabled`
1946
+ );
1947
+ }
1948
+ const parsed = new SafeURL(dsn);
1949
+ const hostname = parsed.hostname;
1950
+ const username = source.user || parsed.username;
1951
+ const defaultPort = getDefaultPortForType(source.type);
1952
+ const port = parsed.port ? parseInt(parsed.port) : defaultPort;
1953
+ if (!hostname || !username || !port) {
1954
+ throw new Error(
1955
+ `Source '${source.id}': unable to resolve host, username, or port for AWS IAM authentication`
1956
+ );
1957
+ }
1958
+ const token = await generateRdsAuthToken({
1959
+ hostname,
1960
+ port,
1961
+ username,
1962
+ region: source.aws_region
1963
+ });
1964
+ const queryParams = new Map(parsed.searchParams);
1965
+ queryParams.set("sslmode", "require");
1966
+ const protocol = parsed.protocol.endsWith(":") ? parsed.protocol.slice(0, -1) : parsed.protocol;
1967
+ const encodedUser = encodeURIComponent(username);
1968
+ const encodedToken = encodeURIComponent(token);
1969
+ const path3 = parsed.pathname || "/";
1970
+ const query = Array.from(queryParams.entries()).map(
1971
+ ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
1972
+ ).join("&");
1973
+ return `${protocol}://${encodedUser}:${encodedToken}@${hostname}:${port}${path3}${query ? `?${query}` : ""}`;
1974
+ }
1756
1975
  };
1757
1976
 
1758
1977
  // src/utils/sql-parser.ts
1759
- function stripCommentsAndStrings(sql) {
1760
- let result = "";
1978
+ var TokenType = { Plain: 0, Comment: 1, QuotedBlock: 2 };
1979
+ function plainToken(i) {
1980
+ return { type: TokenType.Plain, end: i + 1 };
1981
+ }
1982
+ function scanSingleLineComment(sql, i) {
1983
+ if (sql[i] !== "-" || sql[i + 1] !== "-") {
1984
+ return null;
1985
+ }
1986
+ let j = i;
1987
+ while (j < sql.length && sql[j] !== "\n") {
1988
+ j++;
1989
+ }
1990
+ return { type: TokenType.Comment, end: j };
1991
+ }
1992
+ function scanMultiLineComment(sql, i) {
1993
+ if (sql[i] !== "/" || sql[i + 1] !== "*") {
1994
+ return null;
1995
+ }
1996
+ let j = i + 2;
1997
+ while (j < sql.length && !(sql[j] === "*" && sql[j + 1] === "/")) {
1998
+ j++;
1999
+ }
2000
+ if (j < sql.length) {
2001
+ j += 2;
2002
+ }
2003
+ return { type: TokenType.Comment, end: j };
2004
+ }
2005
+ function scanNestedMultiLineComment(sql, i) {
2006
+ if (sql[i] !== "/" || sql[i + 1] !== "*") {
2007
+ return null;
2008
+ }
2009
+ let j = i + 2;
2010
+ let depth = 1;
2011
+ while (j < sql.length && depth > 0) {
2012
+ if (sql[j] === "/" && sql[j + 1] === "*") {
2013
+ depth++;
2014
+ j += 2;
2015
+ } else if (sql[j] === "*" && sql[j + 1] === "/") {
2016
+ depth--;
2017
+ j += 2;
2018
+ } else {
2019
+ j++;
2020
+ }
2021
+ }
2022
+ return { type: TokenType.Comment, end: j };
2023
+ }
2024
+ function scanSingleQuotedString(sql, i) {
2025
+ if (sql[i] !== "'") {
2026
+ return null;
2027
+ }
2028
+ let j = i + 1;
2029
+ while (j < sql.length) {
2030
+ if (sql[j] === "'" && sql[j + 1] === "'") {
2031
+ j += 2;
2032
+ } else if (sql[j] === "'") {
2033
+ j++;
2034
+ break;
2035
+ } else {
2036
+ j++;
2037
+ }
2038
+ }
2039
+ return { type: TokenType.QuotedBlock, end: j };
2040
+ }
2041
+ function scanDoubleQuotedString(sql, i) {
2042
+ if (sql[i] !== '"') {
2043
+ return null;
2044
+ }
2045
+ let j = i + 1;
2046
+ while (j < sql.length) {
2047
+ if (sql[j] === '"' && sql[j + 1] === '"') {
2048
+ j += 2;
2049
+ } else if (sql[j] === '"') {
2050
+ j++;
2051
+ break;
2052
+ } else {
2053
+ j++;
2054
+ }
2055
+ }
2056
+ return { type: TokenType.QuotedBlock, end: j };
2057
+ }
2058
+ var dollarQuoteOpenRegex = /^\$([a-zA-Z_]\w*)?\$/;
2059
+ function scanDollarQuotedBlock(sql, i) {
2060
+ if (sql[i] !== "$") {
2061
+ return null;
2062
+ }
2063
+ const next = sql[i + 1];
2064
+ if (next >= "0" && next <= "9") {
2065
+ return null;
2066
+ }
2067
+ const remaining = sql.substring(i);
2068
+ const m = dollarQuoteOpenRegex.exec(remaining);
2069
+ if (!m) {
2070
+ return null;
2071
+ }
2072
+ const tag = m[0];
2073
+ const bodyStart = i + tag.length;
2074
+ const closeIdx = sql.indexOf(tag, bodyStart);
2075
+ const end = closeIdx !== -1 ? closeIdx + tag.length : sql.length;
2076
+ return { type: TokenType.QuotedBlock, end };
2077
+ }
2078
+ function scanBacktickQuotedIdentifier(sql, i) {
2079
+ if (sql[i] !== "`") {
2080
+ return null;
2081
+ }
2082
+ let j = i + 1;
2083
+ while (j < sql.length) {
2084
+ if (sql[j] === "`" && sql[j + 1] === "`") {
2085
+ j += 2;
2086
+ } else if (sql[j] === "`") {
2087
+ j++;
2088
+ break;
2089
+ } else {
2090
+ j++;
2091
+ }
2092
+ }
2093
+ return { type: TokenType.QuotedBlock, end: j };
2094
+ }
2095
+ function scanBracketQuotedIdentifier(sql, i) {
2096
+ if (sql[i] !== "[") {
2097
+ return null;
2098
+ }
2099
+ let j = i + 1;
2100
+ while (j < sql.length) {
2101
+ if (sql[j] === "]" && sql[j + 1] === "]") {
2102
+ j += 2;
2103
+ } else if (sql[j] === "]") {
2104
+ j++;
2105
+ break;
2106
+ } else {
2107
+ j++;
2108
+ }
2109
+ }
2110
+ return { type: TokenType.QuotedBlock, end: j };
2111
+ }
2112
+ function scanTokenAnsi(sql, i) {
2113
+ return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? plainToken(i);
2114
+ }
2115
+ function scanTokenPostgres(sql, i) {
2116
+ return scanSingleLineComment(sql, i) ?? scanNestedMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanDollarQuotedBlock(sql, i) ?? plainToken(i);
2117
+ }
2118
+ function scanTokenMySQL(sql, i) {
2119
+ return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? plainToken(i);
2120
+ }
2121
+ function scanTokenSQLite(sql, i) {
2122
+ return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBacktickQuotedIdentifier(sql, i) ?? scanBracketQuotedIdentifier(sql, i) ?? plainToken(i);
2123
+ }
2124
+ function scanTokenSQLServer(sql, i) {
2125
+ return scanSingleLineComment(sql, i) ?? scanMultiLineComment(sql, i) ?? scanSingleQuotedString(sql, i) ?? scanDoubleQuotedString(sql, i) ?? scanBracketQuotedIdentifier(sql, i) ?? plainToken(i);
2126
+ }
2127
+ var dialectScanners = {
2128
+ postgres: scanTokenPostgres,
2129
+ mysql: scanTokenMySQL,
2130
+ mariadb: scanTokenMySQL,
2131
+ sqlite: scanTokenSQLite,
2132
+ sqlserver: scanTokenSQLServer
2133
+ };
2134
+ function getScanner(dialect) {
2135
+ return dialect ? dialectScanners[dialect] ?? scanTokenAnsi : scanTokenAnsi;
2136
+ }
2137
+ function stripCommentsAndStrings(sql, dialect) {
2138
+ const scanToken = getScanner(dialect);
2139
+ const parts = [];
2140
+ let plainStart = -1;
1761
2141
  let i = 0;
1762
2142
  while (i < sql.length) {
1763
- if (sql[i] === "-" && sql[i + 1] === "-") {
1764
- while (i < sql.length && sql[i] !== "\n") {
1765
- i++;
2143
+ const token = scanToken(sql, i);
2144
+ if (token.type === TokenType.Plain) {
2145
+ if (plainStart === -1) {
2146
+ plainStart = i;
1766
2147
  }
1767
- result += " ";
1768
- continue;
1769
- }
1770
- if (sql[i] === "/" && sql[i + 1] === "*") {
1771
- i += 2;
1772
- while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
1773
- i++;
2148
+ } else {
2149
+ if (plainStart !== -1) {
2150
+ parts.push(sql.substring(plainStart, i));
2151
+ plainStart = -1;
1774
2152
  }
1775
- i += 2;
1776
- result += " ";
1777
- continue;
2153
+ parts.push(" ");
1778
2154
  }
1779
- if (sql[i] === "'") {
1780
- i++;
1781
- while (i < sql.length) {
1782
- if (sql[i] === "'" && sql[i + 1] === "'") {
1783
- i += 2;
1784
- } else if (sql[i] === "'") {
1785
- i++;
1786
- break;
1787
- } else {
1788
- i++;
1789
- }
2155
+ i = token.end;
2156
+ }
2157
+ if (plainStart !== -1) {
2158
+ parts.push(sql.substring(plainStart));
2159
+ }
2160
+ return parts.join("");
2161
+ }
2162
+ function splitSQLStatements(sql, dialect) {
2163
+ const scanToken = getScanner(dialect);
2164
+ const statements = [];
2165
+ let stmtStart = 0;
2166
+ let i = 0;
2167
+ while (i < sql.length) {
2168
+ if (sql[i] === ";") {
2169
+ const trimmed2 = sql.substring(stmtStart, i).trim();
2170
+ if (trimmed2.length > 0) {
2171
+ statements.push(trimmed2);
1790
2172
  }
1791
- result += " ";
1792
- continue;
1793
- }
1794
- if (sql[i] === '"') {
2173
+ stmtStart = i + 1;
1795
2174
  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
2175
  continue;
1808
2176
  }
1809
- result += sql[i];
1810
- i++;
2177
+ const token = scanToken(sql, i);
2178
+ i = token.end;
2179
+ }
2180
+ const trimmed = sql.substring(stmtStart).trim();
2181
+ if (trimmed.length > 0) {
2182
+ statements.push(trimmed);
1811
2183
  }
1812
- return result;
2184
+ return statements;
1813
2185
  }
1814
2186
 
1815
2187
  // src/utils/parameter-mapper.ts
@@ -2145,12 +2517,15 @@ export {
2145
2517
  getDatabaseTypeFromDSN,
2146
2518
  getDefaultPortForType,
2147
2519
  stripCommentsAndStrings,
2520
+ splitSQLStatements,
2148
2521
  isDemoMode,
2149
2522
  resolveTransport,
2150
2523
  resolvePort,
2151
2524
  resolveSourceConfigs,
2152
2525
  BUILTIN_TOOL_EXECUTE_SQL,
2153
2526
  BUILTIN_TOOL_SEARCH_OBJECTS,
2527
+ loadTomlConfig,
2528
+ resolveTomlConfigPath,
2154
2529
  ConnectorManager,
2155
2530
  mapArgumentsToArray,
2156
2531
  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-YKDZH7G5.js";
23
+ } from "./chunk-LUNM7TUY.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.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
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.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
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.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
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.split(";").map((statement) => statement.trim()).filter((statement) => statement.length > 0);
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-7HJVUJCM.js");
3611
- initializeToolRegistry({
3700
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-6VNMKD6G.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());
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-YKDZH7G5.js";
5
+ } from "./chunk-LUNM7TUY.js";
6
6
  export {
7
7
  ToolRegistry,
8
8
  getToolRegistry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",