@yejiming/dsh-data-agent 0.0.6 → 0.0.10

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.
Files changed (39) hide show
  1. package/README.en.md +142 -119
  2. package/README.md +138 -118
  3. package/cordis.patch.yml +8 -9
  4. package/lib/client.js +42423 -524
  5. package/lib/client.js.map +1 -1
  6. package/lib/command-LFgLb6el.js +875 -0
  7. package/lib/command.js +2 -0
  8. package/lib/connections-WmjuUrDj.js +1608 -0
  9. package/lib/defaults-DP4RyRh1.js +21 -0
  10. package/lib/index.js +265 -68
  11. package/lib/routes.js +94 -170
  12. package/lib/tool-Dka6RyEp.js +1128 -0
  13. package/lib/tool.js +1 -426
  14. package/lib/types/analysis.d.ts +1071 -0
  15. package/lib/types/client/AnalysisChart.d.ts +26 -0
  16. package/lib/types/client/AnalysisDashboard.d.ts +30 -0
  17. package/lib/types/client/DataAgentWorkbench.d.ts +2 -2
  18. package/lib/types/client/analysis-charts.d.ts +40 -0
  19. package/lib/types/client/analysis-view-model.d.ts +44 -0
  20. package/lib/types/client/index.d.ts +3 -4
  21. package/lib/types/client/locales.d.ts +66 -0
  22. package/lib/types/client/persistence.d.ts +6 -1
  23. package/lib/types/client-discovery.d.ts +45 -0
  24. package/lib/types/clients.d.ts +17 -10
  25. package/lib/types/command.d.ts +41 -0
  26. package/lib/types/connections.d.ts +115 -40
  27. package/lib/types/defaults.d.ts +2 -0
  28. package/lib/types/index.d.ts +109 -63
  29. package/lib/types/routes.d.ts +25 -91
  30. package/lib/types/sql.d.ts +1 -1
  31. package/lib/types/storage.d.ts +70 -0
  32. package/lib/types/structured-read.d.ts +50 -0
  33. package/lib/types/tool.d.ts +29 -20
  34. package/lib/types/tui-connection-form.d.ts +98 -0
  35. package/package.json +65 -4
  36. package/preset/data-agent/agent.cordis.yml +22 -25
  37. package/preset/data-agent/preset.yml +1 -1
  38. package/lib/defaults-Bac6QvNt.js +0 -911
  39. package/lib/query-CmhTFklw.js +0 -86
