@yejiming/dsh-data-agent 0.0.3 → 0.0.6

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.
@@ -1,4 +1,322 @@
1
1
  import z from "schemastery";
2
+ //#region src/sql.ts
3
+ /**
4
+ * Lightweight SQL-text scanning helpers shared by the sqlcmd tool half and
5
+ * the /query route. This is intentionally NOT a SQL parser: the scanner only
6
+ * understands lexical boundaries (strings, quoted identifiers, comments and
7
+ * parenthesis depth) well enough to make the two agent-loop guarantees from
8
+ * docs/optimization-opportunities.md:
9
+ *
10
+ * - a single tool call carries at most ONE SQL statement;
11
+ * - `maxRows` can be enforced with a real top-level LIMIT, not just a prompt.
12
+ *
13
+ * @module @yejiming/dsh-data-agent/sql
14
+ */
15
+ const IDENT_CHAR = /[A-Za-z0-9_$]/;
16
+ function isWhitespace(char) {
17
+ return /\s/.test(char);
18
+ }
19
+ function isIdentChar(char) {
20
+ return IDENT_CHAR.test(char);
21
+ }
22
+ function skipQuoted(sql, start) {
23
+ const quote = sql[start];
24
+ let index = start + 1;
25
+ while (index < sql.length) {
26
+ const char = sql[index];
27
+ if (char === "\\" && index + 1 < sql.length && quote !== "`") {
28
+ index += 2;
29
+ continue;
30
+ }
31
+ if (char === quote) {
32
+ if (sql[index + 1] === quote) {
33
+ index += 2;
34
+ continue;
35
+ }
36
+ return index + 1;
37
+ }
38
+ index += 1;
39
+ }
40
+ return sql.length;
41
+ }
42
+ function skipDollarQuoted(sql, start) {
43
+ const match = sql.slice(start).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
44
+ if (match === null) return -1;
45
+ const delimiter = match[0];
46
+ const end = sql.indexOf(delimiter, start + delimiter.length);
47
+ return end === -1 ? sql.length : end + delimiter.length;
48
+ }
49
+ function skipOracleQuoted(sql, start) {
50
+ if (!/^q'/i.test(sql.slice(start, start + 2))) return -1;
51
+ const open = sql[start + 2];
52
+ if (open === void 0) return sql.length;
53
+ const close = {
54
+ "[": "]",
55
+ "{": "}",
56
+ "(": ")",
57
+ "<": ">"
58
+ }[open] ?? open;
59
+ let index = start + 3;
60
+ while (index < sql.length) {
61
+ if (sql[index] === close && sql[index + 1] === "'") return index + 2;
62
+ index += 1;
63
+ }
64
+ return sql.length;
65
+ }
66
+ function skipBlockComment(sql, start) {
67
+ let depth = 1;
68
+ let index = start + 2;
69
+ while (index < sql.length) {
70
+ if (sql.startsWith("/*", index)) {
71
+ depth += 1;
72
+ index += 2;
73
+ continue;
74
+ }
75
+ if (sql.startsWith("*/", index)) {
76
+ depth -= 1;
77
+ index += 2;
78
+ if (depth === 0) return index;
79
+ continue;
80
+ }
81
+ index += 1;
82
+ }
83
+ return sql.length;
84
+ }
85
+ function skipLineComment(sql, start) {
86
+ const newline = sql.indexOf("\n", start);
87
+ return newline === -1 ? sql.length : newline + 1;
88
+ }
89
+ /**
90
+ * Walk the SQL text, invoking `onSemicolon` for every top-level statement
91
+ * separator (parenthesis depth zero, outside strings, quoted identifiers and
92
+ * comments).
93
+ */
94
+ function scanTopLevelSemicolons(sql, onSemicolon) {
95
+ let depth = 0;
96
+ let index = 0;
97
+ while (index < sql.length) {
98
+ const char = sql[index];
99
+ if (isWhitespace(char)) {
100
+ index += 1;
101
+ continue;
102
+ }
103
+ if (sql.startsWith("--", index)) {
104
+ index = skipLineComment(sql, index + 2);
105
+ continue;
106
+ }
107
+ if (sql.startsWith("/*", index)) {
108
+ index = skipBlockComment(sql, index);
109
+ continue;
110
+ }
111
+ if (char === "'" || char === "\"" || char === "`") {
112
+ index = skipQuoted(sql, index);
113
+ continue;
114
+ }
115
+ if (char === "$") {
116
+ const dollarEnd = skipDollarQuoted(sql, index);
117
+ if (dollarEnd !== -1) {
118
+ index = dollarEnd;
119
+ continue;
120
+ }
121
+ }
122
+ const oracleEnd = skipOracleQuoted(sql, index);
123
+ if (oracleEnd !== -1) {
124
+ index = oracleEnd;
125
+ continue;
126
+ }
127
+ if (char === "(") {
128
+ depth += 1;
129
+ index += 1;
130
+ continue;
131
+ }
132
+ if (char === ")") {
133
+ depth = Math.max(0, depth - 1);
134
+ index += 1;
135
+ continue;
136
+ }
137
+ if (char === ";" && depth === 0) onSemicolon(index);
138
+ index += 1;
139
+ }
140
+ }
141
+ /** Whether meaningful SQL content exists after `index` (trailing `;`/comments ignored). */
142
+ function hasContentAfter(sql, index) {
143
+ let cursor = index;
144
+ while (cursor < sql.length) {
145
+ const char = sql[cursor];
146
+ if (isWhitespace(char)) {
147
+ cursor += 1;
148
+ continue;
149
+ }
150
+ if (char === ";") {
151
+ cursor += 1;
152
+ continue;
153
+ }
154
+ if (sql.startsWith("--", cursor)) {
155
+ cursor = skipLineComment(sql, cursor + 2);
156
+ continue;
157
+ }
158
+ if (sql.startsWith("/*", cursor)) {
159
+ cursor = skipBlockComment(sql, cursor);
160
+ continue;
161
+ }
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+ /**
167
+ * Throw unless `sql` contains at most one statement. A single trailing
168
+ * semicolon (and any number of repeated trailing semicolons / comments) is
169
+ * accepted; a semicolon followed by real content is rejected.
170
+ */
171
+ function assertSingleStatement(sql, label = "SQL") {
172
+ if (stripTrailingTerminator(sql).trim().length === 0) throw new Error(`${label}: SQL 不能为空`);
173
+ const semicolons = [];
174
+ scanTopLevelSemicolons(sql, (index) => {
175
+ semicolons.push(index);
176
+ });
177
+ const offending = semicolons.find((index) => hasContentAfter(sql, index + 1));
178
+ if (offending === void 0) return;
179
+ throw new Error(`${label}: 一次只允许执行一条 SQL 语句(第 ${offending + 1} 个字符后的分号不是末尾分号)。多条语句请拆成多次调用;客户端进程独立、自动提交,不支持在多次调用间保持事务。`);
180
+ }
181
+ /** Whether `keyword` appears at top level as a whole word in `sql`. */
182
+ function hasTopLevelKeyword(sql, keyword) {
183
+ const needle = keyword.toLowerCase();
184
+ let depth = 0;
185
+ let index = 0;
186
+ while (index < sql.length) {
187
+ const char = sql[index];
188
+ if (isWhitespace(char)) {
189
+ index += 1;
190
+ continue;
191
+ }
192
+ if (sql.startsWith("--", index)) {
193
+ index = skipLineComment(sql, index + 2);
194
+ continue;
195
+ }
196
+ if (sql.startsWith("/*", index)) {
197
+ index = skipBlockComment(sql, index);
198
+ continue;
199
+ }
200
+ if (char === "'" || char === "\"" || char === "`") {
201
+ index = skipQuoted(sql, index);
202
+ continue;
203
+ }
204
+ if (char === "$") {
205
+ const dollarEnd = skipDollarQuoted(sql, index);
206
+ if (dollarEnd !== -1) {
207
+ index = dollarEnd;
208
+ continue;
209
+ }
210
+ }
211
+ const oracleEnd = skipOracleQuoted(sql, index);
212
+ if (oracleEnd !== -1) {
213
+ index = oracleEnd;
214
+ continue;
215
+ }
216
+ if (char === "(") {
217
+ depth += 1;
218
+ index += 1;
219
+ continue;
220
+ }
221
+ if (char === ")") {
222
+ depth = Math.max(0, depth - 1);
223
+ index += 1;
224
+ continue;
225
+ }
226
+ 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;
227
+ index += 1;
228
+ }
229
+ return false;
230
+ }
231
+ function trailingLineCommentStart(sql, end) {
232
+ let index = sql.lastIndexOf("\n", end - 1) + 1;
233
+ while (index < end) {
234
+ const char = sql[index];
235
+ if (char === "'" || char === "\"" || char === "`") {
236
+ index = skipQuoted(sql, index);
237
+ continue;
238
+ }
239
+ if (sql.startsWith("--", index)) {
240
+ const tail = sql.slice(index + 2, end);
241
+ return tail.length === 0 || isWhitespace(tail[0]) ? index : -1;
242
+ }
243
+ index += 1;
244
+ }
245
+ return -1;
246
+ }
247
+ function blockCommentEndingAt(sql, end) {
248
+ let candidate = -1;
249
+ let index = 0;
250
+ while (index < end) {
251
+ const char = sql[index];
252
+ if (isWhitespace(char)) {
253
+ index += 1;
254
+ continue;
255
+ }
256
+ if (char === "'" || char === "\"" || char === "`") {
257
+ index = skipQuoted(sql, index);
258
+ continue;
259
+ }
260
+ if (sql.startsWith("--", index)) {
261
+ index = skipLineComment(sql, index + 2);
262
+ continue;
263
+ }
264
+ if (sql.startsWith("/*", index)) {
265
+ const start = index;
266
+ let commentDepth = 1;
267
+ index += 2;
268
+ while (index < end && commentDepth > 0) {
269
+ if (sql.startsWith("/*", index)) {
270
+ commentDepth += 1;
271
+ index += 2;
272
+ continue;
273
+ }
274
+ if (sql.startsWith("*/", index)) {
275
+ commentDepth -= 1;
276
+ index += 2;
277
+ if (commentDepth === 0) {
278
+ if (index === end) candidate = start;
279
+ break;
280
+ }
281
+ continue;
282
+ }
283
+ index += 1;
284
+ }
285
+ continue;
286
+ }
287
+ index += 1;
288
+ }
289
+ return candidate;
290
+ }
291
+ /**
292
+ * Strip trailing whitespace, statement terminators and trailing comments so a
293
+ * limit clause can be appended to the actual statement text. Only comments
294
+ * that occupy the whole tail are removed; the preceding statement is kept.
295
+ */
296
+ function stripTrailingTerminator(sql) {
297
+ let end = sql.length;
298
+ for (;;) {
299
+ while (end > 0 && isWhitespace(sql[end - 1])) end -= 1;
300
+ if (end > 0 && sql[end - 1] === ";") {
301
+ end -= 1;
302
+ continue;
303
+ }
304
+ if (end >= 2 && sql.slice(end - 2, end) === "*/") {
305
+ const start = blockCommentEndingAt(sql, end);
306
+ if (start !== -1) {
307
+ end = start;
308
+ continue;
309
+ }
310
+ }
311
+ const lineComment = trailingLineCommentStart(sql, end);
312
+ if (lineComment !== -1) {
313
+ end = lineComment;
314
+ continue;
315
+ }
316
+ return sql.slice(0, end);
317
+ }
318
+ }
319
+ //#endregion
2
320
  //#region src/clients.ts
3
321
  /**
4
322
  * Whitespace / comment stripping for {@link classifyStatement}: remove
@@ -119,7 +437,8 @@ function skipParens(sql, start) {
119
437
  /**
120
438
  * Classify a SQL text as a read or write statement by its FIRST effective
121
439
  * token (a conservative read whitelist, not a parser). `with` is read only
122
- * when its body's first token is `select`. `pragma` is read-only for SQLite.
440
+ * when its body's first token is `select`. SQLite `pragma` is read in its
441
+ * query form and write when a value is assigned.
123
442
  */
124
443
  function classifyStatement(sql, type) {
125
444
  const rest = stripLeadingComments(sql);
@@ -131,12 +450,116 @@ function classifyStatement(sql, type) {
131
450
  case "describe":
132
451
  case "desc":
133
452
  case "explain": return "read";
134
- case "pragma": return type === "sqlite" ? "read" : "write";
453
+ case "pragma": {
454
+ if (type !== "sqlite") return "write";
455
+ const afterPragma = rest.replace(/^pragma\b/i, "").trimStart();
456
+ return /^(?:[A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_$]*)?|"[^"]+"|`[^`]+`)\s*=/.test(afterPragma) ? "write" : "read";
457
+ }
135
458
  case "with": return stripWithBody(rest).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase() === "select" ? "read" : "write";
136
459
  default: return "write";
137
460
  }
138
461
  }
139
462
  /**
463
+ * Enforce the configured `maxRows` on a read query instead of relying on the
464
+ * prompt. SELECT/CTE-read statements get a real top-level LIMIT (Oracle uses
465
+ * a ROWNUM wrapper because it has no LIMIT); SHOW/DESCRIBE/EXPLAIN/PRAGMA are
466
+ * left untouched here and are capped while parsing structured output.
467
+ *
468
+ * An existing numeric top-level LIMIT is rewritten when it is larger than
469
+ * `maxRows`; a smaller existing LIMIT is preserved, and a non-numeric or
470
+ * unparseable LIMIT is left for the client (structured tools still truncate).
471
+ */
472
+ function enforceReadRowLimit(sql, type, maxRows) {
473
+ if (classifyStatement(sql, type) !== "read") return sql;
474
+ const first = stripLeadingComments(sql).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase();
475
+ if (first !== "select" && first !== "with") return sql;
476
+ const hadTrailingSemicolon = /;\s*$/.test(sql);
477
+ if (!hasTopLevelKeyword(sql, "limit") && type !== "oracle") return `${stripTrailingTerminator(sql)} LIMIT ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
478
+ if (type === "oracle") return `SELECT * FROM (${stripTrailingTerminator(sql)}) dsh_limit WHERE ROWNUM <= ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
479
+ if (!hasTopLevelKeyword(sql, "limit")) return sql;
480
+ return rewriteTopLevelLimit(sql, maxRows);
481
+ }
482
+ /** Rewrite the first top-level `LIMIT n` / `LIMIT n, m` with a capped row count. */
483
+ function rewriteTopLevelLimit(sql, maxRows) {
484
+ let depth = 0;
485
+ let index = 0;
486
+ while (index < sql.length) {
487
+ const char = sql[index];
488
+ if (/\s/.test(char)) {
489
+ index += 1;
490
+ continue;
491
+ }
492
+ if (sql.startsWith("--", index)) {
493
+ const newline = sql.indexOf("\n", index + 2);
494
+ index = newline === -1 ? sql.length : newline + 1;
495
+ continue;
496
+ }
497
+ if (sql.startsWith("/*", index)) {
498
+ let depthComment = 1;
499
+ index += 2;
500
+ while (index < sql.length && depthComment > 0) {
501
+ if (sql.startsWith("/*", index)) {
502
+ depthComment += 1;
503
+ index += 2;
504
+ continue;
505
+ }
506
+ if (sql.startsWith("*/", index)) {
507
+ depthComment -= 1;
508
+ index += 2;
509
+ continue;
510
+ }
511
+ index += 1;
512
+ }
513
+ continue;
514
+ }
515
+ if (char === "'" || char === "\"" || char === "`") {
516
+ const quote = char;
517
+ index += 1;
518
+ while (index < sql.length) {
519
+ if (sql[index] === "\\" && index + 1 < sql.length && quote !== "`") {
520
+ index += 2;
521
+ continue;
522
+ }
523
+ if (sql[index] === quote) {
524
+ if (sql[index + 1] === quote) {
525
+ index += 2;
526
+ continue;
527
+ }
528
+ index += 1;
529
+ break;
530
+ }
531
+ index += 1;
532
+ }
533
+ continue;
534
+ }
535
+ if (char === "(") {
536
+ depth += 1;
537
+ index += 1;
538
+ continue;
539
+ }
540
+ if (char === ")") {
541
+ depth = Math.max(0, depth - 1);
542
+ index += 1;
543
+ continue;
544
+ }
545
+ 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]))) {
546
+ const match = sql.slice(index).match(/^LIMIT\s+(ALL|\d+)(\s*,\s*\d+)?/i);
547
+ if (match === null) return sql;
548
+ const firstValue = match[1];
549
+ const hasOffsetPart = match[2] !== void 0;
550
+ let replacement = "";
551
+ if (hasOffsetPart) {
552
+ const rowCount = Number(match[2].match(/\d+/)[0]);
553
+ replacement = `LIMIT ${firstValue === "ALL" ? "0" : firstValue}, ${Math.min(rowCount, maxRows)}`;
554
+ } else if (/^\d+$/.test(firstValue)) replacement = `LIMIT ${Math.min(Number(firstValue), maxRows)}`;
555
+ else replacement = `LIMIT ${maxRows}`;
556
+ return sql.slice(0, index) + replacement + sql.slice(index + match[0].length);
557
+ }
558
+ index += 1;
559
+ }
560
+ return sql;
561
+ }
562
+ /**
140
563
  * Validate and quote one schema/table identifier for a safe metadata query.
141
564
  * Identifiers are restricted to `[A-Za-z0-9_$]+` and then wrapped per type:
142
565
  * backticks (mysql/hive/impala) or double quotes (postgres/oracle/sqlite),
@@ -191,6 +614,15 @@ const INTROSPECT_ARGS = {
191
614
  hive: ["--silent=true", "--outputformat=tsv2"],
192
615
  impala: ["-B"]
193
616
  };
617
+ /** Structured `sql-query` flag arguments: header + one row per line. */
618
+ const STRUCTURED_QUERY_ARGS = {
619
+ mysql: ["--batch", "--raw"],
620
+ postgres: ["-A"],
621
+ sqlite: ["-header", "-csv"],
622
+ oracle: ["-S", "/nolog"],
623
+ hive: ["--silent=true", "--outputformat=tsv2"],
624
+ impala: ["-B", "--print_header"]
625
+ };
194
626
  /** Default ports when the connection does not carry one. */
