@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 passwordRequired = source.authentication !== "azure-active-directory-access-token";
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 only for azure-active-directory-access-token authentication)`
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 = buildDSNFromSource(source);
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
- function stripCommentsAndStrings(sql) {
1760
- let result = "";
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
- if (sql[i] === "-" && sql[i + 1] === "-") {
1764
- while (i < sql.length && sql[i] !== "\n") {
1765
- i++;
2113
+ const token = scanToken(sql, i);
2114
+ if (token.type === TokenType.Plain) {
2115
+ if (plainStart === -1) {
2116
+ plainStart = i;
1766
2117
  }
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++;
2118
+ } else {
2119
+ if (plainStart !== -1) {
2120
+ parts.push(sql.substring(plainStart, i));
2121
+ plainStart = -1;
1774
2122
  }
1775
- i += 2;
1776
- result += " ";
1777
- continue;
2123
+ parts.push(" ");
1778
2124
  }
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
- }
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
- result += " ";
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
- result += sql[i];
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 result;
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-YKDZH7G5.js";
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.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-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());
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-YKDZH7G5.js";
5
+ } from "./chunk-WCXOWHL3.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.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",