dsh-db-tool 1.5.9 → 1.5.91
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.md +14 -2
- package/README_EN.md +14 -2
- package/client/client.js +1117 -182
- package/dist/adapters/dmdb/index.js +8 -2
- package/dist/adapters/oracle/index.js +17 -10
- package/dist/guard/index.js +4 -2
- package/dist/http/index.js +19 -0
- package/dist/manager.js +306 -4
- package/package.json +12 -2
- package/scripts/build-codemirror.mjs +263 -0
- package/vendor/codemirror-sql.cjs +27643 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - 返回 { metaData, rows, rowsAffected } 与 oracledb 兼容。
|
|
17
17
|
*/
|
|
18
18
|
import db from 'dmdb';
|
|
19
|
+
import { sqlHead, SQL_READ_HEADS } from '../../guard/index.js';
|
|
19
20
|
const ROWS_MAX = 500;
|
|
20
21
|
const CELL_TRUNC = 1000;
|
|
21
22
|
const DDL_KEYWORDS = new Set([
|
|
@@ -199,14 +200,19 @@ export async function createDmAdapter(conn, opts) {
|
|
|
199
200
|
throw humanizeDmError(e);
|
|
200
201
|
}
|
|
201
202
|
},
|
|
202
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* 事务:专用连接多条语句,成功 commit、异常 rollback。DM 事务隐式开始,无需 SET TRANSACTION。
|
|
205
|
+
* exec 回调按语句头分流:读语句(SQL_READ_HEADS 命中首词,如 SELECT/WITH)在
|
|
206
|
+
* 本事务连接上执行并返回结果集(事务内读未提交数据是官方标准用法);
|
|
207
|
+
* 其余语句返回执行回执。所有语句同处一个事务,随 commit/rollback 收口。
|
|
208
|
+
*/
|
|
203
209
|
async tx(fn) {
|
|
204
210
|
requireRw('事务(tx)');
|
|
205
211
|
const c = await pool.getConnection();
|
|
206
212
|
try {
|
|
207
213
|
const exec = async (sql, binds) => {
|
|
208
214
|
const r = await c.execute(sql, binds ?? [], execOptsTx);
|
|
209
|
-
return toExecResult(sql, r);
|
|
215
|
+
return SQL_READ_HEADS.has(sqlHead(sql)) ? rowsToQueryResult(r) : toExecResult(sql, r);
|
|
210
216
|
};
|
|
211
217
|
const out = await fn(exec);
|
|
212
218
|
await c.commit();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - 锁号/密码验证器等常见连接错误转人类可读提示(见 ORA_HINTS)。
|
|
14
14
|
*/
|
|
15
15
|
import oracledb from 'oracledb';
|
|
16
|
+
import { sqlHead, SQL_READ_HEADS } from '../../guard/index.js';
|
|
16
17
|
// CLOB 统一按字符串返回(BLOB 不能走 fetchAsString,须用 execute 的 fetchInfo)
|
|
17
18
|
if (!oracledb.fetchAsString.includes(oracledb.CLOB)) {
|
|
18
19
|
oracledb.fetchAsString = [...oracledb.fetchAsString, oracledb.CLOB];
|
|
@@ -153,6 +154,17 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
153
154
|
message: `${sql.trim().split(/\s+/)[0]?.toUpperCase() ?? 'SQL'} 完成${r.rowsAffected ? `,影响 ${r.rowsAffected} 行` : ''}${ddlNote}`,
|
|
154
155
|
};
|
|
155
156
|
}
|
|
157
|
+
/** 执行结果 → QueryResult(行值规范化 + 超限截断);query 与事务内读语句共用 */
|
|
158
|
+
async function rowsToQueryResult(r) {
|
|
159
|
+
const allRows = r.rows ?? [];
|
|
160
|
+
const truncated = allRows.length > ROWS_MAX;
|
|
161
|
+
const columns = (r.metaData ?? []).map((m) => m.name);
|
|
162
|
+
const rows = [];
|
|
163
|
+
for (const row of allRows.slice(0, ROWS_MAX)) {
|
|
164
|
+
rows.push(await Promise.all(Object.values(row).map(normalizeCell)));
|
|
165
|
+
}
|
|
166
|
+
return { columns, rows, rowCount: rows.length, truncated: truncated || undefined };
|
|
167
|
+
}
|
|
156
168
|
// autoCommit 默认 true:query/元数据 SELECT 结束隐式事务避免 ro 事务悬挂;tx 专用连接显式 autoCommit:false 覆盖
|
|
157
169
|
const execOpts = { outFormat: oracledb.OBJECT, maxRows: ROWS_MAX + 1, autoCommit: true, ...BLOB_FETCH };
|
|
158
170
|
/**
|
|
@@ -211,15 +223,7 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
211
223
|
}
|
|
212
224
|
try {
|
|
213
225
|
const r = await withConn((c) => c.execute(sql, params ?? [], execOpts));
|
|
214
|
-
|
|
215
|
-
const truncated = allRows.length > ROWS_MAX;
|
|
216
|
-
const rows = allRows.slice(0, ROWS_MAX);
|
|
217
|
-
const columns = (r.metaData ?? []).map((m) => m.name);
|
|
218
|
-
const normRows = [];
|
|
219
|
-
for (const row of rows) {
|
|
220
|
-
normRows.push(await Promise.all(Object.values(row).map(normalizeCell)));
|
|
221
|
-
}
|
|
222
|
-
return { columns, rows: normRows, rowCount: normRows.length, truncated: truncated || undefined };
|
|
226
|
+
return await rowsToQueryResult(r);
|
|
223
227
|
}
|
|
224
228
|
catch (e) {
|
|
225
229
|
throw humanizeOraError(e);
|
|
@@ -238,6 +242,9 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
238
242
|
/**
|
|
239
243
|
* 事务:从池取专用连接执行多条语句,成功 commit、异常 rollback。
|
|
240
244
|
* Oracle 无 BEGIN,用 SET TRANSACTION READ WRITE 开始显式事务。
|
|
245
|
+
* exec 回调按语句头分流:读语句(SQL_READ_HEADS 命中首词,如 SELECT/WITH)在
|
|
246
|
+
* 本事务连接上执行并返回结果集(事务内读未提交数据是官方标准用法);
|
|
247
|
+
* 其余语句返回执行回执。所有语句同处一个事务,随 commit/rollback 收口。
|
|
241
248
|
*/
|
|
242
249
|
async tx(fn) {
|
|
243
250
|
requireRw('事务(tx)');
|
|
@@ -246,7 +253,7 @@ export async function createOracleAdapter(conn, opts) {
|
|
|
246
253
|
await c.execute('SET TRANSACTION READ WRITE');
|
|
247
254
|
const exec = async (sql, binds) => {
|
|
248
255
|
const r = await c.execute(sql, binds ?? [], { ...execOpts, autoCommit: false });
|
|
249
|
-
return toExecResult(sql, r);
|
|
256
|
+
return SQL_READ_HEADS.has(sqlHead(sql)) ? rowsToQueryResult(r) : toExecResult(sql, r);
|
|
250
257
|
};
|
|
251
258
|
const out = await fn(exec);
|
|
252
259
|
await c.commit();
|
package/dist/guard/index.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import { createHash, randomBytes } from 'node:crypto';
|
|
9
9
|
import { DANGEROUS_COMMANDS, READ_COMMANDS, WRITE_COMMANDS, parseRedisCommand } from '../adapters/redis/index.js';
|
|
10
10
|
import { DANGEROUS_OPS, READ_OPS } from '../adapters/mongodb/index.js';
|
|
11
|
-
/** SQL
|
|
12
|
-
|
|
11
|
+
/** SQL 读取类首词白名单(大小写不敏感;首词必须命中,否则视为非读)。
|
|
12
|
+
* 导出供控制台事务会话 exec 通道复用:读语句走 query 通道保留结果集。
|
|
13
|
+
* 类型收紧为 ReadonlySet——security 原语不允许调用方增删条目(编译期保证,运行时不变)。 */
|
|
14
|
+
export const SQL_READ_HEADS = new Set([
|
|
13
15
|
'select', 'with', 'show', 'describe', 'desc', 'explain', 'use', 'set', 'help', 'table',
|
|
14
16
|
]);
|
|
15
17
|
/** DDL:隐式提交、不可回滚 */
|
package/dist/http/index.js
CHANGED
|
@@ -241,6 +241,25 @@ async function route(req, res, service, path, q, opts) {
|
|
|
241
241
|
const b = await readBody(req);
|
|
242
242
|
return await sendMaybeConfirm(res, service.runScript(projectOfBody(b), str(b['connId']), str(b['code']), optStr(b['challengeId'])));
|
|
243
243
|
}
|
|
244
|
+
// 控制台事务会话(粘性连接):begin 发 token;exec 语义等同 execute(可能
|
|
245
|
+
// NEEDS_CONFIRMATION);commit/rollback 提交/回滚后销毁会话。trust 与 body
|
|
246
|
+
// 校验复用入口层既有链路。
|
|
247
|
+
if (path === '/api/console/begin' && req.method === 'POST') {
|
|
248
|
+
const b = await readBody(req);
|
|
249
|
+
return sendOk(res, await service.consoleBegin(projectOfBody(b), str(b['connId']), optStr(b['database'])));
|
|
250
|
+
}
|
|
251
|
+
if (path === '/api/console/exec' && req.method === 'POST') {
|
|
252
|
+
const b = await readBody(req);
|
|
253
|
+
return await sendMaybeConfirm(res, service.consoleExec(str(b['sessionToken']), str(b['statement']), b['params'], optStr(b['challengeId'])));
|
|
254
|
+
}
|
|
255
|
+
if (path === '/api/console/commit' && req.method === 'POST') {
|
|
256
|
+
const b = await readBody(req);
|
|
257
|
+
return await sendMaybeConfirm(res, service.consoleCommit(str(b['sessionToken'])));
|
|
258
|
+
}
|
|
259
|
+
if (path === '/api/console/rollback' && req.method === 'POST') {
|
|
260
|
+
const b = await readBody(req);
|
|
261
|
+
return await sendMaybeConfirm(res, service.consoleRollback(str(b['sessionToken'])));
|
|
262
|
+
}
|
|
244
263
|
if (path === '/api/audit' && req.method === 'GET') {
|
|
245
264
|
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
246
265
|
return sendOk(res, service.auditTail(q.get('project') ?? undefined, limit));
|
package/dist/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { getAdapter } from './adapters/index.js';
|
|
2
|
-
import { ChallengeStore, classifyStatement, } from './guard/index.js';
|
|
3
|
+
import { ChallengeStore, classifyStatement, sqlHead, SQL_READ_HEADS, } from './guard/index.js';
|
|
3
4
|
import { mergeUrlCredentials, normalizeProjectKey, } from './store/index.js';
|
|
4
5
|
import { runScriptInChild } from './script/runner.js';
|
|
5
6
|
/* ---------------- 错误与结果类型 ---------------- */
|
|
@@ -12,16 +13,28 @@ export class DbToolError extends Error {
|
|
|
12
13
|
this.name = 'DbToolError';
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
/** 粘性通道的语句串行队列:前序失败不阻塞后续,但执行严格按到达顺序 */
|
|
17
|
+
function enqueue(chain, task) {
|
|
18
|
+
const next = chain.p.then(task, task);
|
|
19
|
+
chain.p = next.catch(() => { });
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
15
22
|
function assertNonEmpty(v, label) {
|
|
16
23
|
const s = typeof v === 'string' ? v.trim() : '';
|
|
17
24
|
if (s === '')
|
|
18
25
|
throw new DbToolError('INVALID_ARGUMENT', `缺少必填参数: ${label}`);
|
|
19
26
|
return s;
|
|
20
27
|
}
|
|
28
|
+
/** 单连接并发控制台事务会话上限(每会话独占驱动池 + checkout 连接,防连接耗尽) */
|
|
29
|
+
const MAX_CONSOLE_SESSIONS_PER_CONN = 4;
|
|
21
30
|
/* ---------------- 服务核心 ---------------- */
|
|
22
31
|
export class DbToolService {
|
|
23
32
|
store;
|
|
24
33
|
adapterCache = new Map();
|
|
34
|
+
/** 控制台事务会话注册表(token → 会话) */
|
|
35
|
+
consoleSessions = new Map();
|
|
36
|
+
consoleTtlMs;
|
|
37
|
+
consoleSweepTimer;
|
|
25
38
|
challenges;
|
|
26
39
|
resolver;
|
|
27
40
|
disposed = false;
|
|
@@ -29,6 +42,12 @@ export class DbToolService {
|
|
|
29
42
|
this.store = store;
|
|
30
43
|
this.resolver = opts?.adapterResolver ?? getAdapter;
|
|
31
44
|
this.challenges = opts?.challenges ?? new ChallengeStore();
|
|
45
|
+
this.consoleTtlMs = opts?.consoleTx?.ttlMs ?? 5 * 60 * 1000;
|
|
46
|
+
const sweep = opts?.consoleTx?.sweepIntervalMs ?? 30 * 1000;
|
|
47
|
+
if (sweep > 0) {
|
|
48
|
+
this.consoleSweepTimer = setInterval(() => this.consoleSweep(), sweep);
|
|
49
|
+
this.consoleSweepTimer.unref?.();
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
/* -- 连接管理(无需授权) -- */
|
|
34
53
|
listConnections() {
|
|
@@ -43,8 +62,10 @@ export class DbToolService {
|
|
|
43
62
|
const meta = this.store.connections.update(id, patch);
|
|
44
63
|
if (!meta)
|
|
45
64
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
46
|
-
// url/凭证/ssl
|
|
65
|
+
// url/凭证/ssl 变更后旧适配器仍持旧连接串,必须作废重建;
|
|
66
|
+
// 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL)
|
|
47
67
|
void this.dropAdapters(id);
|
|
68
|
+
void this.dropConsoleSessions(id);
|
|
48
69
|
this.audit('', id, 'update_connection', id, 'none', false, true);
|
|
49
70
|
return meta;
|
|
50
71
|
}
|
|
@@ -53,6 +74,7 @@ export class DbToolService {
|
|
|
53
74
|
if (!ok)
|
|
54
75
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
55
76
|
void this.dropAdapters(id);
|
|
77
|
+
void this.dropConsoleSessions(id);
|
|
56
78
|
this.audit('', id, 'remove_connection', id, 'none', false, true);
|
|
57
79
|
return ok;
|
|
58
80
|
}
|
|
@@ -133,14 +155,17 @@ export class DbToolService {
|
|
|
133
155
|
grant(projectPath, connId, mode) {
|
|
134
156
|
const key = normalizeProjectKey(projectPath);
|
|
135
157
|
this.store.grants.grant(key, connId, mode);
|
|
136
|
-
// 授权模式变更(含 rw→ro
|
|
158
|
+
// 授权模式变更(含 rw→ro 降级)必须作废旧会话适配器,否则降级不生效;
|
|
159
|
+
// 控制台事务会话的粘性适配器不在缓存里,单独回收(回滚未提交事务并 close)
|
|
137
160
|
void this.dropAdapters(connId);
|
|
161
|
+
void this.dropConsoleSessions(connId);
|
|
138
162
|
this.audit(key, connId, 'grant', `${connId} -> ${mode}`, 'none', false, true);
|
|
139
163
|
}
|
|
140
164
|
revokeGrant(projectPath, connId) {
|
|
141
165
|
const key = normalizeProjectKey(projectPath);
|
|
142
166
|
this.store.grants.revoke(key, connId);
|
|
143
167
|
void this.dropAdapters(connId);
|
|
168
|
+
void this.dropConsoleSessions(connId);
|
|
144
169
|
this.audit(key, connId, 'revoke', connId, 'none', false, true);
|
|
145
170
|
}
|
|
146
171
|
/* -- 浏览(ro 即可) -- */
|
|
@@ -280,6 +305,113 @@ export class DbToolService {
|
|
|
280
305
|
await sessionAdapter.close().catch(() => { });
|
|
281
306
|
}
|
|
282
307
|
}
|
|
308
|
+
/* -- 控制台事务会话(粘性连接;语义对齐 runGuarded) -- */
|
|
309
|
+
/**
|
|
310
|
+
* 开始控制台事务:authorize(needWrite=true)(ro 直接 READ_ONLY 拒)→ 创建会话专用
|
|
311
|
+
* 适配器并打开粘性事务通道 → 返回 sessionToken。database 入参显式拒绝:粘性事务
|
|
312
|
+
* 绑定连接默认库、无法跨库,客户端切了非默认库时静默跑错库比报错更危险。
|
|
313
|
+
*/
|
|
314
|
+
async consoleBegin(projectPath, connId, database) {
|
|
315
|
+
if (database !== undefined) {
|
|
316
|
+
throw new DbToolError('INVALID_ARGUMENT', '事务会话暂不支持指定数据库(粘性事务绑定连接默认库),请清空库选择后再开始事务');
|
|
317
|
+
}
|
|
318
|
+
// authorize(needWrite=true) 已拒绝 ro 授权(与 runScript 同构;顺带建立缓存适配器)
|
|
319
|
+
const { key, mode } = await this.authorize(projectPath, connId, true);
|
|
320
|
+
const rc = this.requireConn(connId);
|
|
321
|
+
// 每会话独占一个驱动池 + checkout 连接,放任创建会耗尽数据库连接(ponytail: 固定上限,不做 LRU)
|
|
322
|
+
const live = [...this.consoleSessions.values()].filter((s) => s.connId === connId).length;
|
|
323
|
+
if (live >= MAX_CONSOLE_SESSIONS_PER_CONN) {
|
|
324
|
+
throw new DbToolError('INVALID_ARGUMENT', `该连接并发事务会话已达上限(${MAX_CONSOLE_SESSIONS_PER_CONN}),请先提交或回滚既有事务`);
|
|
325
|
+
}
|
|
326
|
+
const factory = await this.resolver(rc.meta.kind);
|
|
327
|
+
const sessionAdapter = await factory(rc, { mode }).catch((e) => {
|
|
328
|
+
throw e instanceof DbToolError ? e : this.toDriverError(e);
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
const session = await this.openConsoleSession(key, connId, sessionAdapter);
|
|
332
|
+
try {
|
|
333
|
+
// 注册前复核授权:authorize → 注册之间有多次 await,窗口内 revoke/降级触发的
|
|
334
|
+
// dropConsoleSessions 扫不到本会话(TOCTOU);授权已变更则回滚并经外层审计后抛
|
|
335
|
+
this.recheckConsoleAuth(session);
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
await session.finish('rollback').catch(() => { });
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
this.consoleSessions.set(session.token, session);
|
|
342
|
+
this.audit(key, connId, 'console_begin', 'BEGIN', 'none', false, true);
|
|
343
|
+
return { sessionToken: session.token };
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
await sessionAdapter.close().catch(() => { });
|
|
347
|
+
this.audit(key, connId, 'console_begin', 'BEGIN', 'none', false, false, this.errText(e));
|
|
348
|
+
throw this.toDriverError(e);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 会话内执行一条语句:语义等同 execute——classifyStatement + challenge + 审计,
|
|
353
|
+
* 在粘性通道串行执行。读语句走适配器 query 通道保留结果集(事务内 SELECT 看
|
|
354
|
+
* 未提交数据是核心场景);写/管理语句走 execute 通道。
|
|
355
|
+
*/
|
|
356
|
+
async consoleExec(sessionToken, statement, params, challengeId) {
|
|
357
|
+
const stmt = assertNonEmpty(statement, 'statement');
|
|
358
|
+
if (params !== undefined && !Array.isArray(params)) {
|
|
359
|
+
throw new DbToolError('INVALID_ARGUMENT', 'params 必须是数组(绑定参数)');
|
|
360
|
+
}
|
|
361
|
+
const s = this.requireConsoleSession(sessionToken);
|
|
362
|
+
s.lastAccess = Date.now();
|
|
363
|
+
// 授权复核:会话适配器不在 adapterCache,dropAdapters 管不到——revoke/rw→ro 降级必须即时生效
|
|
364
|
+
this.recheckConsoleAuth(s);
|
|
365
|
+
const kind = s.adapter.kind;
|
|
366
|
+
const verdict = classifyStatement(kind, stmt, 'execute');
|
|
367
|
+
if (verdict.level === 'danger') {
|
|
368
|
+
const scope = { connId: s.connId, projectKey: s.projectKey };
|
|
369
|
+
if (!challengeId) {
|
|
370
|
+
const { id: cid } = this.challenges.create(stmt, scope);
|
|
371
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, 'danger', false, false, 'NEEDS_CONFIRMATION');
|
|
372
|
+
return { needConfirmation: true, challengeId: cid, statement: stmt, danger: 'danger', reason: verdict.reason ?? '危险操作,需要用户确认' };
|
|
373
|
+
}
|
|
374
|
+
if (!this.challenges.consume(challengeId, stmt, scope)) {
|
|
375
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, 'danger', false, false, 'INVALID_CHALLENGE');
|
|
376
|
+
throw new DbToolError('INVALID_CHALLENGE', '确认凭据无效(不存在、已使用、已过期或语句已变更),请重新发起');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const result = await s.run(stmt, params);
|
|
381
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, verdict.level, verdict.level === 'danger', true, undefined, 'rowCount' in result ? result.rowCount : result.affectedRows);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, verdict.level, verdict.level === 'danger', false, this.errText(e));
|
|
386
|
+
throw this.toDriverError(e);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** 粘性连接提交 → 销毁会话并 close 适配器(对应官方 finally release 语义);审计照落 */
|
|
390
|
+
consoleCommit(sessionToken) {
|
|
391
|
+
return this.consoleTeardown(sessionToken, 'commit');
|
|
392
|
+
}
|
|
393
|
+
/** 粘性连接回滚 → 销毁会话并 close 适配器;审计照落 */
|
|
394
|
+
consoleRollback(sessionToken) {
|
|
395
|
+
return this.consoleTeardown(sessionToken, 'rollback');
|
|
396
|
+
}
|
|
397
|
+
/** commit/rollback 共用结算:授权复核 → 摘除会话 → finish → 审计(verb 参数化,两路镜像合一) */
|
|
398
|
+
async consoleTeardown(sessionToken, how) {
|
|
399
|
+
const s = this.requireConsoleSession(sessionToken);
|
|
400
|
+
s.lastAccess = Date.now();
|
|
401
|
+
this.recheckConsoleAuth(s);
|
|
402
|
+
this.consoleSessions.delete(sessionToken); // 先摘除防并发重入;成败都销毁
|
|
403
|
+
const action = how === 'commit' ? 'console_commit' : 'console_rollback';
|
|
404
|
+
const verb = how.toUpperCase();
|
|
405
|
+
try {
|
|
406
|
+
await s.finish(how);
|
|
407
|
+
this.audit(s.projectKey, s.connId, action, verb, 'none', false, true);
|
|
408
|
+
return { message: how === 'commit' ? '事务已提交' : '事务已回滚' };
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
this.audit(s.projectKey, s.connId, action, verb, 'none', false, false, this.errText(e));
|
|
412
|
+
throw this.toDriverError(e);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
283
415
|
/* -- 审计与状态 -- */
|
|
284
416
|
auditTail(projectPath, limit) {
|
|
285
417
|
const n = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
|
|
@@ -296,10 +428,24 @@ export class DbToolService {
|
|
|
296
428
|
auditTail: this.auditTail(projectPath, 50),
|
|
297
429
|
};
|
|
298
430
|
}
|
|
299
|
-
/**
|
|
431
|
+
/** 关闭缓存的适配器、回收控制台事务会话并停止 challenge/TTL 清理 */
|
|
300
432
|
async dispose() {
|
|
301
433
|
this.disposed = true;
|
|
302
434
|
this.challenges.dispose();
|
|
435
|
+
if (this.consoleSweepTimer) {
|
|
436
|
+
clearInterval(this.consoleSweepTimer);
|
|
437
|
+
this.consoleSweepTimer = undefined;
|
|
438
|
+
}
|
|
439
|
+
const sessions = [...this.consoleSessions.values()];
|
|
440
|
+
this.consoleSessions.clear();
|
|
441
|
+
for (const s of sessions) {
|
|
442
|
+
try {
|
|
443
|
+
await s.finish('rollback'); // 服务关闭:未提交事务一律回滚,不留悬挂连接
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// 已断开等场景忽略
|
|
447
|
+
}
|
|
448
|
+
}
|
|
303
449
|
const pending = [...this.adapterCache.values()];
|
|
304
450
|
this.adapterCache.clear();
|
|
305
451
|
for (const p of pending) {
|
|
@@ -355,6 +501,162 @@ export class DbToolService {
|
|
|
355
501
|
}
|
|
356
502
|
}
|
|
357
503
|
}
|
|
504
|
+
/* -- 控制台事务会话内部 -- */
|
|
505
|
+
requireConsoleSession(token) {
|
|
506
|
+
const s = this.consoleSessions.get(token);
|
|
507
|
+
if (!s)
|
|
508
|
+
throw new DbToolError('NOT_FOUND', '事务会话不存在(已提交/回滚或空闲超时回收)');
|
|
509
|
+
return s;
|
|
510
|
+
}
|
|
511
|
+
/** 会话存活期间的授权复核(语义与 authorize 一致,但不取适配器) */
|
|
512
|
+
recheckConsoleAuth(s) {
|
|
513
|
+
const mode = this.store.grants.check(s.projectKey, s.connId);
|
|
514
|
+
if (!mode)
|
|
515
|
+
throw new DbToolError('UNAUTHORIZED_PROJECT', `项目未授权该连接: ${s.connId}`);
|
|
516
|
+
if (mode === 'ro')
|
|
517
|
+
throw new DbToolError('READ_ONLY', '该连接对本项目为只读授权(ro),拒绝写操作');
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* 打开粘性事务通道。按适配器 tx 可选成员的两种运行时形态分派
|
|
521
|
+
* (见 ConsoleTxSession 注释)。不支持事务的 kind(无 tx)报 INVALID_ARGUMENT。
|
|
522
|
+
*/
|
|
523
|
+
async openConsoleSession(key, connId, adapter) {
|
|
524
|
+
const token = 't_' + randomBytes(12).toString('hex');
|
|
525
|
+
const tx = adapter.tx;
|
|
526
|
+
if (!tx) {
|
|
527
|
+
throw new DbToolError('INVALID_ARGUMENT', `连接类型 ${adapter.kind} 不支持控制台事务会话`);
|
|
528
|
+
}
|
|
529
|
+
const chain = { p: Promise.resolve() };
|
|
530
|
+
// 结算标志:commit/rollback/sweep/drop/dispose 并发到达时首个 finisher 生效,后续 no-op
|
|
531
|
+
// (finish 一律经 enqueue 排到链尾,等在途语句完成再提交/回滚,避免 exec 跑在事务外)
|
|
532
|
+
let settled = false;
|
|
533
|
+
const lastAccess = Date.now();
|
|
534
|
+
if (typeof tx === 'function') {
|
|
535
|
+
// —— tx(fn) 形态(oracle/dmdb):挂起 fn 模式。fn 第一段同步登记 exec 通道并
|
|
536
|
+
// 触发 ready;commit 以 resolve 结束 fn、rollback 以 reject 结束 fn,提交/
|
|
537
|
+
// 回滚与异常回滚由适配器 tx 原子实现完成。opened 已把 reject 归一为
|
|
538
|
+
// { txError }(rollback 属正常路径,不算失败)。exec 通道按语句头返回
|
|
539
|
+
// 双形状(读语句 QueryResult / 其余 ExecResult,分流在适配器内完成);
|
|
540
|
+
// 契约侧(types.ts)exec 返回 unknown,落地形状在 run 处收窄断言。
|
|
541
|
+
let channel = null;
|
|
542
|
+
let settle;
|
|
543
|
+
const done = new Promise((resolve, reject) => {
|
|
544
|
+
settle = { resolve, reject };
|
|
545
|
+
});
|
|
546
|
+
let ready;
|
|
547
|
+
const readyP = new Promise((r) => {
|
|
548
|
+
ready = r;
|
|
549
|
+
});
|
|
550
|
+
const opened = tx(async (exec) => {
|
|
551
|
+
channel = exec;
|
|
552
|
+
ready();
|
|
553
|
+
await done;
|
|
554
|
+
return null;
|
|
555
|
+
})
|
|
556
|
+
.then(() => ({}), (e) => ({ txError: e }));
|
|
557
|
+
// begin 等待:fn 被调用(通道就绪)或 tx 开启失败(getConnection/SET TRANSACTION 失败)
|
|
558
|
+
const outcome = await Promise.race([
|
|
559
|
+
readyP.then(() => ({ ok: true })),
|
|
560
|
+
opened.then((v) => ({ ok: false, err: v.txError })),
|
|
561
|
+
]);
|
|
562
|
+
if (!outcome.ok)
|
|
563
|
+
throw this.toDriverError(outcome.err);
|
|
564
|
+
return {
|
|
565
|
+
token,
|
|
566
|
+
projectKey: key,
|
|
567
|
+
connId,
|
|
568
|
+
adapter,
|
|
569
|
+
lastAccess,
|
|
570
|
+
run: (stmt, params) => enqueue(chain, async () => {
|
|
571
|
+
if (!channel)
|
|
572
|
+
throw new DbToolError('DRIVER_ERROR', '事务通道已关闭');
|
|
573
|
+
return (await channel(stmt, params));
|
|
574
|
+
}),
|
|
575
|
+
finish: (how) => {
|
|
576
|
+
// 首个 finisher 生效;任务排到链尾等在途语句完成后才提交/回滚
|
|
577
|
+
if (settled)
|
|
578
|
+
return Promise.resolve();
|
|
579
|
+
settled = true;
|
|
580
|
+
return enqueue(chain, async () => {
|
|
581
|
+
try {
|
|
582
|
+
if (how === 'commit') {
|
|
583
|
+
settle.resolve();
|
|
584
|
+
const v = await opened;
|
|
585
|
+
// commit 路径的 txError 是真实提交失败,必须上抛供审计
|
|
586
|
+
if (v.txError !== undefined)
|
|
587
|
+
throw v.txError;
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
// rollback 以 reject 结束 fn:tx 原子实现执行 ROLLBACK 并吞掉回滚自身的
|
|
591
|
+
// 次生错误(适配器语义),占位 reject 原因不是真实故障,不上抛
|
|
592
|
+
settle.reject(new Error('控制台事务回滚'));
|
|
593
|
+
await opened;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
await adapter.close().catch(() => { });
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
// —— TxHandle 形态(mysql/pg 系):begin 即 checkout 粘性连接并执行 BEGIN;
|
|
604
|
+
// 之后适配器把 query/execute 路由到同一连接,exec 直接透传即可。
|
|
605
|
+
await tx.begin();
|
|
606
|
+
return {
|
|
607
|
+
token,
|
|
608
|
+
projectKey: key,
|
|
609
|
+
connId,
|
|
610
|
+
adapter,
|
|
611
|
+
lastAccess,
|
|
612
|
+
run: (stmt, params) => enqueue(chain, () => SQL_READ_HEADS.has(sqlHead(stmt)) ? adapter.query(stmt, params) : adapter.execute(stmt, params)),
|
|
613
|
+
finish: (how) => {
|
|
614
|
+
// 首个 finisher 生效;任务排到链尾等在途语句完成后才提交/回滚
|
|
615
|
+
if (settled)
|
|
616
|
+
return Promise.resolve();
|
|
617
|
+
settled = true;
|
|
618
|
+
return enqueue(chain, async () => {
|
|
619
|
+
try {
|
|
620
|
+
if (how === 'commit')
|
|
621
|
+
await tx.commit();
|
|
622
|
+
else
|
|
623
|
+
await tx.rollback();
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
await adapter.close().catch(() => { });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** 空闲 TTL 扫描:超时会话强制回滚(close 适配器)并审计一条超时回滚 */
|
|
633
|
+
consoleSweep() {
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
for (const [token, s] of this.consoleSessions) {
|
|
636
|
+
if (now - s.lastAccess <= this.consoleTtlMs)
|
|
637
|
+
continue;
|
|
638
|
+
this.consoleSessions.delete(token);
|
|
639
|
+
void s
|
|
640
|
+
.finish('rollback')
|
|
641
|
+
// 回收原因放 statement 说明槽:ok:true 的记录不得带 error 槽(矛盾审计)
|
|
642
|
+
.then(() => this.audit(s.projectKey, s.connId, 'console_rollback', 'ROLLBACK(空闲超时自动回收)', 'none', false, true))
|
|
643
|
+
.catch((e) => this.audit(s.projectKey, s.connId, 'console_rollback', 'ROLLBACK', 'none', false, false, `空闲超时自动回滚失败: ${this.errText(e)}`));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** 授权变更时回收指定连接的全部控制台事务会话(回滚并 close,不审计——非业务事件) */
|
|
647
|
+
async dropConsoleSessions(connId) {
|
|
648
|
+
for (const [token, s] of this.consoleSessions) {
|
|
649
|
+
if (s.connId !== connId)
|
|
650
|
+
continue;
|
|
651
|
+
this.consoleSessions.delete(token);
|
|
652
|
+
try {
|
|
653
|
+
await s.finish('rollback');
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// 已断开等场景忽略
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
358
660
|
adapterFor(connId, mode) {
|
|
359
661
|
const cacheKey = `${connId}@mode=${mode}`;
|
|
360
662
|
let p = this.adapterCache.get(cacheKey);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.91",
|
|
4
4
|
"description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
"test": "vitest run",
|
|
27
27
|
"test:watch": "vitest",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
|
-
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('fs'); fs.mkdirSync('dist/script',{recursive:true}); fs.copyFileSync('lib/script/worker.cjs','dist/script/worker.cjs'); console.log('build OK')\" && node scripts/build-mongodb.mjs",
|
|
29
|
+
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('fs'); fs.mkdirSync('dist/script',{recursive:true}); fs.copyFileSync('lib/script/worker.cjs','dist/script/worker.cjs'); console.log('build OK')\" && node scripts/build-mongodb.mjs && node scripts/build-codemirror.mjs",
|
|
30
30
|
"build:mongodb": "node scripts/build-mongodb.mjs",
|
|
31
|
+
"build:codemirror": "node scripts/build-codemirror.mjs",
|
|
31
32
|
"prepack": "npm run build",
|
|
32
33
|
"patch:dsh": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/apply-dsh-hotfix.ps1",
|
|
33
34
|
"patch:dsh:sh": "bash scripts/apply-dsh-hotfix.sh"
|
|
@@ -56,7 +57,14 @@
|
|
|
56
57
|
"better-sqlite3": "^13.0.3"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
60
|
+
"@codemirror/autocomplete": "^6.20.3",
|
|
61
|
+
"@codemirror/commands": "^6.11.1",
|
|
62
|
+
"@codemirror/lang-sql": "^6.10.0",
|
|
63
|
+
"@codemirror/language": "^6.12.4",
|
|
64
|
+
"@codemirror/state": "^6.7.6",
|
|
65
|
+
"@codemirror/view": "^6.43.13",
|
|
59
66
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
67
|
+
"@lezer/highlight": "^1.2.4",
|
|
60
68
|
"@stryker-mutator/core": "^10.0.0",
|
|
61
69
|
"@stryker-mutator/vitest-runner": "^10.0.0",
|
|
62
70
|
"@types/better-sqlite3": "latest",
|
|
@@ -64,6 +72,7 @@
|
|
|
64
72
|
"dsh-better-sidebar": "^0.24.1",
|
|
65
73
|
"esbuild": "^0.28.0",
|
|
66
74
|
"mongodb": "^7.6.0",
|
|
75
|
+
"sql-formatter": "^15.9.0",
|
|
67
76
|
"stryker-cli": "^1.1.0",
|
|
68
77
|
"typescript": "^5.9.3",
|
|
69
78
|
"vitest": "^4.1.11"
|
|
@@ -81,6 +90,7 @@
|
|
|
81
90
|
"patches",
|
|
82
91
|
"scripts",
|
|
83
92
|
"vendor/mongodb-driver.cjs",
|
|
93
|
+
"vendor/codemirror-sql.cjs",
|
|
84
94
|
"README.md",
|
|
85
95
|
"README_EN.md"
|
|
86
96
|
]
|