195
627
  const DEFAULT_PORTS = {
196
628
  mysql: 3306,
@@ -284,6 +716,23 @@ function stdinPrefix(type, connection) {
284
716
  case "impala": return "";
285
717
  }
286
718
  }
719
+ /**
720
+ * Oracle structured-query prefix: same connect block as {@link stdinPrefix},
721
+ * but with HEADING ON and UNDERLINE OFF so `sql-query` can read the column
722
+ * names from the first output line.
723
+ */
724
+ function structuredStdinPrefix(type, connection) {
725
+ if (type !== "oracle") return stdinPrefix(type, connection);
726
+ return `${[
727
+ "SET PAGESIZE 0",
728
+ "SET FEEDBACK OFF",
729
+ "SET HEADING ON",
730
+ "SET UNDERLINE OFF",
731
+ "SET COLSEP '|'",
732
+ "SET TRIMSPOOL ON",
733
+ 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}` : ""
734
+ ].filter((line) => line !== "").join("\n")}\n`;
735
+ }
287
736
  /** Apply one deployment override's extra args in front of the built-in flags. */
288
737
  function withOverrides(flags, override) {
289
738
  if (override === void 0 || override.args === void 0) return flags;
@@ -312,6 +761,19 @@ function buildIntrospectTemplate(type, connection, override) {
312
761
  };
313
762
  }
314
763
  /**
764
+ * Build one client invocation for the structured `sql-query` tool: every
765
+ * supported client prints a header row followed by one row per line (mysql
766
+ * tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
767
+ */
768
+ function buildStructuredQueryTemplate(type, connection, override) {
769
+ return {
770
+ command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
771
+ args: [...withOverrides(STRUCTURED_QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
772
+ env: credentialEnv(type, connection),
773
+ stdinPrefix: structuredStdinPrefix(type, connection)
774
+ };
775
+ }
776
+ /**
315
777
  * The table-listing SQL per type, run at /connect time to verify
316
778
  * connectivity: the connected database's own tables (mysql uses the
317
779
  * connection's database as the schema; postgres lists `public`; oracle lists
@@ -329,7 +791,7 @@ function tableListingSql(type, connection) {
329
791
  }
330
792
  /**
331
793
  * Metadata query per kind × type. `schema`/`table` are identifier whitelist
332
- * validated by the caller (`[A-Za-z0-9_$#.-]`) before they reach here.
794
+ * validated by the caller (`[A-Za-z0-9_$]`) before they reach here.
333
795
  */
334
796
  function metadataQuery(kind, type, schema, table) {
335
797
  switch (kind) {
@@ -429,7 +891,7 @@ function parseColumns(type, stdout) {
429
891
  //#region src/defaults.ts
430
892
  /**
431
893
  * Package-wide defaults shared by the server half (`src/index.ts`) and the
432
- * sqlcmd tool half (`src/tool.ts`). Loader schemas carry these as their
894
+ * database tool half (`src/tool.ts`). Loader schemas carry these as their
433
895
  * defaults so a deployment may override every one of them in cordis.yml.
434
896
  * @module @yejiming/dsh-data-agent/defaults
435
897
  */
@@ -437,13 +899,13 @@ function parseColumns(type, stdout) {
437
899
  const DEFAULT_PRESET_ID = "data-agent";
438
900
  /** End-to-end deadline for one `/connect` connectivity check, milliseconds. */
439
901
  const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
440
- /** End-to-end deadline for one sqlcmd query, milliseconds. */
902
+ /** End-to-end deadline for one database-tool query, milliseconds. */
441
903
  const DEFAULT_QUERY_TIMEOUT_MS = 3e4;
442
- /** In-memory cap on sqlcmd captured output (stdout and stderr each). */
904
+ /** In-memory cap on database-tool captured output (stdout and stderr each). */
443
905
  const DEFAULT_MAX_RESULT_CHARS = 2e4;
444
906
  /** Cap on one /query SQL text length (abuse guard; the wire body stays small). */
445
907
  const DEFAULT_MAX_QUERY_CHARS = 65536;
446
908
  /** Grace period for the subprocess terminate escalation. */
447
909
  const DEFAULT_GRACE_MS = 5e3;
448
910
  //#endregion
449
- export { DEFAULT_PRESET_ID as a, buildIntrospectTemplate as c, metadataQuery as d, parseColumns as f, tableListingSql as g, sanitizeIdentifier as h, DEFAULT_MAX_RESULT_CHARS as i, classifyStatement as l, parseTableListing as m, DEFAULT_GRACE_MS as n, DEFAULT_QUERY_TIMEOUT_MS as o, parseListing as p, DEFAULT_MAX_QUERY_CHARS as r, buildClientTemplate as s, DEFAULT_CONNECT_TIMEOUT_MS as t, clientsSchema as u };
911
+ export { sanitizeIdentifier as _, DEFAULT_PRESET_ID as a, buildIntrospectTemplate as c, clientsSchema as d, enforceReadRowLimit as f, parseTableListing as g, parseListing as h, DEFAULT_MAX_RESULT_CHARS as i, buildStructuredQueryTemplate as l, parseColumns as m, DEFAULT_GRACE_MS as n, DEFAULT_QUERY_TIMEOUT_MS as o, metadataQuery as p, DEFAULT_MAX_QUERY_CHARS as r, buildClientTemplate as s, DEFAULT_CONNECT_TIMEOUT_MS as t, classifyStatement as u, tableListingSql as v, assertSingleStatement as y };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as DEFAULT_PRESET_ID, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, t as DEFAULT_CONNECT_TIMEOUT_MS, u as clientsSchema } from "./defaults-Dgu2B2Yq.js";
1
+ import { a as DEFAULT_PRESET_ID, d as clientsSchema, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-Bac6QvNt.js";
2
2
  import { access, cp, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
@@ -53,9 +53,9 @@ function createConnectionStore() {
53
53
  *
54
54
  * The HTTP routes live in the separate `./routes` entry
55
55
  * (`@yejiming/dsh-data-agent/routes`, cordis row `data-agent-routes`) so
56
- * this row keeps working in headless profiles without a webserver; the sqlcmd
57
- * tool itself lives in the `./tool` entry and is mounted only by the
58
- * data-agent preset.
56
+ * this row keeps working in headless profiles without a webserver; the
57
+ * database tools themselves live in the `./tool` entry and are mounted only
58
+ * by the data-agent preset.
59
59
  * @module @yejiming/dsh-data-agent
60
60
  */
61
61
  /** Cordis plugin name (diagnostics only). */
@@ -117,7 +117,7 @@ async function installPreset(ctx, presetId) {
117
117
  await cp(sourceDir, targetDir, { recursive: true });
118
118
  ctx.logger.info("data-agent: installed preset \"%s\" to %s", presetId, targetDir);
119
119
  } catch (error) {
120
- ctx.logger.warn("data-agent: failed to install preset \"%s\" to %s (%s); copy preset/data-agent/ manually to enable the 数据Agent preset", presetId, targetDir, error instanceof Error ? error.message : String(error));
120
+ ctx.logger.warn("data-agent: failed to install preset \"%s\" to %s (%s); copy preset/data-agent/ manually to enable the 数据模式 preset", presetId, targetDir, error instanceof Error ? error.message : String(error));
121
121
  }
122
122
  }
123
123
  /**
@@ -1,4 +1,4 @@
1
- import { c as buildIntrospectTemplate, s as buildClientTemplate } from "./defaults-Dgu2B2Yq.js";
1
+ import { c as buildIntrospectTemplate, l as buildStructuredQueryTemplate, s as buildClientTemplate } from "./defaults-Bac6QvNt.js";
2
2
  //#region src/query.ts
3
3
  /** Read one collected stream from offset 0. */
4
4
  function readCaptured(reader) {
@@ -33,7 +33,7 @@ function readCaptured(reader) {
33
33
  * @returns the captured outcome.
34
34
  */
35
35
  async function runClientQuery(ctx, connection, sql, options, externalSignal, introspect = false) {
36
- const template = introspect ? buildIntrospectTemplate(connection.type, connection, options.clients[connection.type]) : buildClientTemplate(connection.type, connection, options.clients[connection.type]);
36
+ 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]);
37
37
  const controller = new AbortController();
38
38
  const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
39
39
  const onExternalAbort = () => {
package/lib/routes.js CHANGED
@@ -1,5 +1,5 @@
1
- import { d as metadataQuery, f as parseColumns, g as tableListingSql, h as sanitizeIdentifier, i as DEFAULT_MAX_RESULT_CHARS, l as classifyStatement, m as parseTableListing, o as DEFAULT_QUERY_TIMEOUT_MS, p as parseListing, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-Dgu2B2Yq.js";
2
- import { t as runClientQuery } from "./query-vK9dr7Z6.js";
1
+ import { _ as sanitizeIdentifier, g as parseTableListing, h as parseListing, i as DEFAULT_MAX_RESULT_CHARS, m as parseColumns, o as DEFAULT_QUERY_TIMEOUT_MS, p as metadataQuery, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS, u as classifyStatement, v as tableListingSql, y as assertSingleStatement } from "./defaults-Bac6QvNt.js";
2
+ import { t as runClientQuery } from "./query-CmhTFklw.js";
3
3
  import { resolve } from "node:path";
4
4
  import z from "schemastery";
5
5
  //#region src/routes.ts
@@ -231,6 +231,7 @@ function apply(ctx, config) {
231
231
  const sql = body.sql;
232
232
  if (typeof sql !== "string" || sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
233
233
  if (sql.length > config.maxQueryChars) throw new Error(`sql 超过长度上限(${config.maxQueryChars} 字符)`);
234
+ assertSingleStatement(sql, "/query");
234
235
  const connection = requireConnection(sessionId);
235
236
  if ((connection.readonly ?? config.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
236
237
  writeJson(200, {