dsh-sql 0.5.3 → 0.5.4

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
@@ -2,6 +2,13 @@
2
2
 
3
3
  [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
4
 
5
+ ## 0.5.4 (2026-10-28)
6
+
7
+ - 修复 PostgreSQL 查询能修改会话默认设置,再通过写函数绕过只读保护的问题。每次查询强制使用独立只读事务并回滚;允许审批写入时查询工具仍保持只读。
8
+ - PostgreSQL 查询使用扩展协议,服务器拒绝同一请求中的多条命令。MySQL 同步等待只读事务建立,设置失败时拒绝查询;不再忽略连接初始化错误。
9
+ - 查询成功或失败后回滚;取消或达到行数上限时销毁连接;回滚失败的连接不放回池中。保留行数限制、同名列和 64 位整数处理。
10
+ - 增加真实 PostgreSQL 可选回归,覆盖设置修改、写函数、多语句、截流、取消与合法审批写入;默认测试不启动或下载数据库。
11
+
5
12
  ## 0.5.3 (2026-10-27)
6
13
 
7
14
  - 修复分批更新时的新旧更新控件混用:保留原页面、草稿与安装保护,空闲后接管旧控制器,兼容旧入口。
package/README.en.md CHANGED
@@ -13,7 +13,7 @@ Feedback and contributions are welcome: report [issues](https://github.com/STARD
13
13
  ## What it does
14
14
 
15
15
  - Manage connections and inspect schemas or database summaries.
16
- - Limit rows and timeouts for read-only queries.
16
+ - Limit rows and timeouts for read-only queries; MySQL / PostgreSQL enforce a separate read-only transaction for each query.
17
17
  - Keep writes under host approval and check connections.
18
18
 
19
19
  ## Install
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  ## 功能
14
14
 
15
15
  - 管理连接,查询结构和数据概览。
16
- - 只读查询带行数与超时限制。
16
+ - 只读查询带行数与超时限制;MySQL / PostgreSQL 每次查询由数据库强制开启只读事务。
17
17
  - 写操作通过宿主审批,支持连接自检。
18
18
 
19
19
  ## 安装
package/docs/USAGE.md CHANGED
@@ -77,7 +77,8 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
77
77
 
78
78
  - **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、多语句,以及不在只读白名单里的 PRAGMA。去噪按引擎处理反斜杠(MySQL 默认转义、PostgreSQL 只有 `E'…'`/`U&'…'` 转义、SQLite 与引号标识符里都不转义),避免「校验看到一条、服务器执行两条」
79
79
  - **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
80
- - **readOnly 模式**:生产库可整体禁用写。除了禁用 sql_exec,还会把只读约束下沉到数据库自身:SQLite 以只读方式打开文件,PostgreSQL 用 `-c default_transaction_read_only=on` 启动参数,MySQL 在每个新连接上执行 `SET SESSION TRANSACTION READ ONLY`(需要 MySQL 5.6.5+ / MariaDB 10.0+)。即使词法校验被绕过,服务器也会拒绝写入
80
+ - **查询事务**:MySQL / PostgreSQL 的 `sql_query` 每次在独立只读事务中运行,完成或报错后回滚,达到行数上限或取消时销毁专用连接。PostgreSQL 查询采用扩展协议,服务器拒绝同一请求内的多条命令;`set_config` 对默认设置的修改随回滚撤销。MySQL 必须成功执行 `START TRANSACTION READ ONLY`,不支持或设置失败时拒绝运行查询。即使允许审批写入,这些查询约束仍然生效。
81
+ - **readOnly 模式**:生产库可整体禁用 `sql_exec`。SQLite 文件以只读方式打开;PostgreSQL 还保留只读启动默认值。建议使用仅授予所需读取权限的数据库账号;事务只读不等同于数据库账号、文件系统或外部函数的权限隔离。
81
82
  - **审批卡不藏语句**:sql_exec 的审批文本逐条列出每条语句,并给出一行语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,第二条 DROP / DELETE 不会因为截断而从审批卡里消失
82
83
  - **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
83
84
  - **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
@@ -2,6 +2,14 @@
2
2
 
3
3
  本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
4
 
5
+ ## 0.5.4(2026-10-28)
6
+
7
+ - Windows / Node 24.16.0:71 项检查通过,包含真实 PostgreSQL 17.10 的可选集成回归。测试数据库、表、函数与账号均为隔离夹具。
8
+ - 已在 0.5.3 复现:查询修改 `default_transaction_read_only` 后,后续 SELECT 写函数能落盘。0.5.4 在同一真实数据库拒绝该写入,查询始终处于只读事务。
9
+ - 真实 PostgreSQL 覆盖 readOnly 开关两种状态、有界和无界查询、修改当前事务设置、多命令拒绝、写函数拒绝、错误后恢复、行数截流、取消以及合法 exec 写入。MySQL 只验证真实 mysql2 流事件与连接生命周期夹具,未连接真实 MySQL 服务。
10
+ - 官方 DSH 0.2.1-rc.3 原生网页版:工具入口拒绝原 Issue #3 的多语句和 PRAGMA 写入;浏览器审批卡显示 200 字符后面的 DROP,点击拒绝后真实 SQLite 数据保持不变。浏览器使用后台 Chrome,未验收 Electron 原生窗口。
11
+ - 运行可选 PostgreSQL 回归:在专用测试环境中把连接对象 JSON 写入环境变量 `DSH_SQL_TEST_POSTGRES`,再执行 `pnpm test`;未设置时明确跳过该集成项,不启动或下载数据库。
12
+
5
13
  ## 原中文记录
6
14
 
7
15
  验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。53 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 6 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
package/lib/adapters.d.ts CHANGED
@@ -30,9 +30,8 @@ export interface AdapterOptions {
30
30
  /** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
31
31
  export declare function mysqlPoolConfig(connection: SqlConnectionConfig): mysql.PoolOptions;
32
32
  /**
33
- * PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
34
- * 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
35
- * "cannot execute ... in a read-only transaction" 拒绝写操作。
33
+ * PostgreSQL 连接池配置。启动参数仅作为默认值;用户查询还必须使用
34
+ * 独立只读事务,不能依赖可被 set_config 修改的会话默认设置。
36
35
  */
37
36
  export declare function postgresPoolConfig(connection: SqlConnectionConfig, readOnly?: boolean): pg.PoolConfig;
38
37
  /** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
package/lib/adapters.js CHANGED
@@ -83,7 +83,8 @@ function streamPostgresQuery(client, sql, limit, discard) {
83
83
  let settled = false;
84
84
  let columns = [];
85
85
  const rows = [];
86
- const queryConfig = { text: sql, rowMode: 'array' };
86
+ // The extended protocol rejects multiple commands in a single Parse message.
87
+ const queryConfig = { text: sql, rowMode: 'array', queryMode: 'extended' };
87
88
  const query = new pg.Query(queryConfig);
88
89
  query.on('row', (row, result) => {
89
90
  if (settled)
@@ -134,9 +135,8 @@ export function mysqlPoolConfig(connection) {
134
135
  };
135
136
  }
136
137
  /**
137
- * PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
138
- * 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
139
- * "cannot execute ... in a read-only transaction" 拒绝写操作。
138
+ * PostgreSQL 连接池配置。启动参数仅作为默认值;用户查询还必须使用
139
+ * 独立只读事务,不能依赖可被 set_config 修改的会话默认设置。
140
140
  */
141
141
  export function postgresPoolConfig(connection, readOnly = false) {
142
142
  const config = {
@@ -230,17 +230,12 @@ class SqliteAdapter {
230
230
  }
231
231
  /** MySQL 适配器(mysql2 连接池)。 */
232
232
  class MysqlAdapter {
233
+ readOnly;
233
234
  engine = 'mysql';
234
235
  pool;
235
236
  constructor(connection, readOnly = false) {
237
+ this.readOnly = readOnly;
236
238
  this.pool = mysql.createPool(mysqlPoolConfig(connection));
237
- if (readOnly) {
238
- // 会话级只读:MySQL 5.6.5+ 的 SET SESSION TRANSACTION READ ONLY 会让
239
- // 该连接上后续任何 DML/DDL 被服务器拒绝,不依赖词法校验。
240
- this.pool.on('connection', (session) => {
241
- void session.query('SET SESSION TRANSACTION READ ONLY').catch(() => { });
242
- });
243
- }
244
239
  }
245
240
  async withSignalConnection(signal, work) {
246
241
  signal?.throwIfAborted();
@@ -261,7 +256,7 @@ class MysqlAdapter {
261
256
  signal?.addEventListener('abort', onAbort, { once: true });
262
257
  try {
263
258
  signal?.throwIfAborted();
264
- return await Promise.race([work(connection, discard), aborted]);
259
+ return await Promise.race([work(connection, discard, () => destroyed), aborted]);
265
260
  }
266
261
  finally {
267
262
  signal?.removeEventListener('abort', onAbort);
@@ -269,6 +264,26 @@ class MysqlAdapter {
269
264
  connection.release();
270
265
  }
271
266
  }
267
+ async withReadOnlyConnection(signal, work) {
268
+ return await this.withSignalConnection(signal, async (connection, discard, isDiscarded) => {
269
+ try {
270
+ // Await setup and fail closed; never silently ignore an unsupported mode.
271
+ await connection.query('START TRANSACTION READ ONLY');
272
+ return await work(connection, discard);
273
+ }
274
+ finally {
275
+ if (!isDiscarded()) {
276
+ try {
277
+ await connection.query('ROLLBACK');
278
+ }
279
+ catch (error) {
280
+ discard();
281
+ throw error;
282
+ }
283
+ }
284
+ }
285
+ });
286
+ }
272
287
  async queryRows(sql, signal) {
273
288
  if (signal === undefined)
274
289
  return typeof sql === 'string' ? await this.pool.query(sql) : await this.pool.query(sql);
@@ -289,18 +304,20 @@ class MysqlAdapter {
289
304
  }));
290
305
  }
291
306
  async query(sql, limit, signal) {
292
- if (limit === undefined || limit <= 0) {
293
- const [rows, fields] = await this.queryRows({ sql, rowsAsArray: true }, signal);
294
- if (fields?.length)
295
- return { columns: fields.map(field => field.name), rows: rows.map(row => Array.isArray(row) ? row.map(toValue) : fields.map(field => toValue(row[field.name]))) };
296
- return rowsToColumns(rows);
297
- }
298
- return await this.withSignalConnection(signal, async (connection, discard) => {
307
+ return await this.withReadOnlyConnection(signal, async (connection, discard) => {
308
+ if (limit === undefined || limit <= 0) {
309
+ const [rows, fields] = await connection.query({ sql, rowsAsArray: true });
310
+ if (fields?.length)
311
+ return { columns: fields.map(field => field.name), rows: rows.map(row => Array.isArray(row) ? row.map(toValue) : fields.map(field => toValue(row[field.name]))) };
312
+ return rowsToColumns(rows);
313
+ }
299
314
  const coreConnection = connection.connection;
300
315
  return await streamMysqlQuery(coreConnection, sql, limit, discard);
301
316
  });
302
317
  }
303
318
  async exec(sql, signal) {
319
+ if (this.readOnly)
320
+ throw new Error('当前配置 readOnly=true,禁止写入数据库。');
304
321
  const [result] = await this.queryRows(sql, signal);
305
322
  return Number(result?.affectedRows ?? 0);
306
323
  }
@@ -313,9 +330,11 @@ class MysqlAdapter {
313
330
  }
314
331
  /** PostgreSQL 适配器(pg 连接池)。 */
315
332
  class PostgresAdapter {
333
+ readOnly;
316
334
  engine = 'postgres';
317
335
  pool;
318
336
  constructor(connection, readOnly = false) {
337
+ this.readOnly = readOnly;
319
338
  this.pool = new pg.Pool(postgresPoolConfig(connection, readOnly));
320
339
  }
321
340
  async withSignalClient(signal, work) {
@@ -337,7 +356,7 @@ class PostgresAdapter {
337
356
  signal?.addEventListener('abort', onAbort, { once: true });
338
357
  try {
339
358
  signal?.throwIfAborted();
340
- return await Promise.race([work(client, discard), aborted]);
359
+ return await Promise.race([work(client, discard, () => destroyed), aborted]);
341
360
  }
342
361
  finally {
343
362
  signal?.removeEventListener('abort', onAbort);
@@ -345,6 +364,27 @@ class PostgresAdapter {
345
364
  client.release();
346
365
  }
347
366
  }
367
+ async withReadOnlyClient(signal, work) {
368
+ return await this.withSignalClient(signal, async (client, discard, isDiscarded) => {
369
+ try {
370
+ await client.query('BEGIN READ ONLY');
371
+ return await work(client, discard);
372
+ }
373
+ finally {
374
+ // Rollback also reverts set_config(..., false). A capped/cancelled query
375
+ // destroys its connection instead, causing server-side rollback.
376
+ if (!isDiscarded()) {
377
+ try {
378
+ await client.query('ROLLBACK');
379
+ }
380
+ catch (error) {
381
+ discard();
382
+ throw error;
383
+ }
384
+ }
385
+ }
386
+ });
387
+ }
348
388
  async queryWithSignal(query, values, signal) {
349
389
  const run = async (client) => {
350
390
  return values === undefined ? await client.query(query) : await client.query(query, values);
@@ -383,18 +423,18 @@ class PostgresAdapter {
383
423
  }));
384
424
  }
385
425
  async query(sql, limit, signal) {
386
- if (limit === undefined || limit <= 0) {
387
- const result = await this.queryWithSignal({ text: sql, rowMode: 'array' }, undefined, signal);
388
- if (result.fields?.length)
426
+ return await this.withReadOnlyClient(signal, async (client, discard) => {
427
+ if (limit === undefined || limit <= 0) {
428
+ const queryConfig = { text: sql, rowMode: 'array', queryMode: 'extended' };
429
+ const result = await client.query(queryConfig);
389
430
  return { columns: result.fields.map((field) => field.name), rows: result.rows.map((row) => row.map(toValue)) };
390
- const rows = result.rows;
391
- return rowsToColumns(rows);
392
- }
393
- return await this.withSignalClient(signal, async (client, discard) => {
431
+ }
394
432
  return await streamPostgresQuery(client, sql, limit, discard);
395
433
  });
396
434
  }
397
435
  async exec(sql, signal) {
436
+ if (this.readOnly)
437
+ throw new Error('当前配置 readOnly=true,禁止写入数据库。');
398
438
  const result = await this.queryWithSignal(sql, undefined, signal);
399
439
  return Number(result.rowCount ?? 0);
400
440
  }
package/lib/tools.js CHANGED
@@ -473,7 +473,7 @@ export function buildSqlTools(config) {
473
473
  };
474
474
  const sqlQuery = {
475
475
  name: 'sql_query',
476
- description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA 与多语句;readOnly=true 时还会让数据库自身以只读方式连接。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
476
+ description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA 与多语句。MySQL / PostgreSQL 每次查询使用独立只读事务并回滚,不受 readOnly 开关影响;PostgreSQL 还以扩展协议拒绝多命令。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
477
477
  parameters: compileParameters({
478
478
  sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
479
479
  connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
@@ -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.3","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.4","label":"数据库","labelEn":"SQL","suiteComponent":true}));
441
441
 
442
442
  /* STARDUSTLC_UPDATE_CLIENT_BODY_END */
443
443
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sql",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "连接 SQLite、MySQL 或 PostgreSQL,查询数据并检查数据库结构。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "mysql2": "^3.11.0",
62
- "pg": "^8.13.0",
62
+ "pg": "^8.23.0",
63
63
  "@stardustlc/dsh-updates": "0.1.3"
64
64
  },
65
65
  "devDependencies": {