@carllee1983/dbcli 1.54.0 → 1.54.1

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.
@@ -1660,6 +1660,21 @@ shape (`schemaVersion: 1`):
1660
1660
  Recovery codes (fixed in v1.15.0):
1661
1661
  - `CONFIG_MISSING` — no `.dbcli` config; run `dbcli init`.
1662
1662
  - `CONN_REFUSED` / `CONN_AUTH_FAILED` / `CONN_TIMEOUT` / `CONN_HOST_NOT_FOUND` / `CONN_UNKNOWN` — connection failure variants.
1663
+ `CONN_TIMEOUT` also covers a statement the server canceled for exceeding the statement
1664
+ timeout (PostgreSQL `57014`, MySQL `3024`, MariaDB `1969`); `details.connectionCode` is
1665
+ `STATEMENT_TIMEOUT` there instead of `ETIMEDOUT`, the plan targets the query (`lint` →
1666
+ `explain` → re-run with `--statement-timeout <ms>`) rather than `doctor`, and no
1667
+ `branches` / `branchFork` / `verify` is emitted. PostgreSQL `57014` only counts as a
1668
+ statement timeout when the server says so — a `pg_cancel_backend()` cancel keeps the
1669
+ verbatim `Database error (57014): …` form instead of claiming a ceiling.
1670
+ The same `details.connectionCode` field distinguishes the other causes that share a
1671
+ connection code: `CONNECTION_LOST` (server closed the connection mid-session — PostgreSQL
1672
+ class 08 and `57P01`/`57P02`, MySQL `1053`), `TOO_MANY_CONNECTIONS` (PostgreSQL `53300`,
1673
+ MySQL `1040` — its plan counts current connections instead of running `doctor`, because
1674
+ rewriting host/port cannot create a slot), `SERVER_NOT_READY` (`57P03`, still starting up),
1675
+ `CONNECTION_REJECTED` (`08004` — the server answered and refused), `EHOSTUNREACH` (resolved
1676
+ but unroutable) and `TLS_ERROR` (handshake failure; reported as `CONN_UNKNOWN` rather than
1677
+ `CONN_AUTH_FAILED`, whose plan re-runs `init` for credentials that are not the problem).
1663
1678
  - `PERMISSION_DENIED` — active permission level forbids the operation.
1664
1679
  - `BLACKLIST_TABLE` / `BLACKLIST_COLUMN_WRITE` — blacklist violations.
1665
1680
  - `SNIPPET_NOT_FOUND` / `SNIPPET_AMBIGUOUS` / `SNIPPET_PARAM_MISSING` — saved-query failures.
@@ -1660,6 +1660,21 @@ shape (`schemaVersion: 1`):
1660
1660
  Recovery codes (fixed in v1.15.0):
1661
1661
  - `CONFIG_MISSING` — no `.dbcli` config; run `dbcli init`.
1662
1662
  - `CONN_REFUSED` / `CONN_AUTH_FAILED` / `CONN_TIMEOUT` / `CONN_HOST_NOT_FOUND` / `CONN_UNKNOWN` — connection failure variants.
1663
+ `CONN_TIMEOUT` also covers a statement the server canceled for exceeding the statement
1664
+ timeout (PostgreSQL `57014`, MySQL `3024`, MariaDB `1969`); `details.connectionCode` is
1665
+ `STATEMENT_TIMEOUT` there instead of `ETIMEDOUT`, the plan targets the query (`lint` →
1666
+ `explain` → re-run with `--statement-timeout <ms>`) rather than `doctor`, and no
1667
+ `branches` / `branchFork` / `verify` is emitted. PostgreSQL `57014` only counts as a
1668
+ statement timeout when the server says so — a `pg_cancel_backend()` cancel keeps the
1669
+ verbatim `Database error (57014): …` form instead of claiming a ceiling.
1670
+ The same `details.connectionCode` field distinguishes the other causes that share a
1671
+ connection code: `CONNECTION_LOST` (server closed the connection mid-session — PostgreSQL
1672
+ class 08 and `57P01`/`57P02`, MySQL `1053`), `TOO_MANY_CONNECTIONS` (PostgreSQL `53300`,
1673
+ MySQL `1040` — its plan counts current connections instead of running `doctor`, because
1674
+ rewriting host/port cannot create a slot), `SERVER_NOT_READY` (`57P03`, still starting up),
1675
+ `CONNECTION_REJECTED` (`08004` — the server answered and refused), `EHOSTUNREACH` (resolved
1676
+ but unroutable) and `TLS_ERROR` (handshake failure; reported as `CONN_UNKNOWN` rather than
1677
+ `CONN_AUTH_FAILED`, whose plan re-runs `init` for credentials that are not the problem).
1663
1678
  - `PERMISSION_DENIED` — active permission level forbids the operation.
1664
1679
  - `BLACKLIST_TABLE` / `BLACKLIST_COLUMN_WRITE` — blacklist violations.
1665
1680
  - `SNIPPET_NOT_FOUND` / `SNIPPET_AMBIGUOUS` / `SNIPPET_PARAM_MISSING` — saved-query failures.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to dbcli are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.54.1] - 2026-08-13 - Error classification: a failure now says which layer broke
