dsh-db-tool 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,26 +6,45 @@ DSH 社区插件:在聊天中安全操作数据库,配套侧边栏管理台
6
6
 
7
7
  ## 功能
8
8
 
9
- - **8 种数据库**:MySQL、PostgreSQL、GaussDB、SQLite、Redis、MongoDB、Oracle、达梦(DM)
9
+ - **9 种数据库**:MySQL、PostgreSQL、GaussDB、SQLite、Redis、MongoDB、Oracle、达梦(DM)、SQL Server
10
10
  - **DatabaseManager 单工具多 action**:`list_connections / query / execute / schema / preview / run_script`(`run_script` 在 node:vm 沙箱中执行,60s 超时,仅注入受限 `db.{query,execute}` 句柄)
11
11
  - **分级权限**:连接级只读(ro)/读写(rw)+ 项目级授权(`grants.json`:projectPathKey → 连接 → 模式);未授权项目一律拒绝
12
12
  - **危险操作确认**:DDL / FLUSHALL / dropDatabase 等先返回 `NEEDS_CONFIRMATION`,对话内(模型经 ask)或 SQL 控制台(弹窗)确认后携一次性 `challengeId`(绑定语句 SHA256、5 分钟过期)重试
13
13
  - **审计**:全部执行落 `audit.jsonl`(语句、危险级、是否确认、结果)
14
14
  - **侧边栏 4 面板**(dsh-better-sidebar,zh/en):连接管理、项目授权、数据浏览、SQL 控制台
15
- - **db-admin skill**:随插件分发,覆盖 8 库方言速查、安全规范、确认流程
15
+ - **db-admin skill**:随插件分发,覆盖 9 库方言速查、安全规范、确认流程
16
16
 
17
17
  ### SQL 控制台
18
18
 
19
19
  侧边栏 SQL 控制台面板已升级为 Navicat 式多标签编辑器(CodeMirror 6):
20
20
 
21
21
  - **多标签**:新建/关闭/切换查询标签,每个标签独立保存 SQL、结果与历史(历史上限 50 条,可展开点击回填)
22
- - **语法高亮与补全**:按方言切换——mysql→MySQL、postgresql/gaussdb→PostgreSQL、sqlite→SQLite、oracle/dmdb→PL/SQL;Redis/Mongo 为关键字/方法补全的纯文本编辑;表列元数据自动拉取注入补全(上限 50 表)。编辑器 bundle(`vendor/codemirror-sql.cjs`,`npm run build:codemirror` 生成,`npm run build` 自动串接)加载失败时静默降级 textarea
22
+ - **语法高亮与补全**:按方言切换——mysql→MySQL、postgresql/gaussdb→PostgreSQL、sqlite→SQLite、oracle/dmdb→PL/SQL、sqlserver→T-SQL(`@codemirror/lang-sql` 的 MSSQL 方言);Redis/Mongo 为关键字/方法补全的纯文本编辑;表列元数据自动拉取注入补全(上限 50 表)。编辑器 bundle(`vendor/codemirror-sql.cjs`,`npm run build:codemirror` 生成,`npm run build` 自动串接)加载失败时静默降级 textarea
23
23
  - **快捷键**:`Ctrl/Cmd+Enter` 执行全部,`Ctrl/Cmd+Shift+Enter` 执行选中
24
- - **格式化**:sql-formatter 按方言格式化,解析失败回退原文
24
+ - **格式化**:sql-formatter 按方言格式化(sqlserver→`transactsql`),解析失败回退原文
25
25
  - **逐条执行**:客户端预分类读/写语句分别路由 `/api/query` 与 `/api/execute`,只读授权下写语句自动跳过并标注;出错可一键定位到对应语句起始处
26
+ - **批分隔符 GO**:支持 `GO` 与 `GO n`——按 sqlcmd 语义把上一批重复执行 n 次(`GO 0` / `GO 1` 均等同 1 次),`GO` 行本身不产生语句;重复计数上限 **1000**,超出按 1000 截断,防止一次执行被放大成天量请求
26
27
  - **事务**:rw 授权下可开始/提交/回滚(服务端粘性连接会话;空闲 5 分钟超时自动回滚并审计,授权回收/降级即时生效)
27
28
  - **结果网格**:表头三态排序、50 行/页分页、单元格点击复制、CSV/JSON 导出
28
29
 
