dsh-sql 0.4.1 → 0.4.2

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/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **Your agent can query databases now**: SQLite / MySQL / PostgreSQL engines, read-only whitelist + write approval gate.
6
6
 
7
- DSH (DeepSeek Harness) engineer-grade database plugin: four tools covering connection management, read-only queries, write operations, and schema introspection.
7
+ DSH (DeepSeek Harness) engineer-grade database plugin: six tools covering connection management, read-only queries, write operations, schema introspection, database statistics, and health checks.
8
8
 
9
9
  ![npm version](https://img.shields.io/npm/v/dsh-sql?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-sql) ![license](https://img.shields.io/npm/l/dsh-sql) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-sql?style=social)
10
10
 
@@ -12,7 +12,7 @@ DSH (DeepSeek Harness) engineer-grade database plugin: four tools covering conne
12
12
 
13
13
  ## Compatibility
14
14
 
15
- Verified against source-run `@deepseek-ai/dsh@0.1.2-alpha.4` on 2026-09-02. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
15
+ Verified against the official `@deepseek-ai/dsh@0.1.2-rc.1` release and current source checkout on 2026-09-04. `0.1.2-alpha.5` remains supported because both releases use the same plugin/tool contract. Built for the cordis patch-bundle model (`cordis.patch.yml` + `dsh.bundle.patch`) with no runtime imports of `@deepseek-ai/*` internals.
16
16
 
17
17
  ## Installation
18
18
 
@@ -57,6 +57,8 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
57
57
  writeApproval: true # approve write operations first (default true)
58
58
  ```
59
59
 
60
+ With no connection configuration, the plugin provides a `:memory:` SQLite connection. If configuration is present but invalid, the plugin fails to load with the validation error instead of silently falling back to the in-memory database.
61
+
60
62
  ## Tools
61
63
 
62
64
  | Tool | Purpose | Safety |
@@ -65,6 +67,8 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
65
67
  | `sql_query` | Read-only queries (SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH) | Keyword whitelist + rejects multi-statement |
66
68
  | `sql_exec` | Writes / DDL (multi-statement scripts allowed) | readOnly lock + approval gate |
67
69
  | `sql_schema` | Table list / table structure | Identifier whitelist validation |
70
+ | `sql_stats` | Table counts, row estimates, and database size | Quoted identifiers + isolated query failures |
71
+ | `sql_health` | Connection and safety-configuration checks | Per-connection probe; passwords are never returned |
68
72
 
69
73
  ### Examples
70
74
 
@@ -72,6 +76,8 @@ Then restart the web service. To clean up fully, also remove the plugin entry fr
72
76
  sql_list {}
73
77
  sql_schema {} # list all tables
74
78
  sql_schema { table: users } # inspect the users table
79
+ sql_stats {} # inspect the default connection's data size
80
+ sql_health {} # check connections and safety settings
75
81
  sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
76
82
  sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
77
83
  ```
@@ -81,7 +87,9 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
81
87
  - **Lexer-grade read-only guard**: sql_query strips strings/comments before validation, then rejects data-modifying CTEs (WITH…DELETE/UPDATE), SELECT INTO, FOR UPDATE/FOR SHARE, PRAGMA assignment, and multi-statement input
82
88
  - **Write approval gate**: sql_exec asks for approval by default (mirroring dsh-email's send approval); headless environments without an approval channel are denied
83
89
  - **readOnly mode**: lock out writes entirely for production databases
84
- - **Streaming row cap**: SQLite iterator / MySQL stream / PostgreSQL portal all stop at maxRows+1, so large queries are never fully materialized; overflow is flagged with truncated
90
+ - **Streaming row cap**: SQLite iterators, MySQL Readables, and PostgreSQL Query row events collect at most maxRows+1 rows and flag overflow with truncated. MySQL and PostgreSQL close the query's dedicated connection at the cap; smaller results return the connection to the pool, without materializing the full result in memory
91
+ - **Cancellation-aware execution**: queries and writes observe Harness `exec.signal`; cancellation stops waiting and destroys the active dedicated MySQL/PostgreSQL connection
92
+ - **Lossless large integers**: bigint values within JavaScript's safe integer range are returned as numbers; larger values are returned as decimal strings instead of silently losing precision
85
93
  - **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
86
94
  - **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
87
95
 
@@ -95,7 +103,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
95
103
 
96
104
  ```bash
97
105
  pnpm install
98
- pnpm test # build + 35 tests, including a real SQLite integration suite
106
+ pnpm test # build + full test suite, including a real SQLite integration suite
99
107
  ```
100
108
 
101
109
  ## License
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **你的 agent 会查库了**:SQLite / MySQL / PostgreSQL 三引擎,只读白名单 + 写审批门。
6
6
 
7
- DSH(DeepSeek Harness)工程师级数据库插件:四个工具覆盖连接管理、只读查询、写操作与结构探查。
7
+ DSH(DeepSeek Harness)工程师级数据库插件:六个工具覆盖连接管理、只读查询、写操作、结构探查、统计概览与健康自检。
8
8
 
9
9
  ![npm version](https://img.shields.io/npm/v/dsh-sql?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-sql) ![license](https://img.shields.io/npm/l/dsh-sql) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-sql?style=social)
10
10
 
@@ -12,7 +12,7 @@ DSH(DeepSeek Harness)工程师级数据库插件:四个工具覆盖连接
12
12
 
13
13
  ## 兼容性
14
14
 
15
- 在 `@deepseek-ai/dsh@0.1.2-alpha.4` 源码模式下验证(2026-09-02)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
15
+ 已在官方 `@deepseek-ai/dsh@0.1.2-rc.1` 与最新源码模式下验证(2026-09-04);`0.1.2-alpha.5` 仍可使用,两版插件/工具契约一致。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
16
16
 
17
17
  ## 安装
18
18
 
@@ -57,6 +57,8 @@ dsh plugin --profile web remove dsh-sql
57
57
  writeApproval: true # 写操作先弹审批(默认 true)
58
58
  ```
59
59
 
60
+ 配置缺省时会提供一个 `:memory:` SQLite 连接;配置一旦给出但格式无效,插件会直接报错并停止加载,不会静默回退到内存库。
61
+
60
62
  ## 工具一览
61
63
 
62
64
  | 工具 | 作用 | 安全 |
@@ -65,6 +67,8 @@ dsh plugin --profile web remove dsh-sql
65
67
  | `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)| 关键字白名单 + 拒绝多语句 |
66
68
  | `sql_exec` | 写操作/DDL(可多语句脚本)| readOnly 禁用 + 审批门 |
67
69
  | `sql_schema` | 表清单 / 表结构 | 标识符白名单校验 |
70
+ | `sql_stats` | 表数量、行数与库体积概览 | 表名引用 + 查询失败隔离 |
71
+ | `sql_health` | 连接与安全配置自检 | 逐连接探活,不回显密码 |
68
72
 
69
73
  ### 示例
70
74
 
@@ -72,6 +76,8 @@ dsh plugin --profile web remove dsh-sql
72
76
  sql_list {}
73
77
  sql_schema {} # 列出所有表
74
78
  sql_schema { table: users } # 看 users 表结构
79
+ sql_stats {} # 查看默认连接的数据规模
80
+ sql_health {} # 检查连接和安全配置
75
81
  sql_query { sql: SELECT * FROM orders WHERE status = 'pending' LIMIT 50 }
76
82
  sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
77
83
  ```
@@ -81,7 +87,9 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
81
87
  - **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句
82
88
  - **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
83
89
  - **readOnly 模式**:生产库可整体禁用写
84
- - **流式行数钳制**:SQLite 迭代器 / MySQL stream / PostgreSQL portal 都按 maxRows+1 停表,大查询不会全量载入内存,超量标记 truncated
90
+ - **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
91
+ - **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
92
+ - **大整数无损**:数据库返回的 bigint 在 JavaScript 安全整数范围内输出 number,超出范围则输出十进制字符串,避免静默丢精度
85
93
  - **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
86
94
  - **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
87
95
 
@@ -95,7 +103,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
95
103
 
96
104
  ```bash
97
105
  pnpm install
98
- pnpm test # 构建 + 35 个测试(含真实 SQLite 集成)
106
+ pnpm test # 构建 + 完整测试套件(含真实 SQLite 集成)
99
107
  ```
100
108
 
101
109
  ## License
package/lib/adapters.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type SqlConnectionConfig } from './config.js';
2
- /** 查询结果:列名 + 行(值数组,损失 JSON 友好)。 */
2
+ /** 查询结果:列名 + 行(值数组,无损 JSON 友好)。 */
3
3
  export interface QueryResult {
4
4
  columns: string[];
5
5
  rows: unknown[][];
@@ -14,11 +14,11 @@ export interface ColumnInfo {
14
14
  /** 统一适配器接口。 */
15
15
  export interface DatabaseAdapter {
16
16
  engine: 'sqlite' | 'mysql' | 'postgres';
17
- listTables(): Promise<string[]>;
18
- describeTable(table: string): Promise<ColumnInfo[]>;
19
- query(sql: string, limit?: number): Promise<QueryResult>;
20
- exec(sql: string): Promise<number>;
21
- ping(): Promise<void>;
17
+ listTables(signal?: AbortSignal): Promise<string[]>;
18
+ describeTable(table: string, signal?: AbortSignal): Promise<ColumnInfo[]>;
19
+ query(sql: string, limit?: number, signal?: AbortSignal): Promise<QueryResult>;
20
+ exec(sql: string, signal?: AbortSignal): Promise<number>;
21
+ ping(signal?: AbortSignal): Promise<void>;
22
22
  close(): Promise<void>;
23
23
  }
24
24
  /** 按连接配置创建适配器。 */
package/lib/adapters.js CHANGED
@@ -8,9 +8,20 @@ import { DatabaseSync } from 'node:sqlite';
8
8
  import mysql from 'mysql2/promise';
9
9
  import pg from 'pg';
10
10
  import { assertIdentifier } from './config.js';
11
+ function abortReason(signal) {
12
+ if (signal.reason !== undefined)
13
+ return signal.reason;
14
+ const error = new Error('The operation was aborted.');
15
+ error.name = 'AbortError';
16
+ return error;
17
+ }
11
18
  function toValue(value) {
12
- if (typeof value === 'bigint')
13
- return Number(value);
19
+ if (typeof value === 'bigint') {
20
+ if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
21
+ return Number(value);
22
+ }
23
+ return value.toString();
24
+ }
14
25
  if (value instanceof Date)
15
26
  return value.toISOString();
16
27
  if (value instanceof Uint8Array)
@@ -27,6 +38,84 @@ function rowsToColumns(rows) {
27
38
  function quoteSqliteIdentifier(name) {
28
39
  return '"' + name.replace(/"/g, '""') + '"';
29
40
  }
41
+ function streamMysqlQuery(corePool, sql, limit, discard) {
42
+ return new Promise((resolve, reject) => {
43
+ let settled = false;
44
+ let columns = [];
45
+ const rows = [];
46
+ const stream = corePool.query(sql).stream({ highWaterMark: 64 });
47
+ const finish = () => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ resolve({ columns, rows });
52
+ };
53
+ stream.on('fields', (fields) => {
54
+ columns = fields.map((field) => field.name);
55
+ });
56
+ // Consume the Readable, otherwise mysql2 pauses forever at highWaterMark.
57
+ stream.on('data', (row) => {
58
+ if (settled)
59
+ return;
60
+ if (columns.length === 0)
61
+ columns = Object.keys(row);
62
+ rows.push(columns.map((name) => toValue(row[name])));
63
+ if (rows.length >= limit) {
64
+ finish();
65
+ stream.destroy();
66
+ // mysql2 resumes its connection when only the Readable is destroyed.
67
+ discard();
68
+ }
69
+ });
70
+ stream.on('end', finish);
71
+ stream.on('close', finish);
72
+ stream.on('error', (error) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ reject(error instanceof Error ? error : new Error(String(error)));
77
+ });
78
+ });
79
+ }
80
+ /** pg's `rows` option is a page size; row events avoid its full result accumulator. */
81
+ function streamPostgresQuery(client, sql, limit, discard) {
82
+ return new Promise((resolve, reject) => {
83
+ let settled = false;
84
+ let columns = [];
85
+ const rows = [];
86
+ const query = new pg.Query(sql);
87
+ query.on('row', (row, result) => {
88
+ if (settled)
89
+ return;
90
+ if (columns.length === 0)
91
+ columns = result?.fields.map((field) => field.name) ?? Object.keys(row);
92
+ rows.push(columns.map((name) => toValue(row[name])));
93
+ if (rows.length >= limit) {
94
+ // Closing this dedicated connection stops server work and prevents reuse.
95
+ settled = true;
96
+ discard();
97
+ resolve({ columns, rows });
98
+ }
99
+ });
100
+ query.on('end', (result) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ if (columns.length === 0)
105
+ columns = result.fields.map((field) => field.name);
106
+ resolve({ columns, rows });
107
+ });
108
+ // Keep this listener after reaching the cap: destroying the client can emit
109
+ // the driver's asynchronous connection-closed error on this active query.
110
+ query.on('error', (error) => {
111
+ if (settled)
112
+ return;
113
+ settled = true;
114
+ reject(error);
115
+ });
116
+ client.query(query);
117
+ });
118
+ }
30
119
  /** SQLite 适配器(node:sqlite,零依赖)。 */
31
120
  class SqliteAdapter {
32
121
  engine = 'sqlite';
@@ -35,13 +124,17 @@ class SqliteAdapter {
35
124
  this.db = new DatabaseSync(file === ':memory:' ? ':memory:' : file);
36
125
  this.db.exec('PRAGMA busy_timeout = 5000');
37
126
  }
38
- async listTables() {
127
+ async listTables(signal) {
128
+ signal?.throwIfAborted();
39
129
  const result = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
130
+ signal?.throwIfAborted();
40
131
  return result.map((row) => String(row.name));
41
132
  }
42
- async describeTable(table) {
133
+ async describeTable(table, signal) {
134
+ signal?.throwIfAborted();
43
135
  const name = assertIdentifier(table, '表名');
44
136
  const rows = this.db.prepare('PRAGMA table_info(' + quoteSqliteIdentifier(name) + ')').all();
137
+ signal?.throwIfAborted();
45
138
  return rows.map((row) => ({
46
139
  name: String(row.name),
47
140
  type: String(row.type ?? ''),
@@ -49,10 +142,12 @@ class SqliteAdapter {
49
142
  primaryKey: Number(row.pk) === 1,
50
143
  }));
51
144
  }
52
- async query(sql, limit) {
145
+ async query(sql, limit, signal) {
146
+ signal?.throwIfAborted();
53
147
  const statement = this.db.prepare(sql);
54
148
  if (limit === undefined || limit <= 0) {
55
149
  const rows = statement.all();
150
+ signal?.throwIfAborted();
56
151
  return rowsToColumns(rows);
57
152
  }
58
153
  const columns = statement.columns().map((column) => column.name);
@@ -60,22 +155,28 @@ class SqliteAdapter {
60
155
  for (const raw of statement.iterate()) {
61
156
  const row = raw;
62
157
  rows.push(columns.map((name) => toValue(row[name])));
158
+ signal?.throwIfAborted();
63
159
  if (rows.length >= limit)
64
160
  break;
65
161
  }
66
162
  return { columns, rows };
67
163
  }
68
- async exec(sql) {
164
+ async exec(sql, signal) {
165
+ signal?.throwIfAborted();
69
166
  const single = sql.replace(/;\s*$/, '').trim();
70
167
  if (single.includes(';')) {
71
168
  this.db.exec(sql);
169
+ signal?.throwIfAborted();
72
170
  return 0;
73
171
  }
74
172
  const result = this.db.prepare(single).run();
173
+ signal?.throwIfAborted();
75
174
  return Number(result.changes);
76
175
  }
77
- async ping() {
176
+ async ping(signal) {
177
+ signal?.throwIfAborted();
78
178
  this.db.prepare('SELECT 1').get();
179
+ signal?.throwIfAborted();
79
180
  }
80
181
  async close() {
81
182
  this.db.close();
@@ -96,13 +197,45 @@ class MysqlAdapter {
96
197
  enableKeepAlive: true,
97
198
  });
98
199
  }
99
- async listTables() {
100
- const [rows] = await this.pool.query('SHOW TABLES');
200
+ async withSignalConnection(signal, work) {
201
+ signal?.throwIfAborted();
202
+ const connection = await this.pool.getConnection();
203
+ let destroyed = false;
204
+ let rejectAbort = () => { };
205
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
206
+ const discard = () => {
207
+ if (destroyed)
208
+ return;
209
+ destroyed = true;
210
+ connection.destroy();
211
+ };
212
+ const onAbort = () => {
213
+ discard();
214
+ rejectAbort(abortReason(signal));
215
+ };
216
+ signal?.addEventListener('abort', onAbort, { once: true });
217
+ try {
218
+ signal?.throwIfAborted();
219
+ return await Promise.race([work(connection, discard), aborted]);
220
+ }
221
+ finally {
222
+ signal?.removeEventListener('abort', onAbort);
223
+ if (!destroyed)
224
+ connection.release();
225
+ }
226
+ }
227
+ async queryRows(sql, signal) {
228
+ if (signal === undefined)
229
+ return await this.pool.query(sql);
230
+ return await this.withSignalConnection(signal, async (connection) => await connection.query(sql));
231
+ }
232
+ async listTables(signal) {
233
+ const [rows] = await this.queryRows('SHOW TABLES', signal);
101
234
  return rows.map((row) => String(Object.values(row)[0] ?? ''));
102
235
  }
103
- async describeTable(table) {
236
+ async describeTable(table, signal) {
104
237
  const name = assertIdentifier(table, '表名');
105
- const [rows] = await this.pool.query('DESCRIBE `' + name + '`');
238
+ const [rows] = await this.queryRows('DESCRIBE `' + name + '`', signal);
106
239
  return rows.map((row) => ({
107
240
  name: String(row.Field),
108
241
  type: String(row.Type ?? ''),
@@ -110,49 +243,22 @@ class MysqlAdapter {
110
243
  primaryKey: String(row.Key ?? '').toUpperCase() === 'PRI',
111
244
  }));
112
245
  }
113
- async query(sql, limit) {
246
+ async query(sql, limit, signal) {
114
247
  if (limit === undefined || limit <= 0) {
115
- const [rows] = await this.pool.query(sql);
248
+ const [rows] = await this.queryRows(sql, signal);
116
249
  return rowsToColumns(rows);
117
250
  }
118
- const corePool = this.pool.pool;
119
- return await new Promise((resolve, reject) => {
120
- let settled = false;
121
- let columns = [];
122
- const rows = [];
123
- const stream = corePool.query(sql).stream({ highWaterMark: 64 });
124
- const finish = () => {
125
- if (settled)
126
- return;
127
- settled = true;
128
- resolve({ columns, rows });
129
- };
130
- stream.on('fields', (fields) => {
131
- columns = fields.map((field) => field.name);
132
- });
133
- stream.on('result', (row) => {
134
- if (columns.length === 0)
135
- columns = Object.keys(row);
136
- rows.push(columns.map((name) => toValue(row[name])));
137
- if (rows.length >= limit)
138
- stream.destroy();
139
- });
140
- stream.on('end', finish);
141
- stream.on('close', finish);
142
- stream.on('error', (error) => {
143
- if (settled)
144
- return;
145
- settled = true;
146
- reject(error instanceof Error ? error : new Error(String(error)));
147
- });
251
+ return await this.withSignalConnection(signal, async (connection, discard) => {
252
+ const coreConnection = connection.connection;
253
+ return await streamMysqlQuery(coreConnection, sql, limit, discard);
148
254
  });
149
255
  }
150
- async exec(sql) {
151
- const [result] = await this.pool.query(sql);
256
+ async exec(sql, signal) {
257
+ const [result] = await this.queryRows(sql, signal);
152
258
  return Number(result?.affectedRows ?? 0);
153
259
  }
154
- async ping() {
155
- await this.pool.query('SELECT 1');
260
+ async ping(signal) {
261
+ await this.queryRows('SELECT 1', signal);
156
262
  }
157
263
  async close() {
158
264
  await this.pool.end();
@@ -172,13 +278,48 @@ class PostgresAdapter {
172
278
  max: 5,
173
279
  });
174
280
  }
175
- async listTables() {
176
- const result = await this.pool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name");
281
+ async withSignalClient(signal, work) {
282
+ signal?.throwIfAborted();
283
+ const client = await this.pool.connect();
284
+ let destroyed = false;
285
+ let rejectAbort = () => { };
286
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
287
+ const discard = () => {
288
+ if (destroyed)
289
+ return;
290
+ destroyed = true;
291
+ client.release(true);
292
+ };
293
+ const onAbort = () => {
294
+ discard();
295
+ rejectAbort(abortReason(signal));
296
+ };
297
+ signal?.addEventListener('abort', onAbort, { once: true });
298
+ try {
299
+ signal?.throwIfAborted();
300
+ return await Promise.race([work(client, discard), aborted]);
301
+ }
302
+ finally {
303
+ signal?.removeEventListener('abort', onAbort);
304
+ if (!destroyed)
305
+ client.release();
306
+ }
307
+ }
308
+ async queryWithSignal(query, values, signal) {
309
+ const run = async (client) => {
310
+ return values === undefined ? await client.query(query) : await client.query(query, values);
311
+ };
312
+ if (signal === undefined)
313
+ return await run(this.pool);
314
+ return await this.withSignalClient(signal, run);
315
+ }
316
+ async listTables(signal) {
317
+ const result = await this.queryWithSignal("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name", undefined, signal);
177
318
  return result.rows.map((row) => String(row.table_name));
178
319
  }
179
- async describeTable(table) {
320
+ async describeTable(table, signal) {
180
321
  const name = assertIdentifier(table, '表名');
181
- const result = await this.pool.query(`SELECT c.column_name, c.data_type, c.is_nullable,
322
+ const result = await this.queryWithSignal(`SELECT c.column_name, c.data_type, c.is_nullable,
182
323
  EXISTS (
183
324
  SELECT 1
184
325
  FROM information_schema.table_constraints tc
@@ -193,7 +334,7 @@ class PostgresAdapter {
193
334
  FROM information_schema.columns c
194
335
  WHERE c.table_schema = 'public'
195
336
  AND c.table_name = $1
196
- ORDER BY c.ordinal_position`, [name]);
337
+ ORDER BY c.ordinal_position`, [name], signal);
197
338
  return result.rows.map((row) => ({
198
339
  name: String(row.column_name),
199
340
  type: String(row.data_type ?? ''),
@@ -201,22 +342,22 @@ class PostgresAdapter {
201
342
  primaryKey: row.is_primary === true,
202
343
  }));
203
344
  }
204
- async query(sql, limit) {
345
+ async query(sql, limit, signal) {
205
346
  if (limit === undefined || limit <= 0) {
206
- const result = await this.pool.query(sql);
347
+ const result = await this.queryWithSignal(sql, undefined, signal);
207
348
  const rows = result.rows;
208
349
  return rowsToColumns(rows);
209
350
  }
210
- const result = await this.pool.query({ text: sql, values: [], rows: limit });
211
- const rows = result.rows;
212
- return rowsToColumns(rows);
351
+ return await this.withSignalClient(signal, async (client, discard) => {
352
+ return await streamPostgresQuery(client, sql, limit, discard);
353
+ });
213
354
  }
214
- async exec(sql) {
215
- const result = await this.pool.query(sql);
355
+ async exec(sql, signal) {
356
+ const result = await this.queryWithSignal(sql, undefined, signal);
216
357
  return Number(result.rowCount ?? 0);
217
358
  }
218
- async ping() {
219
- await this.pool.query('SELECT 1');
359
+ async ping(signal) {
360
+ await this.queryWithSignal('SELECT 1', undefined, signal);
220
361
  }
221
362
  async close() {
222
363
  await this.pool.end();
package/lib/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * dsh-sql —— 工程师级数据库工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):注册四个面向模型的工具(sql_list / sql_query /
5
- * sql_exec / sql_schema),支持 SQLite / MySQL / PostgreSQL 三引擎与多连接。
4
+ * 插件导出 apply(ctx, config):注册六个面向模型的工具(sql_list / sql_query /
5
+ * sql_exec / sql_schema / sql_stats / sql_health),支持 SQLite / MySQL / PostgreSQL
6
+ * 三引擎与多连接。
6
7
  * sql_exec 默认走宿主审批门(对齐 dsh-email 的发信审批),readOnly 模式可整体禁用写。
7
8
  *
8
9
  * @module dsh-sql
@@ -22,7 +23,7 @@ type SqlPreToolDecision = {
22
23
  kind: 'ask';
23
24
  reason?: string;
24
25
  };
25
- /** 审批策略需要读取的 alpha.4 工具执行字段。 */
26
+ /** 审批策略需要读取的 alpha.5 工具执行字段。 */
26
27
  interface SqlToolExecution {
27
28
  readonly name: string;
28
29
  readonly arguments: unknown;
@@ -38,7 +39,7 @@ export interface SqlPluginContext {
38
39
  on(event: 'dispose', listener: () => void): () => void;
39
40
  }
40
41
  /**
41
- * 插件入口:解析配置、构建四工具、给 sql_exec 注入审批门。
42
+ * 插件入口:解析配置、构建六工具、给 sql_exec 注入审批门。
42
43
  */
43
44
  export declare function apply(ctx: SqlPluginContext, config?: SqlConfig | null): void;
44
45
  export * from './adapters.js';
package/lib/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * dsh-sql —— 工程师级数据库工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):注册四个面向模型的工具(sql_list / sql_query /
5
- * sql_exec / sql_schema),支持 SQLite / MySQL / PostgreSQL 三引擎与多连接。
4
+ * 插件导出 apply(ctx, config):注册六个面向模型的工具(sql_list / sql_query /
5
+ * sql_exec / sql_schema / sql_stats / sql_health),支持 SQLite / MySQL / PostgreSQL
6
+ * 三引擎与多连接。
6
7
  * sql_exec 默认走宿主审批门(对齐 dsh-email 的发信审批),readOnly 模式可整体禁用写。
7
8
  *
8
9
  * @module dsh-sql
@@ -13,17 +14,10 @@ import { buildSqlTools } from './tools.js';
13
14
  export const name = 'sql';
14
15
  export const inject = ['tools'];
15
16
  /**
16
- * 插件入口:解析配置、构建四工具、给 sql_exec 注入审批门。
17
+ * 插件入口:解析配置、构建六工具、给 sql_exec 注入审批门。
17
18
  */
18
19
  export function apply(ctx, config) {
19
- let cfg;
20
- try {
21
- cfg = resolveConfig(config);
22
- }
23
- catch (error) {
24
- console.warn('[dsh-sql] ' + (error instanceof Error ? error.message : String(error)));
25
- cfg = resolveConfig(null);
26
- }
20
+ const cfg = resolveConfig(config);
27
21
  const { tools, adapters } = buildSqlTools(cfg);
28
22
  if (cfg.writeApproval) {
29
23
  ctx.on('tools/pre-execute', async (exec, next) => {
package/lib/tools.d.ts CHANGED
@@ -37,7 +37,7 @@ export interface SqlExecGateContext {
37
37
  callId?: unknown;
38
38
  signal?: unknown;
39
39
  }
40
- /** 构建四个工具定义;adapters 惰性创建并按连接名缓存。 */
40
+ /** 构建六个工具定义;adapters 惰性创建并按连接名缓存。 */
41
41
  export declare function buildSqlTools(config: ResolvedSqlConfig): {
42
42
  tools: SqlToolDefinition[];
43
43
  adapters: Map<string, DatabaseAdapter>;
package/lib/tools.js CHANGED
@@ -32,6 +32,12 @@ function requiredString(args, key, label) {
32
32
  throw new Error(label + '(参数 ' + key + ')为必填,请提供非空字符串。');
33
33
  return value;
34
34
  }
35
+ function executionSignal(exec) {
36
+ if (typeof exec !== 'object' || exec === null)
37
+ return undefined;
38
+ const signal = exec.signal;
39
+ return signal instanceof AbortSignal ? signal : undefined;
40
+ }
35
41
  /** 只读语句关键字白名单。 */
36
42
  const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
37
43
  /** 去掉字符串、引号标识符与注释,保留真实 SQL 关键字与分号。 */
@@ -125,7 +131,7 @@ function stripSqlNoise(sql) {
125
131
  return out;
126
132
  }
127
133
  /** 写操作关键字:出在 SELECT/EXPLAIN/WITH 语句里即拒绝。 */
128
- const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/gi;
134
+ const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/i;
129
135
  /** 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句。 */
130
136
  export function assertReadQuery(sql) {
131
137
  const trimmed = sql.trim();
@@ -241,17 +247,17 @@ function formatBytes(bytes) {
241
247
  return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
242
248
  }
243
249
  /** 库体积:SQLite 用页数×页大小;MySQL/PostgreSQL 走系统函数;失败抛错由调用方兜底。 */
244
- async function databaseSize(adapter, engine) {
250
+ async function databaseSize(adapter, engine, signal) {
245
251
  if (engine === 'sqlite') {
246
- const pageCount = await adapter.query('PRAGMA page_count', 1);
247
- const pageSize = await adapter.query('PRAGMA page_size', 1);
252
+ const pageCount = await adapter.query('PRAGMA page_count', 1, signal);
253
+ const pageSize = await adapter.query('PRAGMA page_size', 1, signal);
248
254
  return Number(pageCount.rows[0]?.[0] ?? 0) * Number(pageSize.rows[0]?.[0] ?? 0);
249
255
  }
250
256
  if (engine === 'mysql') {
251
- const result = await adapter.query('SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE()', 1);
257
+ const result = await adapter.query('SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE()', 1, signal);
252
258
  return Number(result.rows[0]?.[0] ?? -1);
253
259
  }
254
- const result = await adapter.query('SELECT pg_database_size(current_database())', 1);
260
+ const result = await adapter.query('SELECT pg_database_size(current_database())', 1, signal);
255
261
  return Number(result.rows[0]?.[0] ?? -1);
256
262
  }
257
263
  const statsSchema = {
@@ -279,7 +285,7 @@ const healthSchema = {
279
285
  },
280
286
  additionalProperties: true,
281
287
  };
282
- /** 构建四个工具定义;adapters 惰性创建并按连接名缓存。 */
288
+ /** 构建六个工具定义;adapters 惰性创建并按连接名缓存。 */
283
289
  export function buildSqlTools(config) {
284
290
  const cfg = config;
285
291
  const adapters = new Map();
@@ -296,7 +302,7 @@ export function buildSqlTools(config) {
296
302
  }
297
303
  return { adapter, name: connection.name };
298
304
  };
299
- const pingAllConnections = async () => {
305
+ const pingAllConnections = async (signal) => {
300
306
  const rows = [];
301
307
  for (const connection of cfg.connections) {
302
308
  const entry = { name: connection.name, engine: connection.engine };
@@ -308,11 +314,12 @@ export function buildSqlTools(config) {
308
314
  }
309
315
  try {
310
316
  const { adapter } = getAdapter(connection.name);
311
- await adapter.ping();
317
+ await adapter.ping(signal);
312
318
  entry.ok = true;
313
319
  entry.error = '';
314
320
  }
315
321
  catch (error) {
322
+ signal?.throwIfAborted();
316
323
  entry.ok = false;
317
324
  entry.error = error instanceof Error ? error.message : String(error);
318
325
  }
@@ -338,8 +345,8 @@ export function buildSqlTools(config) {
338
345
  return [{ type: 'text', text: lines.join('\n') }];
339
346
  },
340
347
  },
341
- async execute() {
342
- return { connections: await pingAllConnections() };
348
+ async execute(_rawArgs, exec) {
349
+ return { connections: await pingAllConnections(executionSignal(exec)) };
343
350
  },
344
351
  timeoutMs: 30000,
345
352
  };
@@ -369,11 +376,11 @@ export function buildSqlTools(config) {
369
376
  return [{ type: 'text', text: lines.join('\n') }];
370
377
  },
371
378
  },
372
- async execute(rawArgs) {
379
+ async execute(rawArgs, exec) {
373
380
  const args = asRecord(rawArgs);
374
381
  const sql = assertReadQuery(requiredString(args, 'sql', 'SQL 语句'));
375
382
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
376
- const result = await adapter.query(sql, cfg.maxRows + 1);
383
+ const result = await adapter.query(sql, cfg.maxRows + 1, executionSignal(exec));
377
384
  const total = result.rows.length;
378
385
  const rows = result.rows.slice(0, cfg.maxRows);
379
386
  const format = optionalString(args, 'format')?.toLowerCase() ?? 'table';
@@ -409,13 +416,13 @@ export function buildSqlTools(config) {
409
416
  return [{ type: 'text', text: '执行完成(' + rec.connection + '):影响 ' + rec.changes + ' 行。' }];
410
417
  },
411
418
  },
412
- async execute(rawArgs) {
419
+ async execute(rawArgs, exec) {
413
420
  if (cfg.readOnly)
414
421
  throw new Error('当前配置 readOnly=true,sql_exec 已被禁用。需要写操作请把插件配置里的 readOnly 改为 false 后重启。');
415
422
  const args = asRecord(rawArgs);
416
423
  const sql = requiredString(args, 'sql', 'SQL 语句');
417
424
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
418
- const changes = await adapter.exec(sql);
425
+ const changes = await adapter.exec(sql, executionSignal(exec));
419
426
  return { connection: name, changes, readOnly: false };
420
427
  },
421
428
  timeoutMs: cfg.execTimeoutMs,
@@ -444,15 +451,16 @@ export function buildSqlTools(config) {
444
451
  return [{ type: 'text', text: '共 ' + tables.length + ' 张表:' + tables.join(', ') }];
445
452
  },
446
453
  },
447
- async execute(rawArgs) {
454
+ async execute(rawArgs, exec) {
448
455
  const args = asRecord(rawArgs);
449
456
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
450
457
  const table = optionalString(args, 'table');
458
+ const signal = executionSignal(exec);
451
459
  if (table !== undefined) {
452
- const columns = await adapter.describeTable(table);
460
+ const columns = await adapter.describeTable(table, signal);
453
461
  return { connection: name, table, columns, tables: [] };
454
462
  }
455
- const tables = await adapter.listTables();
463
+ const tables = await adapter.listTables(signal);
456
464
  return { connection: name, tables, columns: [] };
457
465
  },
458
466
  timeoutMs: 30000,
@@ -478,15 +486,17 @@ export function buildSqlTools(config) {
478
486
  return [{ type: 'text', text: lines.join('\n') }];
479
487
  },
480
488
  },
481
- async execute(rawArgs) {
489
+ async execute(rawArgs, exec) {
482
490
  const args = asRecord(rawArgs);
483
491
  const { adapter, name } = getAdapter(optionalString(args, 'connection'));
484
492
  const engine = adapter.engine;
493
+ const signal = executionSignal(exec);
485
494
  let sizeBytes = -1;
486
495
  try {
487
- sizeBytes = await databaseSize(adapter, engine);
496
+ sizeBytes = await databaseSize(adapter, engine, signal);
488
497
  }
489
498
  catch {
499
+ signal?.throwIfAborted();
490
500
  sizeBytes = -1;
491
501
  }
492
502
  const tables = [];
@@ -495,23 +505,25 @@ export function buildSqlTools(config) {
495
505
  const sql = engine === 'mysql'
496
506
  ? 'SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE()'
497
507
  : 'SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname';
498
- const result = await adapter.query(sql);
508
+ const result = await adapter.query(sql, undefined, signal);
499
509
  for (const row of result.rows) {
500
510
  tables.push({ name: String(row[0]), rowCount: typeof row[1] === 'number' ? row[1] : Number(row[1] ?? 0) });
501
511
  }
502
512
  }
503
513
  catch (error) {
514
+ signal?.throwIfAborted();
504
515
  tables.push({ name: '', error: error instanceof Error ? error.message : String(error) });
505
516
  }
506
517
  }
507
518
  else {
508
- const names = await adapter.listTables();
519
+ const names = await adapter.listTables(signal);
509
520
  for (const table of names) {
510
521
  try {
511
- const result = await adapter.query('SELECT COUNT(*) FROM ' + quoteIdent(engine, table), 1);
522
+ const result = await adapter.query('SELECT COUNT(*) FROM ' + quoteIdent(engine, table), 1, signal);
512
523
  tables.push({ name: table, rowCount: Number(result.rows[0]?.[0] ?? 0) });
513
524
  }
514
525
  catch (error) {
526
+ signal?.throwIfAborted();
515
527
  tables.push({ name: table, error: error instanceof Error ? error.message : String(error) });
516
528
  }
517
529
  }
@@ -541,8 +553,8 @@ export function buildSqlTools(config) {
541
553
  return [{ type: 'text', text: lines.join('\n') }];
542
554
  },
543
555
  },
544
- async execute() {
545
- const connections = await pingAllConnections();
556
+ async execute(_rawArgs, exec) {
557
+ const connections = await pingAllConnections(executionSignal(exec));
546
558
  const bad = connections.filter((c) => c.ok !== true);
547
559
  return {
548
560
  ok: bad.length === 0,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sql",
3
- "version": "0.4.1",
4
- "description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、词法级只读保护、流式行数钳制与可配置查询/写超时。",
3
+ "version": "0.4.2",
4
+ "description": "DSH 工程师级数据库插件:sql_list/query/exec/schema/stats/health 六工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、词法级只读保护、流式行数钳制与可配置查询/写超时。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",