dsh-sql 0.5.9 → 0.5.10

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.5.10 (2026-11-03)
4
+
5
+ - 连接与模式查询失败保留原文并追加下一步;sql_list 补端口;空态给指引。
6
+ - Connection and schema failures keep the original error and add next steps; sql_list shows ports; empty states guide the next action.
7
+
3
8
  ## 0.5.9 (2026-10-30)
4
9
 
5
10
  - 重整中英文首页:用途、三步流程图、安装入口与操作示例;详细配置与边界集中到完整说明。
package/docs/USAGE.en.md CHANGED
@@ -101,6 +101,23 @@ pnpm install
101
101
  pnpm test # build + full test suite, including a real SQLite integration suite
102
102
  ```
103
103
 
104
+ ## Troubleshooting
105
+
106
+ Run `sql_health` first: it pings every connection and summarizes read-only and approval settings, and each failing check carries the next step.
107
+
108
+ | Symptom | Usual cause | What to do |
109
+ | :-- | :-- | :-- |
110
+ | `ECONNREFUSED` / `ETIMEDOUT` | Server down, wrong host/port, or firewall | Verify the server and port, then check `host`/`port` in the connection |
111
+ | `Access denied for user` / `password authentication failed` | Wrong user or password | Check `user`/`password`; prefer the `DSH_SQL_PASSWORD_<CONNECTION>` env var |
112
+ | `Unknown database` / `database "…" does not exist` | Typo, or the database was never created | Check the `database` field |
113
+ | `SQLITE_CANTOPEN` / `unable to open database file` | Missing path, or no read/write permission | Check the file path, directory permissions, and the account running DSH |
114
+ | `no such table` / `relation "…" does not exist` | Wrong table name, case, or schema | Call `sql_schema` without `table` to list the real names |
115
+ | Write reports "0 rows affected" | `WHERE` matched nothing, or the script was DDL/multi-statement | Re-check the predicate and data with `sql_query` |
116
+ | `sql_exec` says confirmation cannot be asked | Session approval policy is `never` | Switch to an ask-capable policy, or explicitly set `writeApproval: false` and restart (no more prompts) |
117
+ | `sql_schema` returns no columns | Table missing, or the account cannot read it | List all tables first, then check case and permissions |
118
+
119
+ If health is green but a query is still rejected, check the read-only rule: writes (INSERT/UPDATE/DDL) belong in `sql_exec`.
120
+
104
121
  ## License
105
122
 
106
123
  MIT
package/docs/USAGE.md CHANGED
@@ -103,6 +103,23 @@ pnpm install
103
103
  pnpm test # 构建 + 完整测试套件(含真实 SQLite 集成)
104
104
  ```
105
105
 
106
+ ## 故障排查
107
+
108
+ 先运行 `sql_health`:它逐连接探活并汇总只读模式与审批状态,失败项后面会直接给出下一步。
109
+
110
+ | 现象 | 常见原因 | 处理 |
111
+ | :-- | :-- | :-- |
112
+ | `ECONNREFUSED` / `ETIMEDOUT` | 服务没启动、host/port 不对或被防火墙拦住 | 确认服务与端口,核对连接里的 `host`/`port` |
113
+ | `Access denied for user` / `password authentication failed` | 账号或密码不对 | 核对 `user`/`password`;密码建议用环境变量 `DSH_SQL_PASSWORD_<连接名大写>` |
114
+ | `Unknown database` / `database "…" does not exist` | 库名写错或尚未创建 | 核对 `database` 字段 |
115
+ | `SQLITE_CANTOPEN` / `unable to open database file` | 路径不存在,或运行账号没有读写权限 | 检查文件路径、目录可写性与进程账号 |
116
+ | `no such table` / `relation "…" does not exist` | 表名、大小写或 Schema 不对 | 先不带 `table` 运行 `sql_schema` 看真实表名 |
117
+ | 写操作返回「0 行受影响」 | `WHERE` 没命中,或执行的是 DDL/多语句脚本 | 用 `sql_query` 复核条件与数据 |
118
+ | `sql_exec` 报「需要确认但无法询问」 | 会话审批策略是 `never` | 切到可询问模式,或明确设置 `writeApproval: false` 后重启(之后不再询问) |
119
+ | `sql_schema` 返回「没有取到列」 | 表不存在,或当前账号没有读取权限 | 先列全部表,再核对大小写与权限 |
120
+
121
+ 自检正常但查询仍被拒绝时,先确认是不是只读限制:写语句(INSERT/UPDATE/DDL)要走 `sql_exec`。
122
+
106
123
  ## License