30
+ ## SQL Server 支持范围与边界
31
+
32
+ 覆盖 SQL Server 2008/2008R2 → 2022 与 Azure SQL Database;方言与元数据只使用 SQL Server 2005+ 的通用写法。
33
+
34
+ - **连接形态**:唯一形态为 `sqlserver://user:password@host:port/db`,支持 query 参数 `encrypt` / `trustServerCertificate` / `tdsVersion` / `instanceName` / `useUTC`(参数名大小写不敏感)。`tdsVersion` 默认 `7_4`,可配 `7_3_A` / `7_3_B` / `7_2` / `7_1`;端口默认 1433,指定 `instanceName` 时按 tedious 语义忽略端口、走 SQL Browser(UDP 1434);最低 TLS 版本经 `cryptoCredentialsDetails.minVersion` 配置,`ssl=true` 等价于 `encrypt=true`。**不接受** ADO.NET 键值连接串(`Server=...;Database=...`)。
35
+ 默认 `encrypt=true` + `trustServerCertificate=false`(与 mssql 官方默认一致)。**连 SQL Server 2008/2008R2 时通常要在 URL 追加 `?trustServerCertificate=true`**:旧实例多为自签名证书且可能只支持 TLS 1.0,而 Node 默认最低 TLSv1.2,直接按默认值会握手失败;服务器未强制加密时也可用 `?encrypt=false`,并建议显式写 `?tdsVersion=7_3_A`(2008)/ `7_3_B`(2008R2)。
36
+ - **认证边界**:仅 SQL 认证(`user` / `password`)。**不支持 Windows 集成认证**,**不支持 Azure AD 认证**。
37
+ - **方言**:参数占位符对外只写 `?`,适配器内部按顺序重写为 `@p1..@pN`;标识符用 `[x]`,名字中的 `]` 双写为 `]]`;库/表/列元数据只走 `sys.*` catalog views(`sys.databases` / `sys.schemas` / `sys.objects` / `sys.columns` / `sys.types` / `sys.indexes` / `sys.extended_properties`),只用 2005+ 通用写法。
38
+ - **分页按版本分流**:连接后查 `SERVERPROPERTY('ProductMajorVersion')`(取不到时回退解析 `ProductVersion` 前缀)并缓存;major ≥ 11(2012+,含 Azure SQL 的 12)用 `ORDER BY (SELECT NULL) OFFSET n ROWS FETCH NEXT m ROWS ONLY`,< 11(2008/2008R2)降级 `ROW_NUMBER() OVER (ORDER BY (SELECT NULL))` 子查询;探测失败同样走降级分支。
39
+ - **decimal 精度分侧口径**:浏览侧(`query` / `preview` / 数据浏览网格)**不保证 decimal/numeric 逐位保真**——tedious 在解析阶段已把 decimal 转成 JS number(>15 有效位即丢精度),适配器转字符串只能防后续数值化,且 >1e21 会出现科学计数法;需要逐位保真时请在 SQL 里显式 `CONVERT(nvarchar(...), col)`。数据传输(transfer)读端已内建该转换(`CONVERT(nvarchar(64), col)`),写入时按字符串绑定,精度不经过 JS 浮点。
40
+ - **类型搬运口径(传输侧)**:`hierarchyid` / `geometry` / `geography` 按二进制(bytes)保真搬运,目标端建 `VARBINARY(MAX)`——**UDT 类型语义不保留**(目标端没有 `ST_*` / `hierarchyid` 方法);`sql_variant` 读端 `CONVERT(nvarchar(max), col)` 转文本,**原类型信息丢失**(驱动按值的实际类型返回标量,无法统一搬运);`text` / `ntext` / `xml` → `NVARCHAR(MAX)`,源 `varchar` / `char` 长度 4001–8000 也映射为 `NVARCHAR(MAX)` 类(保容量,不做隐式收窄),`varbinary` / `image` / `rowversion` → `VARBINARY(MAX)`,`decimal` / `numeric` / `money` → `DECIMAL`;`datetime2` 读端 `CONVERT(nvarchar(33), col, 126)`、`time` 用 `CONVERT(nvarchar(16), col, 126)` 保真。
41
+ - **ro 只读强度弱于 MySQL/PG**:SQL Server 没有等价的会话级只读语句。ro 授权下适配器 query 闸门共五道:① 首词白名单(`select / with / show / describe / desc / explain / use / set / help / table`);② 拒绝多语句(词法级:顶层分号之后出现任何内容即拒);③ 拒绝 `SELECT ... INTO`(词法 token 级判定,字符串、方括号标识符与注释内的 `into` 不误伤);④ 拒绝 `WITH ... (INSERT|UPDATE|DELETE|MERGE)`;⑤ 拒绝 `OPENQUERY` / `OPENROWSET` / `OPENDATASOURCE`(其字符串参数会在远端/外部数据源执行任意语句、不可审计,连合法的只读远端查询也一律拒)。服务层另拦 `execute`,guard 层对同样两类「读头写体」还会要求确认。**这仍是应用层拦截,不等同于数据库侧的只读会话**;需要强隔离时请给连接配数据库侧只读账号。
42
+ - **数据传输**:写入走参数化多行 `VALUES` INSERT(TDS 单语句参数上限 2100、`VALUES` 表值构造器单语句最多 1000 行,每语句行数取两者最小值;replace/ignore 走 `MERGE`)。传输分页同样按目标库版本分流:2012+ 用 `OFFSET/FETCH`,2008/2008R2 降级——有序游标(keyset/range)用 `SELECT TOP (n) ... ORDER BY key`,无分片键的 offset 全序与 `keyAt` 切块用 `ROW_NUMBER() OVER (...)` 子查询;版本探测失败时按 0 走保守降级,因此**传输在 SQL Server 2008/2008R2 同样可用**。已知限制:目标表已存在且该列为 IDENTITY 时,写入显式值会报错 544(当前不自动 `SET IDENTITY_INSERT ON`);传输自动建表不生成 IDENTITY 列。建表 DDL 在显式事务中可回滚,不需要 Oracle/DM 那样的隐式提交处理。
43
+ - **验证状态**:本轮只交付离线单测(Fake 驱动),SQL Server 路径**未经真机验证**;真机冒烟开关 `DBT_TEST_SQLSERVER_URL`(形如 `sqlserver://sa:pw@host:1433/db`)。
44
+ - **驱动形态**:`mssql@12` 仅在 devDependencies,运行时加载 `npm run build:sqlserver` 生成的 `vendor/sqlserver-driver.cjs`;原因是 mssql → tedious → bl → readable-stream 会把 buffer/events/process/string_decoder 带进生产依赖树,触发宿主 `dsh-app-boot` 的 `require.resolve.paths` bug,导致插件整体加载失败。
45
+ - **错误消息脱敏**:连接 URL 解析失败时,错误文案里的 userinfo 已脱敏,不回显明文密码(错误会进 `audit.jsonl` 与模型上下文,故必须脱敏)。
46
+ - **不支持**:SQL Server 2005(9.x)及更早版本,未支持也未验证;`tdsVersion=8_0`(TDS 8.0)与 `encrypt='strict'`(严格加密)亦未支持——SQL Server 2022 用默认 `7_4` 即可正常连接,TDS 8.0 强加密属后续增强。
47
+
29
48
  ## 数据布局
30
49
 
31
50
  `$DSH_HOME/db-tool/`(0700):
@@ -39,7 +58,7 @@ DSH 社区插件:在聊天中安全操作数据库,配套侧边栏管理台
39
58
 
40
59
  ## 安装
41
60
 
42
- 8 种数据库驱动全部随 npm 包分发(SQLite 优先用 Node 内置 `node:sqlite`,可选 `better-sqlite3`;GaussDB 驱动为华为云官方 npm 包 `gaussdb-node`),无需任何构建步骤。GaussDB 认证支持 sha256 与 md5,md5-sha256 混合与 SM3 暂不支持。
61
+ 9 种数据库驱动随 npm 包分发(SQLite 优先用 Node 内置 `node:sqlite`,可选 `better-sqlite3`;GaussDB 驱动为华为云官方 npm 包 `gaussdb-node`),无需任何构建步骤。其中 SQL Server 驱动(`mssql@12`)只在 devDependencies,运行时加载发布包内置的 `vendor/sqlserver-driver.cjs`(`npm run build:sqlserver` 生成)——mssql 的生产依赖树会带入 buffer/events/process/string_decoder,触发宿主 resolve.paths bug。GaussDB 认证支持 sha256 与 md5,md5-sha256 混合与 SM3 暂不支持。
43
62
 
44
63
  唯一的一次性交互来自 oracledb:其 install 脚本会被 pnpm 默认拦截并使首次安装报告失败——在 DSH 插件安装界面点 **"Allow these scripts and retry"** 即可完成安装(该脚本仅做 Node 版本检查与横幅打印,批准无风险;批准持久化到 profile,后续升级不再提示)。
45
64
 
@@ -78,19 +97,19 @@ npx tsc --noEmit
78
97
  npx stryker run # 变异测试(范围 lib/guard + lib/manager + lib/store,报告 reports/mutation/)
79
98
  ```
80
99
 
81
- 真机冒烟(设了才跑):`DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT`。GaussDB 与 Oracle/Mongo 官方要求均按官方文档实现,未真机验证处以代码内标注为准。
100
+ 真机冒烟(设了才跑):`DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT / DBT_TEST_SQLSERVER_URL`(SQL Server 形如 `sqlserver://sa:pw@host:1433/db`)。GaussDB、Oracle/Mongo 与 SQL Server 的要求均按官方文档实现,未真机验证处以代码内标注为准;**本轮 SQL Server 只有离线单测(Fake 驱动),未经真机验证**。
82
101
 
83
102
  ## 目录结构
84
103
 
