dsh-api-runner 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.js ADDED
@@ -0,0 +1,958 @@
1
+ /**
2
+ * 数据库连接管理与执行层。
3
+ *
4
+ * 设计取舍(与 dsh-db-tool 的关键差别):
5
+ * - **不依赖任何 DSH 服务**(不 inject betterSidebar 之类)。原 dsh-db-tool 把
6
+ * betterSidebar 当硬依赖,服务没就绪就一直 pending,拖垮整个 web 启动
7
+ * (`web boot: 1 entry did not activate`)。这里只依赖 Node 内置能力。
8
+ * - **SQLite 用 Node 内置的 node:sqlite**(Node >= 22.5 自带),不装
9
+ * better-sqlite3 这类原生模块,避免 NODE_MODULE_VERSION 不匹配的坑。
10
+ * - 其它驱动(mysql2 / pg / oracledb)**按需动态 import**:没装对应包时
11
+ * 只影响那一种类型的连接,不会让整个插件加载失败。
12
+ *
13
+ * 安全约定:
14
+ * - 密码落盘前加密,任何对外返回都不含明文(maskSecret 同款思路)。
15
+ * - 只允许单条语句,防多语句注入。
16
+ * - 只读模式默认开启,拒绝写操作。
17
+ * - 结果集行数上限,防撑爆内存。
18
+ */
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { isPlainObject, maskSecret, readStateNamespace, writeStateNamespace } from './util.js';
24
+
25
+ /** 支持的数据库类型 */
26
+ export const DB_TYPES = ['sqlite', 'mysql', 'postgres', 'oracle', 'dm'];
27
+
28
+ /** 各类型的默认端口 */
29
+ const DEFAULT_PORTS = {
30
+ mysql: 3306,
31
+ postgres: 5432,
32
+ oracle: 1521,
33
+ dm: 5236,
34
+ };
35
+
36
+ /** 结果集默认上限 */
37
+ const DEFAULT_MAX_ROWS = 1000;
38
+ /** 查询默认超时 */
39
+ const DEFAULT_TIMEOUT_MS = 30_000;
40
+
41
+ /** 只读模式允许的语句前缀(忽略大小写与前置空白/注释) */
42
+ const READONLY_PREFIX = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*(select|with|show|desc|describe|explain|pragma|table)\b/i;
43
+
44
+ /**
45
+ * 去掉 SQL 末尾的分号,并判断是否只包含一条语句。
46
+ *
47
+ * 为什么不用简单的 `/;/` 判断:字符串字面量里合法地会有分号
48
+ * (如 `WHERE note = 'a;b'`),一律拒绝会误伤正常查询。
49
+ * 这里用「跳过字符串/注释」的扫描,只在语句层面找分隔符。
50
+ */
51
+ export function splitStatements(sql) {
52
+ const text = String(sql || '');
53
+ const out = [];
54
+ let current = '';
55
+ let i = 0;
56
+ let quote = '';
57
+ while (i < text.length) {
58
+ const ch = text[i];
59
+ const next = text[i + 1];
60
+ if (quote) {
61
+ current += ch;
62
+ if (ch === '\\' && (quote === "'" || quote === '"')) {
63
+ current += next ?? '';
64
+ i += 2;
65
+ continue;
66
+ }
67
+ if (ch === quote) {
68
+ // SQL 里的 '' 转义:连续两个引号不结束字符串
69
+ if (next === quote) {
70
+ current += next;
71
+ i += 2;
72
+ continue;
73
+ }
74
+ quote = '';
75
+ }
76
+ i += 1;
77
+ continue;
78
+ }
79
+ if (ch === "'" || ch === '"' || ch === '`') {
80
+ quote = ch;
81
+ current += ch;
82
+ i += 1;
83
+ continue;
84
+ }
85
+ if (ch === '-' && next === '-') {
86
+ // 行注释:跳到行尾
87
+ const nl = text.indexOf('\n', i);
88
+ const end = nl === -1 ? text.length : nl + 1;
89
+ current += text.slice(i, end);
90
+ i = end;
91
+ continue;
92
+ }
93
+ if (ch === '/' && next === '*') {
94
+ const close = text.indexOf('*/', i + 2);
95
+ const end = close === -1 ? text.length : close + 2;
96
+ current += text.slice(i, end);
97
+ i = end;
98
+ continue;
99
+ }
100
+ if (ch === ';') {
101
+ out.push(current);
102
+ current = '';
103
+ i += 1;
104
+ continue;
105
+ }
106
+ current += ch;
107
+ i += 1;
108
+ }
109
+ out.push(current);
110
+ return out.map((item) => item.trim()).filter((item) => item !== '');
111
+ }
112
+
113
+ /** 是否只读语句 */
114
+ export function isReadOnlySql(sql) {
115
+ return READONLY_PREFIX.test(String(sql || ''));
116
+ }
117
+
118
+ /**
119
+ * 机器特征派生密钥。
120
+ *
121
+ * 目的不是抗住有备而来的攻击者(本地文件能读就能解),而是
122
+ * **避免密码以明文出现在备份、同步盘、误提交里**。够用即可。
123
+ */
124
+ function machineKey() {
125
+ const parts = [
126
+ os.hostname(),
127
+ os.userInfo().username,
128
+ process.platform,
129
+ process.env.USERPROFILE || process.env.HOME || '',
130
+ ];
131
+ return crypto.createHash('sha256').update(parts.join('|')).digest();
132
+ }
133
+
134
+ /** 加密密码;空密码原样返回空串 */
135
+ export function encryptSecret(plain) {
136
+ const text = String(plain ?? '');
137
+ if (text === '') return '';
138
+ const iv = crypto.randomBytes(12);
139
+ const cipher = crypto.createCipheriv('aes-256-gcm', machineKey(), iv);
140
+ const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
141
+ const tag = cipher.getAuthTag();
142
+ return `v1:${Buffer.concat([iv, tag, enc]).toString('base64')}`;
143
+ }
144
+
145
+ /** 解密密码;解不开返回空串(换机器/文件损坏时按空密码处理,不让整个插件崩) */
146
+ export function decryptSecret(stored) {
147
+ const text = String(stored ?? '');
148
+ if (text === '') return '';
149
+ if (!text.startsWith('v1:')) return text; // 兼容早期明文
150
+ try {
151
+ const raw = Buffer.from(text.slice(3), 'base64');
152
+ const iv = raw.subarray(0, 12);
153
+ const tag = raw.subarray(12, 28);
154
+ const data = raw.subarray(28);
155
+ const decipher = crypto.createDecipheriv('aes-256-gcm', machineKey(), iv);
156
+ decipher.setAuthTag(tag);
157
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
158
+ } catch {
159
+ return '';
160
+ }
161
+ }
162
+
163
+ /** 生成连接 id */
164
+ function makeId(name) {
165
+ const base = String(name || 'db')
166
+ .toLowerCase()
167
+ .replace(/[^\w-]+/g, '-')
168
+ .replace(/^-+|-+$/g, '')
169
+ .slice(0, 40) || 'db';
170
+ return `${base}-${crypto.randomBytes(3).toString('hex')}`;
171
+ }
172
+
173
+ export class DbManager {
174
+ /**
175
+ * @param {{homeDir?:string, settingsStore?:object, dataDir?:string, maxRows?:number, timeoutMs?:number}} options
176
+ */
177
+ constructor(options = {}) {
178
+ this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
179
+ this.settingsStore = options.settingsStore || null;
180
+ this.maxRows = Number(options.maxRows) || DEFAULT_MAX_ROWS;
181
+ this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
182
+ /** 连接池缓存:connectionId -> driver handle */
183
+ this.pools = new Map();
184
+ this._setDir(options.dataDir || this._preferredDataDir());
185
+ this._installDmGuard();
186
+ }
187
+
188
+ /**
189
+ * 拦掉 dmdb 已知的未捕获异步异常。
190
+ *
191
+ * dmdb 在连接失败后的池清理里会抛
192
+ * `TypeError: Cannot read properties of undefined (reading 'log')`
193
+ * (pool.js:263),且是未捕获的 Promise rejection —— 不拦就会把 DSH 主进程带崩。
194
+ *
195
+ * 只放行**这个精确签名**(message + 栈里含 dmdb/pool.js),
196
+ * 其它未捕获异常照常抛给宿主,避免把真正的问题藏起来。
197
+ */
198
+ _installDmGuard() {
199
+ if (DbManager._dmGuardInstalled) return;
200
+ DbManager._dmGuardInstalled = true;
201
+ const onUnhandled = (reason) => {
202
+ const message = String(reason?.message || reason || '');
203
+ const stack = String(reason?.stack || '');
204
+ const isDmPoolBug = message.includes("reading 'log'") && stack.includes('dmdb');
205
+ if (isDmPoolBug) {
206
+ // 已知问题:连接失败后的清理异常,吞掉即可(连接失败本身已经通过返回值报告)
207
+ return;
208
+ }
209
+ // 其它未捕获异常不归这里管,交回默认行为
210
+ process.emitWarning(`[api-runner] unhandled rejection: ${message}`);
211
+ };
212
+ process.on('unhandledRejection', onUnhandled);
213
+ }
214
+
215
+ _preferredDataDir() {
216
+ try {
217
+ return this.settingsStore ? this.settingsStore.dataDir() : this.homeDir;
218
+ } catch {
219
+ return this.homeDir;
220
+ }
221
+ }
222
+
223
+ /** 数据目录变了之后重新指向(老数据不迁移,只换读写位置) */
224
+ _setDir(dir) {
225
+ this.dataDir = dir || this.homeDir;
226
+ this.file = path.join(this.dataDir, 'db-connections.json');
227
+ }
228
+
229
+ /** 设置里改了数据目录后调用 */
230
+ refreshDataDir() {
231
+ const next = this._preferredDataDir();
232
+ if (next !== this.dataDir) {
233
+ this._setDir(next);
234
+ // 换了目录,旧池子的连接配置来源变了,直接清掉
235
+ this.dispose();
236
+ }
237
+ return this.dataDir;
238
+ }
239
+
240
+ // ---------------------------------------------------------------- 连接存储
241
+ /** 读原始记录(含加密后的密码),内部用 */
242
+ _loadRaw() {
243
+ const ns = readStateNamespace(this.file, 'connections');
244
+ return Array.isArray(ns.items) ? ns.items.filter(isPlainObject) : [];
245
+ }
246
+
247
+ _saveRaw(items) {
248
+ writeStateNamespace(this.file, 'connections', { items });
249
+ }
250
+
251
+ /** 脱敏后的连接视图:对外一律走这个 */
252
+ _view(item) {
253
+ return {
254
+ id: item.id,
255
+ name: item.name,
256
+ type: item.type,
257
+ host: item.host || '',
258
+ port: Number(item.port) || DEFAULT_PORTS[item.type] || 0,
259
+ database: item.database || '',
260
+ username: item.username || '',
261
+ /** 只给掩码,绝不回传明文 */
262
+ hasPassword: Boolean(item.password),
263
+ passwordMask: item.password ? maskSecret('********') : '',
264
+ extra: isPlainObject(item.extra) ? item.extra : {},
265
+ createdAt: item.createdAt || '',
266
+ updatedAt: item.updatedAt || '',
267
+ /** sqlite 特有:文件路径 */
268
+ file: item.type === 'sqlite' ? String(item.database || '') : undefined,
269
+ };
270
+ }
271
+
272
+ listConnections() {
273
+ return this._loadRaw().map((item) => this._view(item));
274
+ }
275
+
276
+ getConnection(id) {
277
+ const raw = this._loadRaw().find((item) => String(item.id) === String(id));
278
+ return raw ? this._view(raw) : undefined;
279
+ }
280
+
281
+ /** 内部取完整配置(含明文密码),只在执行层用,不对外 */
282
+ _fullConfig(id) {
283
+ const raw = this._loadRaw().find((item) => String(item.id) === String(id));
284
+ if (!raw) return undefined;
285
+ return { ...raw, password: decryptSecret(raw.password) };
286
+ }
287
+
288
+ /**
289
+ * 保存连接(新建或更新)。
290
+ * 更新时若 password 传空/未传,保留原密码(避免"编辑时看不到密码→保存后被清空")。
291
+ */
292
+ saveConnection(patch = {}) {
293
+ const input = isPlainObject(patch) ? patch : {};
294
+ const items = this._loadRaw();
295
+ const id = String(input.id || '').trim();
296
+ const existingIndex = id ? items.findIndex((item) => String(item.id) === id) : -1;
297
+ const existing = existingIndex >= 0 ? items[existingIndex] : null;
298
+
299
+ const type = String(input.type || existing?.type || 'sqlite').toLowerCase();
300
+ if (!DB_TYPES.includes(type)) {
301
+ return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${type}(支持 ${DB_TYPES.join('/')})` };
302
+ }
303
+ const name = String(input.name || existing?.name || '').trim();
304
+ if (name === '') return { ok: false, code: 'MISSING_NAME', message: '连接名称不能为空' };
305
+
306
+ // sqlite 用 database 字段存文件路径;其它类型需要 host
307
+ const database = String(input.database ?? existing?.database ?? '').trim();
308
+ if (type === 'sqlite' && database === '') {
309
+ return { ok: false, code: 'MISSING_DATABASE', message: 'SQLite 连接需要填写数据库文件路径(:memory: 表示内存库)' };
310
+ }
311
+ if (type !== 'sqlite' && String(input.host ?? existing?.host ?? '').trim() === '') {
312
+ return { ok: false, code: 'MISSING_HOST', message: `${type} 连接需要填写 host` };
313
+ }
314
+
315
+ // 密码:显式传了新值就加密存;传空或未传则沿用旧值
316
+ let password = existing?.password || '';
317
+ if (input.password !== undefined && input.password !== null && String(input.password) !== '') {
318
+ password = encryptSecret(String(input.password));
319
+ } else if (input.clearPassword === true) {
320
+ password = '';
321
+ }
322
+
323
+ const record = {
324
+ id: existing?.id || id || makeId(name),
325
+ name,
326
+ type,
327
+ host: String(input.host ?? existing?.host ?? '').trim(),
328
+ port: Number(input.port ?? existing?.port) || DEFAULT_PORTS[type] || 0,
329
+ database,
330
+ username: String(input.username ?? existing?.username ?? '').trim(),
331
+ password,
332
+ extra: isPlainObject(input.extra) ? input.extra : (existing?.extra || {}),
333
+ createdAt: existing?.createdAt || new Date().toISOString(),
334
+ updatedAt: new Date().toISOString(),
335
+ };
336
+
337
+ if (existingIndex >= 0) items[existingIndex] = record;
338
+ else items.push(record);
339
+ this._saveRaw(items);
340
+ // 配置变了,旧池子必须销毁,否则还在用老连接串
341
+ this._closePool(record.id);
342
+ return { ok: true, connection: this._view(record), message: `已保存连接 ${record.name}` };
343
+ }
344
+
345
+ deleteConnection(id) {
346
+ const key = String(id || '');
347
+ const items = this._loadRaw();
348
+ const next = items.filter((item) => String(item.id) !== key);
349
+ if (next.length === items.length) {
350
+ return { ok: false, code: 'NOT_FOUND', message: `没有连接 ${key}` };
351
+ }
352
+ this._saveRaw(next);
353
+ this._closePool(key);
354
+ return { ok: true, message: `已删除连接 ${key}` };
355
+ }
356
+
357
+ // ---------------------------------------------------------------- 驱动
358
+ /**
359
+ * 取得(或建立)某个连接的驱动句柄。
360
+ * 失败返回 { ok:false, code, message },不抛异常。
361
+ */
362
+ async _handle(id) {
363
+ const key = String(id || '');
364
+ const cached = this.pools.get(key);
365
+ if (cached) return { ok: true, handle: cached };
366
+
367
+ const config = this._fullConfig(key);
368
+ if (!config) return { ok: false, code: 'NOT_FOUND', message: `没有连接 ${key}` };
369
+
370
+ try {
371
+ let handle;
372
+ if (config.type === 'sqlite') handle = await this._openSqlite(config);
373
+ else if (config.type === 'mysql') handle = await this._openMysql(config);
374
+ else if (config.type === 'postgres') handle = await this._openPostgres(config);
375
+ else if (config.type === 'oracle') handle = await this._openOracle(config);
376
+ else if (config.type === 'dm') handle = await this._openDm(config);
377
+ else return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${config.type}` };
378
+ this.pools.set(key, handle);
379
+ return { ok: true, handle };
380
+ } catch (error) {
381
+ return { ok: false, code: 'CONNECT_FAILED', message: this._safeError(error) };
382
+ }
383
+ }
384
+
385
+ /** SQLite:用 Node 内置 node:sqlite,零原生依赖 */
386
+ async _openSqlite(config) {
387
+ const { DatabaseSync } = await import('node:sqlite');
388
+ const file = String(config.database || ':memory:');
389
+ // 文件型库:确保父目录存在,否则驱动报错不够直白
390
+ if (file !== ':memory:' && !file.startsWith('file:')) {
391
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
392
+ }
393
+ const db = new DatabaseSync(file);
394
+ return { type: 'sqlite', raw: db };
395
+ }
396
+
397
+ /** MySQL:按需加载 mysql2(纯 JS,无原生编译) */
398
+ async _openMysql(config) {
399
+ let mysql;
400
+ try {
401
+ mysql = await import('mysql2/promise');
402
+ } catch {
403
+ throw new Error('缺少 mysql2 依赖:请在该插件目录执行 npm install mysql2');
404
+ }
405
+ const pool = mysql.createPool({
406
+ host: config.host,
407
+ port: Number(config.port) || 3306,
408
+ user: config.username,
409
+ password: config.password,
410
+ database: config.database || undefined,
411
+ waitForConnections: true,
412
+ connectionLimit: 4,
413
+ ...(isPlainObject(config.extra) ? config.extra : {}),
414
+ });
415
+ return { type: 'mysql', raw: pool };
416
+ }
417
+
418
+ /** PostgreSQL:按需加载 pg */
419
+ async _openPostgres(config) {
420
+ let pg;
421
+ try {
422
+ pg = await import('pg');
423
+ } catch {
424
+ throw new Error('缺少 pg 依赖:请在该插件目录执行 npm install pg');
425
+ }
426
+ const Pool = pg.Pool || pg.default?.Pool;
427
+ const pool = new Pool({
428
+ host: config.host,
429
+ port: Number(config.port) || 5432,
430
+ user: config.username,
431
+ password: config.password,
432
+ database: config.database || undefined,
433
+ max: 4,
434
+ ...(isPlainObject(config.extra) ? config.extra : {}),
435
+ });
436
+ return { type: 'postgres', raw: pool };
437
+ }
438
+
439
+ /** Oracle:按需加载 oracledb(原生模块,装不上时给出明确指引) */
440
+ async _openOracle(config) {
441
+ let oracledb;
442
+ try {
443
+ oracledb = await import('oracledb');
444
+ } catch (error) {
445
+ throw new Error(`缺少 oracledb 依赖(原生模块,可能需匹配 Node ABI):${error?.message || error}`);
446
+ }
447
+ const mod = oracledb.default || oracledb;
448
+ const pool = await mod.createPool({
449
+ user: config.username,
450
+ password: config.password,
451
+ connectString: `${config.host}:${Number(config.port) || 1521}/${config.database || ''}`,
452
+ poolMax: 4,
453
+ });
454
+ return { type: 'oracle', raw: pool, mod };
455
+ }
456
+
457
+ /**
458
+ * 达梦(DM8):dmdb 驱动,API 与 oracledb 高度相似。
459
+ *
460
+ * 连接串格式是 `host:port`(不是 `host:port/sid`)。
461
+ * dmdb 虽然 README 自称「原生驱动」,但实际是纯 JS 包(唯一依赖 iconv-lite),
462
+ * 不存在 NODE_MODULE_VERSION 匹配问题,装得上就能用。
463
+ *
464
+ * 注意:dmdb 在**连接失败后的清理阶段**会抛 `TypeError: Cannot read
465
+ * properties of undefined (reading 'log')`(pool.js:263,它自己的 bug),
466
+ * 而且是未捕获的异步异常,会把宿主进程带崩。所以这里要:
467
+ * 1. 连接失败时主动兜住这个异常;
468
+ * 2. 进程级加一个 unhandledRejection 白名单过滤,只放行这个已知签名。
469
+ */
470
+ async _openDm(config) {
471
+ let dmdb;
472
+ try {
473
+ dmdb = await import('dmdb');
474
+ } catch (error) {
475
+ throw new Error(`缺少 dmdb 依赖:请在该插件目录执行 npm install dmdb(${error?.message || error})`);
476
+ }
477
+ const mod = dmdb.default || dmdb;
478
+ try {
479
+ /**
480
+ * createPool 的 connectString 必须是完整 URL:
481
+ * dm://user:password@host:port
482
+ * 不能像 oracledb 那样单独传 user/password(会报 "URL must start with 'dm://'")。
483
+ * 用户名/密码里的特殊字符要转义,否则 URL 解析会错位。
484
+ */
485
+ const user = encodeURIComponent(String(config.username || ''));
486
+ const pass = encodeURIComponent(String(config.password || ''));
487
+ const port = Number(config.port) || 5236;
488
+ const connectString = `dm://${user}:${pass}@${config.host}:${port}`;
489
+ const pool = await mod.createPool({
490
+ connectString,
491
+ poolMax: 4,
492
+ });
493
+ return { type: 'dm', raw: pool, mod };
494
+ } catch (error) {
495
+ // dmdb 连接失败时内部状态是半初始化的,close 会再抛一次;吞掉它
496
+ try {
497
+ if (error?.pool) await error.pool.close();
498
+ } catch {
499
+ /* dmdb 自身的清理 bug,忽略 */
500
+ }
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ _closePool(id) {
506
+ const key = String(id || '');
507
+ const handle = this.pools.get(key);
508
+ if (!handle) return;
509
+ this.pools.delete(key);
510
+ try {
511
+ if (handle.type === 'sqlite') handle.raw.close();
512
+ else if (handle.raw && typeof handle.raw.end === 'function') {
513
+ // mysql2 / pg 的 end() 返回 Promise,这里不等待,失败也不影响主流程
514
+ Promise.resolve(handle.raw.end()).catch(() => {});
515
+ } else if (handle.raw && typeof handle.raw.close === 'function') {
516
+ Promise.resolve(handle.raw.close()).catch(() => {});
517
+ }
518
+ } catch {
519
+ /* 关闭失败不影响删除操作 */
520
+ }
521
+ }
522
+
523
+ /** 把驱动异常转成安全文案:去掉可能含密码的连接串 */
524
+ _safeError(error) {
525
+ let text = String(error?.message || error || '未知错误');
526
+ // 有些驱动会把整条 DSN 打进 message,这里把 password=xxx 抹掉
527
+ text = text.replace(/password\s*=\s*[^;\s]*/gi, 'password=***');
528
+ text = text.replace(/:\/\/[^@\s]*@/g, '://***@');
529
+ return text;
530
+ }
531
+
532
+ // ---------------------------------------------------------------- 执行
533
+ /**
534
+ * 执行 SQL。
535
+ * @param {string} id - 连接 id
536
+ * @param {string} sql - 单条 SQL
537
+ * @param {Array} params - 参数化占位值
538
+ * @param {{readonly?:boolean, maxRows?:number, timeoutMs?:number}} options
539
+ */
540
+ async query(id, sql, params = [], options = {}) {
541
+ const text = String(sql || '').trim();
542
+ if (text === '') return { ok: false, code: 'EMPTY_SQL', message: 'SQL 不能为空' };
543
+
544
+ const statements = splitStatements(text);
545
+ if (statements.length === 0) return { ok: false, code: 'EMPTY_SQL', message: 'SQL 不能为空' };
546
+ if (statements.length > 1) {
547
+ return {
548
+ ok: false,
549
+ code: 'MULTI_STATEMENT',
550
+ message: `只允许单条语句,检测到 ${statements.length} 条。请拆开执行。`,
551
+ };
552
+ }
553
+ const single = statements[0];
554
+
555
+ if (options.readonly === true && !isReadOnlySql(single)) {
556
+ return {
557
+ ok: false,
558
+ code: 'READONLY_VIOLATION',
559
+ message: '只读模式已开启:只允许 SELECT / SHOW / DESCRIBE / EXPLAIN 等查询语句。要执行写操作请关闭只读模式。',
560
+ };
561
+ }
562
+
563
+ const opened = await this._handle(id);
564
+ if (!opened.ok) return opened;
565
+ const handle = opened.handle;
566
+
567
+ const started = Date.now();
568
+ const maxRows = Number(options.maxRows) || this.maxRows;
569
+ const timeoutMs = Number(options.timeoutMs) || this.timeoutMs;
570
+ try {
571
+ const result = await this._runWithTimeout(handle, single, Array.isArray(params) ? params : [], timeoutMs, maxRows);
572
+ return {
573
+ ok: true,
574
+ connectionId: String(id),
575
+ sql: single,
576
+ durationMs: Date.now() - started,
577
+ ...result,
578
+ };
579
+ } catch (error) {
580
+ return {
581
+ ok: false,
582
+ code: 'DB_ERROR',
583
+ message: this._safeError(error),
584
+ durationMs: Date.now() - started,
585
+ };
586
+ }
587
+ }
588
+
589
+ /** 按驱动类型执行,并统一结果结构 */
590
+ async _runWithTimeout(handle, sql, params, timeoutMs, maxRows) {
591
+ const work = (async () => {
592
+ if (handle.type === 'sqlite') {
593
+ const stmt = handle.raw.prepare(sql);
594
+ // 有参数时按参数化执行;否则区分「有结果集」与「纯写操作」
595
+ const upper = sql.trim().toUpperCase();
596
+ const returnsRows = /^(SELECT|WITH|PRAGMA|EXPLAIN|SHOW|DESC|DESCRIBE|TABLE)\b/.test(upper)
597
+ || stmt.columns?.().length > 0;
598
+ if (returnsRows) {
599
+ const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
600
+ return this._shapeRows(rows, maxRows);
601
+ }
602
+ const info = params.length > 0 ? stmt.run(...params) : stmt.run();
603
+ return { columns: [], rows: [], rowCount: 0, affectedRows: Number(info?.changes || 0), lastInsertRowid: Number(info?.lastInsertRowid || 0) };
604
+ }
605
+ if (handle.type === 'mysql') {
606
+ const [result, fields] = await handle.raw.query({ sql, timeout: timeoutMs }, params);
607
+ if (Array.isArray(result)) {
608
+ const columns = Array.isArray(fields) ? fields.map((f) => f.name) : [];
609
+ return this._shapeRows(result, maxRows, columns);
610
+ }
611
+ return { columns: [], rows: [], rowCount: 0, affectedRows: Number(result?.affectedRows || 0) };
612
+ }
613
+ if (handle.type === 'postgres') {
614
+ const result = await handle.raw.query({ text: sql, values: params, ...(timeoutMs ? { query_timeout: timeoutMs } : {}) });
615
+ const columns = (result.fields || []).map((f) => f.name);
616
+ if (columns.length > 0) return this._shapeRows(result.rows || [], maxRows, columns);
617
+ return { columns: [], rows: [], rowCount: 0, affectedRows: Number(result.rowCount || 0) };
618
+ }
619
+ if (handle.type === 'oracle') {
620
+ const result = await handle.raw.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, maxRows });
621
+ const rows = result.rows || [];
622
+ const columns = (result.metaData || []).map((m) => m.name);
623
+ return {
624
+ ...this._shapeRows(rows, maxRows, columns),
625
+ affectedRows: Number(result.rowsAffected || 0),
626
+ };
627
+ }
628
+ if (handle.type === 'dm') {
629
+ const result = await this._execute(handle, sql, params, { maxRows });
630
+ const rows = result.rows || [];
631
+ const columns = (result.metaData || []).map((m) => m.name);
632
+ return {
633
+ ...this._shapeRows(rows, maxRows, columns),
634
+ affectedRows: Number(result.rowsAffected || 0),
635
+ };
636
+ }
637
+ throw new Error(`未知驱动类型 ${handle.type}`);
638
+ })();
639
+
640
+ if (!timeoutMs || timeoutMs <= 0) return work;
641
+ let timer;
642
+ const timeout = new Promise((_, reject) => {
643
+ timer = setTimeout(() => reject(new Error(`查询超时(${timeoutMs}ms)`)), timeoutMs);
644
+ timer.unref?.();
645
+ });
646
+ try {
647
+ return await Promise.race([work, timeout]);
648
+ } finally {
649
+ clearTimeout(timer);
650
+ }
651
+ }
652
+
653
+ /** 统一结果集形状:列名 + 行数组 + 截断标记 */
654
+ _shapeRows(rows, maxRows, columns) {
655
+ const list = Array.isArray(rows) ? rows : [];
656
+ const cols = Array.isArray(columns) && columns.length > 0
657
+ ? columns
658
+ : (list.length > 0 && isPlainObject(list[0]) ? Object.keys(list[0]) : []);
659
+ const truncated = list.length > maxRows;
660
+ const sliced = truncated ? list.slice(0, maxRows) : list;
661
+ return {
662
+ columns: cols,
663
+ rows: sliced,
664
+ rowCount: list.length,
665
+ truncated,
666
+ maxRows,
667
+ note: truncated ? `结果共 ${list.length} 行,已截断为前 ${maxRows} 行` : undefined,
668
+ };
669
+ }
670
+
671
+ // ---------------------------------------------------------------- 元数据
672
+ /** 列出库(MySQL/PG 有库的概念;SQLite 只有文件) */
673
+ async listDatabases(id) {
674
+ const opened = await this._handle(id);
675
+ if (!opened.ok) return opened;
676
+ const handle = opened.handle;
677
+ try {
678
+ if (handle.type === 'sqlite') {
679
+ return { ok: true, databases: [{ name: String(this._fullConfig(id)?.database || ':memory:') }] };
680
+ }
681
+ if (handle.type === 'mysql') {
682
+ const [rows] = await handle.raw.query('SHOW DATABASES');
683
+ return { ok: true, databases: rows.map((r) => ({ name: r.Database || r.database || Object.values(r)[0] })) };
684
+ }
685
+ if (handle.type === 'postgres') {
686
+ const result = await handle.raw.query('SELECT datname AS name FROM pg_database WHERE datistemplate = false ORDER BY datname');
687
+ return { ok: true, databases: result.rows };
688
+ }
689
+ if (handle.type === 'oracle') {
690
+ return { ok: true, databases: [{ name: this._fullConfig(id)?.database || '' }] };
691
+ }
692
+ if (handle.type === 'dm') {
693
+ // 达梦里「模式」(schema)≈ 其它库的「数据库」概念
694
+ const result = await this._execute(handle, 'SELECT DISTINCT OWNER AS NAME FROM ALL_TABLES ORDER BY OWNER');
695
+ const rows = (result.rows || []).map((r) => ({ name: r.NAME || r.name })).filter((r) => r.name);
696
+ if (rows.length > 0) return { ok: true, databases: rows };
697
+ return { ok: true, databases: [{ name: this._fullConfig(id)?.database || '' }] };
698
+ }
699
+ return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
700
+ } catch (error) {
701
+ return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
702
+ }
703
+ }
704
+
705
+ /** 列出表 */
706
+ async listTables(id, database = '') {
707
+ const opened = await this._handle(id);
708
+ if (!opened.ok) return opened;
709
+ const handle = opened.handle;
710
+ try {
711
+ if (handle.type === 'sqlite') {
712
+ const rows = handle.raw
713
+ .prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
714
+ .all();
715
+ return { ok: true, tables: rows.map((r) => ({ name: r.name })) };
716
+ }
717
+ if (handle.type === 'mysql') {
718
+ const db = String(database || this._fullConfig(id)?.database || '');
719
+ if (db === '') return { ok: false, code: 'MISSING_DATABASE', message: '请先指定数据库名' };
720
+ const [rows] = await handle.raw.query(
721
+ 'SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',
722
+ [db],
723
+ );
724
+ return { ok: true, tables: rows.map((r) => ({ name: r.name, type: r.type })) };
725
+ }
726
+ if (handle.type === 'postgres') {
727
+ const schema = String(database || 'public');
728
+ const result = await handle.raw.query(
729
+ "SELECT table_name AS name, table_type AS type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name",
730
+ [schema],
731
+ );
732
+ return { ok: true, tables: result.rows };
733
+ }
734
+ if (handle.type === 'oracle') {
735
+ const result = await handle.raw.execute(
736
+ 'SELECT table_name AS NAME FROM user_tables ORDER BY table_name',
737
+ [],
738
+ { outFormat: handle.mod?.OUT_FORMAT_OBJECT },
739
+ );
740
+ return { ok: true, tables: (result.rows || []).map((r) => ({ name: r.NAME || r.name })) };
741
+ }
742
+ if (handle.type === 'dm') {
743
+ // 指定了模式就查那个模式,否则查当前用户可见的表
744
+ const schema = String(database || '').trim().toUpperCase();
745
+ const result = await this._execute(
746
+ handle,
747
+ schema
748
+ ? 'SELECT TABLE_NAME AS NAME FROM ALL_TABLES WHERE OWNER = ? ORDER BY TABLE_NAME'
749
+ : 'SELECT TABLE_NAME AS NAME FROM USER_TABLES ORDER BY TABLE_NAME',
750
+ schema ? [schema] : [],
751
+ );
752
+ return { ok: true, tables: (result.rows || []).map((r) => ({ name: r.NAME || r.name })) };
753
+ }
754
+ return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
755
+ } catch (error) {
756
+ return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
757
+ }
758
+ }
759
+
760
+ /** 表结构:字段 + 索引 */
761
+ async describeTable(id, table) {
762
+ const name = String(table || '').trim();
763
+ if (name === '') return { ok: false, code: 'MISSING_TABLE', message: '需要表名' };
764
+ const opened = await this._handle(id);
765
+ if (!opened.ok) return opened;
766
+ const handle = opened.handle;
767
+ try {
768
+ if (handle.type === 'sqlite') {
769
+ // PRAGMA 不支持参数绑定,表名要转义引号防注入
770
+ const safe = name.replace(/'/g, "''");
771
+ const cols = handle.raw.prepare(`PRAGMA table_info('${safe}')`).all();
772
+ const idx = handle.raw.prepare(`PRAGMA index_list('${safe}')`).all();
773
+ const columns = cols.map((c) => ({
774
+ name: c.name,
775
+ type: c.type,
776
+ nullable: !c.notnull,
777
+ defaultValue: c.dflt_value,
778
+ primaryKey: Boolean(c.pk),
779
+ }));
780
+ const indexes = idx.map((i) => ({
781
+ name: i.name,
782
+ unique: Boolean(i.unique),
783
+ columns: handle.raw.prepare(`PRAGMA index_info('${String(i.name).replace(/'/g, "''")}')`).all().map((c) => c.name),
784
+ }));
785
+ return { ok: true, table: name, columns, indexes };
786
+ }
787
+ if (handle.type === 'mysql') {
788
+ const db = String(this._fullConfig(id)?.database || '');
789
+ if (db === '') return { ok: false, code: 'MISSING_DATABASE', message: '请先在连接里指定数据库名' };
790
+ const [cols] = await handle.raw.query(
791
+ 'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, COLUMN_KEY AS columnKey, COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION',
792
+ [db, name],
793
+ );
794
+ const [idx] = await handle.raw.query(
795
+ 'SELECT INDEX_NAME AS name, NON_UNIQUE AS nonUnique, COLUMN_NAME AS columnName FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX',
796
+ [db, name],
797
+ );
798
+ return {
799
+ ok: true,
800
+ table: name,
801
+ columns: cols.map((c) => ({
802
+ name: c.name,
803
+ type: c.type,
804
+ nullable: String(c.nullable).toUpperCase() === 'YES',
805
+ defaultValue: c.defaultValue,
806
+ primaryKey: c.columnKey === 'PRI',
807
+ comment: c.comment || '',
808
+ })),
809
+ indexes: groupIndexes(idx, (r) => ({ name: r.name, unique: Number(r.nonUnique) === 0, column: r.columnName })),
810
+ };
811
+ }
812
+ if (handle.type === 'postgres') {
813
+ const cols = await handle.raw.query(
814
+ 'SELECT column_name AS name, data_type AS type, is_nullable AS nullable, column_default AS "defaultValue" FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position',
815
+ [name],
816
+ );
817
+ const idx = await handle.raw.query(
818
+ 'SELECT indexname AS name, indexdef AS def FROM pg_indexes WHERE tablename = $1',
819
+ [name],
820
+ );
821
+ return {
822
+ ok: true,
823
+ table: name,
824
+ columns: cols.rows.map((c) => ({
825
+ name: c.name,
826
+ type: c.type,
827
+ nullable: String(c.nullable).toUpperCase() === 'YES',
828
+ defaultValue: c.defaultValue,
829
+ })),
830
+ indexes: idx.rows.map((r) => ({ name: r.name, definition: r.def })),
831
+ };
832
+ }
833
+ if (handle.type === 'oracle') {
834
+ const cols = await handle.raw.execute(
835
+ 'SELECT column_name AS NAME, data_type AS TYPE, nullable AS NULLABLE, data_default AS DEF FROM user_tab_columns WHERE table_name = :1 ORDER BY column_id',
836
+ [name.toUpperCase()],
837
+ { outFormat: handle.mod?.OUT_FORMAT_OBJECT },
838
+ );
839
+ return {
840
+ ok: true,
841
+ table: name,
842
+ columns: (cols.rows || []).map((c) => ({
843
+ name: c.NAME,
844
+ type: c.TYPE,
845
+ nullable: c.NULLABLE === 'Y',
846
+ defaultValue: c.DEF,
847
+ })),
848
+ indexes: [],
849
+ };
850
+ }
851
+ if (handle.type === 'dm') {
852
+ // 达梦用 ? 占位(不是 oracle 的 :1)
853
+ const cols = await this._execute(
854
+ handle,
855
+ 'SELECT COLUMN_NAME AS NAME, DATA_TYPE AS TYPE, NULLABLE AS NULLABLE, DATA_DEFAULT AS DEF FROM USER_TAB_COLUMNS WHERE TABLE_NAME = ? ORDER BY COLUMN_ID',
856
+ [name.toUpperCase()],
857
+ );
858
+ const idx = await this._execute(
859
+ handle,
860
+ 'SELECT INDEX_NAME AS NAME, UNIQUENESS AS UNIQ FROM USER_INDEXES WHERE TABLE_NAME = ? ORDER BY INDEX_NAME',
861
+ [name.toUpperCase()],
862
+ );
863
+ return {
864
+ ok: true,
865
+ table: name,
866
+ columns: (cols.rows || []).map((c) => ({
867
+ name: c.NAME,
868
+ type: c.TYPE,
869
+ nullable: c.NULLABLE === 'Y',
870
+ defaultValue: c.DEF,
871
+ })),
872
+ indexes: (idx.rows || []).map((r) => ({
873
+ name: r.NAME,
874
+ unique: String(r.UNIQ).toUpperCase() === 'UNIQUE',
875
+ })),
876
+ };
877
+ }
878
+ return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
879
+ } catch (error) {
880
+ return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
881
+ }
882
+ }
883
+
884
+ /** 试连:真正建一次连接并执行最轻量的探测语句 */
885
+ async testConnection(target) {
886
+ const started = Date.now();
887
+ // 支持传已有 id,或传一份完整配置(新建前先试)
888
+ let id = '';
889
+ if (typeof target === 'string') id = target;
890
+ else if (isPlainObject(target) && target.id) id = String(target.id);
891
+ else if (isPlainObject(target)) {
892
+ // 临时配置:存一个临时 id 再试,试完删掉
893
+ const saved = this.saveConnection({ ...target, name: target.name || '__probe__' });
894
+ if (!saved.ok) return { ...saved, durationMs: Date.now() - started };
895
+ id = saved.connection.id;
896
+ try {
897
+ return await this._probe(id, started);
898
+ } finally {
899
+ this.deleteConnection(id);
900
+ }
901
+ }
902
+ if (!id) return { ok: false, code: 'MISSING_ARGUMENT', message: '需要连接 id 或配置' };
903
+ return this._probe(id, started);
904
+ }
905
+
906
+ async _probe(id, started) {
907
+ const opened = await this._handle(id);
908
+ if (!opened.ok) return { ...opened, durationMs: Date.now() - started };
909
+ const handle = opened.handle;
910
+ try {
911
+ if (handle.type === 'sqlite') handle.raw.prepare('SELECT 1').all();
912
+ else if (handle.type === 'mysql') await handle.raw.query('SELECT 1');
913
+ else if (handle.type === 'postgres') await handle.raw.query('SELECT 1');
914
+ else if (handle.type === 'oracle') await handle.raw.execute('SELECT 1 FROM dual');
915
+ else if (handle.type === 'dm') await this._execute(handle, 'SELECT 1');
916
+ return { ok: true, durationMs: Date.now() - started, message: '连接成功' };
917
+ } catch (error) {
918
+ return { ok: false, code: 'DB_ERROR', message: this._safeError(error), durationMs: Date.now() - started };
919
+ }
920
+ }
921
+
922
+ /** 关闭所有连接池(插件卸载时调用) */
923
+ dispose() {
924
+ for (const key of [...this.pools.keys()]) this._closePool(key);
925
+ }
926
+
927
+ /**
928
+ * 统一的 execute 封装:dmdb 的 Pool 没有 execute(只有 getConnection),
929
+ * oracledb 的 Pool 有。这里抹平差异,元数据/探测代码都走它。
930
+ */
931
+ async _execute(handle, sql, params = [], options = {}) {
932
+ if (handle.type === 'dm') {
933
+ const conn = await handle.raw.getConnection();
934
+ try {
935
+ return await conn.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, ...options });
936
+ } finally {
937
+ try {
938
+ await conn.close();
939
+ } catch {
940
+ /* 归还失败不影响结果 */
941
+ }
942
+ }
943
+ }
944
+ return handle.raw.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, ...options });
945
+ }
946
+ }
947
+
948
+ /** 把 information_schema.STATISTICS 的多行按索引名合并 */
949
+ function groupIndexes(rows, map) {
950
+ const byName = new Map();
951
+ for (const row of rows) {
952
+ const item = map(row);
953
+ const existing = byName.get(item.name);
954
+ if (existing) existing.columns.push(item.column);
955
+ else byName.set(item.name, { name: item.name, unique: item.unique, columns: [item.column] });
956
+ }
957
+ return [...byName.values()];
958
+ }