107
124
 
108
125
  MIT
package/lib/tools.d.ts CHANGED
@@ -26,6 +26,16 @@ export interface SqlToolDefinition {
26
26
  execute(args: unknown, exec: unknown): Promise<unknown>;
27
27
  timeoutMs?: number;
28
28
  }
29
+ /**
30
+ * 把驱动/连接的原始错误映射成一行可执行的「下一步」。
31
+ *
32
+ * 之前 sql_list / sql_health / sql_query 直接把驱动原文(ECONNREFUSED、
33
+ * ER_ACCESS_DENIED_ERROR、SQLITE_CANTOPEN…)交给用户,知道怎么修的人能看懂,
34
+ * 不知道的人只能反复重试。这里只补一行提示,原始错误文本一律保留。
35
+ */
36
+ export declare function connectionErrorHint(message: string): string | undefined;
37
+ /** 给错误补一行「下一步」;无法识别时原样返回,避免把断言类错误改写成连接问题。 */
38
+ export declare function annotateError(error: unknown): Error;
29
39
  /** 词法校验需要区分的三种引擎。 */
30
40
  type SqlEngine = 'sqlite' | 'mysql' | 'postgres';
31
41
  /**
package/lib/tools.js CHANGED
@@ -38,6 +38,42 @@ function executionSignal(exec) {
38
38
  const signal = exec.signal;
39
39
  return signal instanceof AbortSignal ? signal : undefined;
40
40
  }
41
+ /**
42
+ * 把驱动/连接的原始错误映射成一行可执行的「下一步」。
43
+ *
44
+ * 之前 sql_list / sql_health / sql_query 直接把驱动原文(ECONNREFUSED、
45
+ * ER_ACCESS_DENIED_ERROR、SQLITE_CANTOPEN…)交给用户,知道怎么修的人能看懂,
46
+ * 不知道的人只能反复重试。这里只补一行提示,原始错误文本一律保留。
47
+ */
48
+ export function connectionErrorHint(message) {
49
+ if (/ECONNREFUSED/i.test(message))
50
+ return '目标地址拒绝连接:请确认数据库服务已启动、host 与 port 正确,并检查防火墙是否放行。';
51
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(message))
52
+ return '主机名解析失败:请检查连接配置里的 host 拼写,以及本机 DNS/网络是否可用。';
53
+ if (/ETIMEDOUT|timed out|timeout exceeded|connection timeout/i.test(message))
54
+ return '连接或查询超时:请确认网络可达与服务器负载,必要时调大 queryTimeoutMs / execTimeoutMs 后重试。';
55
+ if (/password authentication failed|Access denied for user|authentication failed/i.test(message))
56
+ return '认证失败:请核对 user 与 password;也可改用环境变量 DSH_SQL_PASSWORD_<连接名大写> 提供密码,避免明文写进配置。';
57
+ if (/Unknown database|ER_BAD_DB_ERROR|database .* does not exist|\b3D000\b/i.test(message))
58
+ return '数据库不存在:请核对连接配置里的 database,或先在服务器上创建该库。';
59
+ if (/no such table|SQLITE_ERROR: no such table|relation .* does not exist|\b42P01\b/i.test(message))
60
+ return '表不存在:先用 sql_schema(不带 table)查看该连接的表清单,注意大小写与实际 schema。';
61
+ if (/SQLITE_CANTOPEN|unable to open database file|EACCES|EPERM|readonly database/i.test(message))
62
+ return 'SQLite 文件无法打开或没有权限:请检查文件路径是否存在、所在目录可写、进程账号有读写权限。';
63
+ if (/ECONNRESET|EPIPE|Connection terminated|server closed the connection/i.test(message))
64
+ return '连接被对端关闭:可能是服务器重启或中间代理断开,请重试一次;持续出现时检查连接池与代理设置。';
65
+ if (/no pg_hba\.conf entry|unsupported startup parameter|\b28000\b/i.test(message))
66
+ return '服务器拒绝本次连接:请核对 pg_hba.conf 与账号来源限制,确认该主机与该用户被允许登录。';
67
+ return undefined;
68
+ }
69
+ /** 给错误补一行「下一步」;无法识别时原样返回,避免把断言类错误改写成连接问题。 */
70
+ export function annotateError(error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ const hint = connectionErrorHint(message);
73
+ if (hint === undefined || message.includes('下一步:'))
74
+ return error instanceof Error ? error : new Error(message);
75
+ return new Error(message + '\n下一步:' + hint);
76
+ }
41
77
  /** 只读语句关键字白名单。 */