85
104
  ```
86
- lib/ host 插件(store / adapters×8 / guard / manager / http / index)
105
+ lib/ host 插件(store / adapters×9 / guard / manager / http / index)
87
106
  client/ 侧边栏单文件产物(client.js,即源码)
88
107
  skills/ db-admin skill
89
- scripts/ 构建与工具脚本(mongodb 驱动 / CodeMirror 编辑器 bundle、DSH 宿主热修复 patch:dsh 等)
108
+ scripts/ 构建与工具脚本(mongodb / sqlserver 驱动、CodeMirror 编辑器 bundle、DSH 宿主热修复 patch:dsh 等)
90
109
  patches/ DSH 宿主 dsh-app-boot 热修复补丁(patch:dsh 按宿主版本选用)
91
110
  docs/ 预留(当前为空)
92
111
  tests/ vitest(离线 mock + DBT_TEST_* 门控真机)
93
- vendor/ mongodb-driver.cjs 与 codemirror-sql.cjs bundle(gitignore,发布经 files 白名单收录)
112
+ vendor/ mongodb-driver.cjs、sqlserver-driver.cjs 与 codemirror-sql.cjs bundle(gitignore,发布经 files 白名单收录)
94
113
  ```
95
114
 
96
115
  ## 许可证
package/README_EN.md CHANGED
@@ -6,26 +6,45 @@ A DSH community plugin for operating databases safely from chat, with a sidebar
6
6
 
7
7
  ## Features
8
8
 
9
- - **8 databases**: MySQL, PostgreSQL, GaussDB, SQLite, Redis, MongoDB, Oracle, and DM (Dameng)
9
+ - **9 databases**: MySQL, PostgreSQL, GaussDB, SQLite, Redis, MongoDB, Oracle, DM (Dameng), and SQL Server
10
10
  - **One tool, six actions**: `DatabaseManager` exposes `list_connections / query / execute / schema / preview / run_script` (`run_script` executes in a `node:vm` sandbox with a 60s timeout and only restricted `db.{query,execute}` handles)
11
11
  - **Tiered permissions**: per-connection read-only (ro) / read-write (rw) plus project-scoped grants (`grants.json`: projectPathKey → connection → mode); unauthorized projects are always rejected
12
12
  - **Dangerous-operation confirmation**: DDL / FLUSHALL / dropDatabase and the like first return `NEEDS_CONFIRMATION`; after confirmation in chat (model via ask) or in the SQL console (dialog), the retry carries a one-time `challengeId` (bound to the statement SHA256, 5-minute TTL)
13
13
  - **Auditing**: every execution is appended to `audit.jsonl` (statement, danger level, confirmed or not, outcome)
14
14
  - **Sidebar with 4 panels** (dsh-better-sidebar, zh/en): connections, project grants, data browser, SQL console
15
- - **db-admin skill**: shipped with the plugin, covering dialect cheat sheets for all 8 databases, safety rules, and the confirmation flow
15
+ - **db-admin skill**: shipped with the plugin, covering dialect cheat sheets for all 9 databases, safety rules, and the confirmation flow
16
16
 
17
17
  ### SQL Console
18
18
 
19
19
  The sidebar SQL console panel has been upgraded to a Navicat-style multi-tab editor (CodeMirror 6):
20
20
 
21
21
  - **Multi-tab**: create/close/switch query tabs; each tab keeps its own SQL, result, and history (capped at 50 entries, expandable and click-to-refill)
22
- - **Syntax highlighting & completion**: switches with the dialect — mysql→MySQL, postgresql/gaussdb→PostgreSQL, sqlite→SQLite, oracle/dmdb→PL/SQL; Redis/Mongo get keyword/method completion over plain text; table/column metadata is fetched to feed completion (up to 50 tables). If the editor bundle (`vendor/codemirror-sql.cjs`, built by `npm run build:codemirror`, chained after `npm run build`) fails to load, it silently falls back to a textarea
22
+ - **Syntax highlighting & completion**: switches with the dialect — mysql→MySQL, postgresql/gaussdb→PostgreSQL, sqlite→SQLite, oracle/dmdb→PL/SQL, sqlserver→T-SQL (the MSSQL dialect from `@codemirror/lang-sql`); Redis/Mongo get keyword/method completion over plain text; table/column metadata is fetched to feed completion (up to 50 tables). If the editor bundle (`vendor/codemirror-sql.cjs`, built by `npm run build:codemirror`, chained after `npm run build`) fails to load, it silently falls back to a textarea
23
23
  - **Shortcuts**: `Ctrl/Cmd+Enter` runs everything, `Ctrl/Cmd+Shift+Enter` runs the selection
24
- - **Formatting**: dialect-aware formatting via sql-formatter, falling back to the original text on parse failure
24
+ - **Formatting**: dialect-aware formatting via sql-formatter (sqlserver→`transactsql`), falling back to the original text on parse failure
25
25
  - **Per-statement execution**: statements are pre-classified client-side as read/write and routed to `/api/query` vs `/api/execute`; write statements are skipped and flagged under read-only grants; errors can jump to the offending statement
26
+ - **`GO` batch separator**: `GO` and `GO n` follow sqlcmd semantics — the preceding batch is repeated n times (`GO 0` / `GO 1` both mean once) and the `GO` line itself produces no statement; the repeat count is capped at **1000** (values above it are truncated) so one run cannot be amplified into an unbounded number of requests
26
27
  - **Transactions**: begin/commit/rollback under rw grants (server-side sticky-connection session; idle sessions roll back after a 5-minute TTL and are audited; grant revocation takes effect immediately)
27
28
  - **Result grid**: tri-state column sorting, pagination at 50 rows/page, click-to-copy cells, CSV/JSON export
28
29
 
