@yejiming/dsh-data-agent 0.0.12 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.en.md +97 -16
  2. package/README.md +97 -16
  3. package/conformance/dsh-ecosystem/baseline.json +59 -0
  4. package/conformance/dsh-ecosystem/dependencies.json +41 -0
  5. package/conformance/dsh-ecosystem/fixtures/host-degraded.fixture.json +12 -0
  6. package/conformance/dsh-ecosystem/fixtures/host-eligible.fixture.json +13 -0
  7. package/conformance/dsh-ecosystem/fixtures/host-rejected.fixture.json +12 -0
  8. package/conformance/dsh-ecosystem/fixtures/profiles/native-only/package.json +8 -0
  9. package/conformance/dsh-ecosystem/fixtures/profiles/native-plus-adapter/package.json +9 -0
  10. package/conformance/dsh-ecosystem/inventory.json +100 -0
  11. package/conformance/dsh-ecosystem/restrictions.json +20 -0
  12. package/cordis.patch.yml +6 -3
  13. package/dsh-plugin.json +72 -0
  14. package/lib/catalog-DEJqOXRo.js +1944 -0
  15. package/lib/catalog-identity-CVftmvQL.js +96 -0
  16. package/lib/client.js +3465 -138
  17. package/lib/client.js.map +1 -1
  18. package/lib/command-CzzSPmag.js +1719 -0
  19. package/lib/command.js +2 -2
  20. package/lib/{connections-5sfdEDsG.js → connections-CFXOZTHZ.js} +964 -68
  21. package/lib/ecosystem.js +19 -0
  22. package/lib/index.js +392 -34
  23. package/lib/routes.js +260 -8
  24. package/lib/{tool-DgL0fBfj.js → tool-DNkywSph.js} +367 -168
  25. package/lib/tool.js +1 -1
  26. package/lib/types/catalog-adapters.d.ts +52 -0
  27. package/lib/types/catalog-ai.d.ts +49 -0
  28. package/lib/types/catalog-command.d.ts +28 -0
  29. package/lib/types/catalog-identity.d.ts +23 -0
  30. package/lib/types/catalog-storage.d.ts +265 -0
  31. package/lib/types/catalog-tools.d.ts +5 -0
  32. package/lib/types/catalog-tui.d.ts +18 -0
  33. package/lib/types/catalog-types.d.ts +1376 -0
  34. package/lib/types/catalog.d.ts +59 -0
  35. package/lib/types/client/CatalogPanel.d.ts +15 -0
  36. package/lib/types/client/DataAgentWorkbench.d.ts +1 -2
  37. package/lib/types/client/QueryResultTable.d.ts +13 -0
  38. package/lib/types/client/catalog-client.d.ts +57 -0
  39. package/lib/types/client/locales.d.ts +290 -0
  40. package/lib/types/client/persistence.d.ts +3 -1
  41. package/lib/types/client/query-export.d.ts +11 -0
  42. package/lib/types/client-discovery.d.ts +3 -4
  43. package/lib/types/clients.d.ts +14 -8
  44. package/lib/types/command.d.ts +16 -5
  45. package/lib/types/connections.d.ts +42 -4
  46. package/lib/types/database-types.d.ts +23 -0
  47. package/lib/types/defaults.d.ts +26 -0
  48. package/lib/types/ecosystem.d.ts +13 -0
  49. package/lib/types/index.d.ts +58 -15
  50. package/lib/types/query.d.ts +13 -11
  51. package/lib/types/sql.d.ts +9 -1
  52. package/lib/types/storage.d.ts +11 -1
  53. package/lib/types/structured-read.d.ts +2 -2
  54. package/lib/types/structured.d.ts +1 -1
  55. package/lib/types/tool.d.ts +5 -5
  56. package/lib/types/tui-connection-form.d.ts +21 -12
  57. package/package.json +30 -4
  58. package/preset/data-agent/agent.cordis.yml +13 -2
  59. package/lib/command-DuCpwVbl.js +0 -875
  60. package/lib/defaults-DP4RyRh1.js +0 -21
@@ -1,9 +1,115 @@
1
- import "./defaults-DP4RyRh1.js";
2
1
  import { readdir } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { posix, resolve, win32 } from "node:path";
5
4
  import z from "schemastery";
6
5
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
6
+ import { createClient } from "@clickhouse/client";
7
+ //#region src/database-types.ts
8
+ /**
9
+ * Browser-safe database type descriptors shared by every DSH surface.
10
+ * Keep this module dependency-free: server-only client/process details belong
11
+ * in the database adapters, not in Web or persistence bundles.
12
+ */
13
+ const DATABASE_TYPES = [
14
+ "mysql",
15
+ "postgres",
16
+ "sqlite",
17
+ "oracle",
18
+ "hive",
19
+ "impala",
20
+ "clickhouse",
21
+ "doris",
22
+ "sqlserver"
23
+ ];
24
+ const DATABASE_TYPE_DESCRIPTORS = {
25
+ mysql: {
26
+ type: "mysql",
27
+ label: "MySQL",
28
+ localeKey: "type.mysql",
29
+ defaultPort: 3306,
30
+ defaultUser: "root",
31
+ fileBased: false
32
+ },
33
+ postgres: {
34
+ type: "postgres",
35
+ label: "PostgreSQL",
36
+ localeKey: "type.postgres",
37
+ defaultPort: 5432,
38
+ defaultUser: "postgres",
39
+ fileBased: false
40
+ },
41
+ sqlite: {
42
+ type: "sqlite",
43
+ label: "SQLite",
44
+ localeKey: "type.sqlite",
45
+ defaultPort: 0,
46
+ defaultUser: "",
47
+ fileBased: true
48
+ },
49
+ oracle: {
50
+ type: "oracle",
51
+ label: "Oracle",
52
+ localeKey: "type.oracle",
53
+ defaultPort: 1521,
54
+ defaultUser: "",
55
+ fileBased: false
56
+ },
57
+ hive: {
58
+ type: "hive",
59
+ label: "Hive",
60
+ localeKey: "type.hive",
61
+ defaultPort: 1e4,
62
+ defaultUser: "",
63
+ fileBased: false
64
+ },
65
+ impala: {
66
+ type: "impala",
67
+ label: "Impala",
68
+ localeKey: "type.impala",
69
+ defaultPort: 21050,
70
+ defaultUser: "",
71
+ fileBased: false
72
+ },
73
+ clickhouse: {
74
+ type: "clickhouse",
75
+ label: "ClickHouse",
76
+ localeKey: "type.clickhouse",
77
+ defaultPort: 8123,
78
+ securePort: 8443,
79
+ defaultUser: "default",
80
+ fileBased: false
81
+ },
82
+ doris: {
83
+ type: "doris",
84
+ label: "Apache Doris",
85
+ localeKey: "type.doris",
86
+ defaultPort: 9030,
87
+ defaultUser: "root",
88
+ fileBased: false
89
+ },
90
+ sqlserver: {
91
+ type: "sqlserver",
92
+ label: "SQL Server",
93
+ localeKey: "type.sqlserver",
94
+ defaultPort: 1433,
95
+ defaultUser: "sa",
96
+ fileBased: false
97
+ }
98
+ };
99
+ function isDatabaseType(value) {
100
+ return typeof value === "string" && DATABASE_TYPES.includes(value);
101
+ }
102
+ function defaultDatabasePort(type, secure = false) {
103
+ const descriptor = DATABASE_TYPE_DESCRIPTORS[type];
104
+ return secure && descriptor.securePort !== void 0 ? descriptor.securePort : descriptor.defaultPort;
105
+ }
106
+ function defaultDatabaseUser(type) {
107
+ return DATABASE_TYPE_DESCRIPTORS[type].defaultUser;
108
+ }
109
+ function databaseTypeLabel(type) {
110
+ return DATABASE_TYPE_DESCRIPTORS[type].label;
111
+ }
112
+ //#endregion
7
113
  //#region src/sql.ts
8
114
  /**
9
115
  * Lightweight SQL-text scanning helpers shared by the sql-cmd tool half and
@@ -13,7 +119,7 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
13
119
  * docs/optimization-opportunities.md:
14
120
  *
15
121
  * - 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.
122
+ * - `maxRows` can be enforced with a real dialect-level row bound, not just a prompt.
17
123
  *
18
124
  * @module @yejiming/dsh-data-agent/sql
19
125
  */
@@ -233,6 +339,82 @@ function hasTopLevelKeyword(sql, keyword) {
233
339
  }
234
340
  return false;
235
341
  }