42
78
  const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
43
79
  /**
@@ -309,7 +345,7 @@ const listSchema = {
309
345
  type: 'array',
310
346
  items: {
311
347
  type: 'object',
312
- properties: { name: { type: 'string' }, engine: { type: 'string' }, host: { type: 'string' }, database: { type: 'string' }, file: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } },
348
+ properties: { name: { type: 'string' }, engine: { type: 'string' }, host: { type: 'string' }, port: { type: 'integer' }, database: { type: 'string' }, file: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } },
313
349
  additionalProperties: true,
314
350
  },
315
351
  },
@@ -423,6 +459,16 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
423
459
  }
424
460
  return { adapter, name: connection.name };
425
461
  };
462
+ /** 适配器调用统一兜底:被取消时原样抛出,其余错误补「下一步」。 */
463
+ const guard = async (signal, work) => {
464
+ try {
465
+ return await work();
466
+ }
467
+ catch (error) {
468
+ signal?.throwIfAborted();
469
+ throw annotateError(error);
470
+ }
471
+ };
426
472
  const pingAllConnections = async (signal) => {
427
473
  const rows = [];
428
474
  for (const connection of cfg.connections) {
@@ -431,6 +477,8 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
431
477
  entry.file = connection.file ?? ':memory:';
432
478
  else {
433
479
  entry.host = connection.host ?? '';
480
+ // 端口要一并返回,否则渲染只能显示 host:undefined/database。
481
+ entry.port = connection.port;
434
482
  entry.database = connection.database ?? '';
435
483
  }
436
484
  try {
@@ -442,7 +490,7 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
442
490
  catch (error) {
443
491
  signal?.throwIfAborted();
444
492
  entry.ok = false;
445
- entry.error = error instanceof Error ? error.message : String(error);
493
+ entry.error = annotateError(error).message;
446
494
  }
447
495
  rows.push(entry);
448
496
  }
@@ -458,10 +506,16 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
458
506
  const rec = asRecord(value);
459
507
  const connections = Array.isArray(rec.connections) ? rec.connections : [];
460
508
  const lines = ['共 ' + connections.length + ' 个数据库连接:'];
509
+ const bad = [];
461
510
  for (const item of connections) {
462
511
  const c = asRecord(item);
463
- const target = c.file !== undefined && c.file !== '' ? c.file : c.host + '/' + c.database;
512
+ const target = c.file !== undefined && c.file !== '' ? c.file : c.host + ':' + c.port + '/' + c.database;
464
513
  lines.push('- ' + c.name + '(' + c.engine + ' @ ' + target + ')' + (c.ok === true ? ' ✅' : ' ❌ ' + String(c.error ?? '')));
514
+ if (c.ok !== true)
515
+ bad.push(String(c.name));
516
+ }
517
+ if (bad.length > 0) {
518
+ lines.push('', '下一步:先修好 ' + bad.join('、') + '(核对 host/port、服务是否启动、账号密码或 SQLite 文件路径),再运行 sql_list 复查。');
465
519
  }
466
520
  return [{ type: 'text', text: lines.join('\n') }];
467
521
  },
@@ -504,7 +558,7 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
504
558
  // 先按目标连接的引擎做词法校验,再打开数据库:被拒绝的 SQL 不该去建连接或建库文件。
505
559
  const sql = assertReadQuery(rawSql, findConnection(connectionName).engine);
506
560
  const { adapter, name } = getAdapter(connectionName);
507
- const result = await adapter.query(sql, cfg.maxRows + 1, executionSignal(exec));
561
+ const result = await guard(executionSignal(exec), () => adapter.query(sql, cfg.maxRows + 1, executionSignal(exec)));
508
562
  const total = result.rows.length;
509
563
  const rows = result.rows.slice(0, cfg.maxRows);
510
564
  const format = optionalString(args, 'format')?.toLowerCase() ?? 'table';
@@ -540,7 +594,9 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
540
594
  schema: execSchema,
541
595
  render: (_args, value) => {
542
596
  const rec = asRecord(value);
543
- return [{ type: 'text', text: '执行完成(' + rec.connection + '):影响 ' + rec.changes + ' 行。' }];
597
+ const changes = typeof rec.changes === 'number' ? rec.changes : 0;
598
+ const tail = changes === 0 ? '\n注意:0 行受影响,通常是 WHERE 条件没有命中数据,或用的是 DDL/多语句脚本;可用 sql_query 复核。' : '';
599
+ return [{ type: 'text', text: '执行完成(' + rec.connection + '):影响 ' + changes + ' 行。' + tail }];
544
600
  },
545
601
  },
