dsh-db-tool 1.5.9 → 1.6.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/README.md +14 -2
- package/README_EN.md +14 -2
- package/client/client.js +1284 -183
- 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 +40 -0
- package/dist/manager.js +362 -5
- package/dist/store/connections.js +85 -2
- package/dist/store/export-crypto.js +78 -0
- package/dist/store/index.js +2 -1
- package/docs/connection-export-import.md +172 -0
- 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
|
@@ -148,6 +148,27 @@ async function route(req, res, service, path, q, opts) {
|
|
|
148
148
|
}));
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
// 连接导出/导入(口令加密包)。必须置于 connMatch 之前:
|
|
152
|
+
// /api/connections/export 与 /api/connections/import 都是单层路径,
|
|
153
|
+
// 若落到 connMatch 会被当作连接 id 匹配(DELETE 无分支静默 404、PUT 误更新)。
|
|
154
|
+
if (path === '/api/connections/export' && req.method === 'POST') {
|
|
155
|
+
const b = await readBody(req);
|
|
156
|
+
const passphrase = str(b['passphrase']);
|
|
157
|
+
if (!passphrase)
|
|
158
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
159
|
+
const count = service.listConnections().length; // 权威条数,前端不再用可能过期的本地列表
|
|
160
|
+
return sendOk(res, { json: service.exportConnections(passphrase), count });
|
|
161
|
+
}
|
|
162
|
+
if (path === '/api/connections/import' && req.method === 'POST') {
|
|
163
|
+
const b = await readBody(req);
|
|
164
|
+
const json = str(b['json']);
|
|
165
|
+
const passphrase = str(b['passphrase']);
|
|
166
|
+
if (!json)
|
|
167
|
+
return send(res, 400, { ok: false, error: '缺少 json', code: 'INVALID_ARGUMENT' });
|
|
168
|
+
if (!passphrase)
|
|
169
|
+
return send(res, 400, { ok: false, error: '缺少 passphrase', code: 'INVALID_ARGUMENT' });
|
|
170
|
+
return sendOk(res, service.importConnections(json, passphrase));
|
|
171
|
+
}
|
|
151
172
|
// 测试未保存的连接草稿(保存前测试;不落库)
|
|
152
173
|
if (path === '/api/test-draft' && req.method === 'POST') {
|
|
153
174
|
const b = await readBody(req);
|
|
@@ -241,6 +262,25 @@ async function route(req, res, service, path, q, opts) {
|
|
|
241
262
|
const b = await readBody(req);
|
|
242
263
|
return await sendMaybeConfirm(res, service.runScript(projectOfBody(b), str(b['connId']), str(b['code']), optStr(b['challengeId'])));
|
|
243
264
|
}
|
|
265
|
+
// 控制台事务会话(粘性连接):begin 发 token;exec 语义等同 execute(可能
|
|
266
|
+
// NEEDS_CONFIRMATION);commit/rollback 提交/回滚后销毁会话。trust 与 body
|
|
267
|
+
// 校验复用入口层既有链路。
|
|
268
|
+
if (path === '/api/console/begin' && req.method === 'POST') {
|
|
269
|
+
const b = await readBody(req);
|
|
270
|
+
return sendOk(res, await service.consoleBegin(projectOfBody(b), str(b['connId']), optStr(b['database'])));
|
|
271
|
+
}
|
|
272
|
+
if (path === '/api/console/exec' && req.method === 'POST') {
|
|
273
|
+
const b = await readBody(req);
|
|
274
|
+
return await sendMaybeConfirm(res, service.consoleExec(str(b['sessionToken']), str(b['statement']), b['params'], optStr(b['challengeId'])));
|
|
275
|
+
}
|
|
276
|
+
if (path === '/api/console/commit' && req.method === 'POST') {
|
|
277
|
+
const b = await readBody(req);
|
|
278
|
+
return await sendMaybeConfirm(res, service.consoleCommit(str(b['sessionToken'])));
|
|
279
|
+
}
|
|
280
|
+
if (path === '/api/console/rollback' && req.method === 'POST') {
|
|
281
|
+
const b = await readBody(req);
|
|
282
|
+
return await sendMaybeConfirm(res, service.consoleRollback(str(b['sessionToken'])));
|
|
283
|
+
}
|
|
244
284
|
if (path === '/api/audit' && req.method === 'GET') {
|
|
245
285
|
const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
|
|
246
286
|
return sendOk(res, service.auditTail(q.get('project') ?? undefined, limit));
|
package/dist/manager.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { getAdapter } from './adapters/index.js';
|
|
2
|
-
import { ChallengeStore, classifyStatement, } from './guard/index.js';
|
|
3
|
-
import { mergeUrlCredentials, normalizeProjectKey, } from './store/index.js';
|
|
3
|
+
import { ChallengeStore, classifyStatement, sqlHead, SQL_READ_HEADS, } from './guard/index.js';
|
|
4
|
+
import { mergeUrlCredentials, normalizeProjectKey, DECRYPT_FAIL_MESSAGE, } from './store/index.js';
|
|
4
5
|
import { runScriptInChild } from './script/runner.js';
|
|
6
|
+
import { decryptPayload, encryptPayload } from './store/export-crypto.js';
|
|
5
7
|
/* ---------------- 错误与结果类型 ---------------- */
|
|
6
8
|
/** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
|
|
7
9
|
export class DbToolError extends Error {
|
|
@@ -12,16 +14,28 @@ export class DbToolError extends Error {
|
|
|
12
14
|
this.name = 'DbToolError';
|
|
13
15
|
}
|
|
14
16
|
}
|
|
17
|
+
/** 粘性通道的语句串行队列:前序失败不阻塞后续,但执行严格按到达顺序 */
|
|
18
|
+
function enqueue(chain, task) {
|
|
19
|
+
const next = chain.p.then(task, task);
|
|
20
|
+
chain.p = next.catch(() => { });
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
15
23
|
function assertNonEmpty(v, label) {
|
|
16
24
|
const s = typeof v === 'string' ? v.trim() : '';
|
|
17
25
|
if (s === '')
|
|
18
26
|
throw new DbToolError('INVALID_ARGUMENT', `缺少必填参数: ${label}`);
|
|
19
27
|
return s;
|
|
20
28
|
}
|
|
29
|
+
/** 单连接并发控制台事务会话上限(每会话独占驱动池 + checkout 连接,防连接耗尽) */
|
|
30
|
+
const MAX_CONSOLE_SESSIONS_PER_CONN = 4;
|
|
21
31
|
/* ---------------- 服务核心 ---------------- */
|
|
22
32
|
export class DbToolService {
|
|
23
33
|
store;
|
|
24
34
|
adapterCache = new Map();
|
|
35
|
+
/** 控制台事务会话注册表(token → 会话) */
|
|
36
|
+
consoleSessions = new Map();
|
|
37
|
+
consoleTtlMs;
|
|
38
|
+
consoleSweepTimer;
|
|
25
39
|
challenges;
|
|
26
40
|
resolver;
|
|
27
41
|
disposed = false;
|
|
@@ -29,6 +43,12 @@ export class DbToolService {
|
|
|
29
43
|
this.store = store;
|
|
30
44
|
this.resolver = opts?.adapterResolver ?? getAdapter;
|
|
31
45
|
this.challenges = opts?.challenges ?? new ChallengeStore();
|
|
46
|
+
this.consoleTtlMs = opts?.consoleTx?.ttlMs ?? 5 * 60 * 1000;
|
|
47
|
+
const sweep = opts?.consoleTx?.sweepIntervalMs ?? 30 * 1000;
|
|
48
|
+
if (sweep > 0) {
|
|
49
|
+
this.consoleSweepTimer = setInterval(() => this.consoleSweep(), sweep);
|
|
50
|
+
this.consoleSweepTimer.unref?.();
|
|
51
|
+
}
|
|
32
52
|
}
|
|
33
53
|
/* -- 连接管理(无需授权) -- */
|
|
34
54
|
listConnections() {
|
|
@@ -43,8 +63,10 @@ export class DbToolService {
|
|
|
43
63
|
const meta = this.store.connections.update(id, patch);
|
|
44
64
|
if (!meta)
|
|
45
65
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
46
|
-
// url/凭证/ssl
|
|
66
|
+
// url/凭证/ssl 变更后旧适配器仍持旧连接串,必须作废重建;
|
|
67
|
+
// 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL)
|
|
47
68
|
void this.dropAdapters(id);
|
|
69
|
+
void this.dropConsoleSessions(id);
|
|
48
70
|
this.audit('', id, 'update_connection', id, 'none', false, true);
|
|
49
71
|
return meta;
|
|
50
72
|
}
|
|
@@ -53,6 +75,7 @@ export class DbToolService {
|
|
|
53
75
|
if (!ok)
|
|
54
76
|
throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
|
|
55
77
|
void this.dropAdapters(id);
|
|
78
|
+
void this.dropConsoleSessions(id);
|
|
56
79
|
this.audit('', id, 'remove_connection', id, 'none', false, true);
|
|
57
80
|
return ok;
|
|
58
81
|
}
|
|
@@ -122,6 +145,60 @@ export class DbToolService {
|
|
|
122
145
|
throw this.toDriverError(e);
|
|
123
146
|
}
|
|
124
147
|
}
|
|
148
|
+
/* -- 连接导出/导入(口令加密,无需授权;口令不落审计) -- */
|
|
149
|
+
/** 口令下限:导出包含全部连接机密(明文密码/完整 URL),安全边界在服务端而非 UI */
|
|
150
|
+
static PASSPHRASE_MIN = 8;
|
|
151
|
+
/**
|
|
152
|
+
* 导出全部连接(含机密)为口令加密的 JSON 密文。
|
|
153
|
+
* 审计只记连接条数,绝不落口令与密文内容。
|
|
154
|
+
*/
|
|
155
|
+
exportConnections(passphrase) {
|
|
156
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
157
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
158
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
159
|
+
}
|
|
160
|
+
const bundle = this.store.connections.exportBundle();
|
|
161
|
+
const json = encryptPayload(bundle, pp);
|
|
162
|
+
this.audit('', '', 'export_connections', `导出 ${bundle.connections.length} 个连接`, 'none', false, true);
|
|
163
|
+
return json;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 从口令加密的 JSON 密文导入连接。口令错误或密文损坏统一报
|
|
167
|
+
* INVALID_ARGUMENT(DECRYPT_FAIL_MESSAGE)(不区分原因,防口令探测)。
|
|
168
|
+
*/
|
|
169
|
+
importConnections(encryptedJson, passphrase) {
|
|
170
|
+
const enc = assertNonEmpty(encryptedJson, 'json');
|
|
171
|
+
const pp = assertNonEmpty(passphrase, 'passphrase');
|
|
172
|
+
if (pp.length < DbToolService.PASSPHRASE_MIN) {
|
|
173
|
+
throw new DbToolError('INVALID_ARGUMENT', `口令长度至少 ${DbToolService.PASSPHRASE_MIN} 位`);
|
|
174
|
+
}
|
|
175
|
+
let bundle;
|
|
176
|
+
try {
|
|
177
|
+
bundle = decryptPayload(enc, pp);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// 口令错误/密文损坏/非合法 JSON 统一归一(decryptPayload 已统一文案,这里兜底)
|
|
181
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
182
|
+
}
|
|
183
|
+
// 结构校验:必须是含 connections 数组的对象,且每个元素为带非空 string id/kind 的对象。
|
|
184
|
+
// 畸形元素会让 importBundle 在 try 之外抛 TypeError(绕开统一文案),
|
|
185
|
+
// 或以 undefined id 静默落库成 UI 删不掉的幽灵记录
|
|
186
|
+
const conns = bundle?.connections;
|
|
187
|
+
if (!bundle ||
|
|
188
|
+
typeof bundle !== 'object' ||
|
|
189
|
+
!Array.isArray(conns) ||
|
|
190
|
+
conns.some((c) => c === null ||
|
|
191
|
+
typeof c !== 'object' ||
|
|
192
|
+
typeof c.id !== 'string' ||
|
|
193
|
+
c.id === '' ||
|
|
194
|
+
typeof c.kind !== 'string' ||
|
|
195
|
+
c.kind === '')) {
|
|
196
|
+
throw new DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE);
|
|
197
|
+
}
|
|
198
|
+
const summary = this.store.connections.importBundle(bundle);
|
|
199
|
+
this.audit('', '', 'import_connections', `导入 ${summary.imported} 个连接,跳过 ${summary.skipped.length} 个,失败 ${summary.errors.length} 个`, 'none', false, true);
|
|
200
|
+
return summary;
|
|
201
|
+
}
|
|
125
202
|
/* -- 授权管理 -- */
|
|
126
203
|
/** 项目路径归一化(/api/project-context 无会话解析器时的回退路径) */
|
|
127
204
|
projectKey(projectPath) {
|
|
@@ -133,14 +210,17 @@ export class DbToolService {
|
|
|
133
210
|
grant(projectPath, connId, mode) {
|
|
134
211
|
const key = normalizeProjectKey(projectPath);
|
|
135
212
|
this.store.grants.grant(key, connId, mode);
|
|
136
|
-
// 授权模式变更(含 rw→ro
|
|
213
|
+
// 授权模式变更(含 rw→ro 降级)必须作废旧会话适配器,否则降级不生效;
|
|
214
|
+
// 控制台事务会话的粘性适配器不在缓存里,单独回收(回滚未提交事务并 close)
|
|
137
215
|
void this.dropAdapters(connId);
|
|
216
|
+
void this.dropConsoleSessions(connId);
|
|
138
217
|
this.audit(key, connId, 'grant', `${connId} -> ${mode}`, 'none', false, true);
|
|
139
218
|
}
|
|
140
219
|
revokeGrant(projectPath, connId) {
|
|
141
220
|
const key = normalizeProjectKey(projectPath);
|
|
142
221
|
this.store.grants.revoke(key, connId);
|
|
143
222
|
void this.dropAdapters(connId);
|
|
223
|
+
void this.dropConsoleSessions(connId);
|
|
144
224
|
this.audit(key, connId, 'revoke', connId, 'none', false, true);
|
|
145
225
|
}
|
|
146
226
|
/* -- 浏览(ro 即可) -- */
|
|
@@ -280,6 +360,113 @@ export class DbToolService {
|
|
|
280
360
|
await sessionAdapter.close().catch(() => { });
|
|
281
361
|
}
|
|
282
362
|
}
|
|
363
|
+
/* -- 控制台事务会话(粘性连接;语义对齐 runGuarded) -- */
|
|
364
|
+
/**
|
|
365
|
+
* 开始控制台事务:authorize(needWrite=true)(ro 直接 READ_ONLY 拒)→ 创建会话专用
|
|
366
|
+
* 适配器并打开粘性事务通道 → 返回 sessionToken。database 入参显式拒绝:粘性事务
|
|
367
|
+
* 绑定连接默认库、无法跨库,客户端切了非默认库时静默跑错库比报错更危险。
|
|
368
|
+
*/
|
|
369
|
+
async consoleBegin(projectPath, connId, database) {
|
|
370
|
+
if (database !== undefined) {
|
|
371
|
+
throw new DbToolError('INVALID_ARGUMENT', '事务会话暂不支持指定数据库(粘性事务绑定连接默认库),请清空库选择后再开始事务');
|
|
372
|
+
}
|
|
373
|
+
// authorize(needWrite=true) 已拒绝 ro 授权(与 runScript 同构;顺带建立缓存适配器)
|
|
374
|
+
const { key, mode } = await this.authorize(projectPath, connId, true);
|
|
375
|
+
const rc = this.requireConn(connId);
|
|
376
|
+
// 每会话独占一个驱动池 + checkout 连接,放任创建会耗尽数据库连接(ponytail: 固定上限,不做 LRU)
|
|
377
|
+
const live = [...this.consoleSessions.values()].filter((s) => s.connId === connId).length;
|
|
378
|
+
if (live >= MAX_CONSOLE_SESSIONS_PER_CONN) {
|
|
379
|
+
throw new DbToolError('INVALID_ARGUMENT', `该连接并发事务会话已达上限(${MAX_CONSOLE_SESSIONS_PER_CONN}),请先提交或回滚既有事务`);
|
|
380
|
+
}
|
|
381
|
+
const factory = await this.resolver(rc.meta.kind);
|
|
382
|
+
const sessionAdapter = await factory(rc, { mode }).catch((e) => {
|
|
383
|
+
throw e instanceof DbToolError ? e : this.toDriverError(e);
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
const session = await this.openConsoleSession(key, connId, sessionAdapter);
|
|
387
|
+
try {
|
|
388
|
+
// 注册前复核授权:authorize → 注册之间有多次 await,窗口内 revoke/降级触发的
|
|
389
|
+
// dropConsoleSessions 扫不到本会话(TOCTOU);授权已变更则回滚并经外层审计后抛
|
|
390
|
+
this.recheckConsoleAuth(session);
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
await session.finish('rollback').catch(() => { });
|
|
394
|
+
throw e;
|
|
395
|
+
}
|
|
396
|
+
this.consoleSessions.set(session.token, session);
|
|
397
|
+
this.audit(key, connId, 'console_begin', 'BEGIN', 'none', false, true);
|
|
398
|
+
return { sessionToken: session.token };
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
await sessionAdapter.close().catch(() => { });
|
|
402
|
+
this.audit(key, connId, 'console_begin', 'BEGIN', 'none', false, false, this.errText(e));
|
|
403
|
+
throw this.toDriverError(e);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 会话内执行一条语句:语义等同 execute——classifyStatement + challenge + 审计,
|
|
408
|
+
* 在粘性通道串行执行。读语句走适配器 query 通道保留结果集(事务内 SELECT 看
|
|
409
|
+
* 未提交数据是核心场景);写/管理语句走 execute 通道。
|
|
410
|
+
*/
|
|
411
|
+
async consoleExec(sessionToken, statement, params, challengeId) {
|
|
412
|
+
const stmt = assertNonEmpty(statement, 'statement');
|
|
413
|
+
if (params !== undefined && !Array.isArray(params)) {
|
|
414
|
+
throw new DbToolError('INVALID_ARGUMENT', 'params 必须是数组(绑定参数)');
|
|
415
|
+
}
|
|
416
|
+
const s = this.requireConsoleSession(sessionToken);
|
|
417
|
+
s.lastAccess = Date.now();
|
|
418
|
+
// 授权复核:会话适配器不在 adapterCache,dropAdapters 管不到——revoke/rw→ro 降级必须即时生效
|
|
419
|
+
this.recheckConsoleAuth(s);
|
|
420
|
+
const kind = s.adapter.kind;
|
|
421
|
+
const verdict = classifyStatement(kind, stmt, 'execute');
|
|
422
|
+
if (verdict.level === 'danger') {
|
|
423
|
+
const scope = { connId: s.connId, projectKey: s.projectKey };
|
|
424
|
+
if (!challengeId) {
|
|
425
|
+
const { id: cid } = this.challenges.create(stmt, scope);
|
|
426
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, 'danger', false, false, 'NEEDS_CONFIRMATION');
|
|
427
|
+
return { needConfirmation: true, challengeId: cid, statement: stmt, danger: 'danger', reason: verdict.reason ?? '危险操作,需要用户确认' };
|
|
428
|
+
}
|
|
429
|
+
if (!this.challenges.consume(challengeId, stmt, scope)) {
|
|
430
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, 'danger', false, false, 'INVALID_CHALLENGE');
|
|
431
|
+
throw new DbToolError('INVALID_CHALLENGE', '确认凭据无效(不存在、已使用、已过期或语句已变更),请重新发起');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const result = await s.run(stmt, params);
|
|
436
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, verdict.level, verdict.level === 'danger', true, undefined, 'rowCount' in result ? result.rowCount : result.affectedRows);
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
this.audit(s.projectKey, s.connId, 'console_exec', stmt, verdict.level, verdict.level === 'danger', false, this.errText(e));
|
|
441
|
+
throw this.toDriverError(e);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** 粘性连接提交 → 销毁会话并 close 适配器(对应官方 finally release 语义);审计照落 */
|
|
445
|
+
consoleCommit(sessionToken) {
|
|
446
|
+
return this.consoleTeardown(sessionToken, 'commit');
|
|
447
|
+
}
|
|
448
|
+
/** 粘性连接回滚 → 销毁会话并 close 适配器;审计照落 */
|
|
449
|
+
consoleRollback(sessionToken) {
|
|
450
|
+
return this.consoleTeardown(sessionToken, 'rollback');
|
|
451
|
+
}
|
|
452
|
+
/** commit/rollback 共用结算:授权复核 → 摘除会话 → finish → 审计(verb 参数化,两路镜像合一) */
|
|
453
|
+
async consoleTeardown(sessionToken, how) {
|
|
454
|
+
const s = this.requireConsoleSession(sessionToken);
|
|
455
|
+
s.lastAccess = Date.now();
|
|
456
|
+
this.recheckConsoleAuth(s);
|
|
457
|
+
this.consoleSessions.delete(sessionToken); // 先摘除防并发重入;成败都销毁
|
|
458
|
+
const action = how === 'commit' ? 'console_commit' : 'console_rollback';
|
|
459
|
+
const verb = how.toUpperCase();
|
|
460
|
+
try {
|
|
461
|
+
await s.finish(how);
|
|
462
|
+
this.audit(s.projectKey, s.connId, action, verb, 'none', false, true);
|
|
463
|
+
return { message: how === 'commit' ? '事务已提交' : '事务已回滚' };
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
this.audit(s.projectKey, s.connId, action, verb, 'none', false, false, this.errText(e));
|
|
467
|
+
throw this.toDriverError(e);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
283
470
|
/* -- 审计与状态 -- */
|
|
284
471
|
auditTail(projectPath, limit) {
|
|
285
472
|
const n = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
|
|
@@ -296,10 +483,24 @@ export class DbToolService {
|
|
|
296
483
|
auditTail: this.auditTail(projectPath, 50),
|
|
297
484
|
};
|
|
298
485
|
}
|
|
299
|
-
/**
|
|
486
|
+
/** 关闭缓存的适配器、回收控制台事务会话并停止 challenge/TTL 清理 */
|
|
300
487
|
async dispose() {
|
|
301
488
|
this.disposed = true;
|
|
302
489
|
this.challenges.dispose();
|
|
490
|
+
if (this.consoleSweepTimer) {
|
|
491
|
+
clearInterval(this.consoleSweepTimer);
|
|
492
|
+
this.consoleSweepTimer = undefined;
|
|
493
|
+
}
|
|
494
|
+
const sessions = [...this.consoleSessions.values()];
|
|
495
|
+
this.consoleSessions.clear();
|
|
496
|
+
for (const s of sessions) {
|
|
497
|
+
try {
|
|
498
|
+
await s.finish('rollback'); // 服务关闭:未提交事务一律回滚,不留悬挂连接
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
// 已断开等场景忽略
|
|
502
|
+
}
|
|
503
|
+
}
|
|
303
504
|
const pending = [...this.adapterCache.values()];
|
|
304
505
|
this.adapterCache.clear();
|
|
305
506
|
for (const p of pending) {
|
|
@@ -355,6 +556,162 @@ export class DbToolService {
|
|
|
355
556
|
}
|
|
356
557
|
}
|
|
357
558
|
}
|
|
559
|
+
/* -- 控制台事务会话内部 -- */
|
|
560
|
+
requireConsoleSession(token) {
|
|
561
|
+
const s = this.consoleSessions.get(token);
|
|
562
|
+
if (!s)
|
|
563
|
+
throw new DbToolError('NOT_FOUND', '事务会话不存在(已提交/回滚或空闲超时回收)');
|
|
564
|
+
return s;
|
|
565
|
+
}
|
|
566
|
+
/** 会话存活期间的授权复核(语义与 authorize 一致,但不取适配器) */
|
|
567
|
+
recheckConsoleAuth(s) {
|
|
568
|
+
const mode = this.store.grants.check(s.projectKey, s.connId);
|
|
569
|
+
if (!mode)
|
|
570
|
+
throw new DbToolError('UNAUTHORIZED_PROJECT', `项目未授权该连接: ${s.connId}`);
|
|
571
|
+
if (mode === 'ro')
|
|
572
|
+
throw new DbToolError('READ_ONLY', '该连接对本项目为只读授权(ro),拒绝写操作');
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* 打开粘性事务通道。按适配器 tx 可选成员的两种运行时形态分派
|
|
576
|
+
* (见 ConsoleTxSession 注释)。不支持事务的 kind(无 tx)报 INVALID_ARGUMENT。
|
|
577
|
+
*/
|
|
578
|
+
async openConsoleSession(key, connId, adapter) {
|
|
579
|
+
const token = 't_' + randomBytes(12).toString('hex');
|
|
580
|
+
const tx = adapter.tx;
|
|
581
|
+
if (!tx) {
|
|
582
|
+
throw new DbToolError('INVALID_ARGUMENT', `连接类型 ${adapter.kind} 不支持控制台事务会话`);
|
|
583
|
+
}
|
|
584
|
+
const chain = { p: Promise.resolve() };
|
|
585
|
+
// 结算标志:commit/rollback/sweep/drop/dispose 并发到达时首个 finisher 生效,后续 no-op
|
|
586
|
+
// (finish 一律经 enqueue 排到链尾,等在途语句完成再提交/回滚,避免 exec 跑在事务外)
|
|
587
|
+
let settled = false;
|
|
588
|
+
const lastAccess = Date.now();
|
|
589
|
+
if (typeof tx === 'function') {
|
|
590
|
+
// —— tx(fn) 形态(oracle/dmdb):挂起 fn 模式。fn 第一段同步登记 exec 通道并
|
|
591
|
+
// 触发 ready;commit 以 resolve 结束 fn、rollback 以 reject 结束 fn,提交/
|
|
592
|
+
// 回滚与异常回滚由适配器 tx 原子实现完成。opened 已把 reject 归一为
|
|
593
|
+
// { txError }(rollback 属正常路径,不算失败)。exec 通道按语句头返回
|
|
594
|
+
// 双形状(读语句 QueryResult / 其余 ExecResult,分流在适配器内完成);
|
|
595
|
+
// 契约侧(types.ts)exec 返回 unknown,落地形状在 run 处收窄断言。
|
|
596
|
+
let channel = null;
|
|
597
|
+
let settle;
|
|
598
|
+
const done = new Promise((resolve, reject) => {
|
|
599
|
+
settle = { resolve, reject };
|
|
600
|
+
});
|
|
601
|
+
let ready;
|
|
602
|
+
const readyP = new Promise((r) => {
|
|
603
|
+
ready = r;
|
|
604
|
+
});
|
|
605
|
+
const opened = tx(async (exec) => {
|
|
606
|
+
channel = exec;
|
|
607
|
+
ready();
|
|
608
|
+
await done;
|
|
609
|
+
return null;
|
|
610
|
+
})
|
|
611
|
+
.then(() => ({}), (e) => ({ txError: e }));
|
|
612
|
+
// begin 等待:fn 被调用(通道就绪)或 tx 开启失败(getConnection/SET TRANSACTION 失败)
|
|
613
|
+
const outcome = await Promise.race([
|
|
614
|
+
readyP.then(() => ({ ok: true })),
|
|
615
|
+
opened.then((v) => ({ ok: false, err: v.txError })),
|
|
616
|
+
]);
|
|
617
|
+
if (!outcome.ok)
|
|
618
|
+
throw this.toDriverError(outcome.err);
|
|
619
|
+
return {
|
|
620
|
+
token,
|
|
621
|
+
projectKey: key,
|
|
622
|
+
connId,
|
|
623
|
+
adapter,
|
|
624
|
+
lastAccess,
|
|
625
|
+
run: (stmt, params) => enqueue(chain, async () => {
|
|
626
|
+
if (!channel)
|
|
627
|
+
throw new DbToolError('DRIVER_ERROR', '事务通道已关闭');
|
|
628
|
+
return (await channel(stmt, params));
|
|
629
|
+
}),
|
|
630
|
+
finish: (how) => {
|
|
631
|
+
// 首个 finisher 生效;任务排到链尾等在途语句完成后才提交/回滚
|
|
632
|
+
if (settled)
|
|
633
|
+
return Promise.resolve();
|
|
634
|
+
settled = true;
|
|
635
|
+
return enqueue(chain, async () => {
|
|
636
|
+
try {
|
|
637
|
+
if (how === 'commit') {
|
|
638
|
+
settle.resolve();
|
|
639
|
+
const v = await opened;
|
|
640
|
+
// commit 路径的 txError 是真实提交失败,必须上抛供审计
|
|
641
|
+
if (v.txError !== undefined)
|
|
642
|
+
throw v.txError;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
// rollback 以 reject 结束 fn:tx 原子实现执行 ROLLBACK 并吞掉回滚自身的
|
|
646
|
+
// 次生错误(适配器语义),占位 reject 原因不是真实故障,不上抛
|
|
647
|
+
settle.reject(new Error('控制台事务回滚'));
|
|
648
|
+
await opened;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
await adapter.close().catch(() => { });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// —— TxHandle 形态(mysql/pg 系):begin 即 checkout 粘性连接并执行 BEGIN;
|
|
659
|
+
// 之后适配器把 query/execute 路由到同一连接,exec 直接透传即可。
|
|
660
|
+
await tx.begin();
|
|
661
|
+
return {
|
|
662
|
+
token,
|
|
663
|
+
projectKey: key,
|
|
664
|
+
connId,
|
|
665
|
+
adapter,
|
|
666
|
+
lastAccess,
|
|
667
|
+
run: (stmt, params) => enqueue(chain, () => SQL_READ_HEADS.has(sqlHead(stmt)) ? adapter.query(stmt, params) : adapter.execute(stmt, params)),
|
|
668
|
+
finish: (how) => {
|
|
669
|
+
// 首个 finisher 生效;任务排到链尾等在途语句完成后才提交/回滚
|
|
670
|
+
if (settled)
|
|
671
|
+
return Promise.resolve();
|
|
672
|
+
settled = true;
|
|
673
|
+
return enqueue(chain, async () => {
|
|
674
|
+
try {
|
|
675
|
+
if (how === 'commit')
|
|
676
|
+
await tx.commit();
|
|
677
|
+
else
|
|
678
|
+
await tx.rollback();
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
await adapter.close().catch(() => { });
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/** 空闲 TTL 扫描:超时会话强制回滚(close 适配器)并审计一条超时回滚 */
|
|
688
|
+
consoleSweep() {
|
|
689
|
+
const now = Date.now();
|
|
690
|
+
for (const [token, s] of this.consoleSessions) {
|
|
691
|
+
if (now - s.lastAccess <= this.consoleTtlMs)
|
|
692
|
+
continue;
|
|
693
|
+
this.consoleSessions.delete(token);
|
|
694
|
+
void s
|
|
695
|
+
.finish('rollback')
|
|
696
|
+
// 回收原因放 statement 说明槽:ok:true 的记录不得带 error 槽(矛盾审计)
|
|
697
|
+
.then(() => this.audit(s.projectKey, s.connId, 'console_rollback', 'ROLLBACK(空闲超时自动回收)', 'none', false, true))
|
|
698
|
+
.catch((e) => this.audit(s.projectKey, s.connId, 'console_rollback', 'ROLLBACK', 'none', false, false, `空闲超时自动回滚失败: ${this.errText(e)}`));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** 授权变更时回收指定连接的全部控制台事务会话(回滚并 close,不审计——非业务事件) */
|
|
702
|
+
async dropConsoleSessions(connId) {
|
|
703
|
+
for (const [token, s] of this.consoleSessions) {
|
|
704
|
+
if (s.connId !== connId)
|
|
705
|
+
continue;
|
|
706
|
+
this.consoleSessions.delete(token);
|
|
707
|
+
try {
|
|
708
|
+
await s.finish('rollback');
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// 已断开等场景忽略
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
358
715
|
adapterFor(connId, mode) {
|
|
359
716
|
const cacheKey = `${connId}@mode=${mode}`;
|
|
360
717
|
let p = this.adapterCache.get(cacheKey);
|