30
+ ## SQL Server Support Scope and Boundaries
31
+
32
+ Coverage: SQL Server 2008/2008R2 → 2022 and Azure SQL Database; the dialect and metadata queries only use SQL Server 2005+ constructs.
33
+
34
+ - **Connection form**: exclusively `sqlserver://user:password@host:port/db`, with query parameters `encrypt` / `trustServerCertificate` / `tdsVersion` / `instanceName` / `useUTC` (names are case-insensitive). `tdsVersion` defaults to `7_4` and accepts `7_3_A` / `7_3_B` / `7_2` / `7_1`; the port defaults to 1433, and when `instanceName` is set the port is ignored per tedious semantics (SQL Browser over UDP 1434); the minimum TLS version is set via `cryptoCredentialsDetails.minVersion`, and `ssl=true` is equivalent to `encrypt=true`. ADO.NET key/value connection strings (`Server=...;Database=...`) are **not** accepted.
35
+ The defaults are `encrypt=true` + `trustServerCertificate=false` (the same as mssql's own defaults). **Connecting to SQL Server 2008/2008R2 usually requires appending `?trustServerCertificate=true`**: those instances typically use self-signed certificates and may only speak TLS 1.0, while Node's default minimum is TLSv1.2, so the defaults fail the handshake; use `?encrypt=false` when the server does not force encryption, and set `?tdsVersion=7_3_A` (2008) / `7_3_B` (2008R2) explicitly.
36
+ - **Authentication**: SQL authentication only (`user` / `password`). **Windows integrated authentication is not supported**, and neither is **Azure AD authentication**.
37
+ - **Dialect**: write `?` as the parameter placeholder — the adapter rewrites them in order to `@p1..@pN`; identifiers use `[x]` with `]` doubled as `]]`; database/table/column metadata goes through `sys.*` catalog views only (`sys.databases` / `sys.schemas` / `sys.objects` / `sys.columns` / `sys.types` / `sys.indexes` / `sys.extended_properties`), using 2005+ constructs only.
38
+ - **Paging is version-split**: after connecting it reads `SERVERPROPERTY('ProductMajorVersion')` (falling back to parsing the `ProductVersion` prefix) and caches the result; major ≥ 11 (2012+, including Azure SQL's 12) uses `ORDER BY (SELECT NULL) OFFSET n ROWS FETCH NEXT m ROWS ONLY`, while < 11 (2008/2008R2) degrades to a `ROW_NUMBER() OVER (ORDER BY (SELECT NULL))` subquery; a failed probe also takes the degraded branch.
39
+ - **decimal precision is side-dependent**: on the browsing side (`query` / `preview` / data-browser grid) **decimal/numeric is not guaranteed digit-exact** — tedious has already turned decimal into a JS number during parsing (>15 significant digits lose precision), and converting it to a string afterwards only prevents later numeric coercion (>1e21 also renders in scientific notation); ask for `CONVERT(nvarchar(...), col)` in SQL when you need digit-exact values. The transfer reader already does that conversion (`CONVERT(nvarchar(64), col)`) and binds strings on write, so precision never passes through JS floats.
40
+ - **Type mapping on the transfer side**: `hierarchyid` / `geometry` / `geography` move as binary (bytes) into `VARBINARY(MAX)` on the target — the values are preserved but **UDT type semantics are not** (no `ST_*` / `hierarchyid` methods on the target); `sql_variant` is read as `CONVERT(nvarchar(max), col)` text, so **the original type information is lost** (the driver returns scalars of the value's actual type, which cannot be moved uniformly); `text` / `ntext` / `xml` → `NVARCHAR(MAX)`, source `varchar` / `char` of length 4001–8000 also maps to `NVARCHAR(MAX)` class (capacity preserved, never silently narrowed), `varbinary` / `image` / `rowversion` → `VARBINARY(MAX)`, `decimal` / `numeric` / `money` → `DECIMAL`; `datetime2` is read via `CONVERT(nvarchar(33), col, 126)` and `time` via `CONVERT(nvarchar(16), col, 126)` for exactness.
41
+ - **ro read-only is weaker than MySQL/PG**: SQL Server has no equivalent session-level read-only statement. Under an ro grant the adapter's query gate has five checks: ① a first-word allowlist (`select / with / show / describe / desc / explain / use / set / help / table`); ② rejection of multiple statements (lexical: the moment anything follows a top-level semicolon); ③ rejection of `SELECT ... INTO` (token-level, so `into` inside strings, bracket identifiers, or comments is not a false positive); ④ rejection of `WITH ... (INSERT|UPDATE|DELETE|MERGE)`; ⑤ rejection of `OPENQUERY` / `OPENROWSET` / `OPENDATASOURCE` (their string arguments execute arbitrary statements remotely/unattended and cannot be audited, so even legitimate read-only remote queries are refused). The service layer also blocks `execute`, and the guard layer still requires confirmation for the same two "read head, write body" forms. **This is still application-level interception, not the same as a database-side read-only session**; use a database-side read-only account when you need strong isolation.
42
+ - **Data transfer**: writes use parameterized multi-row `VALUES` INSERTs (TDS caps a statement at 2100 parameters and a `VALUES` table constructor at 1000 rows, so rows per statement is the smaller of the two; replace/ignore go through `MERGE`). Transfer paging is version-split as well: 2012+ uses `OFFSET/FETCH`, while 2008/2008R2 degrades — ordered cursors (keyset/range) use `SELECT TOP (n) ... ORDER BY key`, and the offset full ordering plus `keyAt` chunking use `ROW_NUMBER() OVER (...)` subqueries; a failed version probe falls back to 0 and takes the conservative branch, so **transfer works on SQL Server 2008/2008R2 too**. Known limits: when the target table already exists and a column is IDENTITY, writing explicit values fails with error 544 (the plugin does not issue `SET IDENTITY_INSERT ON`); tables created by transfer do not generate IDENTITY columns. CREATE TABLE DDL can be rolled back inside an explicit transaction, so no Oracle/DM-style implicit-commit handling is needed.
43
+ - **Verification status**: this round ships offline unit tests only (Fake driver); the SQL Server path is **not verified against a live database**. The live smoke switch is `DBT_TEST_SQLSERVER_URL` (e.g. `sqlserver://sa:pw@host:1433/db`).
44
+ - **Driver packaging**: `mssql@12` lives in devDependencies only; at runtime the plugin loads `vendor/sqlserver-driver.cjs` built by `npm run build:sqlserver`. Reason: mssql → tedious → bl → readable-stream drags buffer/events/process/string_decoder into the production dependency tree, tripping the host's `dsh-app-boot` `require.resolve.paths` bug and failing the whole plugin load.
45
+ - **Error-message redaction**: when a connection URL fails to parse, the error text has the userinfo redacted and never echoes the plaintext password (errors reach `audit.jsonl` and the model context, so redaction is mandatory).
46
+ - **Not supported**: SQL Server 2005 (9.x) and earlier — neither supported nor verified; `tdsVersion=8_0` (TDS 8.0) and `encrypt='strict'` are also unsupported — SQL Server 2022 connects fine with the default `7_4`, and TDS 8.0 strict encryption is a later enhancement.
47
+
29
48
  ## Data Layout
30
49
 
31
50
  `$DSH_HOME/db-tool/` (0700):
@@ -39,7 +58,7 @@ The sidebar SQL console panel has been upgraded to a Navicat-style multi-tab edi
39
58
 
40
59
  ## Installation
41
60
 
42
- Drivers for all 8 databases ship with the npm package (SQLite prefers the built-in `node:sqlite`, optionally `better-sqlite3`; the GaussDB driver is the official Huawei Cloud npm package `gaussdb-node`) — no build steps of any kind. GaussDB authentication supports sha256 and md5; md5-sha256 hybrid and SM3 are not supported yet.
61
+ Drivers for all 9 databases ship with the npm package (SQLite prefers the built-in `node:sqlite`, optionally `better-sqlite3`; the GaussDB driver is the official Huawei Cloud npm package `gaussdb-node`) — no build steps of any kind. The SQL Server driver is the exception in form: `mssql@12` lives in devDependencies only, and the runtime loads the bundled `vendor/sqlserver-driver.cjs` built by `npm run build:sqlserver` — mssql's production dependency tree pulls in buffer/events/process/string_decoder, which trips the host's resolve.paths bug. GaussDB authentication supports sha256 and md5; md5-sha256 hybrid and SM3 are not supported yet.
43
62
 
44
63
  The only one-time interaction comes from oracledb: pnpm blocks its install script by default, which makes the first install report a failure — click **"Allow these scripts and retry"** in the DSH plugin installation UI to finish (the script only checks the Node version and prints a banner, so approving is risk-free; the approval is persisted to the profile and upgrades won't prompt again).
45
64
 
@@ -78,19 +97,19 @@ npx tsc --noEmit
78
97
  npx stryker run # mutation testing (lib/guard + lib/manager + lib/store, reports in reports/mutation/)
79
98
  ```
80
99
 
81
- Live-database smoke tests (run only when set): `DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT`. GaussDB, Oracle, and Mongo official requirements are implemented per their official docs; anything not verified against live databases is annotated in code.
100
+ Live-database smoke tests (run only when set): `DBT_TEST_MYSQL_URL / DBT_TEST_PG_URL / DBT_TEST_REDIS_URL / DBT_TEST_DM_CONNECT / DBT_TEST_MONGO_URL / DBT_TEST_ORACLE_CONNECT / DBT_TEST_SQLSERVER_URL` (SQL Server in the form `sqlserver://sa:pw@host:1433/db`). The GaussDB, Oracle/Mongo, and SQL Server requirements are implemented per their official docs; anything not verified against live databases is annotated in code. **This round ships offline unit tests only (Fake driver) for SQL Server — no live-database verification.**
82
101
 
83
102
  ## Project Structure
84
103
 
85
104
  ```
86
- lib/ host plugin (store / adapters×8 / guard / manager / http / index)
105
+ lib/ host plugin (store / adapters×9 / guard / manager / http / index)
87
106
  client/ sidebar single-file artifact (client.js — this is the source)
88
107
  skills/ db-admin skill
89
- scripts/ build & tooling scripts (MongoDB driver / CodeMirror editor bundles, DSH host hotfix patch:dsh, etc.)
108
+ scripts/ build & tooling scripts (MongoDB / SQL Server driver and CodeMirror editor bundles, DSH host hotfix patch:dsh, etc.)
90
109
  patches/ DSH host dsh-app-boot hotfix patches (selected by host version for patch:dsh)
91
110
  docs/ reserved (currently empty)
92
111
  tests/ vitest (offline mocks + DBT_TEST_*-gated live runs)
93
- vendor/ mongodb-driver.cjs and codemirror-sql.cjs bundles (gitignored; shipped via the files whitelist)
112
+ vendor/ mongodb-driver.cjs, sqlserver-driver.cjs and codemirror-sql.cjs bundles (gitignored; shipped via the files whitelist)
94
113
  ```
95
114
 
96
115
  ## License
package/client/client.js CHANGED
@@ -468,7 +468,7 @@ window.__ModuleLoader__.load({
468
468
 
469
469
  const TAB_ID = "dsh-db-tool";
470
470
  const API = "/dsh-db-tool/api";
471
- const DB_KINDS = ["mysql", "postgresql", "gaussdb", "sqlite", "redis", "mongodb", "oracle", "dmdb"];
471
+ const DB_KINDS = ["mysql", "postgresql", "gaussdb", "sqlite", "redis", "mongodb", "oracle", "dmdb", "sqlserver"];
472
472
  const PAGE_SIZE = 50; // 契约:preview limit≤50 服务端强制
473
473
 
474
474
  // --- HTTP 封装:保留 code / challengeId 供确认通道使用 ---
@@ -876,9 +876,10 @@ window.__ModuleLoader__.load({
876
876
  // 统一返回形状:{ ok:true, ops:[{statement, params?, database?}] }(多条顺序执行,如 redis 两步)
877
877
  // 或 { ok:false, error:"<i18n key>" }(error 为 key 名,组件侧 t() 本地化;null 表示通用错误)
878
878
 
879
- // 方言判定:q=问号占位符(mysql/sqlite)、dollar=$n(postgresql/gaussdb)、colon=:n(oracle/dmdb)
879
+ // 方言判定:q=问号占位符(mysql/sqlite/sqlserver,sqlserver 的 @pN 由适配器内部重写)、
880
+ // dollar=$n(postgresql/gaussdb)、colon=:n(oracle/dmdb)
880
881
  function dialectOf(kind) {
881
- if (kind === "mysql" || kind === "sqlite") return "q";
882
+ if (kind === "mysql" || kind === "sqlite" || kind === "sqlserver") return "q";
882
883
  if (kind === "postgresql" || kind === "gaussdb") return "dollar";
883
884
  if (kind === "oracle" || kind === "dmdb") return "colon";
884
885
  if (kind === "redis") return "redis";
@@ -886,8 +887,10 @@ window.__ModuleLoader__.load({
886
887
  return null;
887
888
  }
888
889
 
889
- // 标识符引用:mysql 用反引号(内部双写转义),其余统一双引号(内部双写转义)
890
+ // 标识符引用:mysql 用反引号(内部双写转义),sqlserver 用方括号(] 双写转义),
891
+ // 其余统一双引号(内部双写转义)
890
892
  function quoteIdent(kind, name) {
893
+ if (kind === "sqlserver") return "[" + String(name).split("]").join("]]") + "]";
891
894
  const q = kind === "mysql" ? "`" : "\"";
892
895
  return q + String(name).split(q).join(q + q) + q;
893
896
  }
@@ -920,7 +923,9 @@ window.__ModuleLoader__.load({
920
923
  return { ok: false, error: "noPkHint" };
921
924
  }
922
925
  // 表全限定:pg/gauss 用 schema 前缀(库由 execute 的 database 参数路由,语句内不写库名);
923
- // mysql 带库名(adapter 无 database 路由);oracle/dmdb 的 dbRef 即 OWNER;sqlite 仅表名
926
+ // mysql 表名同样带库名(execute 的 database 参数已按库路由,全限定在目标库上执行等价,双保险);
927
+ // oracle/dmdb 的 dbRef 即 OWNER;sqlite 仅表名;
928
+ // sqlserver 有 schema 时写 [库].[schema].[表] 三段
924
929
  // execute 的 database 参数只传库名(db.schema 形态取点前段),连接目标只能是库,schema 靠语句内全限定
925
930
  let table;
926
931
  if (kind === "postgresql" || kind === "gaussdb") {
@@ -929,6 +934,13 @@ window.__ModuleLoader__.load({
929
934
  : quoteIdent(kind, tableName);
930
935
  } else if (kind === "mysql" || kind === "oracle" || kind === "dmdb") {
931
936
  table = quoteIdent(kind, dbRef) + "." + quoteIdent(kind, tableName);
937
+ } else if (kind === "sqlserver") {
938
+ // 无 schema 时只写表名:T-SQL 两段式是 schema.object(不是 database.object),
939
+ // 写 [库].[表] 会被解析成 schema=库名 → 报 208 对象不存在;库由 database 参数路由
940
+ // (适配器按库名切池),与 pg/gauss 分支同构。
941
+ table = schemaName
942
+ ? quoteIdent(kind, String(dbRef).split(".")[0]) + "." + quoteIdent(kind, schemaName) + "." + quoteIdent(kind, tableName)
943
+ : quoteIdent(kind, tableName);
932
944
  } else {
933
945
  table = quoteIdent(kind, tableName);
934
946
  }
@@ -1029,23 +1041,29 @@ window.__ModuleLoader__.load({
1029
1041
  if (kind === "postgresql" || kind === "gaussdb") return "postgresql";
1030
1042
  if (kind === "sqlite") return "sqlite";
1031
1043
  if (kind === "oracle" || kind === "dmdb") return "plsql";
1044
+ if (kind === "sqlserver") return "transactsql";
1032
1045
  return null;
1033
1046
  }
1034
1047
 
1035
1048
  // SQL 文本 → 语句列表 [{ text, start }]:text 为原文切片(保留内部注释/空白),start 为语句
1036
1049
  // 首字符偏移(跳过前导空白/注释,错误就地显示后定位光标用)。词法跳过与 lang-sql 方言 spec
1037
- // 一一对应:单/双引号字符串(反斜杠与双写转义,mysql 另含反引号标识符)、-- 行注释
1038
- // (slashComments)、/* */ 块注释、$$...$$ dollar-quoting(doubleDollarQuotedStrings,仅
1050
+ // 一一对应:单/双引号字符串(双写转义;反斜杠转义是 MySQL 专有语义,其它 kind 按标准 SQL)、
1051
+ // -- 行注释(slashComments)、/* */ 块注释、$$...$$ dollar-quoting(doubleDollarQuotedStrings,仅
1039
1052
  // postgresql/gaussdb)、MySQL DELIMITER 自定义分隔符(客户端指令:仅语句缓冲为空时切换后续
1040
1053
  // 分隔符,指令行不产生语句)、Oracle/达梦 BEGIN..END 块(块内分号不切分;CREATE
1041
- // PROCEDURE/FUNCTION 体的分号由该块覆盖)。空/纯注释语句被过滤;无尾分隔符的最后一条也返回。
1054
+ // PROCEDURE/FUNCTION 体的分号由该块覆盖)、SQL Server [方括号] 标识符与 GO 批分隔符
1055
+ // (GO 支持重复计数与行尾 -- 注释;GO n 按总数语义展开上一批)。
1056
+ // 空/纯注释语句被过滤;无尾分隔符的最后一条也返回。
1042
1057
  // ponytail: depth 计数不识别 CASE..END/LOOP/IF 等(块内混用可能提前切分);需要时换完整 PL/SQL 词法
1058
+ // GO n 的重复上限:GO 是客户端批分隔符,n 次展开会成倍放大执行请求数,钳制防手滑/恶意写出天量请求
1059
+ const GO_REPEAT_MAX = 1000;
1043
1060
  function splitSqlStatements(sql, kind) {
1044
1061
  const src = String(sql == null ? "" : sql);
1045
1062
  const out = [];
1046
1063
  const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
1047
1064
  let sep = ";";
1048
1065
  let i = 0, stmtStart = 0, contentStart = 0, hasContent = false, depth = 0; // depth: BEGIN..END 嵌套计数
1066
+ let batchFrom = 0; // 当前批(GO 之间)在 out 中的起始下标,供 GO n 整批重复用
1049
1067
  while (i < src.length) {
1050
1068
  const c = src[i];
1051
1069
  if (c === "-" && src[i + 1] === "-") { // -- 行注释至行尾
@@ -1058,12 +1076,14 @@ window.__ModuleLoader__.load({
1058
1076
  i = Math.min(src.length, i + 2);
1059
1077
  continue;
1060
1078
  }
1061
- if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { // 引号串:反斜杠与双写转义
1079
+ if (c === "'" || c === '"' || (kind === "mysql" && c === "`")) { // 引号串:双写转义;反斜杠转义仅 MySQL 语义
1062
1080
  if (!hasContent) contentStart = i;
1063
1081
  hasContent = true;
1064
1082
  i++;
1065
1083
  while (i < src.length) {
1066
- if (src[i] === "\\") { i += 2; continue; }
1084
+ // 反斜杠转义是 MySQL 专有语义;其它 kind(pg/oracle/sqlite/sqlserver)按标准 SQL,
1085
+ // \ 是普通字符(T-SQL 下 'C:\temp\' 正常闭合,否则会误吞后续语句)
1086
+ if (kind === "mysql" && src[i] === "\\") { i += 2; continue; }
1067
1087
  if (src[i] === c) {
1068
1088
  if (src[i + 1] === c) { i += 2; continue; } // 双写转义
1069
1089
  i++;
@@ -1073,6 +1093,21 @@ window.__ModuleLoader__.load({
1073
1093
  }
1074
1094
  continue;
1075
1095
  }
1096
+ // [方括号] 标识符(sqlserver):内部 ; 不切分,]] 为转义(方括号内反斜杠无转义语义)
1097
+ if (kind === "sqlserver" && c === "[") {
1098
+ if (!hasContent) contentStart = i;
1099
+ hasContent = true;
1100
+ i++;
1101
+ while (i < src.length) {
1102
+ if (src[i] === "]") {
1103
+ if (src[i + 1] === "]") { i += 2; continue; }
1104
+ i++;
1105
+ break;
1106
+ }
1107
+ i++;
1108
+ }
1109
+ continue;
1110
+ }
1076
1111
  if ((kind === "postgresql" || kind === "gaussdb") && c === "$" && src[i + 1] === "$") {
1077
1112
  if (!hasContent) contentStart = i; // $$dollar 串至下一 $$(未闭合则吞到结尾)
1078
1113
  hasContent = true;
@@ -1091,6 +1126,50 @@ window.__ModuleLoader__.load({
1091
1126
  while (j < e && (src[j] === " " || src[j] === "\t")) j++;
1092
1127
  if (j < e) { sep = src.slice(j, e); stmtStart = e; i = e; continue; }
1093
1128
  }
1129
+ // GO 批分隔符(sqlserver):独占一行——行首可缩进、行尾可跟空白与 -- 行注释,
1130
+ // 可带重复计数(GO 3 = 上一批执行 3 次,总数语义,与 sqlcmd 一致);大小写不敏感;
1131
+ // GO 行本身不产生语句。计数超 GO_REPEAT_MAX 时截断(防天量请求)。
1132
+ if (kind === "sqlserver" && depth === 0 && (i === 0 || src[i - 1] === "\n")) {
1133
+ let j = i;
1134
+ while (j < src.length && (src[j] === " " || src[j] === "\t")) j++;
1135
+ if (src.slice(j, j + 2).toUpperCase() === "GO" && !isWord(src[j + 2] || " ")) {
1136
+ let k = j + 2, count = 1;
1137
+ while (k < src.length && (src[k] === " " || src[k] === "\t")) k++;
1138
+ if (src[k] >= "0" && src[k] <= "9") { // 可选重复计数:GO 0 / GO 1 均等同 1 次
1139
+ let n = 0;
1140
+ while (k < src.length && src[k] >= "0" && src[k] <= "9") {
1141
+ n = Math.min(n * 10 + (src.charCodeAt(k) - 48), GO_REPEAT_MAX);
1142
+ k++;
1143
+ }
1144
+ count = Math.max(1, n);
1145
+ }
1146
+ while (k < src.length && (src[k] === " " || src[k] === "\t")) k++;
1147
+ if (src[k] === "-" && src[k + 1] === "-") { // 行尾注释:GO -- 说明
1148
+ while (k < src.length && src[k] !== "\n") k++;
1149
+ }
1150
+ if (k >= src.length || src[k] === "\n" || src[k] === "\r") { // 确认行尾:行内 GO 不算
1151
+ if (hasContent) {
1152
+ // 语句切到 GO 行首前一行的行尾换行之前(text 不带 GO 行前导换行)
1153
+ let end = i;
1154
+ if (end > stmtStart && src[end - 1] === "\n") end -= src[end - 2] === "\r" ? 2 : 1;
1155
+ out.push({ text: src.slice(stmtStart, end), start: contentStart });
1156
+ }
1157
+ // 批重复:把刚切出的当前批再追加 count-1 遍(总数语义;复制对象,不共享引用)
1158
+ if (count > 1 && out.length > batchFrom) {
1159
+ const batch = out.slice(batchFrom);
1160
+ for (let r = 1; r < count; r++) {
1161
+ for (const s of batch) out.push({ text: s.text, start: s.start });
1162
+ }
1163
+ }
1164
+ batchFrom = out.length; // 下一批从后续新语句开始
1165
+ i = k;
1166
+ stmtStart = k;
1167
+ contentStart = k;
1168
+ hasContent = false;
1169
+ continue;
1170
+ }
1171
+ }
1172
+ }
1094
1173
  // BEGIN/END 词计数(oracle/dmdb):块内分号不切分;END 不把 depth 减到负(顶层 CASE..END 不受影响)
1095
1174
  if ((kind === "oracle" || kind === "dmdb") && (i === 0 || !isWord(src[i - 1]))) {
1096
1175
  if (src.slice(i, i + 5).toUpperCase() === "BEGIN" && !isWord(src[i + 5] || " ")) {
@@ -1172,6 +1251,7 @@ window.__ModuleLoader__.load({
1172
1251
  mongodb: { host: "127.0.0.1", port: 27017, database: "test" },
1173
1252
  oracle: { host: "127.0.0.1", port: 1521, user: "system" },
1174
1253
  dmdb: { host: "127.0.0.1", port: 5236, user: "SYSDBA" },
1254
+ sqlserver: { host: "127.0.0.1", port: 1433, user: "sa" },
1175
1255
  };
1176
1256
 
1177
1257
  /**
@@ -1732,8 +1812,8 @@ window.__ModuleLoader__.load({
1732
1812
  if (!open["c:" + c.id]) toggleConn(c);
1733
1813
  }
1734
1814
  }, [conns, projectPath, autoExpand]); // eslint-disable-line
1735
- // PG/GaussDB 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
1736
- const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
1815
+ // PG/GaussDB/SQL Server 官方层级为 数据库 → 模式(schema) → 表:库节点下先列 schema 再列表
1816
+ const HAS_SCHEMAS = { postgresql: true, gaussdb: true, sqlserver: true };
1737
1817
  function toggleDb(c, d) {
1738
1818
  if (!projectPath) return;
1739
1819
  toggle("d:" + c.id + "/" + d, () => loadByKey("d:" + c.id + "/" + d));
@@ -2397,7 +2477,7 @@ window.__ModuleLoader__.load({
2397
2477
  };
2398
2478
  }
2399
2479
 
2400
- const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true };
2480
+ const CONSOLE_SQL_KINDS = { mysql: true, postgresql: true, gaussdb: true, sqlite: true, oracle: true, dmdb: true, sqlserver: true };
2401
2481
 
2402
2482
  function ConsoleView(props) {
2403
2483
  const { conns, grants, projectPath, askConfirm, onError } = props;
@@ -2408,6 +2488,9 @@ window.__ModuleLoader__.load({
2408
2488
  // Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
2409
2489
  const [dbList, setDbList] = React.useState([]);
2410
2490
  const [db, setDb] = React.useState("");
2491
+ // 列库失败/加载中必须就地可见(对齐 BrowseTree 错误行;此前静默吞错,下拉在失败时无声消失)
2492
+ const [dbsErr, setDbsErr] = React.useState("");
2493
+ const [dbsLoading, setDbsLoading] = React.useState(false);
2411
2494
  // 多标签(内存态,不落盘):tabs[{id,title,sql,result,history}]
2412
2495
  const tabSeq = React.useRef(1);
2413
2496
  const [tabs, setTabs] = React.useState(() => [{ id: "t1", title: t("queryTab", { n: 1 }), sql: "", result: null, history: [] }]);
@@ -2433,11 +2516,19 @@ window.__ModuleLoader__.load({
2433
2516
  const activeTab = tabs[activeIdx] || tabs[0];
2434
2517
  const activeTabId = activeTab ? activeTab.id : "";
2435
2518
 
2436
- // 跨库下拉(保留既有行为:换连接重置)
2519
+ // 跨库下拉(保留既有行为:换连接重置);列库失败可见+可重试(词条与 TransferView 复用)
2520
+ function loadDbs() {
2521
+ setDbsErr(""); // 发起即清:换连接/重试成功/重试失败三路径由此闭合
2522
+ if (!connId || !projectPath) return;
2523
+ setDbsLoading(true);
2524
+ api("databases" + qs({ project: projectPath, connId })).then(
2525
+ (l) => { setDbList(l || []); setDbsLoading(false); },
2526
+ (e) => { setDbsErr(String(e && e.message ? e.message : e)); setDbsLoading(false); },
2527
+ );
2528
+ }
2437
2529
  React.useEffect(() => {
2438
2530
  setDbList([]); setDb("");
2439
- if (!connId || !projectPath) return;
2440
- api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
2531
+ loadDbs();
2441
2532
  }, [connId, projectPath]);
2442
2533
 
2443
2534
  // vendor bundle 异步加载(失败降级 textarea,静默)
@@ -2716,9 +2807,13 @@ window.__ModuleLoader__.load({
2716
2807
  const suffix = g && g.mode ? " · " + g.mode : "";
2717
2808
  return React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + suffix + ")");
2718
2809
  })),
2810
+ dbsErr && connId ? React.createElement("span", { className: "dbt-err", role: "alert" },
2811
+ t("trxLoadDbsFail") + ":" + dbsErr + " ",
2812
+ React.createElement("button", { className: "dbt-btn", onClick: loadDbs }, t("retry"))) : null,
2719
2813
  dbList.length > 1 ? React.createElement("select", { value: db, disabled: !!txToken, onChange: (e) => setDb(e.target.value) },
2720
2814
  React.createElement("option", { value: "" }, t("defaultDb")),
2721
2815
  dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
2816
+ dbsLoading ? React.createElement("span", { className: "dbt-muted" }, "…") : null,
2722
2817
  ),
2723
2818
  ),
2724
2819
  // 多标签条(内存态):标签 + 关闭 + 新建
@@ -2896,8 +2991,8 @@ window.__ModuleLoader__.load({
2896
2991
  const srcConn = conns.find((c) => c.id === srcId);
2897
2992
  const dstConn = conns.find((c) => c.id === dstId);
2898
2993
  const dstGrant = (props.grants || []).find((x) => x.connId === dstId);
2899
- // PG/GaussDB 三层(库→模式→表),与 BrowsePane 同一张表;sqlite 无库层(连接默认上下文)
2900
- const HAS_SCHEMAS = { postgresql: true, gaussdb: true };
2994
+ // PG/GaussDB/SQL Server 三层(库→模式→表),与 BrowsePane 同一张表;sqlite 无库层(连接默认上下文)
2995
+ const HAS_SCHEMAS = { postgresql: true, gaussdb: true, sqlserver: true };
2901
2996
  const NO_DB_LEVEL = { sqlite: true };
2902
2997
 
2903
2998
  // 项目切换清层级缓存(对齐 BrowsePane:避免陈旧授权下的旧数据)
@@ -7,6 +7,7 @@ const LOADERS = {
7
7
  mongodb: () => import('./mongodb/index.js').then((m) => m.createMongoAdapter),
8
8
  oracle: () => import('./oracle/index.js').then((m) => m.createOracleAdapter),
9
9
  dmdb: () => import('./dmdb/index.js').then((m) => m.createDmAdapter),
10
+ sqlserver: () => import('./sqlserver/index.js').then((m) => m.createSqlserverAdapter),
10
11
  };
11
12
  /** 驱动缺失时的安装指引 */
12
13
  const INSTALL_HINTS = {
@@ -18,6 +19,7 @@ const INSTALL_HINTS = {
18
19
  mongodb: 'npm run build:mongodb(构建 vendor/mongodb-driver.cjs;发布包已内置产物,仅在缺失时需要)',
19
20
  oracle: 'npm install oracledb',
20
21
  dmdb: 'npm install dmdb',
22
+ sqlserver: 'npm run build:sqlserver(重建 vendor/sqlserver-driver.cjs;mssql 仅在 devDependencies)',
21
23
  };
22
24
  /** 生成带安装指引的驱动缺失错误 */
23
25
  export function driverMissingError(kind, cause) {
@@ -78,16 +78,46 @@ function connOptions(conn) {
78
78
  return base;
79
79
  }
80
80
  export async function createMysqlAdapter(conn, opts) {
81
- const pool = mysql.createPool(connOptions(conn));
81
+ const baseOpts = connOptions(conn);
82
+ const pool = mysql.createPool(baseOpts);
82
83
  const readOnly = opts?.mode === 'ro';
83
- if (readOnly) {
84
- // 服务器级 ro 强制:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
85
- // 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
86
- // 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。
87
- pool.on('connection', (c) => {
84
+ // 跨库(Navicat 式):按库名缓存独立池。mysql2 官方多库方案即多池管理——池上
85
+ // changeUser/USE 只影响被调用的那条池内连接,池保持建池配置,切库不可靠。
86
+ const mainDb = typeof baseOpts.database === 'string' ? baseOpts.database : '';
87
+ const dbPools = new Map();
88
+ /**
89
+ * 关闭幂等标志(对齐 pg-like/sqlserver):关闭后一切访问必须被拦下——
90
+ * 否则跨库访问会新建池(关不掉的连接泄漏),主池请求则报英文驱动错。
91
+ */
92
+ let closed = false;
93
+ const assertOpen = () => {
94
+ if (closed)
95
+ throw new Error('mysql 连接已关闭:请重新连接后重试');
96
+ };
97
+ /** ro 模式:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
98
+ * 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
99
+ * 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。主池与跨库池共用同一注册。 */
100
+ const attachReadOnly = (p) => {
101
+ if (!readOnly)
102
+ return;
103
+ p.on('connection', (c) => {
88
104
  swallowSetError(c.query('SET SESSION TRANSACTION READ ONLY'));
89
105
  });
90
- }
106
+ };
107
+ attachReadOnly(pool);
108
+ const poolFor = (db) => {
109
+ assertOpen();
110
+ if (db === mainDb)
111
+ return pool;
112
+ let p = dbPools.get(db);
113
+ if (!p) {
114
+ // connOptions 已把 URL/fields 拆成纯字段,覆盖 database 即建出目标库的池
115
+ p = mysql.createPool({ ...baseOpts, database: db });
116
+ dbPools.set(db, p);
117
+ attachReadOnly(p);
118
+ }
119
+ return p;
120
+ };
91
121
  /** 事务激活期间路由到同一连接,保证 BEGIN/COMMIT 生效 */
92
122
  let txConn = null;
93
123
  // PoolConnection 与 Pool 的联合会让 mysql2 的 query/execute 重载解析失败,收窄为 Pool 视图
@@ -160,18 +190,23 @@ export async function createMysqlAdapter(conn, opts) {
160
190
  const [rows] = await pool.query('SELECT VERSION() AS v');
161
191
  return { ok: true, serverInfo: `MySQL ${String(rows[0]?.v ?? '')}` };
162
192
  }),
163
- query: (sql, params) => humanize('mysql 查询失败', async () => {
193
+ query: (sql, params, database) => humanize('mysql 查询失败', async () => {
164
194
  if (readOnly && !RO_READ_PREFIX.test(sql.trimStart())) {
165
195
  throw new Error('DRIVER_ERROR: 只读(ro)模式下 query 仅允许读语句(select/show/desc/describe/explain/use/help/table),写操作请使用可写连接');
166
196
  }
167
- const [rows, fields] = await runner().query(sql, params ?? []);
197
+ // 跨库请求路由到该库自己的池(白名单校验在前,拒绝发生在建池之前;
198
+ // 缺省判断用 != null:空串等非法值必须进 assertIdent 被拒,不能落回主池);
199
+ // 无 database 时维持事务粘主池语义(服务层已拒绝事务携带 database)
200
+ const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
201
+ const [rows, fields] = await target.query(sql, params ?? []);
168
202
  if (!Array.isArray(rows))
169
203
  return { columns: [], rows: [], rowCount: 0 };
170
204
  return toQueryResult(rows, fields);
171
205
  }),
172
- execute: (statement, params) => humanize('mysql 执行失败', async () => {
206
+ execute: (statement, params, database) => humanize('mysql 执行失败', async () => {
173
207
  // mysql2 的 ExecuteValues 不接受 unknown[],此处值均为绑定参数(string|number|null)
174
- const [result] = await runner().execute(statement, (params ?? []));
208
+ const target = database != null ? poolFor(assertIdent(database, '库名')) : runner();
209
+ const [result] = await target.execute(statement, (params ?? []));
175
210
  const header = (Array.isArray(result) ? result[0] : result);
176
211
  const n = header?.affectedRows;
177
212
  return {
@@ -231,7 +266,12 @@ export async function createMysqlAdapter(conn, opts) {
231
266
  return { columns: [], rows: [], rowCount: 0 };
232
267
  return toQueryResult(rows, fields);
233
268
  }),
234
- close: () => humanize('mysql 关闭连接', () => pool.end()),
269
+ close: () => humanize('mysql 关闭连接', async () => {
270
+ if (closed)
271
+ return; // 幂等:重复 close 为 no-op(pg-pool 二次 end() 会抛的教训)
272
+ closed = true;
273
+ await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
274
+ }),
235
275
  };
236
276
  }
237
277
  export const factory = (conn) => createMysqlAdapter(conn);
@@ -44,8 +44,9 @@ export function normalizeCell(v) {
44
44
  ? Number(b)
45
45
  : b.toString();
46
46
  }
47
+ // Invalid Date(如驱动返回无法解析的日期)不能抛 RangeError:单格异常会放大成整查询失败
47
48
  if (v instanceof Date)
48
- return v.toISOString();
49
+ return Number.isNaN(v.getTime()) ? String(v) : v.toISOString();
49
50
  if (v instanceof Uint8Array)
50
51
  return `[BLOB ${v.byteLength} bytes]`;
51
52
  let s;