@@ -0,0 +1,1608 @@
1
+ import "./defaults-DP4RyRh1.js";
2
+ import { readdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { posix, resolve, win32 } from "node:path";
5
+ import z from "schemastery";
6
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
+ //#region src/sql.ts
8
+ /**
9
+ * Lightweight SQL-text scanning helpers shared by the sql-cmd tool half and
10
+ * the /query route. This is intentionally NOT a SQL parser: the scanner only
11
+ * understands lexical boundaries (strings, quoted identifiers, comments and
12
+ * parenthesis depth) well enough to make the two agent-loop guarantees from
13
+ * docs/optimization-opportunities.md:
14
+ *
15
+ * - a single tool call carries at most ONE SQL statement;
16
+ * - `maxRows` can be enforced with a real top-level LIMIT, not just a prompt.
17
+ *
18
+ * @module @yejiming/dsh-data-agent/sql
19
+ */
20
+ const IDENT_CHAR = /[A-Za-z0-9_$]/;
21
+ function isWhitespace(char) {
22
+ return /\s/.test(char);
23
+ }
24
+ function isIdentChar(char) {
25
+ return IDENT_CHAR.test(char);
26
+ }
27
+ function skipQuoted(sql, start) {
28
+ const quote = sql[start];
29
+ let index = start + 1;
30
+ while (index < sql.length) {
31
+ const char = sql[index];
32
+ if (char === "\\" && index + 1 < sql.length && quote !== "`") {
33
+ index += 2;
34
+ continue;
35
+ }
36
+ if (char === quote) {
37
+ if (sql[index + 1] === quote) {
38
+ index += 2;
39
+ continue;
40
+ }
41
+ return index + 1;
42
+ }
43
+ index += 1;
44
+ }
45
+ return sql.length;
46
+ }
47
+ function skipDollarQuoted(sql, start) {
48
+ const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
49
+ if (match === null) return -1;
50
+ const delimiter = match[0];
51
+ const end = sql.indexOf(delimiter, start + delimiter.length);
52
+ return end === -1 ? sql.length : end + delimiter.length;
53
+ }
54
+ function skipOracleQuoted(sql, start) {
55
+ if (!/^q'/i.test(sql.slice(start, start + 2))) return -1;
56
+ const open = sql[start + 2];
57
+ if (open === void 0) return sql.length;
58
+ const close = {
59
+ "[": "]",
60
+ "{": "}",
61
+ "(": ")",
62
+ "<": ">"
63
+ }[open] ?? open;
64
+ let index = start + 3;
65
+ while (index < sql.length) {
66
+ if (sql[index] === close && sql[index + 1] === "'") return index + 2;
67
+ index += 1;
68
+ }
69
+ return sql.length;
70
+ }
71
+ function skipBlockComment(sql, start) {
72
+ let depth = 1;
73
+ let index = start + 2;
74
+ while (index < sql.length) {
75
+ if (sql.startsWith("/*", index)) {
76
+ depth += 1;
77
+ index += 2;
78
+ continue;
79
+ }
80
+ if (sql.startsWith("*/", index)) {
81
+ depth -= 1;
82
+ index += 2;
83
+ if (depth === 0) return index;
84
+ continue;
85
+ }
86
+ index += 1;
87
+ }
88
+ return sql.length;
89
+ }
90
+ function skipLineComment(sql, start) {
91
+ const newline = sql.indexOf("\n", start);
92
+ return newline === -1 ? sql.length : newline + 1;
93
+ }
94
+ /**
95
+ * Walk the SQL text, invoking `onSemicolon` for every top-level statement
96
+ * separator (parenthesis depth zero, outside strings, quoted identifiers and
97
+ * comments).
98
+ */
99
+ function scanTopLevelSemicolons(sql, onSemicolon) {
100
+ let depth = 0;
101
+ let index = 0;
102
+ while (index < sql.length) {
103
+ const char = sql[index];
104
+ if (isWhitespace(char)) {
105
+ index += 1;
106
+ continue;
107
+ }
108
+ if (sql.startsWith("--", index)) {
109
+ index = skipLineComment(sql, index + 2);
110
+ continue;
111
+ }
112
+ if (sql.startsWith("/*", index)) {
113
+ index = skipBlockComment(sql, index);
114
+ continue;
115
+ }
116
+ if (char === "'" || char === "\"" || char === "`") {
117
+ index = skipQuoted(sql, index);
118
+ continue;
119
+ }
120
+ if (char === "$") {
121
+ const dollarEnd = skipDollarQuoted(sql, index);
122
+ if (dollarEnd !== -1) {
123
+ index = dollarEnd;
124
+ continue;
125
+ }
126
+ }
127
+ const oracleEnd = skipOracleQuoted(sql, index);
128
+ if (oracleEnd !== -1) {
129
+ index = oracleEnd;
130
+ continue;
131
+ }
132
+ if (char === "(") {
133
+ depth += 1;
134
+ index += 1;
135
+ continue;
136
+ }
137
+ if (char === ")") {
138
+ depth = Math.max(0, depth - 1);
139
+ index += 1;
140
+ continue;
141
+ }
142
+ if (char === ";" && depth === 0) onSemicolon(index);
143
+ index += 1;
144
+ }
145
+ }
146
+ /** Whether meaningful SQL content exists after `index` (trailing `;`/comments ignored). */
147
+ function hasContentAfter(sql, index) {
148
+ let cursor = index;
149
+ while (cursor < sql.length) {
150
+ const char = sql[cursor];
151
+ if (isWhitespace(char)) {
152
+ cursor += 1;
153
+ continue;
154
+ }
155
+ if (char === ";") {
156
+ cursor += 1;
157
+ continue;
158
+ }
159
+ if (sql.startsWith("--", cursor)) {
160
+ cursor = skipLineComment(sql, cursor + 2);
161
+ continue;
162
+ }
163
+ if (sql.startsWith("/*", cursor)) {
164
+ cursor = skipBlockComment(sql, cursor);
165
+ continue;
166
+ }
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+ /**
172
+ * Throw unless `sql` contains at most one statement. A single trailing
173
+ * semicolon (and any number of repeated trailing semicolons / comments) is
174
+ * accepted; a semicolon followed by real content is rejected.
175
+ */
176
+ function assertSingleStatement(sql, label = "SQL") {
177
+ if (stripTrailingTerminator(sql).trim().length === 0) throw new Error(`${label}: SQL 不能为空`);
178
+ const semicolons = [];
179
+ scanTopLevelSemicolons(sql, (index) => {
180
+ semicolons.push(index);
181
+ });
182
+ const offending = semicolons.find((index) => hasContentAfter(sql, index + 1));
183
+ if (offending === void 0) return;
184
+ throw new Error(`${label}: 一次只允许执行一条 SQL 语句(第 ${offending + 1} 个字符后的分号不是末尾分号)。多条语句请拆成多次调用;客户端进程独立、自动提交,不支持在多次调用间保持事务。`);
185
+ }
186
+ /** Whether `keyword` appears at top level as a whole word in `sql`. */
187
+ function hasTopLevelKeyword(sql, keyword) {
188
+ const needle = keyword.toLowerCase();
189
+ let depth = 0;
190
+ let index = 0;
191
+ while (index < sql.length) {
192
+ const char = sql[index];
193
+ if (isWhitespace(char)) {
194
+ index += 1;
195
+ continue;
196
+ }
197
+ if (sql.startsWith("--", index)) {
198
+ index = skipLineComment(sql, index + 2);
199
+ continue;
200
+ }
201
+ if (sql.startsWith("/*", index)) {
202
+ index = skipBlockComment(sql, index);
203
+ continue;
204
+ }
205
+ if (char === "'" || char === "\"" || char === "`") {
206
+ index = skipQuoted(sql, index);
207
+ continue;
208
+ }
209
+ if (char === "$") {
210
+ const dollarEnd = skipDollarQuoted(sql, index);
211
+ if (dollarEnd !== -1) {
212
+ index = dollarEnd;
213
+ continue;
214
+ }
215
+ }
216
+ const oracleEnd = skipOracleQuoted(sql, index);
217
+ if (oracleEnd !== -1) {
218
+ index = oracleEnd;
219
+ continue;
220
+ }
221
+ if (char === "(") {
222
+ depth += 1;
223
+ index += 1;
224
+ continue;
225
+ }
226
+ if (char === ")") {
227
+ depth = Math.max(0, depth - 1);
228
+ index += 1;
229
+ continue;
230
+ }
231
+ if (depth === 0 && sql.slice(index, index + needle.length).toLowerCase() === needle && (index === 0 || !isIdentChar(sql[index - 1])) && (index + needle.length >= sql.length || !isIdentChar(sql[index + needle.length]))) return true;
232
+ index += 1;
233
+ }
234
+ return false;
235
+ }
236
+ function trailingLineCommentStart(sql, end) {
237
+ let index = sql.lastIndexOf("\n", end - 1) + 1;
238
+ while (index < end) {
239
+ const char = sql[index];
240
+ if (char === "'" || char === "\"" || char === "`") {
241
+ index = skipQuoted(sql, index);
242
+ continue;
243
+ }
244
+ if (sql.startsWith("--", index)) {
245
+ const tail = sql.slice(index + 2, end);
246
+ return tail.length === 0 || isWhitespace(tail[0]) ? index : -1;
247
+ }
248
+ index += 1;
249
+ }
250
+ return -1;
251
+ }
252
+ function blockCommentEndingAt(sql, end) {
253
+ let candidate = -1;
254
+ let index = 0;
255
+ while (index < end) {
256
+ const char = sql[index];
257
+ if (isWhitespace(char)) {
258
+ index += 1;
259
+ continue;
260
+ }
261
+ if (char === "'" || char === "\"" || char === "`") {
262
+ index = skipQuoted(sql, index);
263
+ continue;
264
+ }
265
+ if (sql.startsWith("--", index)) {
266
+ index = skipLineComment(sql, index + 2);
267
+ continue;
268
+ }
269
+ if (sql.startsWith("/*", index)) {
270
+ const start = index;
271
+ let commentDepth = 1;
272
+ index += 2;
273
+ while (index < end && commentDepth > 0) {
274
+ if (sql.startsWith("/*", index)) {
275
+ commentDepth += 1;
276
+ index += 2;
277
+ continue;
278
+ }
279
+ if (sql.startsWith("*/", index)) {
280
+ commentDepth -= 1;
281
+ index += 2;
282
+ if (commentDepth === 0) {
283
+ if (index === end) candidate = start;
284
+ break;
285
+ }
286
+ continue;
287
+ }
288
+ index += 1;
289
+ }
290
+ continue;
291
+ }
292
+ index += 1;
293
+ }
294
+ return candidate;
295
+ }
296
+ /**
297
+ * Strip trailing whitespace, statement terminators and trailing comments so a
298
+ * limit clause can be appended to the actual statement text. Only comments
299
+ * that occupy the whole tail are removed; the preceding statement is kept.
300
+ */
301
+ function stripTrailingTerminator(sql) {
302
+ let end = sql.length;
303
+ for (;;) {
304
+ while (end > 0 && isWhitespace(sql[end - 1])) end -= 1;
305
+ if (end > 0 && sql[end - 1] === ";") {
306
+ end -= 1;
307
+ continue;
308
+ }
309
+ if (end >= 2 && sql.slice(end - 2, end) === "*/") {
310
+ const start = blockCommentEndingAt(sql, end);
311
+ if (start !== -1) {
312
+ end = start;
313
+ continue;
314
+ }
315
+ }
316
+ const lineComment = trailingLineCommentStart(sql, end);
317
+ if (lineComment !== -1) {
318
+ end = lineComment;
319
+ continue;
320
+ }
321
+ return sql.slice(0, end);
322
+ }
323
+ }
324
+ //#endregion
325
+ //#region src/clients.ts
326
+ /**
327
+ * Whitespace / comment stripping for {@link classifyStatement}: remove
328
+ * leading whitespace, `--` line comments, and nested `/* ... *​/` block
329
+ * comments so the first meaningful token can be read reliably.
330
+ */
331
+ function stripLeadingComments(sql) {
332
+ let rest = sql;
333
+ for (;;) {
334
+ let changed = false;
335
+ const trimmed = rest.replace(/^\s+/, "");
336
+ if (trimmed !== rest) {
337
+ rest = trimmed;
338
+ changed = true;
339
+ }
340
+ if (rest.startsWith("--")) {
341
+ const newline = rest.indexOf("\n");
342
+ rest = newline === -1 ? "" : rest.slice(newline + 1);
343
+ changed = true;
344
+ continue;
345
+ }
346
+ if (rest.startsWith("/*")) {
347
+ const end = scanBlockCommentEnd(rest, 2);
348
+ rest = end === -1 ? "" : rest.slice(end);
349
+ changed = true;
350
+ continue;
351
+ }
352
+ if (!changed) {
353
+ const retrim = rest.replace(/^\s+/, "");
354
+ if (retrim !== rest) {
355
+ rest = retrim;
356
+ continue;
357
+ }
358
+ break;
359
+ }
360
+ }
361
+ return rest;
362
+ }
363
+ /** Find the index just past a `/* ... *​/` block starting at `start` (nesting-aware). */
364
+ function scanBlockCommentEnd(sql, start) {
365
+ let depth = 1;
366
+ let i = start;
367
+ while (i < sql.length) {
368
+ if (sql.startsWith("/*", i)) {
369
+ depth += 1;
370
+ i += 2;
371
+ continue;
372
+ }
373
+ if (sql.startsWith("*/", i)) {
374
+ depth -= 1;
375
+ i += 2;
376
+ if (depth === 0) return i;
377
+ continue;
378
+ }
379
+ i += 1;
380
+ }
381
+ return -1;
382
+ }
383
+ /**
384
+ * Strip a `WITH` prefix down to the main query: remove `WITH [RECURSIVE]`,
385
+ * then consume successive `name [ (cols) ] AS ( ... )` clauses (comma
386
+ * separated, parenthesis-aware) until the leading keyword of the main
387
+ * statement. Falls back to the whole (comment-stripped) input when the CTE
388
+ * shape does not parse cleanly, in which case {@link classifyStatement} treats
389
+ * it as a write (conservative).
390
+ */
391
+ function stripWithBody(sql) {
392
+ let rest = stripLeadingComments(sql).replace(/^[A-Za-z_]+/, "");
393
+ rest = stripLeadingComments(rest);
394
+ if (/^RECURSIVE\b/i.test(rest)) rest = stripLeadingComments(rest.replace(/^[A-Za-z_]+/, ""));
395
+ for (;;) {
396
+ rest = stripLeadingComments(rest);
397
+ if (rest === "" || !/^[A-Za-z_][A-Za-z0-9_$]*/.test(rest)) break;
398
+ rest = stripLeadingComments(rest.replace(/^[A-Za-z_][A-Za-z0-9_$]*/, ""));
399
+ rest = stripLeadingComments(rest);
400
+ if (rest.startsWith("(")) {
401
+ const afterCols = skipParens(rest, 0);
402
+ rest = stripLeadingComments(afterCols === -1 ? rest : rest.slice(afterCols));
403
+ }
404
+ rest = stripLeadingComments(rest);
405
+ if (!/^AS\b/i.test(rest)) break;
406
+ rest = stripLeadingComments(rest.replace(/^[A-Za-z_]+/, ""));
407
+ rest = stripLeadingComments(rest);
408
+ if (!rest.startsWith("(")) break;
409
+ const afterBody = skipParens(rest, 0);
410
+ if (afterBody === -1) return sql;
411
+ rest = stripLeadingComments(rest.slice(afterBody));
412
+ rest = stripLeadingComments(rest);
413
+ if (rest.startsWith(",")) {
414
+ rest = stripLeadingComments(rest.slice(1));
415
+ continue;
416
+ }
417
+ break;
418
+ }
419
+ return stripLeadingComments(rest);
420
+ }
421
+ /** Index just past a balanced parenthesis group starting at `start` (0-based). */
422
+ function skipParens(sql, start) {
423
+ let depth = 0;
424
+ let i = start;
425
+ while (i < sql.length) {
426
+ const ch = sql[i];
427
+ if (ch === "(") {
428
+ depth += 1;
429
+ i += 1;
430
+ continue;
431
+ }
432
+ if (ch === ")") {
433
+ depth -= 1;
434
+ i += 1;
435
+ if (depth === 0) return i;
436
+ continue;
437
+ }
438
+ i += 1;
439
+ }
440
+ return -1;
441
+ }
442
+ /**
443
+ * Classify a SQL text as a read or write statement by its FIRST effective
444
+ * token (a conservative read whitelist, not a parser). `with` is read only
445
+ * when its body's first token is `select`. SQLite `pragma` is read in its
446
+ * query form and write when a value is assigned.
447
+ */
448
+ function classifyStatement(sql, type) {
449
+ const rest = stripLeadingComments(sql);
450
+ const tokenMatch = rest.match(/^[A-Za-z_]+/);
451
+ if (tokenMatch === null) return "write";
452
+ switch (tokenMatch[0].toLowerCase()) {
453
+ case "select":
454
+ case "show":
455
+ case "describe":
456
+ case "desc":
457
+ case "explain": return "read";
458
+ case "pragma": {
459
+ if (type !== "sqlite") return "write";
460
+ const afterPragma = rest.replace(/^pragma\b/i, "").trimStart();
461
+ return /^(?:[A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_$]*)?|"[^"]+"|`[^`]+`)\s*=/.test(afterPragma) ? "write" : "read";
462
+ }
463
+ case "with": return stripWithBody(rest).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase() === "select" ? "read" : "write";
464
+ default: return "write";
465
+ }
466
+ }
467
+ /**
468
+ * Enforce the configured `maxRows` on a read query instead of relying on the
469
+ * prompt. SELECT/CTE-read statements get a real top-level LIMIT (Oracle uses
470
+ * a ROWNUM wrapper because it has no LIMIT); SHOW/DESCRIBE/EXPLAIN/PRAGMA are
471
+ * left untouched here and are capped while parsing structured output.
472
+ *
473
+ * An existing numeric top-level LIMIT is rewritten when it is larger than
474
+ * `maxRows`; a smaller existing LIMIT is preserved, and a non-numeric or
475
+ * unparseable LIMIT is left for the client (structured tools still truncate).
476
+ */
477
+ function enforceReadRowLimit(sql, type, maxRows) {
478
+ if (classifyStatement(sql, type) !== "read") return sql;
479
+ const first = stripLeadingComments(sql).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase();
480
+ if (first !== "select" && first !== "with") return sql;
481
+ const hadTrailingSemicolon = /;\s*$/.test(sql);
482
+ if (!hasTopLevelKeyword(sql, "limit") && type !== "oracle") return `${stripTrailingTerminator(sql)} LIMIT ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
483
+ if (type === "oracle") return `SELECT * FROM (${stripTrailingTerminator(sql)}) dsh_limit WHERE ROWNUM <= ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
484
+ if (!hasTopLevelKeyword(sql, "limit")) return sql;
485
+ return rewriteTopLevelLimit(sql, maxRows);
486
+ }
487
+ /** Rewrite the first top-level `LIMIT n` / `LIMIT n, m` with a capped row count. */
488
+ function rewriteTopLevelLimit(sql, maxRows) {
489
+ let depth = 0;
490
+ let index = 0;
491
+ while (index < sql.length) {
492
+ const char = sql[index];
493
+ if (/\s/.test(char)) {
494
+ index += 1;
495
+ continue;
496
+ }
497
+ if (sql.startsWith("--", index)) {
498
+ const newline = sql.indexOf("\n", index + 2);
499
+ index = newline === -1 ? sql.length : newline + 1;
500
+ continue;
501
+ }
502
+ if (sql.startsWith("/*", index)) {
503
+ let depthComment = 1;
504
+ index += 2;
505
+ while (index < sql.length && depthComment > 0) {
506
+ if (sql.startsWith("/*", index)) {
507
+ depthComment += 1;
508
+ index += 2;
509
+ continue;
510
+ }
511
+ if (sql.startsWith("*/", index)) {
512
+ depthComment -= 1;
513
+ index += 2;
514
+ continue;
515
+ }
516
+ index += 1;
517
+ }
518
+ continue;
519
+ }
520
+ if (char === "'" || char === "\"" || char === "`") {
521
+ const quote = char;
522
+ index += 1;
523
+ while (index < sql.length) {
524
+ if (sql[index] === "\\" && index + 1 < sql.length && quote !== "`") {
525
+ index += 2;
526
+ continue;
527
+ }
528
+ if (sql[index] === quote) {
529
+ if (sql[index + 1] === quote) {
530
+ index += 2;
531
+ continue;
532
+ }
533
+ index += 1;
534
+ break;
535
+ }
536
+ index += 1;
537
+ }
538
+ continue;
539
+ }
540
+ if (char === "(") {
541
+ depth += 1;
542
+ index += 1;
543
+ continue;
544
+ }
545
+ if (char === ")") {
546
+ depth = Math.max(0, depth - 1);
547
+ index += 1;
548
+ continue;
549
+ }
550
+ if (depth === 0 && sql.slice(index, index + 5).toLowerCase() === "limit" && (index === 0 || !/[A-Za-z0-9_$]/.test(sql[index - 1])) && (index + 5 >= sql.length || !/[A-Za-z0-9_$]/.test(sql[index + 5]))) {
551
+ const match = sql.slice(index).match(/^LIMIT\s+(ALL|\d+)(\s*,\s*\d+)?/i);
552
+ if (match === null) return sql;
553
+ const firstValue = match[1];
554
+ const hasOffsetPart = match[2] !== void 0;
555
+ let replacement = "";
556
+ if (hasOffsetPart) {
557
+ const rowCount = Number(match[2].match(/\d+/)[0]);
558
+ replacement = `LIMIT ${firstValue === "ALL" ? "0" : firstValue}, ${Math.min(rowCount, maxRows)}`;
559
+ } else if (/^\d+$/.test(firstValue)) replacement = `LIMIT ${Math.min(Number(firstValue), maxRows)}`;
560
+ else replacement = `LIMIT ${maxRows}`;
561
+ return sql.slice(0, index) + replacement + sql.slice(index + match[0].length);
562
+ }
563
+ index += 1;
564
+ }
565
+ return sql;
566
+ }
567
+ /**
568
+ * Validate and quote one schema/table identifier for a safe metadata query.
569
+ * Identifiers are restricted to `[A-Za-z0-9_$]+` and then wrapped per type:
570
+ * backticks (mysql/hive/impala) or double quotes (postgres/oracle/sqlite),
571
+ * with the wrapping quote doubled for any interior occurrence. Rejects any
572
+ * input that could cross the identifier boundary (`#`, `--`, `;`, `'`, `` ` ``,
573
+ * `"`, `.`, `-` are all refused).
574
+ */
575
+ function sanitizeIdentifier(type, identifier) {
576
+ if (!/^[A-Za-z0-9_$]+$/.test(identifier)) throw new Error(`标识符含非法字符(仅允许字母、数字与 _ $):${identifier}`);
577
+ switch (type) {
578
+ case "mysql":
579
+ case "hive":
580
+ case "impala": return "`" + identifier.replace(/`/g, "``") + "`";
581
+ case "postgres":
582
+ case "oracle":
583
+ case "sqlite": return "\"" + identifier.replace(/"/g, "\"\"") + "\"";
584
+ }
585
+ }
586
+ /**
587
+ * Quote one identifier-shaped value as a SQL string literal (single quotes,
588
+ * interior `'` doubled). postgres/oracle metadata queries filter system
589
+ * catalogs by NAME (a string value), not by identifier, so those positions
590
+ * need a quoted literal — not {@link sanitizeIdentifier}'s identifier quoting.
591
+ * The whitelist already excludes `'`, so doubling is a defense-in-depth no-op
592
+ * here but keeps the helper correct for any future widened charset.
593
+ */
594
+ function quoteStringLiteral(value) {
595
+ return "'" + value.replace(/'/g, "''") + "'";
596
+ }
597
+ /** Loader schema for one client override (all fields optional at input). */
598
+ const clientConfigSchema = z.object({
599
+ command: z.string(),
600
+ args: z.array(z.string()),
601
+ searchPaths: z.array(z.string())
602
+ });
603
+ /** Loader schema for the whole `clients` config object (any type key). */
604
+ const clientsSchema = z.dict(clientConfigSchema).default({});
605
+ /** Query-mode flag arguments per type (plain/human output). */
606
+ const QUERY_ARGS = {
607
+ mysql: ["--batch", "--raw"],
608
+ postgres: ["-A"],
609
+ sqlite: ["-header", "-column"],
610
+ oracle: ["-S", "/nolog"],
611
+ hive: ["--silent=true", "--outputformat=tsv2"],
612
+ impala: ["-B"]
613
+ };
614
+ /** Introspection-mode flag arguments per type (machine-readable listing). */
615
+ const INTROSPECT_ARGS = {
616
+ mysql: ["--batch", "--raw"],
617
+ postgres: ["-t", "-A"],
618
+ sqlite: ["-noheader", "-list"],
619
+ oracle: ["-S", "/nolog"],
620
+ hive: ["--silent=true", "--outputformat=tsv2"],
621
+ impala: ["-B"]
622
+ };
623
+ /** Structured `sql-query` flag arguments: header + one row per line. */
624
+ const STRUCTURED_QUERY_ARGS = {
625
+ mysql: ["--batch", "--raw"],
626
+ postgres: ["-A"],
627
+ sqlite: ["-header", "-csv"],
628
+ oracle: ["-S", "/nolog"],
629
+ hive: ["--silent=true", "--outputformat=tsv2"],
630
+ impala: ["-B", "--print_header"]
631
+ };
632
+ /** Default ports when the connection does not carry one. */
633
+ const DEFAULT_PORTS = {
634
+ mysql: 3306,
635
+ postgres: 5432,
636
+ sqlite: 0,
637
+ oracle: 1521,
638
+ hive: 1e4,
639
+ impala: 21050
640
+ };
641
+ /** Built-in commands per type (also the loader defaults; see `src/defaults.ts`). */
642
+ const DEFAULT_CLIENTS_COMMAND = {
643
+ mysql: "mysql",
644
+ postgres: "psql",
645
+ sqlite: "sqlite3",
646
+ oracle: "sqlplus",
647
+ hive: "beeline",
648
+ impala: "impala-shell"
649
+ };
650
+ /**
651
+ * Connection flags for one type. Oracle and Hive carry NO connection flags:
652
+ * their endpoint + credentials travel in the stdin prefix; Impala takes
653
+ * `-i host:port -d db` on the argv. SQLite's `database` file is positional
654
+ * and must come AFTER the flags.
655
+ */
656
+ function connectionArgs(type, connection) {
657
+ switch (type) {
658
+ case "mysql": return [
659
+ "-h",
660
+ connection.host ?? "127.0.0.1",
661
+ "-P",
662
+ String(connection.port ?? DEFAULT_PORTS.mysql),
663
+ "-u",
664
+ connection.user ?? "root",
665
+ "-D",
666
+ connection.database
667
+ ];
668
+ case "postgres": return [
669
+ "-h",
670
+ connection.host ?? "127.0.0.1",
671
+ "-p",
672
+ String(connection.port ?? DEFAULT_PORTS.postgres),
673
+ "-U",
674
+ connection.user ?? "postgres",
675
+ "-d",
676
+ connection.database
677
+ ];
678
+ case "sqlite": return [connection.database];
679
+ case "impala": return [
680
+ "-i",
681
+ `${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.impala}`,
682
+ "-d",
683
+ connection.database
684
+ ];
685
+ case "oracle":
686
+ case "hive": return [];
687
+ }
688
+ }
689
+ /** Credential environment entries per type; absent password yields an empty env. */
690
+ function credentialEnv(type, connection) {
691
+ const password = connection.password;
692
+ if (password === void 0) return {};
693
+ switch (type) {
694
+ case "mysql": return { MYSQL_PWD: password };
695
+ case "postgres": return { PGPASSWORD: password };
696
+ case "sqlite":
697
+ case "oracle":
698
+ case "hive":
699
+ case "impala": return {};
700
+ }
701
+ }
702
+ /**
703
+ * The stdin prefix per type: Oracle and Hive establish the session here, so
704
+ * their credentials never appear in argv. Oracle also silences sqlplus
705
+ * decoration (PAGESIZE/FEEDBACK/HEADING) and pins the column separator to
706
+ * `|` for the describe parser; Hive connects through beeline's `!connect`.
707
+ */
708
+ function stdinPrefix(type, connection) {
709
+ switch (type) {
710
+ case "oracle": return `${[
711
+ "SET PAGESIZE 0",
712
+ "SET FEEDBACK OFF",
713
+ "SET HEADING OFF",
714
+ "SET COLSEP '|'",
715
+ "SET TRIMSPOOL ON",
716
+ connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.oracle}/${connection.database}` : ""
717
+ ].filter((line) => line !== "").join("\n")}\n`;
718
+ case "hive": return connection.user !== void 0 ? `!connect jdbc:hive2://${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.hive}/${connection.database} ${connection.user} ${connection.password ?? ""}\n` : "";
719
+ case "mysql":
720
+ case "postgres":
721
+ case "sqlite":
722
+ case "impala": return "";
723
+ }
724
+ }
725
+ /**
726
+ * Oracle structured-query prefix: same connect block as {@link stdinPrefix},
727
+ * but with HEADING ON and UNDERLINE OFF so `sql-query` can read the column
728
+ * names from the first output line.
729
+ */
730
+ function structuredStdinPrefix(type, connection) {
731
+ if (type !== "oracle") return stdinPrefix(type, connection);
732
+ return `${[
733
+ "SET PAGESIZE 0",
734
+ "SET FEEDBACK OFF",
735
+ "SET HEADING ON",
736
+ "SET UNDERLINE OFF",
737
+ "SET COLSEP '|'",
738
+ "SET TRIMSPOOL ON",
739
+ connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.oracle}/${connection.database}` : ""
740
+ ].filter((line) => line !== "").join("\n")}\n`;
741
+ }
742
+ /** Apply one deployment override's extra args in front of the built-in flags. */
743
+ function withOverrides(flags, override) {
744
+ if (override === void 0 || override.args === void 0) return flags;
745
+ return [...override.args, ...flags];
746
+ }
747
+ /**
748
+ * Build one client invocation for a query execution (plain output). Flags
749
+ * come BEFORE the connection arguments everywhere: sqlite3 takes
750
+ * `[options] <database>`, and putting flags first is harmless for the others.
751
+ */
752
+ function buildClientTemplate(type, connection, override) {
753
+ return {
754
+ command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
755
+ args: [...withOverrides(QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
756
+ env: credentialEnv(type, connection),
757
+ stdinPrefix: stdinPrefix(type, connection)
758
+ };
759
+ }
760
+ /** Build one client invocation for metadata runs (machine-readable flags). */
761
+ function buildIntrospectTemplate(type, connection, override) {
762
+ return {
763
+ command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
764
+ args: [...withOverrides(INTROSPECT_ARGS[type], override), ...connectionArgs(type, connection)],
765
+ env: credentialEnv(type, connection),
766
+ stdinPrefix: stdinPrefix(type, connection)
767
+ };
768
+ }
769
+ /**
770
+ * Build one client invocation for the structured `sql-query` tool: every
771
+ * supported client prints a header row followed by one row per line (mysql
772
+ * tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
773
+ */
774
+ function buildStructuredQueryTemplate(type, connection, override) {
775
+ return {
776
+ command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
777
+ args: [...withOverrides(STRUCTURED_QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
778
+ env: credentialEnv(type, connection),
779
+ stdinPrefix: structuredStdinPrefix(type, connection)
780
+ };
781
+ }
782
+ /**
783
+ * The table-listing SQL per type, run at /connect time to verify
784
+ * connectivity: the connected database's own tables (mysql uses the
785
+ * connection's database as the schema; postgres lists `public`; oracle lists
786
+ * the connected user's tables; hive/impala list the default database).
787
+ */
788
+ function tableListingSql(type, connection) {
789
+ switch (type) {
790
+ case "mysql": return `SHOW TABLES FROM \`${connection?.database ?? ""}\`;`;
791
+ case "postgres": return "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1;";
792
+ case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
793
+ case "oracle": return "SELECT table_name FROM user_tables ORDER BY 1;";
794
+ case "hive":
795
+ case "impala": return "SHOW TABLES;";
796
+ }
797
+ }
798
+ /**
799
+ * Metadata query per kind × type. `schema`/`table` are identifier whitelist
800
+ * validated by the caller (`[A-Za-z0-9_$]`) before they reach here.
801
+ */
802
+ function metadataQuery(kind, type, schema, table) {
803
+ switch (kind) {
804
+ case "schemas": switch (type) {
805
+ case "mysql": return "SHOW DATABASES;";
806
+ case "postgres": return "SELECT schema_name FROM information_schema.schemata ORDER BY 1;";
807
+ case "sqlite": return "SELECT 'main';";
808
+ case "oracle": return "SELECT username FROM all_users ORDER BY 1;";
809
+ case "hive":
810
+ case "impala": return "SHOW DATABASES;";
811
+ }
812
+ case "tables": switch (type) {
813
+ case "mysql": return `SHOW TABLES FROM ${sanitizeIdentifier(type, schema)};`;
814
+ case "postgres": return `SELECT tablename FROM pg_tables WHERE schemaname=${quoteStringLiteral(schema)} ORDER BY 1;`;
815
+ case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
816
+ case "oracle": return `SELECT table_name FROM all_tables WHERE owner=${quoteStringLiteral(schema)} ORDER BY 1;`;
817
+ case "hive":
818
+ case "impala": return `SHOW TABLES IN ${sanitizeIdentifier(type, schema)};`;
819
+ }
820
+ case "describe": switch (type) {
821
+ case "mysql": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
822
+ case "postgres": return `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema=${quoteStringLiteral(schema)} AND table_name=${quoteStringLiteral(table)} ORDER BY ordinal_position;`;
823
+ case "sqlite": return `PRAGMA table_info(${sanitizeIdentifier(type, table)});`;
824
+ case "oracle": return `SELECT column_name, data_type, nullable FROM all_tab_columns WHERE owner=${quoteStringLiteral(schema)} AND table_name=${quoteStringLiteral(table)} ORDER BY column_id;`;
825
+ case "hive":
826
+ case "impala": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
827
+ }
828
+ }
829
+ }
830
+ /**
831
+ * Split one type's machine-readable listing output into trimmed lines.
832
+ * Header lines are stripped per type: mysql `--batch` prints a header row
833
+ * (skip 1); postgres `-t`, sqlite `-noheader`, oracle `SET HEADING OFF`,
834
+ * hive/impala batch modes print none (skip 0).
835
+ */
836
+ function parseListing(type, stdout) {
837
+ const lines = stdout.split("\n");
838
+ const start = type === "mysql" ? 1 : 0;
839
+ const items = [];
840
+ for (let index = start; index < lines.length; index += 1) {
841
+ const name = lines[index].trim();
842
+ if (name.length > 0) items.push(name);
843
+ }
844
+ return items;
845
+ }
846
+ /** Parse one type's table-listing output (the /connect connectivity check). */
847
+ function parseTableListing(type, stdout) {
848
+ return parseListing(type, stdout);
849
+ }
850
+ /**
851
+ * Parse one type's describe output into columns. Formats:
852
+ * - mysql `--batch`: `Field\tType\tNull\tKey\t...` (skip header);
853
+ * - postgres `-t -A`: `name|type|is_nullable`;
854
+ * - sqlite `-noheader -list`: `cid|name|type|notnull|dflt|pk` (name is part 1);
855
+ * - oracle (`SET COLSEP '|'`, heading off): `NAME|TYPE|NULLABLE`;
856
+ * - hive/impala batch: `name\ttype\tcomment`.
857
+ */
858
+ function parseColumns(type, stdout) {
859
+ const lines = stdout.split("\n");
860
+ const start = type === "mysql" ? 1 : 0;
861
+ const columns = [];
862
+ for (let index = start; index < lines.length; index += 1) {
863
+ const line = lines[index].trim();
864
+ if (line.length === 0) continue;
865
+ const parts = line.includes(" ") ? line.split(" ") : line.split("|");
866
+ const nameIndex = type === "sqlite" ? 1 : 0;
867
+ const name = parts[nameIndex]?.trim() ?? "";
868
+ const columnType = parts[nameIndex + 1]?.trim() ?? "";
869
+ if (name.length === 0) continue;
870
+ const rawNullable = parts[nameIndex + 2]?.trim().toLowerCase();
871
+ let nullable;
872
+ switch (type) {
873
+ case "mysql":
874
+ nullable = rawNullable === "yes";
875
+ break;
876
+ case "postgres":
877
+ nullable = rawNullable === "yes";
878
+ break;
879
+ case "sqlite":
880
+ nullable = rawNullable !== "1";
881
+ break;
882
+ case "oracle":
883
+ nullable = rawNullable === "y";
884
+ break;
885
+ case "hive":
886
+ case "impala": nullable = void 0;
887
+ }
888
+ columns.push({
889
+ name,
890
+ type: columnType,
891
+ ...nullable !== void 0 ? { nullable } : {}
892
+ });
893
+ }
894
+ return columns;
895
+ }
896
+ //#endregion
897
+ //#region src/client-discovery.ts
898
+ /**
899
+ * Cross-platform database CLI discovery.
900
+ *
901
+ * The subprocess provider remains the authority for executable validation.
902
+ * This module only builds a bounded, platform-aware PATH fallback when the
903
+ * provider cannot resolve the configured/default bare command from its
904
+ * current execution environment. No shell, registry, or recursive scan is
905
+ * involved, and the exact discovery environment is returned for spawn.
906
+ * @module @yejiming/dsh-data-agent/client-discovery
907
+ */
908
+ /** Maximum child names consumed from one known version/formula directory. */
909
+ const MAX_DYNAMIC_ENTRIES = 64;
910
+ /** Production host facts. */
911
+ const DEFAULT_SYSTEM = {
912
+ platform: process.platform,
913
+ env: process.env,
914
+ homeDir: homedir(),
915
+ cwd: process.cwd(),
916
+ async readDirectory(directory) {
917
+ return await readdir(directory);
918
+ }
919
+ };
920
+ const HOME_ENV_BY_TYPE = {
921
+ mysql: ["MYSQL_HOME"],
922
+ postgres: ["PGHOME", "PGROOT"],
923
+ sqlite: ["SQLITE_HOME"],
924
+ oracle: ["ORACLE_HOME"],
925
+ hive: ["HIVE_HOME"],
926
+ impala: ["IMPALA_HOME"]
927
+ };
928
+ function pathApi(platform) {
929
+ return platform === "win32" ? win32 : posix;
930
+ }
931
+ function environmentValue(env, name, platform) {
932
+ if (platform !== "win32") return env[name];
933
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
934
+ return key === void 0 ? void 0 : env[key];
935
+ }
936
+ function expandHome(directory, system, paths) {
937
+ const trimmed = directory.trim();
938
+ if (trimmed === "~") return system.homeDir;
939
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return paths.join(system.homeDir, trimmed.slice(2));
940
+ return paths.isAbsolute(trimmed) ? paths.normalize(trimmed) : paths.resolve(system.cwd, trimmed);
941
+ }
942
+ function normalizeDirectories(directories, system, paths) {
943
+ const result = [];
944
+ const seen = /* @__PURE__ */ new Set();
945
+ for (const raw of directories) {
946
+ if (raw.trim() === "") continue;
947
+ const directory = expandHome(raw, system, paths);
948
+ const key = system.platform === "win32" ? directory.toLowerCase() : directory;
949
+ if (seen.has(key)) continue;
950
+ seen.add(key);
951
+ result.push(directory);
952
+ }
953
+ return result;
954
+ }
955
+ function clientHomeDirectories(type, system, paths) {
956
+ const result = [];
957
+ for (const name of HOME_ENV_BY_TYPE[type]) {
958
+ const value = environmentValue(system.env, name, system.platform)?.trim();
959
+ if (value === void 0 || value === "") continue;
960
+ result.push(paths.join(value, "bin"), value);
961
+ }
962
+ return result;
963
+ }
964
+ function macFixedDirectories(type) {
965
+ return [
966
+ "/opt/homebrew/bin",
967
+ ...{
968
+ mysql: [
969
+ "/opt/homebrew/opt/mysql-client/bin",
970
+ "/opt/homebrew/opt/mysql/bin",
971
+ "/usr/local/opt/mysql-client/bin",
972
+ "/usr/local/opt/mysql/bin",
973
+ "/usr/local/mysql/bin"
974
+ ],
975
+ postgres: [
976
+ "/opt/homebrew/opt/libpq/bin",
977
+ "/usr/local/opt/libpq/bin",
978
+ "/Applications/Postgres.app/Contents/Versions/latest/bin"
979
+ ],
980
+ sqlite: ["/opt/homebrew/opt/sqlite/bin", "/usr/local/opt/sqlite/bin"],
981
+ oracle: [],
982
+ hive: ["/opt/homebrew/opt/hive/bin", "/usr/local/opt/hive/bin"],
983
+ impala: ["/opt/homebrew/opt/impala/bin", "/usr/local/opt/impala/bin"]
984
+ }[type],
985
+ "/usr/local/bin",
986
+ "/opt/local/bin",
987
+ "/usr/bin"
988
+ ];
989
+ }
990
+ function linuxFixedDirectories(system, paths) {
991
+ return [
992
+ paths.join(system.homeDir, ".local", "bin"),
993
+ "/home/linuxbrew/.linuxbrew/bin",
994
+ paths.join(system.homeDir, ".linuxbrew", "bin"),
995
+ "/usr/local/bin",
996
+ "/usr/bin",
997
+ "/snap/bin",
998
+ paths.join(system.homeDir, ".nix-profile", "bin"),
999
+ "/nix/var/nix/profiles/default/bin"
1000
+ ];
1001
+ }
1002
+ function windowsFixedDirectories(type, system, paths) {
1003
+ const localAppData = environmentValue(system.env, "LOCALAPPDATA", system.platform);
1004
+ const userProfile = environmentValue(system.env, "USERPROFILE", system.platform) ?? system.homeDir;
1005
+ const chocolatey = environmentValue(system.env, "ChocolateyInstall", system.platform);
1006
+ const programData = environmentValue(system.env, "ProgramData", system.platform) ?? "C:\\ProgramData";
1007
+ const programFiles = environmentValue(system.env, "ProgramFiles", system.platform) ?? "C:\\Program Files";
1008
+ const typeSpecific = {
1009
+ mysql: [],
1010
+ postgres: [],
1011
+ sqlite: [paths.join("C:\\", "sqlite"), paths.join(programFiles, "SQLite")],
1012
+ oracle: [],
1013
+ hive: [],
1014
+ impala: []
1015
+ };
1016
+ return [
1017
+ ...localAppData === void 0 ? [] : [paths.join(localAppData, "Microsoft", "WinGet", "Links")],
1018
+ paths.join(userProfile, "scoop", "shims"),
1019
+ ...chocolatey === void 0 ? [] : [paths.join(chocolatey, "bin")],
1020
+ paths.join(programData, "chocolatey", "bin"),
1021
+ ...typeSpecific[type]
1022
+ ];
1023
+ }
1024
+ function formulaPattern(type) {
1025
+ switch (type) {
1026
+ case "mysql": return /^(?:mysql|mysql-client)(?:@.+)?$/i;
1027
+ case "postgres": return /^(?:postgresql(?:@.+)?|libpq)$/i;
1028
+ case "sqlite": return /^sqlite(?:@.+)?$/i;
1029
+ case "oracle": return /^(?:oracle|instantclient)(?:@.+)?$/i;
1030
+ case "hive": return /^hive(?:@.+)?$/i;
1031
+ case "impala": return /^impala(?:@.+)?$/i;
1032
+ }
1033
+ }
1034
+ function dynamicDirectories(type, system, paths) {
1035
+ const result = [];
1036
+ if (system.platform === "darwin") {
1037
+ const pattern = formulaPattern(type);
1038
+ result.push({
1039
+ root: "/opt/homebrew/opt",
1040
+ accepts: (name) => pattern.test(name),
1041
+ suffix: ["bin"]
1042
+ }, {
1043
+ root: "/usr/local/opt",
1044
+ accepts: (name) => pattern.test(name),
1045
+ suffix: ["bin"]
1046
+ });
1047
+ if (type === "postgres") result.push({
1048
+ root: "/Library/PostgreSQL",
1049
+ accepts: () => true,
1050
+ suffix: ["bin"]
1051
+ }, {
1052
+ root: "/Applications/Postgres.app/Contents/Versions",
1053
+ accepts: (name) => name !== "latest",
1054
+ suffix: ["bin"]
1055
+ });
1056
+ if (type === "oracle") result.push({
1057
+ root: "/opt/oracle",
1058
+ accepts: (name) => /^instantclient/i.test(name),
1059
+ suffix: []
1060
+ });
1061
+ } else if (system.platform === "linux") {
1062
+ const pattern = formulaPattern(type);
1063
+ result.push({
1064
+ root: "/home/linuxbrew/.linuxbrew/opt",
1065
+ accepts: (name) => pattern.test(name),
1066
+ suffix: ["bin"]
1067
+ }, {
1068
+ root: paths.join(system.homeDir, ".linuxbrew", "opt"),
1069
+ accepts: (name) => pattern.test(name),
1070
+ suffix: ["bin"]
1071
+ });
1072
+ } else if (system.platform === "win32") {
1073
+ const roots = [environmentValue(system.env, "ProgramFiles", system.platform) ?? "C:\\Program Files", environmentValue(system.env, "ProgramFiles(x86)", system.platform) ?? "C:\\Program Files (x86)"];
1074
+ for (const root of roots) if (type === "mysql") result.push({
1075
+ root: paths.join(root, "MySQL"),
1076
+ accepts: () => true,
1077
+ suffix: ["bin"]
1078
+ }, {
1079
+ root,
1080
+ accepts: (name) => /^MariaDB/i.test(name),
1081
+ suffix: ["bin"]
1082
+ });
1083
+ else if (type === "postgres") result.push({
1084
+ root: paths.join(root, "PostgreSQL"),
1085
+ accepts: () => true,
1086
+ suffix: ["bin"]
1087
+ });
1088
+ else if (type === "oracle") result.push({
1089
+ root: paths.join(root, "Oracle"),
1090
+ accepts: () => true,
1091
+ suffix: ["bin"]
1092
+ });
1093
+ }
1094
+ return result;
1095
+ }
1096
+ async function expandDynamicDirectories(descriptors, system, paths, signal) {
1097
+ return (await Promise.all(descriptors.map(async (descriptor) => {
1098
+ signal.throwIfAborted();
1099
+ let names;
1100
+ try {
1101
+ names = await system.readDirectory(descriptor.root);
1102
+ } catch {
1103
+ return [];
1104
+ }
1105
+ signal.throwIfAborted();
1106
+ return names.filter((name) => descriptor.accepts(name)).sort((left, right) => right.localeCompare(left, void 0, {
1107
+ numeric: true,
1108
+ sensitivity: "base"
1109
+ })).slice(0, MAX_DYNAMIC_ENTRIES).map((name) => paths.join(descriptor.root, name, ...descriptor.suffix));
1110
+ }))).flat();
1111
+ }
1112
+ /** Build ordered fallback directories without recursively scanning the host. */
1113
+ async function buildClientSearchDirectories(type, config, signal, system = DEFAULT_SYSTEM) {
1114
+ const paths = pathApi(system.platform);
1115
+ const configured = config?.searchPaths ?? [];
1116
+ const homes = clientHomeDirectories(type, system, paths);
1117
+ const fixed = system.platform === "win32" ? windowsFixedDirectories(type, system, paths) : system.platform === "darwin" ? macFixedDirectories(type) : linuxFixedDirectories(system, paths);
1118
+ const dynamic = await expandDynamicDirectories(dynamicDirectories(type, system, paths), system, paths, signal);
1119
+ signal.throwIfAborted();
1120
+ return normalizeDirectories([
1121
+ ...configured,
1122
+ ...homes,
1123
+ ...fixed,
1124
+ ...dynamic
1125
+ ], system, paths);
1126
+ }
1127
+ function hasPathSeparator(command) {
1128
+ return command.includes("/") || command.includes("\\");
1129
+ }
1130
+ function withSearchPath(explicitEnv, directories, system) {
1131
+ const pathName = system.platform === "win32" ? Object.keys(system.env).find((name) => name.toLowerCase() === "path") ?? "Path" : "PATH";
1132
+ const explicitPathName = Object.keys(explicitEnv).find((name) => system.platform === "win32" ? name.toLowerCase() === "path" : name === "PATH");
1133
+ const parentPath = explicitPathName === void 0 ? environmentValue(system.env, "PATH", system.platform) : explicitEnv[explicitPathName];
1134
+ const separator = system.platform === "win32" ? ";" : ":";
1135
+ const prefix = directories.join(separator);
1136
+ const combined = parentPath === void 0 || parentPath === "" ? prefix : `${prefix}${separator}${parentPath}`;
1137
+ const result = { ...explicitEnv };
1138
+ if (explicitPathName !== void 0 && explicitPathName !== pathName) delete result[explicitPathName];
1139
+ result[pathName] = combined;
1140
+ return result;
1141
+ }
1142
+ function errorText(error) {
1143
+ return error instanceof Error ? error.message : String(error);
1144
+ }
1145
+ function checkedDirectoriesText(directories) {
1146
+ const visible = directories.slice(0, 16);
1147
+ const suffix = directories.length > visible.length ? `,另有${directories.length - visible.length}个目录` : "";
1148
+ return visible.length === 0 ? "无补充目录" : `${visible.join("、")}${suffix}`;
1149
+ }
1150
+ /**
1151
+ * Resolve one configured/default client. Current PATH (or an explicit path)
1152
+ * always wins. Only a missing bare command activates bounded PATH discovery.
1153
+ */
1154
+ async function resolveClientExecutable(options) {
1155
+ const system = options.system ?? DEFAULT_SYSTEM;
1156
+ let initialError;
1157
+ try {
1158
+ return {
1159
+ executable: await options.resolveExecutable(options.command, options.env, options.signal),
1160
+ env: options.env,
1161
+ searchedDirectories: []
1162
+ };
1163
+ } catch (error) {
1164
+ options.signal.throwIfAborted();
1165
+ initialError = error;
1166
+ }
1167
+ if (pathApi(system.platform).isAbsolute(options.command) || hasPathSeparator(options.command)) throw new Error(`无法解析数据库客户端 "${options.command}"(类型 ${options.type}:${errorText(initialError)});该显式路径不会回退到默认命令,请检查 clients.${options.type}.command`);
1168
+ const directories = await buildClientSearchDirectories(options.type, options.config, options.signal, system);
1169
+ const discoveryEnv = withSearchPath(options.env, directories, system);
1170
+ try {
1171
+ return {
1172
+ executable: await options.resolveExecutable(options.command, discoveryEnv, options.signal),
1173
+ env: discoveryEnv,
1174
+ searchedDirectories: directories
1175
+ };
1176
+ } catch (fallbackError) {
1177
+ options.signal.throwIfAborted();
1178
+ throw new Error(`无法解析数据库客户端 "${options.command}"(类型 ${options.type};当前PATH:${errorText(initialError)};补充PATH:${errorText(fallbackError)})。已检查:${checkedDirectoriesText(directories)};请确认客户端已安装,或配置 clients.${options.type}.command / clients.${options.type}.searchPaths`);
1179
+ }
1180
+ }
1181
+ //#endregion
1182
+ //#region src/query.ts
1183
+ /** Read one collected stream from offset 0. */
1184
+ function readCaptured(reader) {
1185
+ if (reader === void 0) return {
1186
+ text: "",
1187
+ truncated: false
1188
+ };
1189
+ const read = reader.readFrom(0);
1190
+ return {
1191
+ text: read.text,
1192
+ truncated: read.lossy
1193
+ };
1194
+ }
1195
+ /**
1196
+ * Run one SQL text through the type's CLI client. The SQL is written to the
1197
+ * child's stdin (`{ data }` batch disposition) so it never appears in argv;
1198
+ * passwords travel in the env entries built by the template.
1199
+ *
1200
+ * Failure classification:
1201
+ * - the caller's external signal (e.g. the tool exec signal) aborts → the
1202
+ * abort reason propagates;
1203
+ * - the internal timeout fires → an Error naming the deadline is thrown;
1204
+ * - the executable cannot be resolved → an Error naming the command is thrown;
1205
+ * - the process runs to completion → `{ exitCode, stdout, stderr, truncated }`
1206
+ * is returned even for a non-zero exit (the caller decides what that means).
1207
+ * @param ctx - context exposing the subprocess service.
1208
+ * @param connection - the stored connection (password included).
1209
+ * @param sql - the SQL text (or client command) to run.
1210
+ * @param options - timeouts, caps, client overrides.
1211
+ * @param externalSignal - caller-owned cancellation (the tool exec signal).
1212
+ * @param introspect - use the machine-readable introspection flag set.
1213
+ * @returns the captured outcome.
1214
+ */
1215
+ async function runClientQuery(ctx, connection, sql, options, externalSignal, introspect = false) {
1216
+ const template = options.mode === "structured" ? buildStructuredQueryTemplate(connection.type, connection, options.clients[connection.type]) : options.mode === "introspect" || introspect ? buildIntrospectTemplate(connection.type, connection, options.clients[connection.type]) : buildClientTemplate(connection.type, connection, options.clients[connection.type]);
1217
+ const controller = new AbortController();
1218
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
1219
+ const onExternalAbort = () => {
1220
+ controller.abort(externalSignal.reason);
1221
+ };
1222
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
1223
+ else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
1224
+ try {
1225
+ const resolution = await resolveClientExecutable({
1226
+ type: connection.type,
1227
+ command: template.command,
1228
+ config: options.clients[connection.type],
1229
+ env: template.env,
1230
+ signal: controller.signal,
1231
+ resolveExecutable: ctx.subprocess.resolveExecutable.bind(ctx.subprocess)
1232
+ });
1233
+ const handle = ctx.subprocess.spawn({
1234
+ argv: [resolution.executable, ...template.args],
1235
+ cwd: process.cwd(),
1236
+ stdio: {
1237
+ stdin: { data: `${template.stdinPrefix}${sql}\n` },
1238
+ stdout: { maxBytes: options.maxResultChars },
1239
+ stderr: { maxBytes: options.maxResultChars }
1240
+ },
1241
+ graceMs: options.graceMs ?? 5e3,
1242
+ signal: controller.signal,
1243
+ env: resolution.env
1244
+ });
1245
+ let outcome;
1246
+ try {
1247
+ outcome = await handle.done;
1248
+ } catch (error) {
1249
+ controller.signal.throwIfAborted();
1250
+ throw new Error(`启动数据库客户端失败:${error instanceof Error ? error.message : String(error)}`);
1251
+ }
1252
+ if (controller.signal.aborted) controller.signal.throwIfAborted();
1253
+ const stdout = readCaptured(handle.collected.stdout);
1254
+ const stderr = readCaptured(handle.collected.stderr);
1255
+ return {
1256
+ exitCode: outcome.exitCode,
1257
+ stdout: stdout.text,
1258
+ stderr: stderr.text,
1259
+ truncated: stdout.truncated || stderr.truncated
1260
+ };
1261
+ } finally {
1262
+ clearTimeout(timer);
1263
+ externalSignal.removeEventListener("abort", onExternalAbort);
1264
+ }
1265
+ }
1266
+ /** Build a password-stripped copy of one connection. */
1267
+ function summarize(connection) {
1268
+ const summary = {
1269
+ type: connection.type,
1270
+ database: connection.database
1271
+ };
1272
+ if (connection.host !== void 0) summary.host = connection.host;
1273
+ if (connection.port !== void 0) summary.port = connection.port;
1274
+ if (connection.user !== void 0) summary.user = connection.user;
1275
+ if (connection.passwordRef !== void 0) summary.passwordRef = connection.passwordRef;
1276
+ if (connection.readonly !== void 0) summary.readonly = connection.readonly;
1277
+ if (connection.profileId !== void 0) summary.profileId = connection.profileId;
1278
+ if (connection.name !== void 0) summary.name = connection.name;
1279
+ if (connection.tables !== void 0) summary.tables = [...connection.tables];
1280
+ return summary;
1281
+ }
1282
+ /** Replace every occurrence of a resolved secret before crossing a public seam. */
1283
+ function redactSecretText(text, secrets) {
1284
+ let redacted = text;
1285
+ for (const secret of secrets) if (secret !== void 0 && secret.length > 0) redacted = redacted.split(secret).join("[REDACTED]");
1286
+ return redacted;
1287
+ }
1288
+ /** Redact a client result without mutating the runner-owned object. */
1289
+ function redactQueryResult(result, connection) {
1290
+ const secrets = [connection.password];
1291
+ return {
1292
+ ...result,
1293
+ stdout: redactSecretText(result.stdout, secrets),
1294
+ stderr: redactSecretText(result.stderr, secrets)
1295
+ };
1296
+ }
1297
+ /** Validate/normalize a shared connect input before any I/O. */
1298
+ function normalizeConnectionInput(input, cwd = process.cwd()) {
1299
+ if (!isDatabaseType(input.type)) throw new Error("数据库类型无效");
1300
+ if (typeof input.database !== "string" || input.database.trim().length === 0) throw new Error("database 必须是非空字符串");
1301
+ if (input.password !== void 0 && input.passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
1302
+ if (input.passwordRef !== void 0) validatePasswordRef(input.passwordRef);
1303
+ if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) throw new Error("port 必须是 1-65535 的整数");
1304
+ if (input.profileId !== void 0 && input.profileId.trim().length === 0) throw new Error("profileId 不能为空");
1305
+ if (input.name !== void 0 && input.name.trim().length === 0) throw new Error("name 不能为空");
1306
+ const connection = {
1307
+ type: input.type,
1308
+ database: input.type === "sqlite" ? resolve(cwd, input.database) : input.database
1309
+ };
1310
+ if (input.type !== "sqlite") {
1311
+ if (input.host !== void 0 && input.host.length > 0) connection.host = input.host;
1312
+ if (input.port !== void 0) connection.port = input.port;
1313
+ if (input.user !== void 0 && input.user.length > 0) connection.user = input.user;
1314
+ if (input.password !== void 0 && input.password.length > 0) connection.password = input.password;
1315
+ if (input.passwordRef !== void 0) connection.passwordRef = input.passwordRef;
1316
+ }
1317
+ if (input.readonly !== void 0) connection.readonly = input.readonly;
1318
+ if (input.profileId !== void 0) connection.profileId = input.profileId;
1319
+ if (input.name !== void 0) connection.name = input.name;
1320
+ return connection;
1321
+ }
1322
+ /** Create the surface-independent service. */
1323
+ function createConnectionService(ctx, options, persistence) {
1324
+ const resolvedOptions = options ?? {
1325
+ connectTimeoutMs: 15e3,
1326
+ queryTimeoutMs: 3e4,
1327
+ maxResultChars: 2e5,
1328
+ maxQueryChars: 65536,
1329
+ introspectMaxTables: 500,
1330
+ readonly: false,
1331
+ clients: {}
1332
+ };
1333
+ const runtime = /* @__PURE__ */ new Map();
1334
+ const formDrafts = /* @__PURE__ */ new Map();
1335
+ const profileConnection = (sessionId) => {
1336
+ if (persistence === void 0) return void 0;
1337
+ const binding = persistence.getBinding(sessionId);
1338
+ if (binding === void 0) return void 0;
1339
+ const profile = persistence.getProfile(binding.profileId);
1340
+ return profile === void 0 ? void 0 : connectionFromProfile(binding.profileId, profile);
1341
+ };
1342
+ /** Required precedence: exact runtime, exact binding, wildcard runtime, wildcard binding. */
1343
+ const rawConnection = (sessionId) => runtime.get(sessionId) ?? profileConnection(sessionId) ?? runtime.get("*") ?? profileConnection("*");
1344
+ const requireContext = () => {
1345
+ if (ctx === void 0) throw new Error("数据库执行服务尚未配置");
1346
+ return ctx;
1347
+ };
1348
+ const resolveCredential = async (connection) => {
1349
+ if (connection.passwordRef === void 0) return {
1350
+ ...connection,
1351
+ tables: copyTables(connection.tables)
1352
+ };
1353
+ const ref = validatedCredentialRef(connection.passwordRef);
1354
+ const hit = await requireContext().credentials.resolve(ref);
1355
+ if (hit === void 0 || hit.value.length === 0) throw new Error(`凭据引用 "${connection.passwordRef}" 未配置`);
1356
+ return {
1357
+ ...connection,
1358
+ password: hit.value,
1359
+ tables: copyTables(connection.tables)
1360
+ };
1361
+ };
1362
+ const queryOptions = (mode, connect = false) => ({
1363
+ clients: resolvedOptions.clients,
1364
+ timeoutMs: connect ? resolvedOptions.connectTimeoutMs : resolvedOptions.queryTimeoutMs,
1365
+ maxResultChars: resolvedOptions.maxResultChars,
1366
+ ...mode !== void 0 ? { mode } : {}
1367
+ });
1368
+ const run = async (connection, sql, signal, introspection = false, connect = false) => {
1369
+ try {
1370
+ return redactQueryResult(await runClientQuery(requireContext(), connection, sql, queryOptions(void 0, connect), signal, introspection), connection);
1371
+ } catch (error) {
1372
+ const message = redactSecretText(error instanceof Error ? error.message : String(error), [connection.password]);
1373
+ throw new Error(message, error instanceof Error ? { cause: error } : void 0);
1374
+ }
1375
+ };
1376
+ const verify = async (connection, signal, connect = false) => {
1377
+ const result = await run(connection, tableListingSql(connection.type, connection), signal, true, connect);
1378
+ if (result.exitCode !== 0) {
1379
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
1380
+ throw new Error(`数据库连接验证失败(exit ${result.exitCode}):${detail}`);
1381
+ }
1382
+ return parseTableListing(connection.type, result.stdout).slice(0, resolvedOptions.introspectMaxTables);
1383
+ };
1384
+ const persistAtomically = async (sessionId, profileId, profile) => {
1385
+ if (persistence === void 0) return;
1386
+ const previousProfile = persistence.getProfile(profileId);
1387
+ const previousBinding = persistence.getBinding(sessionId);
1388
+ await persistence.putProfile(profileId, profile);
1389
+ try {
1390
+ await persistence.putBinding(sessionId, {
1391
+ profileId,
1392
+ updatedAt: profile.updatedAt
1393
+ });
1394
+ } catch (error) {
1395
+ if (previousProfile === void 0) await persistence.deleteProfile(profileId);
1396
+ else await persistence.putProfile(profileId, previousProfile);
1397
+ if (previousBinding === void 0) await persistence.deleteBinding(sessionId);
1398
+ else await persistence.putBinding(sessionId, previousBinding);
1399
+ throw error;
1400
+ }
1401
+ };
1402
+ const credentialSummary = async (connection) => {
1403
+ if (connection.type === "sqlite") return void 0;
1404
+ if (connection.password !== void 0) return {
1405
+ configured: true,
1406
+ source: "memory"
1407
+ };
1408
+ if (connection.passwordRef === void 0) return { configured: false };
1409
+ const info = await requireContext().credentials.describe(validatedCredentialRef(connection.passwordRef));
1410
+ return {
1411
+ configured: info.configured,
1412
+ ...info.source !== void 0 ? { source: info.source } : {}
1413
+ };
1414
+ };
1415
+ const service = {
1416
+ set(sessionId, connection) {
1417
+ if (connection.password !== void 0 && connection.passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
1418
+ if (connection.passwordRef !== void 0) validatePasswordRef(connection.passwordRef);
1419
+ runtime.set(sessionId, {
1420
+ ...connection,
1421
+ tables: copyTables(connection.tables)
1422
+ });
1423
+ },
1424
+ get(sessionId) {
1425
+ const connection = rawConnection(sessionId);
1426
+ return connection === void 0 ? void 0 : summarize(connection);
1427
+ },
1428
+ getWithSecret(sessionId) {
1429
+ const connection = rawConnection(sessionId);
1430
+ return connection === void 0 ? void 0 : {
1431
+ ...connection,
1432
+ tables: copyTables(connection.tables)
1433
+ };
1434
+ },
1435
+ has(sessionId) {
1436
+ return rawConnection(sessionId) !== void 0;
1437
+ },
1438
+ clear(sessionId) {
1439
+ runtime.delete(sessionId);
1440
+ },
1441
+ getFormDraft(sessionId) {
1442
+ const draft = persistence?.getDraft?.(sessionId) ?? formDrafts.get(sessionId);
1443
+ return draft === void 0 ? void 0 : copyFormDraft(draft);
1444
+ },
1445
+ async saveFormDraft(sessionId, draft) {
1446
+ if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
1447
+ const safe = normalizeFormDraft(draft);
1448
+ if (persistence?.putDraft !== void 0) await persistence.putDraft(sessionId, {
1449
+ ...safe,
1450
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1451
+ });
1452
+ else formDrafts.set(sessionId, safe);
1453
+ },
1454
+ async status(sessionId) {
1455
+ const connection = rawConnection(sessionId);
1456
+ if (connection === void 0) return void 0;
1457
+ const summary = summarize(connection);
1458
+ summary.credential = await credentialSummary(connection);
1459
+ return summary;
1460
+ },
1461
+ async connect(sessionId, input, signal) {
1462
+ if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
1463
+ const normalized = normalizeConnectionInput(input, resolvedOptions.cwd);
1464
+ const execution = await resolveCredential(normalized);
1465
+ const tables = await verify(execution, signal, true);
1466
+ const profileId = normalized.profileId ?? `session:${sessionId}`;
1467
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1468
+ await persistAtomically(sessionId, profileId, profileFromConnection(normalized, updatedAt));
1469
+ const published = {
1470
+ ...normalized,
1471
+ profileId,
1472
+ tables
1473
+ };
1474
+ runtime.set(sessionId, published);
1475
+ const summary = summarize(published);
1476
+ summary.credential = await credentialSummary(published);
1477
+ return {
1478
+ tables,
1479
+ summary
1480
+ };
1481
+ },
1482
+ async disconnect(sessionId) {
1483
+ runtime.delete(sessionId);
1484
+ if (persistence !== void 0) await persistence.deleteBinding(sessionId);
1485
+ },
1486
+ async test(sessionId, signal) {
1487
+ const connection = await service.resolveForExecution(sessionId);
1488
+ const tables = await verify(connection, signal);
1489
+ const published = {
1490
+ ...rawConnection(sessionId),
1491
+ tables
1492
+ };
1493
+ runtime.set(sessionId, published);
1494
+ const summary = summarize(published);
1495
+ summary.credential = await credentialSummary(published);
1496
+ return {
1497
+ tables,
1498
+ summary
1499
+ };
1500
+ },
1501
+ async resolveForExecution(sessionId) {
1502
+ const connection = rawConnection(sessionId);
1503
+ if (connection === void 0) throw new Error("请先在 Web「数据库」标签页连接数据库,或在 TUI 运行 /database connect(未找到当前会话的连接)");
1504
+ return resolveCredential(connection);
1505
+ },
1506
+ async listSchemas(sessionId, signal) {
1507
+ const connection = await service.resolveForExecution(sessionId);
1508
+ const stdout = await runMetadata(connection, "schemas", signal);
1509
+ return parseListing(connection.type, stdout).slice(0, resolvedOptions.introspectMaxTables);
1510
+ },
1511
+ async listTables(sessionId, schema, signal) {
1512
+ const connection = await service.resolveForExecution(sessionId);
1513
+ if (connection.type !== "sqlite") requireIdentifier(connection.type, schema, "schema");
1514
+ const stdout = await runMetadata(connection, "tables", signal, schema);
1515
+ return parseListing(connection.type, stdout).slice(0, resolvedOptions.introspectMaxTables);
1516
+ },
1517
+ async describe(sessionId, schema, table, signal) {
1518
+ const connection = await service.resolveForExecution(sessionId);
1519
+ if (connection.type !== "sqlite") requireIdentifier(connection.type, schema, "schema");
1520
+ requireIdentifier(connection.type, table, "table");
1521
+ const stdout = await runMetadata(connection, "describe", signal, schema, table);
1522
+ return parseColumns(connection.type, stdout);
1523
+ },
1524
+ async query(sessionId, sql, signal) {
1525
+ if (sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
1526
+ const maxQueryChars = resolvedOptions.maxQueryChars ?? 65536;
1527
+ if (sql.length > maxQueryChars) throw new Error(`sql 超过长度上限(${maxQueryChars} 字符)`);
1528
+ assertSingleStatement(sql, "/query");
1529
+ const connection = await service.resolveForExecution(sessionId);
1530
+ if ((connection.readonly ?? resolvedOptions.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
1531
+ return run(connection, sql, signal);
1532
+ }
1533
+ };
1534
+ async function runMetadata(connection, kind, signal, schema, table) {
1535
+ const result = await run(connection, metadataQuery(kind, connection.type, schema, table), signal, true);
1536
+ if (result.exitCode !== 0) {
1537
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
1538
+ throw new Error(`元数据查询失败(exit ${result.exitCode}):${detail}`);
1539
+ }
1540
+ return result.stdout;
1541
+ }
1542
+ return service;
1543
+ }
1544
+ function copyTables(tables) {
1545
+ return tables === void 0 ? void 0 : [...tables];
1546
+ }
1547
+ function normalizeFormDraft(draft) {
1548
+ if (!isDatabaseType(draft.type)) throw new Error("数据库类型无效");
1549
+ if (typeof draft.host !== "string" || typeof draft.port !== "string" || typeof draft.user !== "string" || typeof draft.database !== "string" || typeof draft.readonly !== "boolean") throw new Error("数据库表单草稿无效");
1550
+ return copyFormDraft(draft);
1551
+ }
1552
+ function copyFormDraft(draft) {
1553
+ return {
1554
+ type: draft.type,
1555
+ host: draft.host,
1556
+ port: draft.port,
1557
+ user: draft.user,
1558
+ database: draft.database,
1559
+ readonly: draft.readonly
1560
+ };
1561
+ }
1562
+ function isDatabaseType(value) {
1563
+ return value === "mysql" || value === "postgres" || value === "sqlite" || value === "oracle" || value === "hive" || value === "impala";
1564
+ }
1565
+ function validatePasswordRef(value) {
1566
+ try {
1567
+ credentialRef(value);
1568
+ } catch {
1569
+ throw new Error(`passwordRef "${value}" 无效;必须是 POSIX 环境变量形式的名称`);
1570
+ }
1571
+ }
1572
+ function validatedCredentialRef(value) {
1573
+ validatePasswordRef(value);
1574
+ return credentialRef(value);
1575
+ }
1576
+ function connectionFromProfile(profileId, profile) {
1577
+ return {
1578
+ type: profile.type,
1579
+ database: profile.database,
1580
+ profileId,
1581
+ ...profile.name !== void 0 ? { name: profile.name } : {},
1582
+ ...profile.host !== void 0 ? { host: profile.host } : {},
1583
+ ...profile.port !== void 0 ? { port: profile.port } : {},
1584
+ ...profile.user !== void 0 ? { user: profile.user } : {},
1585
+ ...profile.readonly !== void 0 ? { readonly: profile.readonly } : {},
1586
+ ...profile.passwordRef !== void 0 ? { passwordRef: profile.passwordRef } : {}
1587
+ };
1588
+ }
1589
+ function profileFromConnection(connection, updatedAt) {
1590
+ return {
1591
+ type: connection.type,
1592
+ database: connection.database,
1593
+ updatedAt,
1594
+ ...connection.name !== void 0 ? { name: connection.name } : {},
1595
+ ...connection.host !== void 0 ? { host: connection.host } : {},
1596
+ ...connection.port !== void 0 ? { port: connection.port } : {},
1597
+ ...connection.user !== void 0 ? { user: connection.user } : {},
1598
+ ...connection.readonly !== void 0 ? { readonly: connection.readonly } : {},
1599
+ ...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {}
1600
+ };
1601
+ }
1602
+ function requireIdentifier(type, value, label) {
1603
+ if (value === void 0 || value.length === 0) throw new Error(`${label} 不能为空`);
1604
+ sanitizeIdentifier(type, value);
1605
+ return value;
1606
+ }
1607
+ //#endregion
1608
+ export { classifyStatement as a, assertSingleStatement as c, runClientQuery as i, redactQueryResult as n, clientsSchema as o, redactSecretText as r, enforceReadRowLimit as s, createConnectionService as t };