342
+ /**
343
+ * Preserve executable SQL text while replacing strings, quoted identifiers,
344
+ * dollar/Oracle quoted bodies, and comments with spaces. Newlines are kept so
345
+ * line-oriented client directives can be checked without false positives.
346
+ */
347
+ function maskSqlLiteralsAndComments(sql) {
348
+ const chars = sql.split("");
349
+ const mask = (start, end) => {
350
+ for (let index = start; index < end; index += 1) if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
351
+ };
352
+ let index = 0;
353
+ while (index < sql.length) {
354
+ const char = sql[index];
355
+ if (sql.startsWith("--", index)) {
356
+ const end = skipLineComment(sql, index + 2);
357
+ mask(index, end);
358
+ index = end;
359
+ continue;
360
+ }
361
+ if (sql.startsWith("/*", index)) {
362
+ const end = skipBlockComment(sql, index);
363
+ mask(index, end);
364
+ index = end;
365
+ continue;
366
+ }
367
+ if (char === "'" || char === "\"" || char === "`") {
368
+ const end = skipQuoted(sql, index);
369
+ mask(index, end);
370
+ index = end;
371
+ continue;
372
+ }
373
+ if (char === "[") {
374
+ let end = index + 1;
375
+ while (end < sql.length) {
376
+ if (sql[end] === "]" && sql[end + 1] === "]") {
377
+ end += 2;
378
+ continue;
379
+ }
380
+ if (sql[end] === "]") {
381
+ end += 1;
382
+ break;
383
+ }
384
+ end += 1;
385
+ }
386
+ mask(index, end);
387
+ index = end;
388
+ continue;
389
+ }
390
+ if (char === "$") {
391
+ const end = skipDollarQuoted(sql, index);
392
+ if (end !== -1) {
393
+ mask(index, end);
394
+ index = end;
395
+ continue;
396
+ }
397
+ }
398
+ const oracleEnd = skipOracleQuoted(sql, index);
399
+ if (oracleEnd !== -1) {
400
+ mask(index, oracleEnd);
401
+ index = oracleEnd;
402
+ continue;
403
+ }
404
+ index += 1;
405
+ }
406
+ return chars.join("");
407
+ }
408
+ /** Reject commands interpreted by sqlcmd itself rather than by SQL Server. */
409
+ function assertSqlServerSafeInput(sql, label = "SQL Server SQL") {
410
+ const executable = maskSqlLiteralsAndComments(sql);
411
+ if (/\$\([^\r\n)]*\)/.test(executable)) throw new Error(`${label}: 禁止 sqlcmd 变量替换 $(...)`);
412
+ for (const line of executable.split(/\r?\n/)) {
413
+ const command = line.trimStart();
414
+ if (command === "") continue;
415
+ if (/^!!/.test(command) || /^:/.test(command) || /^(?:reset|ed|exit|quit)\b/i.test(command) || /^go(?:\s+\d+)?\s*;?\s*$/i.test(command)) throw new Error(`${label}: 禁止 sqlcmd 元命令、GO 批次分隔符与客户端脚本指令`);
416
+ }
417
+ }
236
418
  function trailingLineCommentStart(sql, end) {
237
419
  let index = sql.lastIndexOf("\n", end - 1) + 1;
238
420
  while (index < end) {
@@ -449,7 +631,11 @@ function classifyStatement(sql, type) {
449
631
  const rest = stripLeadingComments(sql);
450
632
  const tokenMatch = rest.match(/^[A-Za-z_]+/);
451
633
  if (tokenMatch === null) return "write";
452
- switch (tokenMatch[0].toLowerCase()) {
634
+ const token = tokenMatch[0].toLowerCase();
635
+ const executable = maskSqlLiteralsAndComments(rest);
636
+ if (type === "sqlserver" && /\binto\b/i.test(executable)) return "write";
637
+ if ((type === "mysql" || type === "doris" || type === "clickhouse") && /\binto\s+(?:out|dump)file\b/i.test(executable)) return "write";
638
+ switch (token) {
453
639
  case "select":
454
640
  case "show":
455
641
  case "describe":
@@ -478,12 +664,69 @@ function enforceReadRowLimit(sql, type, maxRows) {
478
664
  if (classifyStatement(sql, type) !== "read") return sql;
479
665
  const first = stripLeadingComments(sql).match(/^[A-Za-z_]+/)?.[0]?.toLowerCase();
480
666
  if (first !== "select" && first !== "with") return sql;
667
+ if (type === "sqlserver") return enforceSqlServerRowLimit(sql, maxRows);
481
668
  const hadTrailingSemicolon = /;\s*$/.test(sql);
482
669
  if (!hasTopLevelKeyword(sql, "limit") && type !== "oracle") return `${stripTrailingTerminator(sql)} LIMIT ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
483
670
  if (type === "oracle") return `SELECT * FROM (${stripTrailingTerminator(sql)}) dsh_limit WHERE ROWNUM <= ${maxRows}${hadTrailingSemicolon ? ";" : ""}`;
484
671
  if (!hasTopLevelKeyword(sql, "limit")) return sql;
485
672
  return rewriteTopLevelLimit(sql, maxRows);
486
673
  }
674
+ function findTopLevelKeywordIndex(sql, keyword) {
675
+ const masked = maskSqlLiteralsAndComments(sql);
676
+ const needle = keyword.toLowerCase();
677
+ let depth = 0;
678
+ for (let index = 0; index < masked.length; index += 1) {
679
+ const char = masked[index];
680
+ if (char === "(") {
681
+ depth += 1;
682
+ continue;
683
+ }
684
+ if (char === ")") {
685
+ depth = Math.max(0, depth - 1);
686
+ continue;
687
+ }
688
+ if (depth !== 0) continue;
689
+ if (masked.slice(index, index + needle.length).toLowerCase() !== needle) continue;
690
+ const before = index === 0 ? "" : masked[index - 1];
691
+ const after = masked[index + needle.length] ?? "";
692
+ if ((before === "" || !/[A-Za-z0-9_$]/.test(before)) && (after === "" || !/[A-Za-z0-9_$]/.test(after))) return index;
693
+ }
694
+ return -1;
695
+ }
696
+ /** Add or tighten a T-SQL row limit without ever emitting MySQL LIMIT. */
697
+ function enforceSqlServerRowLimit(sql, maxRows) {
698
+ const hadTrailingSemicolon = /;\s*$/.test(sql);
699
+ const body = stripTrailingTerminator(sql);
700
+ for (const keyword of [
701
+ "union",
702
+ "intersect",
703
+ "except"
704
+ ]) if (hasTopLevelKeyword(body, keyword)) throw new Error("SQL Server compound query 无法安全自动限行,请显式包装查询并使用 TOP");
705
+ const selectIndex = findTopLevelKeywordIndex(body, "select");
706
+ if (selectIndex === -1) throw new Error("SQL Server 查询无法定位顶层 SELECT,无法安全自动限行");
707
+ const offsetIndex = findTopLevelKeywordIndex(body, "offset");
708
+ const fetchIndex = findTopLevelKeywordIndex(body, "fetch");
709
+ if (offsetIndex !== -1 || fetchIndex !== -1) {
710
+ if (offsetIndex === -1 || fetchIndex === -1 || fetchIndex < offsetIndex) throw new Error("SQL Server OFFSET/FETCH 查询无法安全自动改写,请使用完整的 OFFSET ... FETCH NEXT n ROWS ONLY");
711
+ const fetch = body.slice(fetchIndex).match(/^fetch\s+next\s+(\d+)\s+rows?\s+only\b/i);
712
+ if (fetch === null) throw new Error("SQL Server OFFSET/FETCH 查询无法安全自动改写,请显式设置数字 FETCH NEXT");
713
+ if (Number(fetch[1]) <= maxRows) return sql;
714
+ const replacement = fetch[0].replace(fetch[1], String(maxRows));
715
+ return `${body.slice(0, fetchIndex)}${replacement}${body.slice(fetchIndex + fetch[0].length)}${hadTrailingSemicolon ? ";" : ""}`;
716
+ }
717
+ const prefixMatch = body.slice(selectIndex + 6).match(/^(\s+(?:all\s+|distinct\s+)?)(?:top\s*(?:\(\s*(\d+)\s*\)|(\d+))(\s+percent)?(\s+with\s+ties)?\s*)?/i);
718
+ if (prefixMatch === null) throw new Error("SQL Server SELECT 形态无法安全自动限行");
719
+ const existing = prefixMatch[2] ?? prefixMatch[3];
720
+ if (prefixMatch[4] !== void 0 || prefixMatch[5] !== void 0) throw new Error("SQL Server TOP PERCENT/WITH TIES 无法安全自动限行,请改用显式 TOP (n)");
721
+ if (existing !== void 0) {
722
+ if (Number(existing) <= maxRows) return sql;
723
+ const topStart = selectIndex + 6 + prefixMatch[1].length;
724
+ const topLength = prefixMatch[0].length - prefixMatch[1].length;
725
+ return `${body.slice(0, topStart)}TOP (${maxRows}) ${body.slice(topStart + topLength)}${hadTrailingSemicolon ? ";" : ""}`;
726
+ }
727
+ const insertAt = selectIndex + 6 + prefixMatch[1].length;
728
+ return `${body.slice(0, insertAt)}TOP (${maxRows}) ${body.slice(insertAt)}${hadTrailingSemicolon ? ";" : ""}`;
729
+ }
487
730
  /** Rewrite the first top-level `LIMIT n` / `LIMIT n, m` with a capped row count. */
488
731
  function rewriteTopLevelLimit(sql, maxRows) {
489
732
  let depth = 0;
@@ -576,11 +819,14 @@ function sanitizeIdentifier(type, identifier) {
576
819
  if (!/^[A-Za-z0-9_$]+$/.test(identifier)) throw new Error(`标识符含非法字符(仅允许字母、数字与 _ $):${identifier}`);
577
820
  switch (type) {
578
821
  case "mysql":
822
+ case "doris":
823
+ case "clickhouse":
579
824
  case "hive":
580
825
  case "impala": return "`" + identifier.replace(/`/g, "``") + "`";
581
826
  case "postgres":
582
827
  case "oracle":
583
828
  case "sqlite": return "\"" + identifier.replace(/"/g, "\"\"") + "\"";
829
+ case "sqlserver": return "[" + identifier.replace(/]/g, "]]") + "]";
584
830
  }
585
831
  }
586
832
  /**
@@ -600,43 +846,100 @@ const clientConfigSchema = z.object({
600
846
  args: z.array(z.string()),
601
847
  searchPaths: z.array(z.string())
602
848
  });
603
- /** Loader schema for the whole `clients` config object (any type key). */
604
- const clientsSchema = z.dict(clientConfigSchema).default({});
849
+ /** Loader schema for CLI overrides; ClickHouse has connection-level HTTP transport instead. */
850
+ const cliDatabaseTypeSchema = z.union([
851
+ z.const("mysql"),
852
+ z.const("postgres"),
853
+ z.const("sqlite"),
854
+ z.const("oracle"),
855
+ z.const("hive"),
856
+ z.const("impala"),
857
+ z.const("doris"),
858
+ z.const("sqlserver")
859
+ ]);
860
+ const clientsSchema = z.dict(clientConfigSchema, cliDatabaseTypeSchema).default({});
861
+ /**
862
+ * MySQL output must match the subprocess collector's UTF-8 decoder instead of
863
+ * inheriting a platform locale such as a legacy Windows code page.
864
+ */
865
+ const MYSQL_COMMON_ARGS = [
866
+ "--default-character-set=utf8mb4",
867
+ "--batch",
868
+ "--raw"
869
+ ];
605
870
  /** Query-mode flag arguments per type (plain/human output). */
606
871
  const QUERY_ARGS = {
607
- mysql: ["--batch", "--raw"],
872
+ mysql: MYSQL_COMMON_ARGS,
873
+ doris: MYSQL_COMMON_ARGS,
608
874
  postgres: ["-A"],
609
875
  sqlite: ["-header", "-column"],
610
876
  oracle: ["-S", "/nolog"],
611
877
  hive: ["--silent=true", "--outputformat=tsv2"],
612
- impala: ["-B"]
878
+ impala: ["-B"],
879
+ sqlserver: [
880
+ "-b",
881
+ "-V",
882
+ "11",
883
+ "-r",
884
+ "1",
885
+ "-x",
886
+ "-W",
887
+ "-w",
888
+ "65535",
889
+ "-s",
890
+ ""
891
+ ],
892
+ clickhouse: []
613
893
  };
614
894
  /** Introspection-mode flag arguments per type (machine-readable listing). */
615
895
  const INTROSPECT_ARGS = {
616
- mysql: ["--batch", "--raw"],
896
+ mysql: MYSQL_COMMON_ARGS,
897
+ doris: MYSQL_COMMON_ARGS,
617
898
  postgres: ["-t", "-A"],
618
899
  sqlite: ["-noheader", "-list"],
619
900
  oracle: ["-S", "/nolog"],
620
901
  hive: ["--silent=true", "--outputformat=tsv2"],
621
- impala: ["-B"]
902
+ impala: ["-B"],
903
+ sqlserver: [
904
+ "-b",
905
+ "-V",
906
+ "11",
907
+ "-r",
908
+ "1",
909
+ "-x",
910
+ "-W",
911
+ "-w",
912
+ "65535",
913
+ "-s",
914
+ "",
915
+ "-h",
916
+ "-1"
917
+ ],
918
+ clickhouse: []
622
919
  };
623
920
  /** Structured `sql-query` flag arguments: header + one row per line. */
624
921
  const STRUCTURED_QUERY_ARGS = {
625
- mysql: ["--batch", "--raw"],
922
+ mysql: MYSQL_COMMON_ARGS,
923
+ doris: MYSQL_COMMON_ARGS,
626
924
  postgres: ["-A"],
627
925
  sqlite: ["-header", "-csv"],
628
926
  oracle: ["-S", "/nolog"],
629
927
  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
928
+ impala: ["-B", "--print_header"],
929
+ sqlserver: [
930
+ "-b",
931
+ "-V",
932
+ "11",
933
+ "-r",
934
+ "1",
935
+ "-x",
936
+ "-W",
937
+ "-w",
938
+ "65535",
939
+ "-s",
940
+ ""
941
+ ],
942
+ clickhouse: []
640
943
  };
641
944
  /** Built-in commands per type (also the loader defaults; see `src/defaults.ts`). */
642
945
  const DEFAULT_CLIENTS_COMMAND = {
@@ -645,7 +948,10 @@ const DEFAULT_CLIENTS_COMMAND = {
645
948
  sqlite: "sqlite3",
646
949
  oracle: "sqlplus",
647
950
  hive: "beeline",
648
- impala: "impala-shell"
951
+ impala: "impala-shell",
952
+ doris: "mysql",
953
+ sqlserver: "sqlcmd",
954
+ clickhouse: ""
649
955
  };
650
956
  /**
651
957
  * Connection flags for one type. Oracle and Hive carry NO connection flags:
@@ -655,13 +961,14 @@ const DEFAULT_CLIENTS_COMMAND = {
655
961
  */
656
962
  function connectionArgs(type, connection) {
657
963
  switch (type) {
658
- case "mysql": return [
964
+ case "mysql":
965
+ case "doris": return [
659
966
  "-h",
660
967
  connection.host ?? "127.0.0.1",
661
968
  "-P",
662
- String(connection.port ?? DEFAULT_PORTS.mysql),
969
+ String(connection.port ?? defaultDatabasePort(type)),
663
970
  "-u",
664
- connection.user ?? "root",
971
+ connection.user ?? defaultDatabaseUser(type),
665
972
  "-D",
666
973
  connection.database
667
974
  ];
@@ -669,7 +976,7 @@ function connectionArgs(type, connection) {
669
976
  "-h",
670
977
  connection.host ?? "127.0.0.1",
671
978
  "-p",
672
- String(connection.port ?? DEFAULT_PORTS.postgres),
979
+ String(connection.port ?? defaultDatabasePort("postgres")),
673
980
  "-U",
674
981
  connection.user ?? "postgres",
675
982
  "-d",
@@ -678,12 +985,21 @@ function connectionArgs(type, connection) {
678
985
  case "sqlite": return [connection.database];
679
986
  case "impala": return [
680
987
  "-i",
681
- `${connection.host ?? "127.0.0.1"}:${connection.port ?? DEFAULT_PORTS.impala}`,
988
+ `${connection.host ?? "127.0.0.1"}:${connection.port ?? defaultDatabasePort("impala")}`,
989
+ "-d",
990
+ connection.database
991
+ ];
992
+ case "sqlserver": return [
993
+ "-S",
994
+ `${connection.host ?? "127.0.0.1"},${connection.port ?? defaultDatabasePort("sqlserver")}`,
995
+ "-U",
996
+ connection.user ?? defaultDatabaseUser(type),
682
997
  "-d",
683
998
  connection.database
684
999
  ];
685
1000
  case "oracle":
686
1001
  case "hive": return [];
1002
+ case "clickhouse": throw new Error("ClickHouse 使用官方 HTTP 客户端,不构造 CLI argv");
687
1003
  }
688
1004
  }
689
1005
  /** Credential environment entries per type; absent password yields an empty env. */
@@ -691,8 +1007,11 @@ function credentialEnv(type, connection) {
691
1007
  const password = connection.password;
692
1008
  if (password === void 0) return {};
693
1009
  switch (type) {
694
- case "mysql": return { MYSQL_PWD: password };
1010
+ case "mysql":
1011
+ case "doris": return { MYSQL_PWD: password };
695
1012
  case "postgres": return { PGPASSWORD: password };
1013
+ case "sqlserver": return { SQLCMDPASSWORD: password };
1014
+ case "clickhouse":
696
1015
  case "sqlite":
697
1016
  case "oracle":
698
1017
  case "hive":
@@ -713,13 +1032,16 @@ function stdinPrefix(type, connection) {
713
1032
  "SET HEADING OFF",
714
1033
  "SET COLSEP '|'",
715
1034
  "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}` : ""
1035
+ connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? defaultDatabasePort("oracle")}/${connection.database}` : ""
717
1036
  ].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` : "";
1037
+ case "hive": return connection.user !== void 0 ? `!connect jdbc:hive2://${connection.host ?? "127.0.0.1"}:${connection.port ?? defaultDatabasePort("hive")}/${connection.database} ${connection.user} ${connection.password ?? ""}\n` : "";
719
1038
  case "mysql":
1039
+ case "doris":
720
1040
  case "postgres":
721
1041
  case "sqlite":
722
- case "impala": return "";
1042
+ case "impala":
1043
+ case "clickhouse": return "";
1044
+ case "sqlserver": return "SET NOCOUNT ON;\n";
723
1045
  }
724
1046
  }
725
1047
  /**
@@ -736,7 +1058,7 @@ function structuredStdinPrefix(type, connection) {
736
1058
  "SET UNDERLINE OFF",
737
1059
  "SET COLSEP '|'",
738
1060
  "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}` : ""
1061
+ connection.user !== void 0 ? `connect ${connection.user}${connection.password !== void 0 ? `/${connection.password}` : ""}@${connection.host ?? "127.0.0.1"}:${connection.port ?? defaultDatabasePort("oracle")}/${connection.database}` : ""
740
1062
  ].filter((line) => line !== "").join("\n")}\n`;
741
1063
  }
742
1064
  /** Apply one deployment override's extra args in front of the built-in flags. */
@@ -750,6 +1072,7 @@ function withOverrides(flags, override) {
750
1072
  * `[options] <database>`, and putting flags first is harmless for the others.
751
1073
  */
752
1074
  function buildClientTemplate(type, connection, override) {
1075
+ if (type === "clickhouse") throw new Error("ClickHouse 使用官方 HTTP 客户端,不构造 CLI 模板");
753
1076
  return {
754
1077
  command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
755
1078
  args: [...withOverrides(QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
@@ -759,6 +1082,7 @@ function buildClientTemplate(type, connection, override) {
759
1082
  }
760
1083
  /** Build one client invocation for metadata runs (machine-readable flags). */
761
1084
  function buildIntrospectTemplate(type, connection, override) {
1085
+ if (type === "clickhouse") throw new Error("ClickHouse 使用官方 HTTP 客户端,不构造 CLI 模板");
762
1086
  return {
763
1087
  command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
764
1088
  args: [...withOverrides(INTROSPECT_ARGS[type], override), ...connectionArgs(type, connection)],
@@ -772,6 +1096,7 @@ function buildIntrospectTemplate(type, connection, override) {
772
1096
  * tab, postgres pipe, sqlite csv, oracle pipe, hive/impala tsv).
773
1097
  */
774
1098
  function buildStructuredQueryTemplate(type, connection, override) {
1099
+ if (type === "clickhouse") throw new Error("ClickHouse 使用官方 HTTP 客户端,不构造 CLI 模板");
775
1100
  return {
776
1101
  command: override?.command ?? DEFAULT_CLIENTS_COMMAND[type],
777
1102
  args: [...withOverrides(STRUCTURED_QUERY_ARGS[type], override), ...connectionArgs(type, connection)],
@@ -787,12 +1112,15 @@ function buildStructuredQueryTemplate(type, connection, override) {
787
1112
  */
788
1113
  function tableListingSql(type, connection) {
789
1114
  switch (type) {
790
- case "mysql": return `SHOW TABLES FROM \`${connection?.database ?? ""}\`;`;
1115
+ case "mysql":
1116
+ case "doris": return `SHOW TABLES FROM \`${connection?.database ?? ""}\`;`;
1117
+ case "clickhouse": return "SELECT name FROM system.tables WHERE database = currentDatabase() ORDER BY name;";
791
1118
  case "postgres": return "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1;";
792
1119
  case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
793
1120
  case "oracle": return "SELECT table_name FROM user_tables ORDER BY 1;";
794
1121
  case "hive":
795
1122
  case "impala": return "SHOW TABLES;";
1123
+ case "sqlserver": return "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME;";
796
1124
  }
797
1125
  }
798
1126
  /**
@@ -802,28 +1130,37 @@ function tableListingSql(type, connection) {
802
1130
  function metadataQuery(kind, type, schema, table) {
803
1131
  switch (kind) {
804
1132
  case "schemas": switch (type) {
805
- case "mysql": return "SHOW DATABASES;";
1133
+ case "mysql":
1134
+ case "doris": return "SHOW DATABASES;";
1135
+ case "clickhouse": return "SELECT name FROM system.databases ORDER BY name;";
806
1136
  case "postgres": return "SELECT schema_name FROM information_schema.schemata ORDER BY 1;";
807
1137
  case "sqlite": return "SELECT 'main';";
808
1138
  case "oracle": return "SELECT username FROM all_users ORDER BY 1;";
809
1139
  case "hive":
810
1140
  case "impala": return "SHOW DATABASES;";
1141
+ case "sqlserver": return "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME;";
811
1142
  }
812
1143
  case "tables": switch (type) {
813
- case "mysql": return `SHOW TABLES FROM ${sanitizeIdentifier(type, schema)};`;
1144
+ case "mysql":
1145
+ case "doris": return `SHOW TABLES FROM ${sanitizeIdentifier(type, schema)};`;
1146
+ case "clickhouse": return `SELECT name FROM system.tables WHERE database=${quoteStringLiteral(schema)} ORDER BY name;`;
814
1147
  case "postgres": return `SELECT tablename FROM pg_tables WHERE schemaname=${quoteStringLiteral(schema)} ORDER BY 1;`;
815
1148
  case "sqlite": return "SELECT name FROM sqlite_master WHERE type='table' ORDER BY 1;";
816
1149
  case "oracle": return `SELECT table_name FROM all_tables WHERE owner=${quoteStringLiteral(schema)} ORDER BY 1;`;
817
1150
  case "hive":
818
1151
  case "impala": return `SHOW TABLES IN ${sanitizeIdentifier(type, schema)};`;
1152
+ case "sqlserver": return `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=${quoteStringLiteral(schema)} AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME;`;
819
1153
  }
820
1154
  case "describe": switch (type) {
821
- case "mysql": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
1155
+ case "mysql":
1156
+ case "doris": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
1157
+ case "clickhouse": return `SELECT name, type, if(startsWith(type, 'Nullable('), 'YES', 'NO') FROM system.columns WHERE database=${quoteStringLiteral(schema)} AND table=${quoteStringLiteral(table)} ORDER BY position;`;
822
1158
  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
1159
  case "sqlite": return `PRAGMA table_info(${sanitizeIdentifier(type, table)});`;
824
1160
  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
1161
  case "hive":
826
1162
  case "impala": return `DESCRIBE ${sanitizeIdentifier(type, schema)}.${sanitizeIdentifier(type, table)};`;
1163
+ case "sqlserver": 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;`;
827
1164
  }
828
1165
  }
829
1166
  }
@@ -834,8 +1171,8 @@ function metadataQuery(kind, type, schema, table) {
834
1171
  * hive/impala batch modes print none (skip 0).
835
1172
  */
836
1173
  function parseListing(type, stdout) {
837
- const lines = stdout.split("\n");
838
- const start = type === "mysql" ? 1 : 0;
1174
+ const lines = (type === "sqlserver" ? stripSqlServerRowCountFooter(stdout) : stdout).split("\n");
1175
+ const start = type === "mysql" || type === "doris" ? 1 : 0;
839
1176
  const items = [];
840
1177
  for (let index = start; index < lines.length; index += 1) {
841
1178
  const name = lines[index].trim();
@@ -847,6 +1184,18 @@ function parseListing(type, stdout) {
847
1184
  function parseTableListing(type, stdout) {
848
1185
  return parseListing(type, stdout);
849
1186
  }
1187
+ const SQLSERVER_ROW_COUNT_FOOTER = /^\((?:\d+\s+rows?\s+affected|(?:共)?影响(?:了)?\s*\d+\s*行|\d+\s*行受(?:到)?影响)\)$/i;
1188
+ /** Remove only terminal sqlcmd row-count footer lines, never matching data in the middle. */
1189
+ function stripSqlServerRowCountFooter(stdout) {
1190
+ const newline = stdout.includes("\r\n") ? "\r\n" : "\n";
1191
+ const lines = stdout.replace(/\r\n?/g, "\n").split("\n");
1192
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
1193
+ while (lines.length > 0 && SQLSERVER_ROW_COUNT_FOOTER.test(lines[lines.length - 1].trim())) {
1194
+ lines.pop();
1195
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
1196
+ }
1197
+ return lines.join(newline);
1198
+ }
850
1199
  /**
851
1200
  * Parse one type's describe output into columns. Formats:
852
1201
  * - mysql `--batch`: `Field\tType\tNull\tKey\t...` (skip header);
@@ -856,13 +1205,13 @@ function parseTableListing(type, stdout) {
856
1205
  * - hive/impala batch: `name\ttype\tcomment`.
857
1206
  */
858
1207
  function parseColumns(type, stdout) {
859
- const lines = stdout.split("\n");
860
- const start = type === "mysql" ? 1 : 0;
1208
+ const lines = (type === "sqlserver" ? stripSqlServerRowCountFooter(stdout) : stdout).split(/\r?\n/);
1209
+ const start = type === "mysql" || type === "doris" ? 1 : 0;
861
1210
  const columns = [];
862
1211
  for (let index = start; index < lines.length; index += 1) {
863
1212
  const line = lines[index].trim();
864
1213
  if (line.length === 0) continue;
865
- const parts = line.includes(" ") ? line.split(" ") : line.split("|");
1214
+ const parts = type === "sqlserver" ? line.split("") : line.includes(" ") ? line.split(" ") : line.split("|");
866
1215
  const nameIndex = type === "sqlite" ? 1 : 0;
867
1216
  const name = parts[nameIndex]?.trim() ?? "";
868
1217
  const columnType = parts[nameIndex + 1]?.trim() ?? "";
@@ -871,6 +1220,10 @@ function parseColumns(type, stdout) {
871
1220
  let nullable;
872
1221
  switch (type) {
873
1222
  case "mysql":
1223
+ case "doris":
1224
+ nullable = rawNullable === "yes";
1225
+ break;
1226
+ case "clickhouse":
874
1227
  nullable = rawNullable === "yes";
875
1228
  break;
876
1229
  case "postgres":
@@ -882,6 +1235,9 @@ function parseColumns(type, stdout) {
882
1235
  case "oracle":
883
1236
  nullable = rawNullable === "y";
884
1237
  break;
1238
+ case "sqlserver":
1239
+ nullable = rawNullable === "yes";
1240
+ break;
885
1241
  case "hive":
886
1242
  case "impala": nullable = void 0;
887
1243
  }
@@ -894,6 +1250,40 @@ function parseColumns(type, stdout) {
894
1250
  return columns;
895
1251
  }
896
1252
  //#endregion
1253
+ //#region src/defaults.ts
1254
+ /**
1255
+ * Package-wide defaults shared by the server half (`src/index.ts`) and the
1256
+ * database tool half (`src/tool.ts`). Loader schemas carry these as their
1257
+ * defaults so a deployment may override every one of them in cordis.yml.
1258
+ * @module @yejiming/dsh-data-agent/defaults
1259
+ */
1260
+ /** Preset directory name installed into `$DSH_HOME/.agent-presets/`. */
1261
+ const DEFAULT_PRESET_ID = "data-agent";
1262
+ /** End-to-end deadline for one `/connect` connectivity check, milliseconds. */
1263
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
1264
+ /** End-to-end deadline for one database-tool query, milliseconds. */
1265
+ const DEFAULT_QUERY_TIMEOUT_MS = 3e4;
1266
+ /** In-memory cap on database-tool captured output (stdout and stderr each). */
1267
+ const DEFAULT_MAX_RESULT_CHARS = 2e4;
1268
+ /** Hard row cap for one structured Web workbench result/export. */
1269
+ const WORKBENCH_MAX_EXPORT_ROWS = 5e4;
1270
+ /** Bounded capture size for the larger structured Web workbench result. */
1271
+ const WORKBENCH_MAX_RESULT_CHARS = 33554432;
1272
+ /** Cap on one /query SQL text length (abuse guard; the wire body stays small). */
1273
+ const DEFAULT_MAX_QUERY_CHARS = 65536;
1274
+ /** Catalog metadata query deadline. Kept separate from user SQL execution. */
1275
+ const DEFAULT_CATALOG_QUERY_TIMEOUT_MS = 3e4;
1276
+ /**
1277
+ * Per-stream capture budget for one system-catalog query. Catalog metadata is
1278
+ * intentionally independent from the much smaller model/interactive SQL
1279
+ * result budget because a schema snapshot can contain thousands of objects.
1280
+ */
1281
+ const DEFAULT_CATALOG_MAX_RESULT_CHARS = 33554432;
1282
+ /** Hard bound on technical assets (including columns) staged by one run. */
1283
+ const DEFAULT_CATALOG_MAX_ASSETS = 5e4;
1284
+ /** Maximum normalized length of one database or human-authored text field. */
1285
+ const DEFAULT_CATALOG_MAX_TEXT_CHARS = 4096;
1286
+ //#endregion
897
1287
  //#region src/client-discovery.ts
898
1288
  /**
899
1289
  * Cross-platform database CLI discovery.
@@ -923,7 +1313,9 @@ const HOME_ENV_BY_TYPE = {
923
1313
  sqlite: ["SQLITE_HOME"],
924
1314
  oracle: ["ORACLE_HOME"],
925
1315
  hive: ["HIVE_HOME"],
926
- impala: ["IMPALA_HOME"]
1316
+ impala: ["IMPALA_HOME"],
1317
+ doris: ["MYSQL_HOME"],
1318
+ sqlserver: ["SQLCMD_HOME", "MSSQL_TOOLS_HOME"]
927
1319
  };
928
1320
  function pathApi(platform) {
929
1321
  return platform === "win32" ? win32 : posix;
@@ -980,7 +1372,20 @@ function macFixedDirectories(type) {
980
1372
  sqlite: ["/opt/homebrew/opt/sqlite/bin", "/usr/local/opt/sqlite/bin"],
981
1373
  oracle: [],
982
1374
  hive: ["/opt/homebrew/opt/hive/bin", "/usr/local/opt/hive/bin"],
983
- impala: ["/opt/homebrew/opt/impala/bin", "/usr/local/opt/impala/bin"]
1375
+ impala: ["/opt/homebrew/opt/impala/bin", "/usr/local/opt/impala/bin"],
1376
+ doris: [
1377
+ "/opt/homebrew/opt/mysql-client/bin",
1378
+ "/opt/homebrew/opt/mysql/bin",
1379
+ "/usr/local/opt/mysql-client/bin",
1380
+ "/usr/local/opt/mysql/bin",
1381
+ "/usr/local/mysql/bin"
1382
+ ],
1383
+ sqlserver: [
1384
+ "/opt/homebrew/opt/mssql-tools18/bin",
1385
+ "/usr/local/opt/mssql-tools18/bin",
1386
+ "/opt/mssql-tools18/bin",
1387
+ "/opt/mssql-tools/bin"
1388
+ ]
984
1389
  }[type],
985
1390
  "/usr/local/bin",
986
1391
  "/opt/local/bin",
@@ -1011,7 +1416,9 @@ function windowsFixedDirectories(type, system, paths) {
1011
1416
  sqlite: [paths.join("C:\\", "sqlite"), paths.join(programFiles, "SQLite")],
1012
1417
  oracle: [],
1013
1418
  hive: [],
1014
- impala: []
1419
+ impala: [],
1420
+ doris: [],
1421
+ sqlserver: [paths.join(programFiles, "Microsoft SQL Server", "Client SDK", "ODBC", "180", "Tools", "Binn"), paths.join(programFiles, "Microsoft SQL Server", "Client SDK", "ODBC", "170", "Tools", "Binn")]
1015
1422
  };
1016
1423
  return [
1017
1424
  ...localAppData === void 0 ? [] : [paths.join(localAppData, "Microsoft", "WinGet", "Links")],
@@ -1029,6 +1436,8 @@ function formulaPattern(type) {
1029
1436
  case "oracle": return /^(?:oracle|instantclient)(?:@.+)?$/i;
1030
1437
  case "hive": return /^hive(?:@.+)?$/i;
1031
1438
  case "impala": return /^impala(?:@.+)?$/i;
1439
+ case "doris": return /^(?:mysql|mysql-client)(?:@.+)?$/i;
1440
+ case "sqlserver": return /^(?:mssql-tools|mssql-tools18)(?:@.+)?$/i;
1032
1441
  }
1033
1442
  }
1034
1443
  function dynamicDirectories(type, system, paths) {
@@ -1071,7 +1480,7 @@ function dynamicDirectories(type, system, paths) {
1071
1480
  });
1072
1481
  } else if (system.platform === "win32") {
1073
1482
  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({
1483
+ for (const root of roots) if (type === "mysql" || type === "doris") result.push({
1075
1484
  root: paths.join(root, "MySQL"),
1076
1485
  accepts: () => true,
1077
1486
  suffix: ["bin"]
@@ -1090,6 +1499,11 @@ function dynamicDirectories(type, system, paths) {
1090
1499
  accepts: () => true,
1091
1500
  suffix: ["bin"]
1092
1501
  });
1502
+ else if (type === "sqlserver") result.push({
1503
+ root: paths.join(root, "Microsoft SQL Server", "Client SDK", "ODBC"),
1504
+ accepts: () => true,
1505
+ suffix: ["Tools", "Binn"]
1506
+ });
1093
1507
  }
1094
1508
  return result;
1095
1509
  }
@@ -1192,10 +1606,85 @@ function readCaptured(reader) {
1192
1606
  truncated: read.lossy
1193
1607
  };
1194
1608
  }
1609
+ /** ClickHouse endpoint construction never embeds username or password. */
1610
+ function clickHouseConnectionUrl(connection) {
1611
+ const secure = connection.secure === true;
1612
+ const url = new URL(`${secure ? "https" : "http"}://127.0.0.1`);
1613
+ url.hostname = connection.host ?? "127.0.0.1";
1614
+ url.port = String(connection.port ?? defaultDatabasePort("clickhouse", secure));
1615
+ return url.toString();
1616
+ }
1617
+ async function collectClickHouseStream(stream, maxBytes, signal) {
1618
+ const chunks = [];
1619
+ let size = 0;
1620
+ let truncated = false;
1621
+ for await (const chunk of stream) {
1622
+ signal.throwIfAborted();
1623
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1624
+ const remaining = maxBytes - size;
1625
+ if (remaining <= 0) {
1626
+ truncated = true;
1627
+ stream.destroy?.();
1628
+ break;
1629
+ }
1630
+ if (buffer.byteLength > remaining) {
1631
+ chunks.push(buffer.subarray(0, remaining));
1632
+ size += remaining;
1633
+ truncated = true;
1634
+ stream.destroy?.();
1635
+ break;
1636
+ }
1637
+ chunks.push(buffer);
1638
+ size += buffer.byteLength;
1639
+ }
1640
+ return {
1641
+ text: Buffer.concat(chunks, size).toString("utf8"),
1642
+ truncated
1643
+ };
1644
+ }
1645
+ async function runClickHouseQuery(connection, sql, options, signal) {
1646
+ const client = createClient({
1647
+ url: clickHouseConnectionUrl(connection),
1648
+ username: connection.user ?? defaultDatabaseUser("clickhouse"),
1649
+ password: connection.password ?? "",
1650
+ database: connection.database,
1651
+ request_timeout: options.timeoutMs
1652
+ });
1653
+ try {
1654
+ if (classifyStatement(sql, "clickhouse") !== "read") {
1655
+ await client.command({
1656
+ query: sql,
1657
+ abort_signal: signal,
1658
+ clickhouse_settings: { wait_end_of_query: 1 }
1659
+ });
1660
+ return {
1661
+ exitCode: 0,
1662
+ stdout: "",
1663
+ stderr: "",
1664
+ truncated: false
1665
+ };
1666
+ }
1667
+ const format = options.mode === "structured" ? "JSONCompactEachRowWithNamesAndTypes" : options.mode === "introspect" ? "TabSeparated" : "TabSeparatedWithNames";
1668
+ const { stream } = await client.exec({
1669
+ query: sql,
1670
+ abort_signal: signal,
1671
+ clickhouse_settings: { default_format: format }
1672
+ });
1673
+ const stdout = await collectClickHouseStream(stream, options.maxResultChars, signal);
1674
+ return {
1675
+ exitCode: 0,
1676
+ stdout: stdout.text,
1677
+ stderr: "",
1678
+ truncated: stdout.truncated
1679
+ };
1680
+ } finally {
1681
+ await client.close();
1682
+ }
1683
+ }
1195
1684
  /**
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.
1685
+ * Run one SQL text through the type's shared adapter. CLI SQL is written to
1686
+ * child stdin (`{ data }` batch disposition), while ClickHouse SQL is an HTTP
1687
+ * request body; neither path puts SQL or credentials in argv.
1199
1688
  *
1200
1689
  * Failure classification:
1201
1690
  * - the caller's external signal (e.g. the tool exec signal) aborts → the
@@ -1213,7 +1702,6 @@ function readCaptured(reader) {
1213
1702
  * @returns the captured outcome.
1214
1703
  */
1215
1704
  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
1705
  const controller = new AbortController();
1218
1706
  const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
1219
1707
  const onExternalAbort = () => {
@@ -1222,6 +1710,12 @@ async function runClientQuery(ctx, connection, sql, options, externalSignal, int
1222
1710
  if (externalSignal.aborted) controller.abort(externalSignal.reason);
1223
1711
  else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
1224
1712
  try {
1713
+ if (connection.type === "clickhouse") return await runClickHouseQuery(connection, sql, {
1714
+ ...options,
1715
+ mode: options.mode ?? (introspect ? "introspect" : "query")
1716
+ }, controller.signal);
1717
+ if (connection.type === "sqlserver") assertSqlServerSafeInput(sql);
1718
+ 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]);
1225
1719
  const resolution = await resolveClientExecutable({
1226
1720
  type: connection.type,
1227
1721
  command: template.command,
@@ -1254,7 +1748,7 @@ async function runClientQuery(ctx, connection, sql, options, externalSignal, int
1254
1748
  const stderr = readCaptured(handle.collected.stderr);
1255
1749
  return {
1256
1750
  exitCode: outcome.exitCode,
1257
- stdout: stdout.text,
1751
+ stdout: connection.type === "sqlserver" ? stripSqlServerRowCountFooter(stdout.text) : stdout.text,
1258
1752
  stderr: stderr.text,
1259
1753
  truncated: stdout.truncated || stderr.truncated
1260
1754
  };
@@ -1263,6 +1757,231 @@ async function runClientQuery(ctx, connection, sql, options, externalSignal, int
1263
1757
  externalSignal.removeEventListener("abort", onExternalAbort);
1264
1758
  }
1265
1759
  }
1760
+ //#endregion
1761
+ //#region src/structured.ts
1762
+ function normalizeNewlines(text) {
1763
+ return text.replace(/\r\n?/g, "\n");
1764
+ }
1765
+ function splitLine(line, delimiter) {
1766
+ return line.split(delimiter);
1767
+ }
1768
+ /** Make column names valid unique JSON object keys. */
1769
+ function uniqueColumns(columns) {
1770
+ const used = /* @__PURE__ */ new Set();
1771
+ return columns.map((raw, index) => {
1772
+ let name = raw.trim();
1773
+ if (name.length === 0) name = `column_${index + 1}`;
1774
+ if (used.has(name)) {
1775
+ let suffix = 2;
1776
+ while (used.has(`${name}_${suffix}`)) suffix += 1;
1777
+ name = `${name}_${suffix}`;
1778
+ }
1779
+ used.add(name);
1780
+ return name;
1781
+ });
1782
+ }
1783
+ function rowObject(columns, fields) {
1784
+ const row = {};
1785
+ for (let index = 0; index < columns.length; index += 1) row[columns[index]] = fields[index] ?? null;
1786
+ return row;
1787
+ }
1788
+ function emptyOutput() {
1789
+ return {
1790
+ columns: [],
1791
+ rows: [],
1792
+ rowLimitExceeded: false
1793
+ };
1794
+ }
1795
+ function skipLeadingBlank(lines) {
1796
+ let index = 0;
1797
+ while (index < lines.length && lines[index].trim().length === 0) index += 1;
1798
+ return index;
1799
+ }
1800
+ /** PostgreSQL `-A` appends a `(N rows)` / `(N row)` footer after SELECT output. */
1801
+ function isPostgresFooter(line) {
1802
+ return /^\(\d+ rows?\)$/.test(line.trim());
1803
+ }
1804
+ function parseDelimited(stdout, delimiter, maxRows, skipFooter = false) {
1805
+ const lines = normalizeNewlines(stdout).split("\n");
1806
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1807
+ const headerIndex = skipLeadingBlank(lines);
1808
+ if (headerIndex >= lines.length) return emptyOutput();
1809
+ const columns = uniqueColumns(splitLine(lines[headerIndex], delimiter));
1810
+ const rows = [];
1811
+ let rowLimitExceeded = false;
1812
+ for (let index = headerIndex + 1; index < lines.length; index += 1) {
1813
+ const line = lines[index];
1814
+ if (skipFooter && isPostgresFooter(line)) continue;
1815
+ if (rows.length >= maxRows) {
1816
+ rowLimitExceeded = true;
1817
+ break;
1818
+ }
1819
+ rows.push(rowObject(columns, splitLine(line, delimiter)));
1820
+ }
1821
+ return {
1822
+ columns,
1823
+ rows,
1824
+ rowLimitExceeded
1825
+ };
1826
+ }
1827
+ /** Minimal RFC-4180-style parser for sqlite3 `-csv` output. */
1828
+ function parseCsv(text) {
1829
+ const records = [];
1830
+ let record = [];
1831
+ let field = "";
1832
+ let quoted = false;
1833
+ let index = 0;
1834
+ const pushField = () => {
1835
+ record.push(field);
1836
+ field = "";
1837
+ };
1838
+ const pushRecord = () => {
1839
+ pushField();
1840
+ records.push(record);
1841
+ record = [];
1842
+ };
1843
+ while (index < text.length) {
1844
+ const char = text[index];
1845
+ if (quoted) {
1846
+ if (char === "\"") {
1847
+ if (text[index + 1] === "\"") {
1848
+ field += "\"";
1849
+ index += 2;
1850
+ continue;
1851
+ }
1852
+ quoted = false;
1853
+ index += 1;
1854
+ continue;
1855
+ }
1856
+ field += char;
1857
+ index += 1;
1858
+ continue;
1859
+ }
1860
+ if (char === "\"" && field.length === 0) {
1861
+ quoted = true;
1862
+ index += 1;
1863
+ continue;
1864
+ }
1865
+ if (char === ",") {
1866
+ pushField();
1867
+ index += 1;
1868
+ continue;
1869
+ }
1870
+ if (char === "\n") {
1871
+ pushRecord();
1872
+ index += 1;
1873
+ continue;
1874
+ }
1875
+ if (char === "\r") {
1876
+ if (text[index + 1] === "\n") index += 1;
1877
+ pushRecord();
1878
+ index += 1;
1879
+ continue;
1880
+ }
1881
+ field += char;
1882
+ index += 1;
1883
+ }
1884
+ if (field.length > 0 || record.length > 0) pushRecord();
1885
+ return records;
1886
+ }
1887
+ function parseCsvOutput(stdout, maxRows) {
1888
+ const records = parseCsv(normalizeNewlines(stdout)).filter((record) => !(record.length === 1 && record[0] === ""));
1889
+ if (records.length === 0) return emptyOutput();
1890
+ const columns = uniqueColumns(records[0]);
1891
+ const rows = [];
1892
+ let rowLimitExceeded = false;
1893
+ for (let index = 1; index < records.length; index += 1) {
1894
+ if (rows.length >= maxRows) {
1895
+ rowLimitExceeded = true;
1896
+ break;
1897
+ }
1898
+ rows.push(rowObject(columns, records[index]));
1899
+ }
1900
+ return {
1901
+ columns,
1902
+ rows,
1903
+ rowLimitExceeded
1904
+ };
1905
+ }
1906
+ function parseClickHouseOutput(stdout, maxRows) {
1907
+ const lines = normalizeNewlines(stdout).split("\n").filter((line) => line.trim() !== "");
1908
+ if (lines.length === 0) return emptyOutput();
1909
+ const parsed = lines.map((line) => JSON.parse(line));
1910
+ if (!Array.isArray(parsed[0])) throw new Error("ClickHouse结构化输出缺少列名行");
1911
+ const columns = uniqueColumns(parsed[0].map((value) => String(value)));
1912
+ const firstDataIndex = parsed.length > 1 && Array.isArray(parsed[1]) ? 2 : 1;
1913
+ const rows = [];
1914
+ let rowLimitExceeded = false;
1915
+ for (let index = firstDataIndex; index < parsed.length; index += 1) {
1916
+ if (rows.length >= maxRows) {
1917
+ rowLimitExceeded = true;
1918
+ break;
1919
+ }
1920
+ const record = parsed[index];
1921
+ if (!Array.isArray(record)) throw new Error("ClickHouse结构化输出包含非数组数据行");
1922
+ rows.push(rowObject(columns, record.map((value) => {
1923
+ if (value === null || value === void 0) return null;
1924
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
1925
+ })));
1926
+ }
1927
+ return {
1928
+ columns,
1929
+ rows,
1930
+ rowLimitExceeded
1931
+ };
1932
+ }
1933
+ function parseSqlServerOutput(stdout, maxRows) {
1934
+ const lines = normalizeNewlines(stripSqlServerRowCountFooter(stdout)).split("\n");
1935
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1936
+ const headerIndex = skipLeadingBlank(lines);
1937
+ if (headerIndex >= lines.length) return emptyOutput();
1938
+ const columns = uniqueColumns(lines[headerIndex].split(""));
1939
+ let dataIndex = headerIndex + 1;
1940
+ const divider = lines[dataIndex]?.split("");
1941
+ if (divider !== void 0 && divider.length === columns.length && divider.every((field) => /^-+$/.test(field.trim()))) dataIndex += 1;
1942
+ const rows = [];
1943
+ let rowLimitExceeded = false;
1944
+ for (let index = dataIndex; index < lines.length; index += 1) {
1945
+ if (lines[index].trim() === "") continue;
1946
+ if (rows.length >= maxRows) {
1947
+ rowLimitExceeded = true;
1948
+ break;
1949
+ }
1950
+ const fields = lines[index].split("");
1951
+ const row = {};
1952
+ for (let column = 0; column < columns.length; column += 1) {
1953
+ const value = fields[column];
1954
+ row[columns[column]] = value === void 0 || value === "NULL" ? null : value;
1955
+ }
1956
+ rows.push(row);
1957
+ }
1958
+ return {
1959
+ columns,
1960
+ rows,
1961
+ rowLimitExceeded
1962
+ };
1963
+ }
1964
+ /**
1965
+ * Parse one database type's structured-query stdout. The matching template is
1966
+ * `buildStructuredQueryTemplate`: mysql tab-separated with a header, postgres
1967
+ * pipe-separated with a header and row-count footer, sqlite CSV with a header,
1968
+ * oracle pipe-separated with heading on, hive/impala tsv with a header.
1969
+ */
1970
+ function parseStructuredQueryOutput(type, stdout, maxRows) {
1971
+ switch (type) {
1972
+ case "mysql": return parseDelimited(stdout, " ", maxRows);
1973
+ case "doris": return parseDelimited(stdout, " ", maxRows);
1974
+ case "clickhouse": return parseClickHouseOutput(stdout, maxRows);
1975
+ case "postgres": return parseDelimited(stdout, "|", maxRows, true);
1976
+ case "sqlite": return parseCsvOutput(stdout, maxRows);
1977
+ case "oracle": return parseDelimited(stdout, "|", maxRows);
1978
+ case "hive":
1979
+ case "impala": return parseDelimited(stdout, " ", maxRows);
1980
+ case "sqlserver": return parseSqlServerOutput(stdout, maxRows);
1981
+ }
1982
+ }
1983
+ const MYSQL_SCHEMA_PROBE_CONCURRENCY = 4;
1984
+ const MYSQL_DATABASE_ACCESS_DENIED = /\bERROR\s+1044\s+\(42000\)/i;
1266
1985
  /** Build a password-stripped copy of one connection. */
1267
1986
  function summarize(connection) {
1268
1987
  const summary = {
@@ -1274,6 +1993,7 @@ function summarize(connection) {
1274
1993
  if (connection.user !== void 0) summary.user = connection.user;
1275
1994
  if (connection.passwordRef !== void 0) summary.passwordRef = connection.passwordRef;
1276
1995
  if (connection.readonly !== void 0) summary.readonly = connection.readonly;
1996
+ if (connection.secure !== void 0) summary.secure = connection.secure;
1277
1997
  if (connection.profileId !== void 0) summary.profileId = connection.profileId;
1278
1998
  if (connection.name !== void 0) summary.name = connection.name;
1279
1999
  if (connection.tables !== void 0) summary.tables = [...connection.tables];
@@ -1303,19 +2023,22 @@ function normalizeConnectionInput(input, cwd = process.cwd()) {
1303
2023
  if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) throw new Error("port 必须是 1-65535 的整数");
1304
2024
  if (input.profileId !== void 0 && input.profileId.trim().length === 0) throw new Error("profileId 不能为空");
1305
2025
  if (input.name !== void 0 && input.name.trim().length === 0) throw new Error("name 不能为空");
2026
+ if (input.secure !== void 0 && typeof input.secure !== "boolean") throw new Error("secure 必须是布尔值");
1306
2027
  const connection = {
1307
2028
  type: input.type,
1308
2029
  database: input.type === "sqlite" ? resolve(cwd, input.database) : input.database,
1309
2030
  credentialMode: input.type === "sqlite" ? "none" : input.passwordRef !== void 0 ? "reference" : input.password !== void 0 && input.password.length > 0 ? "password" : "none"
1310
2031
  };
1311
2032
  if (input.type !== "sqlite") {
1312
- if (input.host !== void 0 && input.host.length > 0) connection.host = input.host;
1313
- if (input.port !== void 0) connection.port = input.port;
1314
- if (input.user !== void 0 && input.user.length > 0) connection.user = input.user;
2033
+ connection.host = input.host !== void 0 && input.host.length > 0 ? input.host : "127.0.0.1";
2034
+ connection.port = input.port ?? defaultDatabasePort(input.type, input.type === "clickhouse" && input.secure === true);
2035
+ const user = input.user !== void 0 && input.user.length > 0 ? input.user : defaultDatabaseUser(input.type);
2036
+ if (user !== "") connection.user = user;
1315
2037
  if (input.password !== void 0 && input.password.length > 0) connection.password = input.password;
1316
2038
  if (input.passwordRef !== void 0) connection.passwordRef = input.passwordRef;
1317
2039
  }
1318
2040
  if (input.readonly !== void 0) connection.readonly = input.readonly;
2041
+ if (input.type === "clickhouse" && input.secure !== void 0) connection.secure = input.secure;
1319
2042
  if (input.profileId !== void 0) connection.profileId = input.profileId;
1320
2043
  if (input.name !== void 0) connection.name = input.name;
1321
2044
  return connection;
@@ -1333,6 +2056,7 @@ function createConnectionService(ctx, options, persistence) {
1333
2056
  };
1334
2057
  const runtime = /* @__PURE__ */ new Map();
1335
2058
  const formDrafts = /* @__PURE__ */ new Map();
2059
+ let latestFormInitial;
1336
2060
  const profileConnection = (sessionId) => {
1337
2061
  if (persistence === void 0) return void 0;
1338
2062
  const binding = persistence.getBinding(sessionId);
@@ -1365,15 +2089,15 @@ function createConnectionService(ctx, options, persistence) {
1365
2089
  tables: copyTables(connection.tables)
1366
2090
  };
1367
2091
  };
1368
- const queryOptions = (mode, connect = false) => ({
2092
+ const queryOptions = (mode, connect = false, maxResultChars = resolvedOptions.maxResultChars, catalog = false) => ({
1369
2093
  clients: resolvedOptions.clients,
1370
- timeoutMs: connect ? resolvedOptions.connectTimeoutMs : resolvedOptions.queryTimeoutMs,
1371
- maxResultChars: resolvedOptions.maxResultChars,
2094
+ timeoutMs: connect ? resolvedOptions.connectTimeoutMs : catalog ? resolvedOptions.catalogQueryTimeoutMs ?? resolvedOptions.queryTimeoutMs : resolvedOptions.queryTimeoutMs,
2095
+ maxResultChars,
1372
2096
  ...mode !== void 0 ? { mode } : {}
1373
2097
  });
1374
- const run = async (connection, sql, signal, introspection = false, connect = false) => {
2098
+ const run = async (connection, sql, signal, introspection = false, connect = false, mode, maxResultChars, catalog = false) => {
1375
2099
  try {
1376
- return redactQueryResult(await runClientQuery(requireContext(), connection, sql, queryOptions(void 0, connect), signal, introspection), connection);
2100
+ return redactQueryResult(await runClientQuery(requireContext(), connection, sql, queryOptions(mode, connect, maxResultChars, catalog), signal, introspection), connection);
1377
2101
  } catch (error) {
1378
2102
  const message = redactSecretText(error instanceof Error ? error.message : String(error), [connection.password]);
1379
2103
  throw new Error(message, error instanceof Error ? { cause: error } : void 0);
@@ -1387,17 +2111,48 @@ function createConnectionService(ctx, options, persistence) {
1387
2111
  }
1388
2112
  return parseTableListing(connection.type, result.stdout).slice(0, resolvedOptions.introspectMaxTables);
1389
2113
  };
1390
- const persistAtomically = async (sessionId, profileId, profile) => {
2114
+ const canAccessMySqlSchema = async (connection, schema, signal) => {
2115
+ if (schema === connection.database) return true;
2116
+ const result = await run({
2117
+ ...connection,
2118
+ database: schema
2119
+ }, "SHOW TABLES;", signal, true);
2120
+ if (result.exitCode === 0) return true;
2121
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
2122
+ if (MYSQL_DATABASE_ACCESS_DENIED.test(detail)) return false;
2123
+ throw new Error(`元数据查询失败(exit ${result.exitCode}):${detail}`);
2124
+ };
2125
+ const listAccessibleMySqlSchemas = async (connection, schemas, signal) => {
2126
+ const visible = [];
2127
+ for (let offset = 0; offset < schemas.length && visible.length < resolvedOptions.introspectMaxTables; offset += MYSQL_SCHEMA_PROBE_CONCURRENCY) {
2128
+ signal.throwIfAborted();
2129
+ const batch = schemas.slice(offset, offset + MYSQL_SCHEMA_PROBE_CONCURRENCY);
2130
+ const accessible = await Promise.all(batch.map((schema) => canAccessMySqlSchema(connection, schema, signal)));
2131
+ for (let index = 0; index < batch.length; index += 1) {
2132
+ if (accessible[index]) visible.push(batch[index]);
2133
+ if (visible.length === resolvedOptions.introspectMaxTables) return visible;
2134
+ }
2135
+ }
2136
+ return visible;
2137
+ };
2138
+ const persistAtomically = async (sessionId, profileId, profile, draft) => {
1391
2139
  if (persistence === void 0) return;
1392
2140
  const previousProfile = persistence.getProfile(profileId);
1393
2141
  const previousBinding = persistence.getBinding(sessionId);
2142
+ const previousDraft = persistence.getDraft?.(sessionId);
1394
2143
  await persistence.putProfile(profileId, profile);
1395
2144
  try {
1396
2145
  await persistence.putBinding(sessionId, {
1397
2146
  profileId,
1398
2147
  updatedAt: profile.updatedAt
1399
2148
  });
2149
+ await persistence.putDraft?.(sessionId, {
2150
+ ...draft,
2151
+ updatedAt: profile.updatedAt
2152
+ });
1400
2153
  } catch (error) {
2154
+ if (previousDraft === void 0) await persistence.deleteDraft?.(sessionId);
2155
+ else await persistence.putDraft?.(sessionId, previousDraft);
1401
2156
  if (previousProfile === void 0) await persistence.deleteProfile(profileId);
1402
2157
  else await persistence.putProfile(profileId, previousProfile);
1403
2158
  if (previousBinding === void 0) await persistence.deleteBinding(sessionId);
@@ -1405,6 +2160,50 @@ function createConnectionService(ctx, options, persistence) {
1405
2160
  throw error;
1406
2161
  }
1407
2162
  };
2163
+ const matchingProfiles = (connection) => (persistence?.listProfiles?.() ?? []).filter((entry) => profileMatchesConnection(entry.profile, connection, resolvedOptions.cwd));
2164
+ const preferredMatches = (matches) => {
2165
+ const preferred = new Set(resolvedOptions.preferredProfileIds?.() ?? []);
2166
+ return matches.filter((entry) => preferred.has(entry.profileId));
2167
+ };
2168
+ const reusableProfileId = (sessionId, connection) => {
2169
+ if (connection.profileId !== void 0) return connection.profileId;
2170
+ const fallback = `session:${sessionId}`;
2171
+ const matches = matchingProfiles(connection);
2172
+ const binding = persistence?.getBinding(sessionId);
2173
+ const boundMatch = binding === void 0 ? void 0 : matches.find((entry) => entry.profileId === binding.profileId);
2174
+ const preferred = preferredMatches(matches);
2175
+ const stableMatches = matches.filter((entry) => !entry.profileId.startsWith("session:"));
2176
+ if (boundMatch !== void 0 && preferred.some((entry) => entry.profileId === boundMatch.profileId)) return boundMatch.profileId;
2177
+ if (preferred.length === 1) return preferred[0].profileId;
2178
+ if (preferred.length > 1) return fallback;
2179
+ if (boundMatch !== void 0 && !boundMatch.profileId.startsWith("session:")) return boundMatch.profileId;
2180
+ if (stableMatches.length === 1) return stableMatches[0].profileId;
2181
+ if (stableMatches.length > 1) return fallback;
2182
+ if (boundMatch !== void 0) return boundMatch.profileId;
2183
+ return matches.length === 1 ? matches[0].profileId : fallback;
2184
+ };
2185
+ const reconcileStableProfile = async (sessionId, connection) => {
2186
+ if (persistence === void 0 || connection.profileId === void 0) return connection;
2187
+ const matches = matchingProfiles(connection);
2188
+ const preferred = preferredMatches(matches);
2189
+ if (preferred.some((entry) => entry.profileId === connection.profileId)) return connection;
2190
+ const stableMatches = matches.filter((entry) => !entry.profileId.startsWith("session:"));
2191
+ const target = preferred.length === 1 ? preferred[0] : connection.profileId.startsWith("session:") ? stableMatches.length === 1 ? stableMatches[0] : void 0 : void 0;
2192
+ if (target === void 0) return connection;
2193
+ const profileId = target.profileId;
2194
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2195
+ await persistence.putBinding(sessionId, {
2196
+ profileId,
2197
+ updatedAt
2198
+ });
2199
+ const reconciled = {
2200
+ ...connection,
2201
+ profileId,
2202
+ tables: copyTables(connection.tables)
2203
+ };
2204
+ runtime.set(sessionId, reconciled);
2205
+ return reconciled;
2206
+ };
1408
2207
  const credentialSummary = async (connection) => {
1409
2208
  const mode = credentialModeOf(connection);
1410
2209
  if (connection.type === "sqlite" || mode === "none") return void 0;
@@ -1457,7 +2256,15 @@ function createConnectionService(ctx, options, persistence) {
1457
2256
  },
1458
2257
  getFormDraft(sessionId) {
1459
2258
  const draft = persistence?.getDraft?.(sessionId) ?? formDrafts.get(sessionId);
1460
- return draft === void 0 ? void 0 : copyFormDraft(draft);
2259
+ const exactProfile = profileConnection(sessionId);
2260
+ if (draft !== void 0) return {
2261
+ ...copyFormDraft(draft),
2262
+ ...exactProfile?.passwordRef !== void 0 ? { passwordRef: exactProfile.passwordRef } : {}
2263
+ };
2264
+ if (exactProfile !== void 0) return formInitialFromConnection(exactProfile);
2265
+ const latestProfile = persistence?.getLatestProfile?.();
2266
+ if (latestProfile !== void 0) return formInitialFromConnection(connectionFromProfile(latestProfile.profileId, latestProfile.profile));
2267
+ return latestFormInitial === void 0 ? void 0 : copyFormInitial(latestFormInitial);
1461
2268
  },
1462
2269
  async saveFormDraft(sessionId, draft) {
1463
2270
  if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
@@ -1471,16 +2278,19 @@ function createConnectionService(ctx, options, persistence) {
1471
2278
  async status(sessionId) {
1472
2279
  const connection = rawConnection(sessionId);
1473
2280
  if (connection === void 0) return void 0;
1474
- return statusSummary(connection);
2281
+ return statusSummary(await reconcileStableProfile(sessionId, connection));
1475
2282
  },
1476
2283
  async connect(sessionId, input, signal) {
1477
2284
  if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
1478
2285
  const normalized = normalizeConnectionInput(input, resolvedOptions.cwd);
1479
2286
  const execution = await resolveCredential(normalized);
1480
2287
  const tables = await verify(execution, signal, true);
1481
- const profileId = normalized.profileId ?? `session:${sessionId}`;
2288
+ const profileId = reusableProfileId(sessionId, normalized);
1482
2289
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1483
- await persistAtomically(sessionId, profileId, profileFromConnection(normalized, updatedAt));
2290
+ const draft = formDraftFromConnection(normalized);
2291
+ await persistAtomically(sessionId, profileId, profileFromConnection(normalized, updatedAt), draft);
2292
+ if (persistence === void 0) formDrafts.set(sessionId, draft);
2293
+ latestFormInitial = formInitialFromConnection(normalized);
1484
2294
  const published = {
1485
2295
  ...normalized,
1486
2296
  profileId,
@@ -1514,10 +2324,26 @@ function createConnectionService(ctx, options, persistence) {
1514
2324
  if (connection === void 0) throw new Error("请先在 Web「数据库」标签页连接数据库,或在 TUI 运行 /database connect(未找到当前会话的连接)");
1515
2325
  return resolveCredential(connection);
1516
2326
  },
2327
+ async queryMetadata(sessionId, sql, signal) {
2328
+ if (sql.trim().length === 0) throw new Error("Catalog metadata SQL must not be empty");
2329
+ const maxQueryChars = resolvedOptions.maxQueryChars ?? 65536;
2330
+ if (sql.length > maxQueryChars) throw new Error(`Catalog metadata SQL exceeds ${maxQueryChars} characters`);
2331
+ assertSingleStatement(sql, "Catalog metadata query");
2332
+ const connection = await service.resolveForExecution(sessionId);
2333
+ if (classifyStatement(sql, connection.type) !== "read") throw new Error("Catalog metadata execution accepts read-only system catalog statements only");
2334
+ const result = await run(connection, sql, signal, true, false, void 0, resolvedOptions.catalogMaxResultChars ?? resolvedOptions.maxResultChars, true);
2335
+ if (result.exitCode !== 0) {
2336
+ const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
2337
+ throw new Error(`Catalog metadata query failed (exit ${result.exitCode}): ${detail}`);
2338
+ }
2339
+ return result;
2340
+ },
1517
2341
  async listSchemas(sessionId, signal) {
1518
2342
  const connection = await service.resolveForExecution(sessionId);
1519
2343
  const stdout = await runMetadata(connection, "schemas", signal);
1520
- return parseListing(connection.type, stdout).slice(0, resolvedOptions.introspectMaxTables);
2344
+ const schemas = parseListing(connection.type, stdout);
2345
+ if (connection.type === "mysql" || connection.type === "doris") return listAccessibleMySqlSchemas(connection, schemas, signal);
2346
+ return schemas.slice(0, resolvedOptions.introspectMaxTables);
1521
2347
  },
1522
2348
  async listTables(sessionId, schema, signal) {
1523
2349
  const connection = await service.resolveForExecution(sessionId);
@@ -1540,6 +2366,36 @@ function createConnectionService(ctx, options, persistence) {
1540
2366
  const connection = await service.resolveForExecution(sessionId);
1541
2367
  if ((connection.readonly ?? resolvedOptions.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
1542
2368
  return run(connection, sql, signal);
2369
+ },
2370
+ async executeInteractive(sessionId, sql, signal) {
2371
+ if (sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
2372
+ const maxQueryChars = resolvedOptions.maxQueryChars ?? 65536;
2373
+ if (sql.length > maxQueryChars) throw new Error(`sql 超过长度上限(${maxQueryChars} 字符)`);
2374
+ assertSingleStatement(sql, "/query");
2375
+ const connection = await service.resolveForExecution(sessionId);
2376
+ const statementKind = classifyStatement(sql, connection.type);
2377
+ if ((connection.readonly ?? resolvedOptions.readonly) && statementKind === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
2378
+ if (statementKind === "write") return {
2379
+ kind: "message",
2380
+ ...await run(connection, sql, signal)
2381
+ };
2382
+ const limitedSql = enforceReadRowLimit(sql, connection.type, 50001);
2383
+ const startedAt = Date.now();
2384
+ const result = await run(connection, limitedSql, signal, false, false, "structured", WORKBENCH_MAX_RESULT_CHARS);
2385
+ if (result.exitCode !== 0) return {
2386
+ kind: "message",
2387
+ ...result
2388
+ };
2389
+ if (result.truncated) throw new Error("查询结果超过 Web 工作台大小上限,请减少返回列或缩小字段后重试");
2390
+ const parsed = parseStructuredQueryOutput(connection.type, result.stdout, WORKBENCH_MAX_EXPORT_ROWS);
2391
+ return {
2392
+ kind: "table",
2393
+ columns: parsed.columns,
2394
+ rows: parsed.rows,
2395
+ elapsedMs: Date.now() - startedAt,
2396
+ truncated: parsed.rowLimitExceeded,
2397
+ maxRows: WORKBENCH_MAX_EXPORT_ROWS
2398
+ };
1543
2399
  }
1544
2400
  };
1545
2401
  async function runMetadata(connection, kind, signal, schema, table) {
@@ -1557,7 +2413,7 @@ function copyTables(tables) {
1557
2413
  }
1558
2414
  function normalizeFormDraft(draft) {
1559
2415
  if (!isDatabaseType(draft.type)) throw new Error("数据库类型无效");
1560
- if (typeof draft.host !== "string" || typeof draft.port !== "string" || typeof draft.user !== "string" || typeof draft.database !== "string" || typeof draft.readonly !== "boolean") throw new Error("数据库表单草稿无效");
2416
+ if (typeof draft.host !== "string" || typeof draft.port !== "string" || typeof draft.user !== "string" || typeof draft.database !== "string" || typeof draft.readonly !== "boolean" || draft.secure !== void 0 && typeof draft.secure !== "boolean") throw new Error("数据库表单草稿无效");
1561
2417
  return copyFormDraft(draft);
1562
2418
  }
1563
2419
  function copyFormDraft(draft) {
@@ -1567,11 +2423,32 @@ function copyFormDraft(draft) {
1567
2423
  port: draft.port,
1568
2424
  user: draft.user,
1569
2425
  database: draft.database,
1570
- readonly: draft.readonly
2426
+ readonly: draft.readonly,
2427
+ ...draft.type === "clickhouse" ? { secure: draft.secure ?? false } : {}
1571
2428
  };
1572
2429
  }
1573
- function isDatabaseType(value) {
1574
- return value === "mysql" || value === "postgres" || value === "sqlite" || value === "oracle" || value === "hive" || value === "impala";
2430
+ function copyFormInitial(initial) {
2431
+ return {
2432
+ ...copyFormDraft(initial),
2433
+ ...initial.passwordRef !== void 0 ? { passwordRef: initial.passwordRef } : {}
2434
+ };
2435
+ }
2436
+ function formDraftFromConnection(connection) {
2437
+ return {
2438
+ type: connection.type,
2439
+ host: connection.type === "sqlite" ? "" : connection.host ?? "",
2440
+ port: connection.type === "sqlite" || connection.port === void 0 ? "" : String(connection.port),
2441
+ user: connection.type === "sqlite" ? "" : connection.user ?? "",
2442
+ database: connection.database,
2443
+ readonly: connection.readonly ?? false,
2444
+ ...connection.type === "clickhouse" ? { secure: connection.secure ?? false } : {}
2445
+ };
2446
+ }
2447
+ function formInitialFromConnection(connection) {
2448
+ return {
2449
+ ...formDraftFromConnection(connection),
2450
+ ...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {}
2451
+ };
1575
2452
  }
1576
2453
  function validatePasswordRef(value) {
1577
2454
  try {
@@ -1594,6 +2471,7 @@ function connectionFromProfile(profileId, profile) {
1594
2471
  ...profile.port !== void 0 ? { port: profile.port } : {},
1595
2472
  ...profile.user !== void 0 ? { user: profile.user } : {},
1596
2473
  ...profile.readonly !== void 0 ? { readonly: profile.readonly } : {},
2474
+ ...profile.secure !== void 0 ? { secure: profile.secure } : {},
1597
2475
  ...profile.passwordRef !== void 0 ? { passwordRef: profile.passwordRef } : {},
1598
2476
  credentialMode: profile.credentialMode ?? (profile.type === "sqlite" ? "none" : profile.passwordRef !== void 0 ? "reference" : "password")
1599
2477
  };
@@ -1608,10 +2486,28 @@ function profileFromConnection(connection, updatedAt) {
1608
2486
  ...connection.port !== void 0 ? { port: connection.port } : {},
1609
2487
  ...connection.user !== void 0 ? { user: connection.user } : {},
1610
2488
  ...connection.readonly !== void 0 ? { readonly: connection.readonly } : {},
2489
+ ...connection.secure !== void 0 ? { secure: connection.secure } : {},
1611
2490
  ...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {},
1612
2491
  ...connection.credentialMode !== void 0 ? { credentialMode: connection.credentialMode } : {}
1613
2492
  };
1614
2493
  }
2494
+ /** Match only normalized, non-secret endpoint/principal identity fields. */
2495
+ function profileMatchesConnection(profile, connection, cwd = process.cwd()) {
2496
+ let candidate;
2497
+ try {
2498
+ candidate = normalizeConnectionInput({
2499
+ type: profile.type,
2500
+ database: profile.database,
2501
+ ...profile.host !== void 0 ? { host: profile.host } : {},
2502
+ ...profile.port !== void 0 ? { port: profile.port } : {},
2503
+ ...profile.user !== void 0 ? { user: profile.user } : {},
2504
+ ...profile.secure !== void 0 ? { secure: profile.secure } : {}
2505
+ }, cwd);
2506
+ } catch {
2507
+ return false;
2508
+ }
2509
+ return candidate.type === connection.type && candidate.database === connection.database && candidate.host === connection.host && candidate.port === connection.port && candidate.user === connection.user && (candidate.secure ?? false) === (connection.secure ?? false);
2510
+ }
1615
2511
  /** Infer legacy records while leaving ambiguous secret-less SQL profiles conservative. */
1616
2512
  function credentialModeOf(connection) {
1617
2513
  if (connection.credentialMode !== void 0) return connection.credentialMode;
@@ -1626,4 +2522,4 @@ function requireIdentifier(type, value, label) {
1626
2522
  return value;
1627
2523
  }
1628
2524
  //#endregion
1629
- 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 };
2525
+ export { isDatabaseType as C, defaultDatabasePort as S, clientsSchema as _, parseStructuredQueryOutput as a, DATABASE_TYPES as b, DEFAULT_CATALOG_MAX_RESULT_CHARS as c, DEFAULT_CONNECT_TIMEOUT_MS as d, DEFAULT_MAX_QUERY_CHARS as f, classifyStatement as g, DEFAULT_QUERY_TIMEOUT_MS as h, validatePasswordRef as i, DEFAULT_CATALOG_MAX_TEXT_CHARS as l, DEFAULT_PRESET_ID as m, redactQueryResult as n, runClientQuery as o, DEFAULT_MAX_RESULT_CHARS as p, redactSecretText as r, DEFAULT_CATALOG_MAX_ASSETS as s, createConnectionService as t, DEFAULT_CATALOG_QUERY_TIMEOUT_MS as u, enforceReadRowLimit as v, databaseTypeLabel as x, assertSingleStatement as y };