546
602
  async execute(rawArgs, exec) {
@@ -549,7 +605,7 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
549
605
  const args = asRecord(rawArgs);
550
606
  const sql = requiredString(args, 'sql', 'SQL 语句');
551
607
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
552
- const changes = await adapter.exec(sql, executionSignal(exec));
608
+ const changes = await guard(executionSignal(exec), () => adapter.exec(sql, executionSignal(exec)));
553
609
  return { connection: name, changes, readOnly: false };
554
610
  },
555
611
  timeoutMs: cfg.execTimeoutMs,
@@ -566,7 +622,11 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
566
622
  render: (_args, value) => {
567
623
  const rec = asRecord(value);
568
624
  const columns = Array.isArray(rec.columns) ? rec.columns : [];
569
- if (columns.length > 0) {
625
+ // 指定了 table 却没有列:不能落到「共 0 张表」,那会把「表不存在」说成「库是空的」。
626
+ if (typeof rec.table === 'string' && rec.table !== '') {
627
+ if (columns.length === 0) {
628
+ return [{ type: 'text', text: '没有取到表 ' + rec.table + ' 的列:该表可能不存在、大小写/Schema 不匹配,或当前账号没有读取权限。\n下一步:先调用 sql_schema(不带 table)查看连接 ' + rec.connection + ' 的表清单,再用清单里的原名重试。' }];
629
+ }
570
630
  const lines = ['表 ' + rec.table + ' 的列:'];
571
631
  for (const item of columns) {
572
632
  const c = asRecord(item);
@@ -575,6 +635,8 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
575
635
  return [{ type: 'text', text: lines.join('\n') }];
576
636
  }
577
637
  const tables = Array.isArray(rec.tables) ? rec.tables : [];
638
+ if (tables.length === 0)
639
+ return [{ type: 'text', text: '连接 ' + rec.connection + ' 里没有用户表(库可能是空的,或该账号只能看到部分 Schema)。' }];
578
640
  return [{ type: 'text', text: '共 ' + tables.length + ' 张表:' + tables.join(', ') }];
579
641
  },
580
642
  },
@@ -584,10 +646,10 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
584
646
  const table = optionalString(args, 'table');
585
647
  const signal = executionSignal(exec);
586
648
  if (table !== undefined) {
587
- const columns = await adapter.describeTable(table, signal);
649
+ const columns = await guard(signal, () => adapter.describeTable(table, signal));
588
650
  return { connection: name, table, columns, tables: [] };
589
651
  }
590
- const tables = await adapter.listTables(signal);
652
+ const tables = await guard(signal, () => adapter.listTables(signal));
591
653
  return { connection: name, tables, columns: [] };
592
654
  },
593
655
  timeoutMs: 30000,
@@ -606,7 +668,11 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
606
668
  const lines = ['连接 ' + rec.connection + '(' + rec.engine + '):共 ' + tables.length + ' 张表' + (typeof rec.sizeBytes === 'number' && rec.sizeBytes >= 0 ? ',库体积 ' + formatBytes(rec.sizeBytes) : '') + '。'];