9
+
10
+ ### Fixed
11
+
12
+ - **A dropped connection, an exhausted connection pool, an unreachable host, and a TLS failure each say so.** `TRANSPORT_CODES` listed seven keys, so `ECONNRESET`, `EPIPE`, `EHOSTUNREACH`, `ENETUNREACH`, TLS certificate codes, PostgreSQL SQLSTATE class `08` and `53300`, and MySQL `1040` all carried a code, skipped the message-pattern fallback (which only runs when there is none), and landed on `Database error (ECONNRESET): …` with hints about `dbcli schema`. They now map to four new categories — `CONNECTION_LOST`, `TOO_MANY_CONNECTIONS`, `EHOSTUNREACH`, `TLS_ERROR` — each with the remedy that actually applies: retry a transient drop, inspect `pg_stat_activity` / `Threads_connected` against `max_connections`, check routing and VPN rather than DNS, or point `caPath` / `rejectUnauthorized` at the right certificate. PostgreSQL class `57` (`57P01` / `57P02` / `57P03`) and MySQL `1053` / `2006` / `2013` join them: restarting the server under a running query was reported as `Database error (57P01)` with hints about confirming the statement's objects. The codeless wordings drivers use for the same event — `Connection terminated unexpectedly`, `server closed the connection`, `server has gone away` — are recognized too. `57P03` (still starting up) and `08004` (the server answered and refused) get their own categories rather than borrowing a message that says the opposite, and TLS is matched by code prefix because enumerating OpenSSL's verify codes would keep missing some. The envelope states each cause in its own words instead of inheriting the shared recovery code's description — `CONN_HOST_NOT_FOUND` says the name could not be resolved, which is the one thing `EHOSTUNREACH` rules out — and TLS failures no longer route to the credentials plan, whose second step re-runs `init` for a certificate it never asks about. The adapter-code-to-envelope-code and the connection-versus-statement tables are both exhaustive `Record`s over the code union now, so a future code cannot silently inherit a generic category, and the REPL's reconnect decision reads the same table instead of its own list of codes and message substrings (#62).
13
+
14
+ - **`insert` / `update` / `delete` / `q` no longer report every failure as "failed to connect".** All four branched on `instanceof ConnectionError` and applied one message key, but that class carries every adapter-level error and only four of its nine codes are transport failures — so a missing table, a syntax error, or a statement timeout all arrived as `Failed to connect to database: …` while the connection was fine. The wording is now chosen by code, and rendering goes through the same `formatCliError` the central presenter uses, so these commands print the stable `Code:` line and the error's hints — both previously dropped on this path — exactly as `query` does (#61).
15
+
16
+ - **A statement the server canceled is no longer reported as a connection failure.** PostgreSQL `57014`, MySQL `3024`, and MariaDB `1969` fell through to `UNKNOWN` / `CONN_UNKNOWN`, so the CLI answered a query that ran out of statement time with connection-troubleshooting hints and a recovery plan that opened with `dbcli doctor` — the one thing that was not broken. They now map to a `STATEMENT_TIMEOUT` adapter code whose hints point at the query (`dbcli lint`, `dbcli explain`, re-run with an explicit `--statement-timeout <ms>`). The `--recovery` envelope keeps `schemaVersion` 1 and reports `CONN_TIMEOUT` with `details.connectionCode: "STATEMENT_TIMEOUT"`; that field selects the query-oriented plan, replaces the network-flavored message with one that states the ceiling that was in force, and suppresses both the `doctor-*` branches — whose `branchFork.after: 1` assumed step 1 was `doctor` — and the `verify` step, since nothing verifies this error except re-running the statement, which only the caller has. PostgreSQL `57014` is `query_canceled`, not only `statement_timeout`, so a `pg_cancel_backend()` or recovery-conflict cancel keeps the verbatim `Database error (57014): …` form rather than asserting a ceiling nobody set.
17
+
8
18
  ## [1.54.0] - 2026-08-13 - Query engine hardening: timeout semantics, load-on-demand, deterministic builds
9
19
 
10
20
  ### Added
@@ -1660,6 +1660,21 @@ shape (`schemaVersion: 1`):
1660
1660
  Recovery codes (fixed in v1.15.0):
1661
1661
  - `CONFIG_MISSING` — no `.dbcli` config; run `dbcli init`.
1662
1662
  - `CONN_REFUSED` / `CONN_AUTH_FAILED` / `CONN_TIMEOUT` / `CONN_HOST_NOT_FOUND` / `CONN_UNKNOWN` — connection failure variants.
1663
+ `CONN_TIMEOUT` also covers a statement the server canceled for exceeding the statement
1664
+ timeout (PostgreSQL `57014`, MySQL `3024`, MariaDB `1969`); `details.connectionCode` is
1665
+ `STATEMENT_TIMEOUT` there instead of `ETIMEDOUT`, the plan targets the query (`lint` →
1666
+ `explain` → re-run with `--statement-timeout <ms>`) rather than `doctor`, and no
1667
+ `branches` / `branchFork` / `verify` is emitted. PostgreSQL `57014` only counts as a
1668
+ statement timeout when the server says so — a `pg_cancel_backend()` cancel keeps the
1669
+ verbatim `Database error (57014): …` form instead of claiming a ceiling.
1670
+ The same `details.connectionCode` field distinguishes the other causes that share a
1671
+ connection code: `CONNECTION_LOST` (server closed the connection mid-session — PostgreSQL
1672
+ class 08 and `57P01`/`57P02`, MySQL `1053`), `TOO_MANY_CONNECTIONS` (PostgreSQL `53300`,
1673
+ MySQL `1040` — its plan counts current connections instead of running `doctor`, because
1674
+ rewriting host/port cannot create a slot), `SERVER_NOT_READY` (`57P03`, still starting up),
1675
+ `CONNECTION_REJECTED` (`08004` — the server answered and refused), `EHOSTUNREACH` (resolved
1676
+ but unroutable) and `TLS_ERROR` (handshake failure; reported as `CONN_UNKNOWN` rather than
1677
+ `CONN_AUTH_FAILED`, whose plan re-runs `init` for credentials that are not the problem).
1663
1678
  - `PERMISSION_DENIED` — active permission level forbids the operation.
1664
1679
  - `BLACKLIST_TABLE` / `BLACKLIST_COLUMN_WRITE` — blacklist violations.
1665
1680
  - `SNIPPET_NOT_FOUND` / `SNIPPET_AMBIGUOUS` / `SNIPPET_PARAM_MISSING` — saved-query failures.
@@ -51,7 +51,7 @@ var package_default;
51
51
  var init_package = __esm(() => {
52
52
  package_default = {
53
53
  name: "@carllee1983/dbcli",
54
- version: "1.54.0",
54
+ version: "1.54.1",
55
55
  description: "Database CLI for AI agents",
56
56
  type: "module",
57
57
  publishConfig: {
@@ -9674,10 +9674,12 @@ var init_types2 = __esm(() => {
9674
9674
  ConnectionError = class ConnectionError extends Error {
9675
9675
  code;
9676
9676
  hints;
9677
- constructor(code, message, hints) {
9677
+ limitMs;
9678
+ constructor(code, message, hints, limitMs) {
9678
9679
  super(message);
9679
9680
  this.code = code;
9680
9681
  this.hints = hints;
9682
+ this.limitMs = limitMs;
9681
9683
  this.name = "ConnectionError";
9682
9684
  Object.setPrototypeOf(this, ConnectionError.prototype);
9683
9685
  }
@@ -9836,18 +9838,28 @@ var init_capabilities = __esm(() => {
9836
9838
  });
9837
9839
 
9838
9840
  // src/adapters/error-mapper.ts
9839
- function categorize(errCode, errno, errMsg) {
9841
+ function isTransportDriverCode(code) {
9842
+ return TLS_CODE_PREFIX.test(code) || TRANSPORT_CODES[code] !== undefined;
9843
+ }
9844
+ function categorize(errCode, errno, errMsg, system) {
9840
9845
  if (errCode) {
9846
+ if (TLS_CODE_PREFIX.test(errCode))
9847
+ return "TLS_ERROR";
9841
9848
  const known = TRANSPORT_CODES[errCode] ?? MYSQL_CODES[errCode] ?? SQLSTATES[errCode] ?? REDIS_PREFIXES[errCode];
9849
+ if (known === "STATEMENT_TIMEOUT" && errCode === "57014" && !PG_CANCEL_BY_TIMEOUT.test(errMsg)) {
9850
+ return null;
9851
+ }
9842
9852
  if (known)
9843
9853
  return known;
9844
9854
  }
9845
- if (typeof errno === "number" && MYSQL_ERRNOS[errno])
9855
+ const isMysqlFamily = system === "mysql" || system === "mariadb";
9856
+ if (isMysqlFamily && typeof errno === "number" && MYSQL_ERRNOS[errno]) {
9846
9857
  return MYSQL_ERRNOS[errno];
9858
+ }
9847
9859
  const redisPrefix = errMsg.match(/^([A-Z]+)\s/)?.[1];
9848
9860
  if (redisPrefix && REDIS_PREFIXES[redisPrefix])
9849
9861
  return REDIS_PREFIXES[redisPrefix];
9850
- if (errCode || typeof errno === "number")
9862
+ if (errCode || isMysqlFamily && typeof errno === "number")
9851
9863
  return null;
9852
9864
  for (const [pattern, category] of FALLBACK_PATTERNS) {
9853
9865
  if (pattern.test(errMsg))
@@ -9870,7 +9882,7 @@ function mapError(error, system, options) {
9870
9882
  const errMsg = String(err?.message || String(error));
9871
9883
  const errCode = String(err?.code || "");
9872
9884
  const errno = err?.errno;
9873
- switch (categorize(errCode, errno, errMsg)) {
9885
+ switch (categorize(errCode, errno, errMsg, system)) {
9874
9886
  case "ECONNREFUSED":
9875
9887
  return new ConnectionError("ECONNREFUSED", `Cannot connect to ${options.host}:${options.port} \u2014 server is not running or not listening on this port`, serviceHints(system, options));
9876
9888
  case "ETIMEDOUT":
@@ -9879,6 +9891,42 @@ function mapError(error, system, options) {
9879
9891
  `Increase timeout: edit .dbcli and add "timeout": 15000`,
9880
9892
  `Verify network connectivity: ping ${options.host} -c 3`
9881
9893
  ]);
9894
+ case "CONNECTION_LOST":
9895
+ return new ConnectionError("CONNECTION_LOST", `Connection to ${options.host}:${options.port} was lost mid-session \u2014 the server closed it or the network dropped`, [
9896
+ "Re-run the command: a dropped connection is often transient",
9897
+ `Check whether the server restarted or is cycling: ${SYSTEM_FACTS[system].logFile}`,
9898
+ "If it happens on long-running work, look at the server idle/wait timeout"
9899
+ ]);
9900
+ case "SERVER_NOT_READY":
9901
+ return new ConnectionError("SERVER_NOT_READY", `${options.host}:${options.port} is not accepting connections yet \u2014 the server is starting up or recovering`, [
9902
+ "Retry shortly: this clears on its own once startup or recovery finishes",
9903
+ `Watch progress in the server log: ${SYSTEM_FACTS[system].logFile}`,
9904
+ "On a replica, this also appears while it catches up with the primary"
9905
+ ]);
9906
+ case "CONNECTION_REJECTED":
9907
+ return new ConnectionError("CONNECTION_REJECTED", `${options.host}:${options.port} answered but rejected the connection attempt`, [
9908
+ "Check the server access rules (pg_hba.conf) for this user, database, and client address",
9909
+ "If a pooler (pgbouncer / ProxySQL) sits in front, check its own rules and limits",
9910
+ "Check whether the connection limit for this user or database is exhausted"
9911
+ ]);
9912
+ case "TOO_MANY_CONNECTIONS":
9913
+ return new ConnectionError("TOO_MANY_CONNECTIONS", `${options.host}:${options.port} refused the connection \u2014 the server has no connection slots left`, [
9914
+ "Wait and retry: the limit is on concurrent connections, not on you",
9915
+ system === "postgresql" ? "Inspect usage: SELECT count(*) FROM pg_stat_activity \u2014 compare against max_connections" : 'Inspect usage: SHOW STATUS LIKE "Threads_connected" \u2014 compare against max_connections',
9916
+ "Close idle sessions, or raise max_connections if the load is legitimate"
9917
+ ]);
9918
+ case "EHOSTUNREACH":
9919
+ return new ConnectionError("EHOSTUNREACH", `No route to ${options.host} \u2014 the name resolved, but the host or network is unreachable`, [
9920
+ "Check that you are on the right network or VPN",
9921
+ `Check routing and firewall between here and ${options.host}`,
9922
+ `Confirm the address is the one you meant: ${options.host}:${options.port}`
9923
+ ]);
9924
+ case "TLS_ERROR":
9925
+ return new ConnectionError("TLS_ERROR", `TLS handshake failed: ${errMsg}`, [
9926
+ 'Point the connection at the right CA bundle: set "caPath" in .dbcli',
9927
+ 'For a self-signed certificate in a trusted network, set "rejectUnauthorized": false',
9928
+ `Confirm the certificate covers the host you connected to: ${options.host}`
9929
+ ]);
9882
9930
  case "ENOTFOUND":
9883
9931
  return new ConnectionError("ENOTFOUND", `Host not found: ${options.host}`, [
9884
9932
  `Check the hostname spelling: ${options.host}`,
@@ -9909,6 +9957,14 @@ function mapError(error, system, options) {
9909
9957
  "Column names are case-sensitive on some databases (PostgreSQL with quoted identifiers)"
9910
9958
  ]);
9911
9959
  }
9960
+ case "STATEMENT_TIMEOUT": {
9961
+ const limitMs = options.statementTimeout ?? options.timeout;
9962
+ return new ConnectionError("STATEMENT_TIMEOUT", `Statement timed out${limitMs ? ` (${limitMs}ms)` : ""} \u2014 the server canceled this query before it finished`, [
9963
+ 'Inspect the query plan: dbcli explain "<sql>"',
9964
+ "Raise the ceiling for this run: dbcli --statement-timeout <ms> \u2026 (0 removes it)",
9965
+ "Narrow the query: add WHERE filters, reduce the LIMIT, or index the scanned columns"
9966
+ ], limitMs);
9967
+ }
9912
9968
  case "SQL_SYNTAX_ERROR":
9913
9969
  return new ConnectionError("SQL_SYNTAX_ERROR", `SQL syntax error: ${errMsg}`, [
9914
9970
  "Check your SQL syntax near the position reported above",
@@ -9929,7 +9985,7 @@ function mapError(error, system, options) {
9929
9985
  `Try connecting directly with the ${SYSTEM_FACTS[system].client} command-line tool`
9930
9986
  ]);
9931
9987
  }
9932
- var TRANSPORT_CODES, MYSQL_CODES, MYSQL_ERRNOS, SQLSTATES, REDIS_PREFIXES, FALLBACK_PATTERNS, SYSTEM_FACTS;
9988
+ var TRANSPORT_CODES, TLS_CODE_PREFIX, MYSQL_CODES, MYSQL_ERRNOS, SQLSTATES, REDIS_PREFIXES, FALLBACK_PATTERNS, PG_CANCEL_BY_TIMEOUT, SYSTEM_FACTS;
9933
9989
  var init_error_mapper = __esm(() => {
9934
9990
  init_types2();
9935
9991
  TRANSPORT_CODES = {
@@ -9938,9 +9994,16 @@ var init_error_mapper = __esm(() => {
9938
9994
  ESOCKETTIMEDOUT: "ETIMEDOUT",
9939
9995
  ENOTFOUND: "ENOTFOUND",
9940
9996
  EAI_AGAIN: "ENOTFOUND",
9941
- PROTOCOL_CONNECTION_LOST: "ECONNREFUSED",
9942
- PROTOCOL_SEQUENCE_TIMEOUT: "ETIMEDOUT"
9943
- };
9997
+ PROTOCOL_CONNECTION_LOST: "CONNECTION_LOST",
9998
+ PROTOCOL_SEQUENCE_TIMEOUT: "ETIMEDOUT",
9999
+ ECONNRESET: "CONNECTION_LOST",
10000
+ EPIPE: "CONNECTION_LOST",
10001
+ ECONNABORTED: "CONNECTION_LOST",
10002
+ EHOSTUNREACH: "EHOSTUNREACH",
10003
+ ENETUNREACH: "EHOSTUNREACH",
10004
+ ENETDOWN: "EHOSTUNREACH"
10005
+ };
10006
+ TLS_CODE_PREFIX = /^(CERT_|SELF_SIGNED_|DEPTH_ZERO_|UNABLE_TO_(?:GET|VERIFY)_|ERR_TLS_|ERR_SSL_)/;
9944
10007
  MYSQL_CODES = {
9945
10008
  ER_NO_SUCH_TABLE: "TABLE_NOT_FOUND",
9946
10009
  ER_BAD_FIELD_ERROR: "COLUMN_NOT_FOUND",
@@ -9948,7 +10011,11 @@ var init_error_mapper = __esm(() => {
9948
10011
  ER_ACCESS_DENIED_ERROR: "AUTH_FAILED",
9949
10012
  ER_DBACCESS_DENIED_ERROR: "AUTH_FAILED",
9950
10013
  ER_NOT_SUPPORTED_AUTH_MODE: "AUTH_FAILED",
9951
- ER_MUST_CHANGE_PASSWORD_LOGIN: "AUTH_FAILED"
10014
+ ER_MUST_CHANGE_PASSWORD_LOGIN: "AUTH_FAILED",
10015
+ ER_QUERY_TIMEOUT: "STATEMENT_TIMEOUT",
10016
+ ER_STATEMENT_TIMEOUT: "STATEMENT_TIMEOUT",
10017
+ ER_CON_COUNT_ERROR: "TOO_MANY_CONNECTIONS",
10018
+ ER_SERVER_SHUTDOWN: "CONNECTION_LOST"
9952
10019
  };
9953
10020
  MYSQL_ERRNOS = {
9954
10021
  1045: "AUTH_FAILED",
@@ -9957,14 +10024,30 @@ var init_error_mapper = __esm(() => {
9957
10024
  1064: "SQL_SYNTAX_ERROR",
9958
10025
  1146: "TABLE_NOT_FOUND",
9959
10026
  1251: "AUTH_FAILED",
9960
- 1698: "AUTH_FAILED"
10027
+ 1698: "AUTH_FAILED",
10028
+ 1040: "TOO_MANY_CONNECTIONS",
10029
+ 1053: "CONNECTION_LOST",
10030
+ 2006: "CONNECTION_LOST",
10031
+ 2013: "CONNECTION_LOST",
10032
+ 1969: "STATEMENT_TIMEOUT",
10033
+ 3024: "STATEMENT_TIMEOUT"
9961
10034
  };
9962
10035
  SQLSTATES = {
9963
10036
  "28000": "AUTH_FAILED",
9964
10037
  "28P01": "AUTH_FAILED",
9965
10038
  "42601": "SQL_SYNTAX_ERROR",
9966
10039
  "42P01": "TABLE_NOT_FOUND",
9967
- "42703": "COLUMN_NOT_FOUND"
10040
+ "42703": "COLUMN_NOT_FOUND",
10041
+ "57014": "STATEMENT_TIMEOUT",
10042
+ "08000": "CONNECTION_LOST",
10043
+ "08003": "CONNECTION_LOST",
10044
+ "08006": "CONNECTION_LOST",
10045
+ "08001": "ECONNREFUSED",
10046
+ "08004": "CONNECTION_REJECTED",
10047
+ "53300": "TOO_MANY_CONNECTIONS",
10048
+ "57P01": "CONNECTION_LOST",
10049
+ "57P02": "CONNECTION_LOST",
10050
+ "57P03": "SERVER_NOT_READY"
9968
10051
  };
9969
10052
  REDIS_PREFIXES = {
9970
10053
  NOAUTH: "AUTH_FAILED",
@@ -9976,6 +10059,10 @@ var init_error_mapper = __esm(() => {
9976
10059
  [/connect(?:ion)? time(?:d )?out/i, "ETIMEDOUT"],
9977
10060
  [/timeout exceeded/i, "ETIMEDOUT"],
9978
10061
  [/timed out/i, "ETIMEDOUT"],
10062
+ [/connection terminated/i, "CONNECTION_LOST"],
10063
+ [/server closed the connection/i, "CONNECTION_LOST"],
10064
+ [/server has gone away/i, "CONNECTION_LOST"],
10065
+ [/lost connection to \S+ server/i, "CONNECTION_LOST"],
9979
10066
  [/getaddrinfo/i, "ENOTFOUND"],
9980
10067
  [/authentication failed/i, "AUTH_FAILED"],
9981
10068
  [/access denied for user/i, "AUTH_FAILED"],
@@ -9983,6 +10070,7 @@ var init_error_mapper = __esm(() => {
9983
10070
  [/role\s+".*?"\s+does not exist/i, "AUTH_FAILED"],
9984
10071
  [/password supplied/i, "AUTH_FAILED"]
9985
10072
  ];
10073
+ PG_CANCEL_BY_TIMEOUT = /statement timeout/i;
9986
10074
  SYSTEM_FACTS = {
9987
10075
  postgresql: {
9988
10076
  serviceCheck: "systemctl status postgresql",
@@ -17956,7 +18044,7 @@ async function mapWithConcurrency(items, limit, worker) {
17956
18044
  }
17957
18045
 
17958
18046
  // src/core/recovery/types.ts
17959
- var RECOVERY_SCHEMA_VERSION = 1, RECOVERY_CODES, MAX_BRANCH_STEPS = 6, MAX_BRANCH_COUNT = 8, SchemaCacheMissingError, RECOVERY_CODE_METADATA;
18047
+ var RECOVERY_SCHEMA_VERSION = 1, RECOVERY_CODES, MAX_BRANCH_STEPS = 6, MAX_BRANCH_COUNT = 8, STATEMENT_TIMEOUT_CODE = "STATEMENT_TIMEOUT", SchemaCacheMissingError, RECOVERY_CODE_METADATA;
17960
18048
  var init_types4 = __esm(() => {
17961
18049
  RECOVERY_CODES = [
17962
18050
  "CONFIG_MISSING",
@@ -18081,6 +18169,64 @@ function dryRunStepForWrite(ctx, quotedTable, placeholders) {
18081
18169
  };
18082
18170
  return draft;
18083
18171
  }
18172
+ function statementTimeoutSteps() {
18173
+ return [
18174
+ {
18175
+ command: 'dbcli lint "<sql>"',
18176
+ rationale: "Static anti-pattern read of the statement; needs no connection, so it cannot time out in turn.",
18177
+ risk: "readonly",
18178
+ expects: "Findings with rewrite drafts, or an empty list when nothing is flagged.",
18179
+ placeholders: ["<sql>"]
18180
+ },
18181
+ {
18182
+ command: 'dbcli explain "<sql>"',
18183
+ rationale: "Read the query plan to find the scan or join that exceeded the statement limit.",
18184
+ risk: "readonly",
18185
+ expects: "Annotated query plan; look for sequential scans and unindexed joins.",
18186
+ placeholders: ["<sql>"]
18187
+ },
18188
+ {
18189
+ command: 'dbcli --statement-timeout <ms> query "<sql>"',
18190
+ rationale: "Re-run with an explicit ceiling once the cost is understood; 0 removes the limit entirely.",
18191
+ risk: "readonly",
18192
+ expects: "Query result, or the same timeout if <ms> is still below what the plan costs.",
18193
+ placeholders: ["<ms>", "<sql>"]
18194
+ }
18195
+ ];
18196
+ }
18197
+ function tlsErrorSteps() {
18198
+ return [
18199
+ {
18200
+ command: "dbcli status --format json",
18201
+ rationale: "Read back the active connection without a live probe to confirm which host and TLS settings are in force.",
18202
+ risk: "readonly",
18203
+ expects: "JSON status with system / permission; no credentials."
18204
+ },
18205
+ {
18206
+ command: "dbcli doctor --format json",
18207
+ rationale: "Doctor reports the handshake failure verbatim, which names the certificate problem (expired, self-signed, altname mismatch).",
18208
+ risk: "readonly",
18209
+ expects: "JSON report whose connection check fails with the TLS error text."
18210
+ }
18211
+ ];
18212
+ }
18213
+ function connectionsExhaustedSteps(ctx) {
18214
+ const inspectSql = ctx.system === "postgresql" ? "SELECT count(*) FROM pg_stat_activity" : 'SHOW STATUS LIKE "Threads_connected"';
18215
+ return [
18216
+ {
18217
+ command: `dbcli query ${shellQuote(inspectSql)} --format json`,
18218
+ rationale: "Count the connections currently held so the limit can be compared against real usage; needs one free slot, so it may have to wait.",
18219
+ risk: "readonly",
18220
+ expects: "A single row with the current connection count."
18221
+ },
18222
+ {
18223
+ command: "dbcli doctor --format json",
18224
+ rationale: "Once a slot frees up, confirm the connection itself is healthy \u2014 the config was never the problem.",
18225
+ risk: "readonly",
18226
+ expects: "JSON report with the connection check passing."
18227
+ }
18228
+ ];
18229
+ }
18084
18230
  function stepsForCode(code, ctx) {
18085
18231
  const drafts = draftsForCode(code, ctx);
18086
18232
  return drafts.slice(0, MAX_RECOVERY_STEPS).map((d, i) => ({ ...d, order: i + 1 }));
@@ -18106,6 +18252,13 @@ function draftsForCode(code, ctx) {
18106
18252
  case "CONN_REFUSED":
18107
18253
  case "CONN_TIMEOUT":
18108
18254
  case "CONN_UNKNOWN": {
18255
+ if (code === "CONN_TIMEOUT" && ctx.connectionCode === STATEMENT_TIMEOUT_CODE) {
18256
+ return statementTimeoutSteps();
18257
+ }
18258
+ if (ctx.connectionCode === "TLS_ERROR")
18259
+ return tlsErrorSteps();
18260
+ if (ctx.connectionCode === "TOO_MANY_CONNECTIONS")
18261
+ return connectionsExhaustedSteps(ctx);
18109
18262
  const out = [
18110
18263
  {
18111
18264
  command: "dbcli doctor --format json",
@@ -18329,7 +18482,9 @@ function draftsForCode(code, ctx) {
18329
18482
  }
18330
18483
  }
18331
18484
  var MAX_RECOVERY_STEPS = 6;
18332
- var init_recovery_steps = () => {};
18485
+ var init_recovery_steps = __esm(() => {
18486
+ init_types4();
18487
+ });
18333
18488
 
18334
18489
  // src/utils/sql-lexical.ts
18335
18490
  function dollarQuoteDelimiterAt(sql, index) {
@@ -19055,7 +19210,9 @@ var init_types5 = __esm(() => {
19055
19210
  });
19056
19211
 
19057
19212
  // src/core/recovery/verify-steps.ts
19058
- function verifyForCode(code, _ctx) {
19213
+ function verifyForCode(code, ctx) {
19214
+ if (ctx.connectionCode === STATEMENT_TIMEOUT_CODE)
19215
+ return null;
19059
19216
  const command = VERIFY_COMMAND_BY_CODE[code];
19060
19217
  if (!command)
19061
19218
  return null;
@@ -19069,6 +19226,7 @@ function verifyForCode(code, _ctx) {
19069
19226
  }
19070
19227
  var VERIFY_COMMAND_BY_CODE, VERIFY_RATIONALE_BY_CODE, VERIFY_EXPECTS_BY_CODE;
19071
19228
  var init_verify_steps = __esm(() => {
19229
+ init_types4();
19072
19230
  VERIFY_COMMAND_BY_CODE = {
19073
19231
  CONFIG_MISSING: "dbcli inspect --no-connect --format json",
19074
19232
  CONN_REFUSED: "dbcli doctor --format json",
@@ -19326,7 +19484,7 @@ function classifyError(error, ctx) {
19326
19484
  const ctxWithDetails = applyDetailsToContext(ctx, recoveryError);
19327
19485
  const recovery = stepsForCode(recoveryError.code, ctxWithDetails);
19328
19486
  const verify = verifyForCode(recoveryError.code, ctxWithDetails);
19329
- const branchExtras = recoveryError.category === "connection" ? buildConnectionBranches(ctxWithDetails) : null;
19487
+ const branchExtras = recoveryError.category === "connection" && ctxWithDetails.connectionCode !== STATEMENT_TIMEOUT_CODE ? buildConnectionBranches(ctxWithDetails) : null;
19330
19488
  return {
19331
19489
  schemaVersion: RECOVERY_SCHEMA_VERSION,
19332
19490
  generatedAt: new Date().toISOString(),
@@ -19365,8 +19523,19 @@ function errorToRecoveryError(error, ctx) {
19365
19523
  return baseError("UNKNOWN");
19366
19524
  }
19367
19525
  function classifyConnection(err) {
19368
- const code = err.code === "ECONNREFUSED" ? "CONN_REFUSED" : err.code === "ETIMEDOUT" ? "CONN_TIMEOUT" : err.code === "AUTH_FAILED" ? "CONN_AUTH_FAILED" : err.code === "ENOTFOUND" ? "CONN_HOST_NOT_FOUND" : "CONN_UNKNOWN";
19369
- return baseError(code, { connectionCode: err.code });
19526
+ const code = RECOVERY_CODE_BY_CONNECTION_CODE[err.code];
19527
+ const base = baseError(code, { connectionCode: err.code });
19528
+ const override = MESSAGE_BY_CONNECTION_CODE[err.code];
19529
+ if (override)
19530
+ return { ...base, message: override };
19531
+ if (err.code === STATEMENT_TIMEOUT_CODE) {
19532
+ const ceiling = err.limitMs !== undefined ? ` The ceiling in force was ${err.limitMs}ms.` : "";
19533
+ return {
19534
+ ...base,
19535
+ message: `The server canceled the statement for exceeding the statement timeout.${ceiling}`
19536
+ };
19537
+ }
19538
+ return base;
19370
19539
  }
19371
19540
  function classifyBlacklist(err) {
19372
19541
  if (/touches blacklisted columns:/i.test(err.message)) {
@@ -19417,9 +19586,11 @@ function applyDetailsToContext(ctx, err) {
19417
19586
  ...ctx,
19418
19587
  table: err.details?.table ?? ctx.table,
19419
19588
  snippet: err.details?.snippet ?? ctx.snippet,
19420
- hint: err.details?.paramName ?? ctx.hint
19589
+ hint: err.details?.paramName ?? ctx.hint,
19590
+ connectionCode: err.details?.connectionCode ?? ctx.connectionCode
19421
19591
  };
19422
19592
  }
19593
+ var RECOVERY_CODE_BY_CONNECTION_CODE, MESSAGE_BY_CONNECTION_CODE;
19423
19594
  var init_classify = __esm(() => {
19424
19595
  init_types2();
19425
19596
  init_permission_guard();
@@ -19429,6 +19600,31 @@ var init_classify = __esm(() => {
19429
19600
  init_verify_steps();
19430
19601
  init_connection_branches();
19431
19602
  init_types4();
19603
+ RECOVERY_CODE_BY_CONNECTION_CODE = {
19604
+ ECONNREFUSED: "CONN_REFUSED",
19605
+ CONNECTION_LOST: "CONN_REFUSED",
19606
+ TOO_MANY_CONNECTIONS: "CONN_REFUSED",
19607
+ SERVER_NOT_READY: "CONN_REFUSED",
19608
+ CONNECTION_REJECTED: "CONN_REFUSED",
19609
+ ETIMEDOUT: "CONN_TIMEOUT",
19610
+ STATEMENT_TIMEOUT: "CONN_TIMEOUT",
19611
+ AUTH_FAILED: "CONN_AUTH_FAILED",
19612
+ TLS_ERROR: "CONN_UNKNOWN",
19613
+ ENOTFOUND: "CONN_HOST_NOT_FOUND",
19614
+ EHOSTUNREACH: "CONN_HOST_NOT_FOUND",
19615
+ SQL_SYNTAX_ERROR: "CONN_UNKNOWN",
19616
+ TABLE_NOT_FOUND: "CONN_UNKNOWN",
19617
+ COLUMN_NOT_FOUND: "CONN_UNKNOWN",
19618
+ UNKNOWN: "CONN_UNKNOWN"
19619
+ };
19620
+ MESSAGE_BY_CONNECTION_CODE = {
19621
+ EHOSTUNREACH: "The host name resolved, but the host or network is unreachable (routing or VPN).",
19622
+ TOO_MANY_CONNECTIONS: "The server has no connection slots left; the limit is on concurrent connections, not on this caller.",
19623
+ CONNECTION_LOST: "The connection was established and then dropped mid-session.",
19624
+ TLS_ERROR: "The TLS handshake failed (certificate or trust chain).",
19625
+ SERVER_NOT_READY: "The server is starting up or recovering and is not accepting connections yet.",
19626
+ CONNECTION_REJECTED: "The server answered and rejected the connection attempt (access rules, pooler, or a per-user limit)."
19627
+ };
19432
19628
  });
19433
19629
 
19434
19630
  // src/core/recovery/render-json.ts
@@ -20579,6 +20775,7 @@ __export(exports_recovery, {
20579
20775
  __resetExecutorForTests: () => __resetExecutorForTests,
20580
20776
  SchemaCacheMissingError: () => SchemaCacheMissingError,
20581
20777
  STEP_RESULT_SUMMARY_FIELD_CAP: () => STEP_RESULT_SUMMARY_FIELD_CAP,
20778
+ STATEMENT_TIMEOUT_CODE: () => STATEMENT_TIMEOUT_CODE,
20582
20779
  RECOVERY_SCHEMA_VERSION: () => RECOVERY_SCHEMA_VERSION,
20583
20780
  RECOVERY_CODE_METADATA: () => RECOVERY_CODE_METADATA,
20584
20781
  RECOVERY_CODES: () => RECOVERY_CODES,
@@ -24498,6 +24695,45 @@ var init_saved_queries = __esm(() => {
24498
24695
  init_snippet_paths();
24499
24696
  });
24500
24697
 
24698
+ // src/utils/connection-error-message.ts
24699
+ function isTransportFailure(error) {
24700
+ if (error instanceof ConnectionError)
24701
+ return IS_TRANSPORT_FAILURE[error.code];
24702
+ const raw = error?.code;
24703
+ return typeof raw === "string" && isTransportDriverCode(raw);
24704
+ }
24705
+ function presentConnectionError(error) {
24706
+ const key = IS_TRANSPORT_FAILURE[error.code] ? "errors.connection_failed" : "errors.message";
24707
+ return {
24708
+ message: t_vars(key, { message: error.message }),
24709
+ code: error.code,
24710
+ hints: error.hints
24711
+ };
24712
+ }
24713
+ var IS_TRANSPORT_FAILURE;
24714
+ var init_connection_error_message = __esm(() => {
24715
+ init_types2();
24716
+ init_error_mapper();
24717
+ init_message_loader();
24718
+ IS_TRANSPORT_FAILURE = {
24719
+ ECONNREFUSED: true,
24720
+ ETIMEDOUT: true,
24721
+ AUTH_FAILED: true,
24722
+ ENOTFOUND: true,
24723
+ EHOSTUNREACH: true,
24724
+ CONNECTION_LOST: true,
24725
+ TOO_MANY_CONNECTIONS: true,
24726
+ TLS_ERROR: true,
24727
+ SERVER_NOT_READY: true,
24728
+ CONNECTION_REJECTED: true,
24729
+ STATEMENT_TIMEOUT: false,
24730
+ SQL_SYNTAX_ERROR: false,
24731
+ TABLE_NOT_FOUND: false,
24732
+ COLUMN_NOT_FOUND: false,
24733
+ UNKNOWN: false
24734
+ };
24735
+ });
24736
+
24501
24737
  // src/commands/q-mongo.ts
24502
24738
  var exports_q_mongo = {};
24503
24739
  __export(exports_q_mongo, {
@@ -24834,7 +25070,7 @@ async function handleQError(error, snippetName, options, config) {
24834
25070
  process.exit(1);
24835
25071
  }
24836
25072
  if (error instanceof ConnectionError) {
24837
- printLocalizedCliError(t_vars("errors.connection_failed", { message: error.message }), error);
25073
+ printLocalizedCliError(formatCliError(presentConnectionError(error)), error);
24838
25074
  process.exit(1);
24839
25075
  }
24840
25076
  printLocalizedCliError(t_vars("errors.message", { message: error.message }), error);
@@ -24952,6 +25188,7 @@ var init_q = __esm(() => {
24952
25188
  init_colors();
24953
25189
  init_applied_limit();
24954
25190
  init_cli_error();
25191
+ init_connection_error_message();
24955
25192
  init_strategies();
24956
25193
  init_slow_query_advisory();
24957
25194
  });
@@ -26197,7 +26434,7 @@ async function insertCommand(table, options, command) {
26197
26434
  process.exit(1);
26198
26435
  }
26199
26436
  if (error instanceof ConnectionError) {
26200
- printLocalizedCliError(t_vars("errors.connection_failed", { message: error.message }), error);
26437
+ printLocalizedCliError(formatCliError(presentConnectionError(error)), error);
26201
26438
  process.exit(1);
26202
26439
  }
26203
26440
  const output = {
@@ -26213,6 +26450,7 @@ async function insertCommand(table, options, command) {
26213
26450
  var init_insert = __esm(() => {
26214
26451
  init_message_loader();
26215
26452
  init_cli_error();
26453
+ init_connection_error_message();
26216
26454
  init_adapters();
26217
26455
  init_data_executor();
26218
26456
  init_config();
@@ -26504,7 +26742,7 @@ async function updateCommand(table, options, command) {
26504
26742
  process.exit(1);
26505
26743
  }
26506
26744
  if (error instanceof ConnectionError) {
26507
- printLocalizedCliError(t_vars("errors.connection_failed", { message: error.message }), error);
26745
+ printLocalizedCliError(formatCliError(presentConnectionError(error)), error);
26508
26746
  process.exit(1);
26509
26747
  }
26510
26748
  const output = {
@@ -26520,6 +26758,7 @@ async function updateCommand(table, options, command) {
26520
26758
  var init_update = __esm(() => {
26521
26759
  init_message_loader();
26522
26760
  init_cli_error();
26761
+ init_connection_error_message();
26523
26762
  init_adapters();
26524
26763
  init_data_executor();
26525
26764
  init_config();
@@ -26795,7 +27034,7 @@ async function deleteCommand(table, options, command) {
26795
27034
  process.exit(1);
26796
27035
  }
26797
27036
  if (error instanceof ConnectionError) {
26798
- printLocalizedCliError(t_vars("errors.connection_failed", { message: error.message }), error);
27037
+ printLocalizedCliError(formatCliError(presentConnectionError(error)), error);
26799
27038
  process.exit(1);
26800
27039
  }
26801
27040
  const output = {
@@ -26811,6 +27050,7 @@ async function deleteCommand(table, options, command) {
26811
27050
  var init_delete = __esm(() => {
26812
27051
  init_message_loader();
26813
27052
  init_cli_error();
27053
+ init_connection_error_message();
26814
27054
  init_adapters();
26815
27055
  init_data_executor();
26816
27056
  init_config();
@@ -43842,13 +44082,12 @@ class ReplEngine {
43842
44082
  return null;
43843
44083
  }
43844
44084
  isConnectionError(error) {
43845
- const e = error;
43846
- const msg = (e.message ?? "").toLowerCase();
43847
- return e.code === "ECONNREFUSED" || e.code === "ECONNRESET" || e.code === "ETIMEDOUT" || msg.includes("connection") || msg.includes("terminated") || msg.includes("socket");
44085
+ return isTransportFailure(error);
43848
44086
  }
43849
44087
  }
43850
44088
  var import_picocolors3;
43851
44089
  var init_repl_engine = __esm(() => {
44090
+ init_connection_error_message();
43852
44091
  init_input_classifier();
43853
44092
  init_meta_commands();
43854
44093
  init_history_manager();
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@carllee1983/dbcli",
6
- version: "1.54.0",
6
+ version: "1.54.1",
7
7
  description: "Database CLI for AI agents",
8
8
  type: "module",
9
9
  publishConfig: {
package/dist/core.d.ts CHANGED
@@ -129,21 +129,31 @@ export interface TableSchema {
129
129
  /** Type of table (table or view) */
130
130
  tableType?: "table" | "view";
131
131
  }
132
- /**
133
- * Connection error with categorized error code and troubleshooting hints
134
- */
132
+ type ConnectionErrorCode = "ECONNREFUSED" | "ETIMEDOUT" | "AUTH_FAILED" | "ENOTFOUND" | "EHOSTUNREACH" | "CONNECTION_LOST" | "TOO_MANY_CONNECTIONS" | "TLS_ERROR" | "SERVER_NOT_READY" | "CONNECTION_REJECTED" | "SQL_SYNTAX_ERROR" | "STATEMENT_TIMEOUT" | "TABLE_NOT_FOUND" | "COLUMN_NOT_FOUND" | "UNKNOWN";
135
133
  export declare class ConnectionError extends Error {
136
134
  /** Error category code */
137
- code: "ECONNREFUSED" | "ETIMEDOUT" | "AUTH_FAILED" | "ENOTFOUND" | "SQL_SYNTAX_ERROR" | "TABLE_NOT_FOUND" | "COLUMN_NOT_FOUND" | "UNKNOWN";
135
+ code: ConnectionErrorCode;
138
136
  /** Array of actionable troubleshooting hints */
139
137
  hints: string[];
138
+ /**
139
+ * The ceiling that was in force, in milliseconds. Set on STATEMENT_TIMEOUT so
140
+ * consumers can state it without parsing `message`; the recovery envelope needs
141
+ * it to tell an agent what `--statement-timeout <ms>` it was already up against.
142
+ */
143
+ limitMs?: number | undefined;
140
144
  constructor(
141
145
  /** Error category code */
142
- code: "ECONNREFUSED" | "ETIMEDOUT" | "AUTH_FAILED" | "ENOTFOUND" | "SQL_SYNTAX_ERROR" | "TABLE_NOT_FOUND" | "COLUMN_NOT_FOUND" | "UNKNOWN",
146
+ code: ConnectionErrorCode,
143
147
  /** User-friendly error message */
144
148
  message: string,
145
149
  /** Array of actionable troubleshooting hints */
146
- hints: string[]);
150
+ hints: string[],
151
+ /**
152
+ * The ceiling that was in force, in milliseconds. Set on STATEMENT_TIMEOUT so
153
+ * consumers can state it without parsing `message`; the recovery envelope needs
154
+ * it to tell an agent what `--statement-timeout <ms>` it was already up against.
155
+ */
156
+ limitMs?: number | undefined);
147
157
  }
148
158
  /**
149
159
  * Result of a database query or command execution
package/dist/core.mjs CHANGED
@@ -1091,10 +1091,12 @@ var init_schema_loader = __esm(() => {
1091
1091
  class ConnectionError extends Error {
1092
1092
  code;
1093
1093
  hints;
1094
- constructor(code, message, hints) {
1094
+ limitMs;
1095
+ constructor(code, message, hints, limitMs) {
1095
1096
  super(message);
1096
1097
  this.code = code;
1097
1098
  this.hints = hints;
1099
+ this.limitMs = limitMs;
1098
1100
  this.name = "ConnectionError";
1099
1101
  Object.setPrototypeOf(this, ConnectionError.prototype);
1100
1102
  }
@@ -1250,9 +1252,16 @@ var TRANSPORT_CODES = {
1250
1252
  ESOCKETTIMEDOUT: "ETIMEDOUT",
1251
1253
  ENOTFOUND: "ENOTFOUND",
1252
1254
  EAI_AGAIN: "ENOTFOUND",
1253
- PROTOCOL_CONNECTION_LOST: "ECONNREFUSED",
1254
- PROTOCOL_SEQUENCE_TIMEOUT: "ETIMEDOUT"
1255
+ PROTOCOL_CONNECTION_LOST: "CONNECTION_LOST",
1256
+ PROTOCOL_SEQUENCE_TIMEOUT: "ETIMEDOUT",
1257
+ ECONNRESET: "CONNECTION_LOST",
1258
+ EPIPE: "CONNECTION_LOST",
1259
+ ECONNABORTED: "CONNECTION_LOST",
1260
+ EHOSTUNREACH: "EHOSTUNREACH",
1261
+ ENETUNREACH: "EHOSTUNREACH",
1262
+ ENETDOWN: "EHOSTUNREACH"
1255
1263
  };
1264
+ var TLS_CODE_PREFIX = /^(CERT_|SELF_SIGNED_|DEPTH_ZERO_|UNABLE_TO_(?:GET|VERIFY)_|ERR_TLS_|ERR_SSL_)/;
1256
1265
  var MYSQL_CODES = {
1257
1266
  ER_NO_SUCH_TABLE: "TABLE_NOT_FOUND",
1258
1267
  ER_BAD_FIELD_ERROR: "COLUMN_NOT_FOUND",
@@ -1260,7 +1269,11 @@ var MYSQL_CODES = {
1260
1269
  ER_ACCESS_DENIED_ERROR: "AUTH_FAILED",
1261
1270
  ER_DBACCESS_DENIED_ERROR: "AUTH_FAILED",
1262
1271
  ER_NOT_SUPPORTED_AUTH_MODE: "AUTH_FAILED",
1263
- ER_MUST_CHANGE_PASSWORD_LOGIN: "AUTH_FAILED"
1272
+ ER_MUST_CHANGE_PASSWORD_LOGIN: "AUTH_FAILED",
1273
+ ER_QUERY_TIMEOUT: "STATEMENT_TIMEOUT",
1274
+ ER_STATEMENT_TIMEOUT: "STATEMENT_TIMEOUT",
1275
+ ER_CON_COUNT_ERROR: "TOO_MANY_CONNECTIONS",
1276
+ ER_SERVER_SHUTDOWN: "CONNECTION_LOST"
1264
1277
  };
1265
1278
  var MYSQL_ERRNOS = {
1266
1279
  1045: "AUTH_FAILED",
@@ -1269,14 +1282,30 @@ var MYSQL_ERRNOS = {
1269
1282
  1064: "SQL_SYNTAX_ERROR",
1270
1283
  1146: "TABLE_NOT_FOUND",
1271
1284
  1251: "AUTH_FAILED",
1272
- 1698: "AUTH_FAILED"
1285
+ 1698: "AUTH_FAILED",
1286
+ 1040: "TOO_MANY_CONNECTIONS",
1287
+ 1053: "CONNECTION_LOST",
1288
+ 2006: "CONNECTION_LOST",
1289
+ 2013: "CONNECTION_LOST",
1290
+ 1969: "STATEMENT_TIMEOUT",
1291
+ 3024: "STATEMENT_TIMEOUT"
1273
1292
  };
1274
1293
  var SQLSTATES = {
1275
1294
  "28000": "AUTH_FAILED",
1276
1295
  "28P01": "AUTH_FAILED",
1277
1296
  "42601": "SQL_SYNTAX_ERROR",
1278
1297
  "42P01": "TABLE_NOT_FOUND",
1279
- "42703": "COLUMN_NOT_FOUND"
1298
+ "42703": "COLUMN_NOT_FOUND",
1299
+ "57014": "STATEMENT_TIMEOUT",
1300
+ "08000": "CONNECTION_LOST",
1301
+ "08003": "CONNECTION_LOST",
1302
+ "08006": "CONNECTION_LOST",
1303
+ "08001": "ECONNREFUSED",
1304
+ "08004": "CONNECTION_REJECTED",
1305
+ "53300": "TOO_MANY_CONNECTIONS",
1306
+ "57P01": "CONNECTION_LOST",
1307
+ "57P02": "CONNECTION_LOST",
1308
+ "57P03": "SERVER_NOT_READY"
1280
1309
  };
1281
1310
  var REDIS_PREFIXES = {
1282
1311
  NOAUTH: "AUTH_FAILED",
@@ -1288,6 +1317,10 @@ var FALLBACK_PATTERNS = [
1288
1317
  [/connect(?:ion)? time(?:d )?out/i, "ETIMEDOUT"],
1289
1318
  [/timeout exceeded/i, "ETIMEDOUT"],
1290
1319
  [/timed out/i, "ETIMEDOUT"],
1320
+ [/connection terminated/i, "CONNECTION_LOST"],
1321
+ [/server closed the connection/i, "CONNECTION_LOST"],
1322
+ [/server has gone away/i, "CONNECTION_LOST"],
1323
+ [/lost connection to \S+ server/i, "CONNECTION_LOST"],
1291
1324
  [/getaddrinfo/i, "ENOTFOUND"],
1292
1325
  [/authentication failed/i, "AUTH_FAILED"],
1293
1326
  [/access denied for user/i, "AUTH_FAILED"],
@@ -1295,18 +1328,26 @@ var FALLBACK_PATTERNS = [
1295
1328
  [/role\s+".*?"\s+does not exist/i, "AUTH_FAILED"],
1296
1329
  [/password supplied/i, "AUTH_FAILED"]
1297
1330
  ];
1298
- function categorize(errCode, errno, errMsg) {
1331
+ var PG_CANCEL_BY_TIMEOUT = /statement timeout/i;
1332
+ function categorize(errCode, errno, errMsg, system) {
1299
1333
  if (errCode) {
1334
+ if (TLS_CODE_PREFIX.test(errCode))
1335
+ return "TLS_ERROR";
1300
1336
  const known = TRANSPORT_CODES[errCode] ?? MYSQL_CODES[errCode] ?? SQLSTATES[errCode] ?? REDIS_PREFIXES[errCode];
1337
+ if (known === "STATEMENT_TIMEOUT" && errCode === "57014" && !PG_CANCEL_BY_TIMEOUT.test(errMsg)) {
1338
+ return null;
1339
+ }
1301
1340
  if (known)
1302
1341
  return known;
1303
1342
  }
1304
- if (typeof errno === "number" && MYSQL_ERRNOS[errno])
1343
+ const isMysqlFamily = system === "mysql" || system === "mariadb";
1344
+ if (isMysqlFamily && typeof errno === "number" && MYSQL_ERRNOS[errno]) {
1305
1345
  return MYSQL_ERRNOS[errno];
1346
+ }
1306
1347
  const redisPrefix = errMsg.match(/^([A-Z]+)\s/)?.[1];
1307
1348
  if (redisPrefix && REDIS_PREFIXES[redisPrefix])
1308
1349
  return REDIS_PREFIXES[redisPrefix];
1309
- if (errCode || typeof errno === "number")
1350
+ if (errCode || isMysqlFamily && typeof errno === "number")
1310
1351
  return null;
1311
1352
  for (const [pattern, category] of FALLBACK_PATTERNS) {
1312
1353
  if (pattern.test(errMsg))
@@ -1359,7 +1400,7 @@ function mapError(error, system, options) {
1359
1400
  const errMsg = String(err?.message || String(error));
1360
1401
  const errCode = String(err?.code || "");
1361
1402
  const errno = err?.errno;
1362
- switch (categorize(errCode, errno, errMsg)) {
1403
+ switch (categorize(errCode, errno, errMsg, system)) {
1363
1404
  case "ECONNREFUSED":
1364
1405
  return new ConnectionError("ECONNREFUSED", `Cannot connect to ${options.host}:${options.port} \u2014 server is not running or not listening on this port`, serviceHints(system, options));
1365
1406
  case "ETIMEDOUT":
@@ -1368,6 +1409,42 @@ function mapError(error, system, options) {
1368
1409
  `Increase timeout: edit .dbcli and add "timeout": 15000`,
1369
1410
  `Verify network connectivity: ping ${options.host} -c 3`
1370
1411
  ]);
1412
+ case "CONNECTION_LOST":
1413
+ return new ConnectionError("CONNECTION_LOST", `Connection to ${options.host}:${options.port} was lost mid-session \u2014 the server closed it or the network dropped`, [
1414
+ "Re-run the command: a dropped connection is often transient",
1415
+ `Check whether the server restarted or is cycling: ${SYSTEM_FACTS[system].logFile}`,
1416
+ "If it happens on long-running work, look at the server idle/wait timeout"
1417
+ ]);
1418
+ case "SERVER_NOT_READY":
1419
+ return new ConnectionError("SERVER_NOT_READY", `${options.host}:${options.port} is not accepting connections yet \u2014 the server is starting up or recovering`, [
1420
+ "Retry shortly: this clears on its own once startup or recovery finishes",
1421
+ `Watch progress in the server log: ${SYSTEM_FACTS[system].logFile}`,
1422
+ "On a replica, this also appears while it catches up with the primary"
1423
+ ]);
1424
+ case "CONNECTION_REJECTED":
1425
+ return new ConnectionError("CONNECTION_REJECTED", `${options.host}:${options.port} answered but rejected the connection attempt`, [
1426
+ "Check the server access rules (pg_hba.conf) for this user, database, and client address",
1427
+ "If a pooler (pgbouncer / ProxySQL) sits in front, check its own rules and limits",
1428
+ "Check whether the connection limit for this user or database is exhausted"
1429
+ ]);
1430
+ case "TOO_MANY_CONNECTIONS":
1431
+ return new ConnectionError("TOO_MANY_CONNECTIONS", `${options.host}:${options.port} refused the connection \u2014 the server has no connection slots left`, [
1432
+ "Wait and retry: the limit is on concurrent connections, not on you",
1433
+ system === "postgresql" ? "Inspect usage: SELECT count(*) FROM pg_stat_activity \u2014 compare against max_connections" : 'Inspect usage: SHOW STATUS LIKE "Threads_connected" \u2014 compare against max_connections',
1434
+ "Close idle sessions, or raise max_connections if the load is legitimate"
1435
+ ]);
1436
+ case "EHOSTUNREACH":
1437
+ return new ConnectionError("EHOSTUNREACH", `No route to ${options.host} \u2014 the name resolved, but the host or network is unreachable`, [
1438
+ "Check that you are on the right network or VPN",
1439
+ `Check routing and firewall between here and ${options.host}`,
1440
+ `Confirm the address is the one you meant: ${options.host}:${options.port}`
1441
+ ]);
1442
+ case "TLS_ERROR":
1443
+ return new ConnectionError("TLS_ERROR", `TLS handshake failed: ${errMsg}`, [
1444
+ 'Point the connection at the right CA bundle: set "caPath" in .dbcli',
1445
+ 'For a self-signed certificate in a trusted network, set "rejectUnauthorized": false',
1446
+ `Confirm the certificate covers the host you connected to: ${options.host}`
1447
+ ]);
1371
1448
  case "ENOTFOUND":
1372
1449
  return new ConnectionError("ENOTFOUND", `Host not found: ${options.host}`, [
1373
1450
  `Check the hostname spelling: ${options.host}`,
@@ -1398,6 +1475,14 @@ function mapError(error, system, options) {
1398
1475
  "Column names are case-sensitive on some databases (PostgreSQL with quoted identifiers)"
1399
1476
  ]);
1400
1477
  }
1478
+ case "STATEMENT_TIMEOUT": {
1479
+ const limitMs = options.statementTimeout ?? options.timeout;
1480
+ return new ConnectionError("STATEMENT_TIMEOUT", `Statement timed out${limitMs ? ` (${limitMs}ms)` : ""} \u2014 the server canceled this query before it finished`, [
1481
+ 'Inspect the query plan: dbcli explain "<sql>"',
1482
+ "Raise the ceiling for this run: dbcli --statement-timeout <ms> \u2026 (0 removes it)",
1483
+ "Narrow the query: add WHERE filters, reduce the LIMIT, or index the scanned columns"
1484
+ ], limitMs);
1485
+ }
1401
1486
  case "SQL_SYNTAX_ERROR":
1402
1487
  return new ConnectionError("SQL_SYNTAX_ERROR", `SQL syntax error: ${errMsg}`, [
1403
1488
  "Check your SQL syntax near the position reported above",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carllee1983/dbcli",
3
- "version": "1.54.0",
3
+ "version": "1.54.1",
4
4
  "description": "Database CLI for AI agents",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1660,6 +1660,21 @@ shape (`schemaVersion: 1`):
1660
1660
  Recovery codes (fixed in v1.15.0):
1661
1661
  - `CONFIG_MISSING` — no `.dbcli` config; run `dbcli init`.
1662
1662
  - `CONN_REFUSED` / `CONN_AUTH_FAILED` / `CONN_TIMEOUT` / `CONN_HOST_NOT_FOUND` / `CONN_UNKNOWN` — connection failure variants.
1663
+ `CONN_TIMEOUT` also covers a statement the server canceled for exceeding the statement
1664
+ timeout (PostgreSQL `57014`, MySQL `3024`, MariaDB `1969`); `details.connectionCode` is
1665
+ `STATEMENT_TIMEOUT` there instead of `ETIMEDOUT`, the plan targets the query (`lint` →
1666
+ `explain` → re-run with `--statement-timeout <ms>`) rather than `doctor`, and no
1667
+ `branches` / `branchFork` / `verify` is emitted. PostgreSQL `57014` only counts as a
1668
+ statement timeout when the server says so — a `pg_cancel_backend()` cancel keeps the
1669
+ verbatim `Database error (57014): …` form instead of claiming a ceiling.
1670
+ The same `details.connectionCode` field distinguishes the other causes that share a
1671
+ connection code: `CONNECTION_LOST` (server closed the connection mid-session — PostgreSQL
1672
+ class 08 and `57P01`/`57P02`, MySQL `1053`), `TOO_MANY_CONNECTIONS` (PostgreSQL `53300`,
1673
+ MySQL `1040` — its plan counts current connections instead of running `doctor`, because
1674
+ rewriting host/port cannot create a slot), `SERVER_NOT_READY` (`57P03`, still starting up),
1675
+ `CONNECTION_REJECTED` (`08004` — the server answered and refused), `EHOSTUNREACH` (resolved
1676
+ but unroutable) and `TLS_ERROR` (handshake failure; reported as `CONN_UNKNOWN` rather than
1677
+ `CONN_AUTH_FAILED`, whose plan re-runs `init` for credentials that are not the problem).
1663
1678
  - `PERMISSION_DENIED` — active permission level forbids the operation.
1664
1679
  - `BLACKLIST_TABLE` / `BLACKLIST_COLUMN_WRITE` — blacklist violations.
1665
1680
  - `SNIPPET_NOT_FOUND` / `SNIPPET_AMBIGUOUS` / `SNIPPET_PARAM_MISSING` — saved-query failures.
@@ -1660,6 +1660,21 @@ shape (`schemaVersion: 1`):
1660
1660
  Recovery codes (fixed in v1.15.0):
1661
1661
  - `CONFIG_MISSING` — no `.dbcli` config; run `dbcli init`.
1662
1662
  - `CONN_REFUSED` / `CONN_AUTH_FAILED` / `CONN_TIMEOUT` / `CONN_HOST_NOT_FOUND` / `CONN_UNKNOWN` — connection failure variants.
1663
+ `CONN_TIMEOUT` also covers a statement the server canceled for exceeding the statement
1664
+ timeout (PostgreSQL `57014`, MySQL `3024`, MariaDB `1969`); `details.connectionCode` is
1665
+ `STATEMENT_TIMEOUT` there instead of `ETIMEDOUT`, the plan targets the query (`lint` →
1666
+ `explain` → re-run with `--statement-timeout <ms>`) rather than `doctor`, and no
1667
+ `branches` / `branchFork` / `verify` is emitted. PostgreSQL `57014` only counts as a
1668
+ statement timeout when the server says so — a `pg_cancel_backend()` cancel keeps the
1669
+ verbatim `Database error (57014): …` form instead of claiming a ceiling.
1670
+ The same `details.connectionCode` field distinguishes the other causes that share a
1671
+ connection code: `CONNECTION_LOST` (server closed the connection mid-session — PostgreSQL
1672
+ class 08 and `57P01`/`57P02`, MySQL `1053`), `TOO_MANY_CONNECTIONS` (PostgreSQL `53300`,
1673
+ MySQL `1040` — its plan counts current connections instead of running `doctor`, because
1674
+ rewriting host/port cannot create a slot), `SERVER_NOT_READY` (`57P03`, still starting up),
1675
+ `CONNECTION_REJECTED` (`08004` — the server answered and refused), `EHOSTUNREACH` (resolved
1676
+ but unroutable) and `TLS_ERROR` (handshake failure; reported as `CONN_UNKNOWN` rather than
1677
+ `CONN_AUTH_FAILED`, whose plan re-runs `init` for credentials that are not the problem).
1663
1678
  - `PERMISSION_DENIED` — active permission level forbids the operation.
1664
1679
  - `BLACKLIST_TABLE` / `BLACKLIST_COLUMN_WRITE` — blacklist violations.
1665
1680
  - `SNIPPET_NOT_FOUND` / `SNIPPET_AMBIGUOUS` / `SNIPPET_PARAM_MISSING` — saved-query failures.