607
669
  for (const item of tables.slice(0, 30)) {
608
670
  const t = asRecord(item);
609
- lines.push('- ' + t.name + ':' + (typeof t.rowCount === 'number' ? t.rowCount + ' 行' : '行数未知' + (t.error ? '(' + t.error + ')' : '')));
671
+ const label = typeof t.name === 'string' && t.name !== '' ? t.name : '(表清单读取失败)';
672
+ lines.push('- ' + label + ':' + (typeof t.rowCount === 'number' ? t.rowCount + ' 行' : '行数未知' + (t.error ? '(' + t.error + ')' : '')));
673
+ }
674
+ if (tables.length > 0 && tables.every((item) => typeof asRecord(item).rowCount !== 'number') && tables.every((item) => asRecord(item).error !== undefined)) {
675
+ lines.push('', '下一步:统计信息读不到时,先用 sql_schema 确认表清单可用、账号有读取系统表/information_schema 的权限,再用 sql_query 手动 COUNT(*)。');
610
676
  }
611
677
  if (tables.length > 30)
612
678
  lines.push('…仅展示前 30 张表');
@@ -643,7 +709,7 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
643
709
  }
644
710
  }
645
711
  else {
646
- const names = await adapter.listTables(signal);
712
+ const names = await guard(signal, () => adapter.listTables(signal));
647
713
  for (const table of names) {
648
714
  try {
649
715
  const result = await adapter.query('SELECT COUNT(*) FROM ' + quoteIdent(engine, table), 1, signal);
@@ -677,6 +743,15 @@ export function buildSqlTools(config, policyOf = () => 'unknown') {
677
743
  lines.push('- 只读模式:' + (rec.readOnly === true ? '开(sql_exec 已禁用)' : '关'));
678
744
  lines.push('- 写审批门:' + String(rec.writeApprovalDetail ?? (rec.writeApproval === true ? '开' : '关')));
679
745
  lines.push('- 行数上限:' + String(rec.maxRows) + ';查询超时 ' + String(rec.queryTimeoutMs) + 'ms;写超时 ' + String(rec.execTimeoutMs) + 'ms');
746
+ // 自检的价值在于失败时告诉用户下一步做什么,而不只是把 ❌ 摆出来。
747
+ if (bad.length > 0) {
748
+ lines.push('- 下一步:逐个修好上面的 ❌ 连接(核对 host/port、数据库服务是否启动、账号密码或 SQLite 文件路径与权限),再运行 sql_health 复查。');
749
+ }
750
+ if (rec.writeApprovalReady === false) {
751
+ lines.push('- 下一步:写审批门当前无法确认。把会话审批策略切到可询问(ask),或由用户明确设置 writeApproval=false 后重启(之后 sql_exec 不再询问,慎用)。');
752
+ }
753
+ if (rec.readOnly === true)
754
+ lines.push('- 说明:readOnly=true 时 sql_exec 被禁用属于配置预期,不是故障。');
680
755
  return [{ type: 'text', text: lines.join('\n') }];
681
756
  },
682
757
  },
@@ -437,7 +437,7 @@ function __stardustUpdatesClient(ctx, React, require, shared) {
437
437
  }
438
438
 
439
439
  /* STARDUSTLC_UPDATE_CLIENT_BODY_START */
440
- window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-sql",factory:require=>({apply(){}})},{"name":"dsh-sql","version":"0.1.0","hasWorkbench":false,"packageVersion":"0.5.9","label":"数据库","labelEn":"SQL","suiteComponent":true}));
440
+ window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-sql",factory:require=>({apply(){}})},{"name":"dsh-sql","version":"0.1.0","hasWorkbench":false,"packageVersion":"0.5.10","label":"数据库","labelEn":"SQL","suiteComponent":true}));
441
441
 
442
442
  /* STARDUSTLC_UPDATE_CLIENT_BODY_END */
443
443
  })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sql",
3
- "version": "0.5.9",
4
- "description": "先看表结构,再用查询回答问题。 需要自己的数据库连接与权限 欢迎提交 issues 和 PR。",
3
+ "version": "0.5.10",
4
+ "description": "DSH 数据库工具:SQLite / MySQL / PostgreSQL 查询、结构与统计、只读保护和写操作确认。欢迎提交 issues 和 PR。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -26,8 +26,8 @@
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json && node scripts/build-updates.mjs",
28
28
  "typecheck": "tsc -p tsconfig.json --noEmit",
29
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
30
- "prepublishOnly": "pnpm run build"
29
+ "test": "npm run build && node --test \"test/*.test.mjs\"",
30
+ "prepublishOnly": "npm run build"
31
31
  },
32
32
  "dsh": {
33
33
  "bundle": {