@nsyan/db 1.0.0 → 1.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nsyan/db",
3
- "version": "1.0.0",
4
- "description": "AI 接入数据库扩展 —— 四大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/Hive/Spark,提供查询/表结构/扫描建连工具给 LLM",
3
+ "version": "1.1.0",
4
+ "description": "AI 接入数据库扩展 —— 五大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/MongoDB/Hive/Spark 九种数据库,提供查询/表结构/扫描建连/连接清单工具给 LLM",
5
5
  "keywords": [
6
6
  "pi-extension",
7
7
  "pi-package",
@@ -13,6 +13,8 @@
13
13
  "dm",
14
14
  "redis",
15
15
  "elasticsearch",
16
+ "mongodb",
17
+ "mongo",
16
18
  "hive",
17
19
  "spark"
18
20
  ],
@@ -43,6 +45,7 @@
43
45
  "es7": "npm:@elastic/elasticsearch@7",
44
46
  "hive-driver": "^1.0.1",
45
47
  "ioredis": "^6.0.0",
48
+ "mongodb": "^6.21.0",
46
49
  "mysql2": "^3.23.1",
47
50
  "oracledb": "^7.0.1",
48
51
  "pg": "^8.22.0"
package/src/config.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // config.ts —— 连接配置与插件配置读写(含旧文件名兼容 + mtime 内存缓存)
2
2
  // 注:本模块只做 JSON 文件 IO,不依赖任何 pi 运行时模块
3
- import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
4
- import { homedir } from "node:os";
3
+ import { existsSync, readFileSync, writeFileSync, statSync, chmodSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { registry } from "./dialects/index.js";
7
- import type { ConnConfig, DbTypeId } from "./core/types.js";
7
+ import type { ConnConfig, DbTypeId, TestConnectionResult } from "./core/types.js";
8
8
 
9
9
  // ── 路径 ──────────────────────────────────────────
10
10
 
@@ -23,6 +23,8 @@ export interface PluginConfig {
23
23
  max_rows: number;
24
24
  /** 单条 SQL 超时秒数 */
25
25
  query_timeout: number;
26
+ /** 写操作审计日志(v1.1 UX 共识 Q4 修订:默认关闭,/db config 开启;reason 强校验不受此开关影响) */
27
+ audit_enabled: boolean;
26
28
  }
27
29
 
28
30
  export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
@@ -30,6 +32,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
30
32
  confirm_before_exec: "write",
31
33
  max_rows: 100,
32
34
  query_timeout: 30,
35
+ audit_enabled: false,
33
36
  };
34
37
 
35
38
  // ── mtime 内存缓存 ────────────────────────────────
@@ -78,6 +81,8 @@ export function loadConfigs(): ConnConfig[] {
78
81
 
79
82
  export function saveConfigs(configs: ConnConfig[]): void {
80
83
  writeFileSync(CONFIG_FILE, JSON.stringify(configs, null, 2));
84
+ // 降险(v1.1 UX 共识 Q4):配置含明文密码,限制为仅当前用户可读写
85
+ try { chmodSync(CONFIG_FILE, 0o600); } catch { /* 只读文件系统等场景容错 */ }
81
86
  invalidateConfigCache(CONFIG_FILE);
82
87
  }
83
88
 
@@ -119,6 +124,7 @@ export function getConfigSummary(cfg: PluginConfig): string {
119
124
  `执行确认: ${confirmLabel}`,
120
125
  `最大行数: ${cfg.max_rows}`,
121
126
  `查询超时: ${cfg.query_timeout}s`,
127
+ `审计日志: ${cfg.audit_enabled ? "开" : "关"}`,
122
128
  ].join("\n");
123
129
  }
124
130
 
@@ -140,6 +146,8 @@ export interface ParsedConnectionString {
140
146
  password?: string;
141
147
  database?: string;
142
148
  dbIndex?: number;
149
+ /** URI 查询参数(MongoDB: authSource/replicaSet/tls...) */
150
+ options?: Record<string, string>;
143
151
  }
144
152
 
145
153
  export function parseConnectionString(input: string): ParsedConnectionString | null {
@@ -154,11 +162,70 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
154
162
  if (p.password !== undefined) out.password = p.password;
155
163
  if (p.database !== undefined) out.database = p.database;
156
164
  if (p.dbIndex !== undefined) out.dbIndex = p.dbIndex;
165
+ if (p.options !== undefined) out.options = p.options;
157
166
  return out;
158
167
  }
159
168
  return null;
160
169
  }
161
170
 
171
+ // ── 使用/测试记录(v1.1 UX 共识 Q1:连接列表摘要的数据源)───
172
+
173
+ /** 回写最近使用时间(查询/列表/表结构成功后调用;找不到连接时静默跳过) */
174
+ export function recordLastUsed(configName: string): void {
175
+ const all = loadConfigs();
176
+ const c = all.find((x) => x.name === configName);
177
+ if (!c) return;
178
+ c.lastUsedAt = new Date().toISOString();
179
+ saveConfigs(all);
180
+ }
181
+
182
+ /** 回写最近一次测试连接结果(测试连接/编辑后自动测试/向导建连成功时调用) */
183
+ export function recordTestResult(configName: string, r: TestConnectionResult): void {
184
+ const all = loadConfigs();
185
+ const c = all.find((x) => x.name === configName);
186
+ if (!c) return;
187
+ c.lastTest = { ok: r.success, latency: r.latency, version: r.version, at: new Date().toISOString() };
188
+ saveConfigs(all);
189
+ }
190
+
191
+ /** 相对时间文案:刚刚 / N 分钟前 / N 小时前 / N 天前 / YYYY-MM-DD */
192
+ export function formatRelativeTime(iso: string): string {
193
+ const t = new Date(iso).getTime();
194
+ if (!Number.isFinite(t)) return "";
195
+ const diff = Date.now() - t;
196
+ const min = Math.floor(diff / 60_000);
197
+ if (min < 1) return "刚刚";
198
+ if (min < 60) return `${min} 分钟前`;
199
+ const hour = Math.floor(min / 60);
200
+ if (hour < 24) return `${hour} 小时前`;
201
+ const day = Math.floor(hour / 24);
202
+ if (day < 30) return `${day} 天前`;
203
+ return iso.slice(0, 10);
204
+ }
205
+
206
+ /** 环境标签片段:[prod·强制只读] / [dev];无标签返回空串 */
207
+ export function envTagLabel(c: Pick<ConnConfig, "envTag" | "forceReadonly">): string {
208
+ if (!c.envTag) return "";
209
+ return `[${c.envTag}${c.forceReadonly ? "·强制只读" : ""}]`;
210
+ }
211
+
212
+ /** 连接摘要行(选择列表/查看列表共用):名称 [类型] ⭐默认 [prod·强制只读] · 上次使用 … · 测试 … */
213
+ export function connSummaryLine(c: ConnConfig): string {
214
+ const parts = [`${c.name} [${shortTypeLabel(c.type)}]`];
215
+ if (c.isDefault) parts.push("⭐默认");
216
+ const tag = envTagLabel(c);
217
+ if (tag) parts.push(tag);
218
+ if (c.lastUsedAt) {
219
+ const rel = formatRelativeTime(c.lastUsedAt);
220
+ if (rel) parts.push(`上次使用 ${rel}`);
221
+ }
222
+ if (c.lastTest) {
223
+ parts.push(`测试 ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ?? ""}`);
224
+ }
225
+ const desc = c.description ? ` - ${c.description}` : "";
226
+ return parts.join(" · ") + desc;
227
+ }
228
+
162
229
  // ── 默认连接(Spec §11.1 P0:database 参数可选,缺省走 isDefault 标记的连接)───
163
230
 
164
231
  export function getDefaultConfig(configs: ConnConfig[]): ConnConfig | undefined {
@@ -171,9 +238,6 @@ export function setDefaultConfig(configs: ConnConfig[], id: string): ConnConfig[
171
238
 
172
239
  // ── 查询结果导出(Spec §11.1 P0:长结果落盘不糊上下文)───
173
240
 
174
- import { writeFileSync } from "node:fs";
175
- import { join } from "node:path";
176
- import { tmpdir } from "node:os";
177
241
  import { toCsv } from "./core/export.js";
178
242
 
179
243
  export interface QueryExport {
@@ -212,9 +276,9 @@ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig
212
276
 
213
277
  // 供 UI 层展示类型短标签(原 index.ts 内 5 处重复映射的收敛点之一)
214
278
  export function shortTypeLabel(type: DbTypeId): string {
215
- return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
279
+ return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB" } as Record<string, string>)[type] ?? type;
216
280
  }
217
281
 
218
282
  export function fullTypeLabel(type: DbTypeId): string {
219
- return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
283
+ return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB" } as Record<string, string>)[type] ?? type;
220
284
  }
@@ -0,0 +1,67 @@
1
+ // core/audit.ts —— 写操作审计日志(v1.1 UX 共识 Q4 + 轮转共识:按本地日期一天一个 .jsonl,SQL 存完整原文不截断)
2
+ // 注:本模块只做文件 IO,不依赖任何 pi 运行时模块;审计失败静默降级,绝不阻断查询主流程
3
+
4
+ import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { DbTypeId } from "./types.js";
8
+
9
+ /** 审计目录:~/.pi/agent/db-audit/,按天一个 <YYYY-MM-DD>.jsonl */
10
+ export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
11
+
12
+ export interface AuditEntry {
13
+ /** ISO 时间 */
14
+ time: string;
15
+ /** 执行时的项目路径(process.cwd()),跨项目区分用 */
16
+ project: string;
17
+ /** 连接名 */
18
+ connection: string;
19
+ /** 数据库类型 */
20
+ type: DbTypeId;
21
+ /** verdict.summary(如 "UPDATE orders(共 1 条语句)") */
22
+ summary: string;
23
+ /** AI 提供的执行理由(全文) */
24
+ reason: string;
25
+ /** 完整 SQL(共识:不截断) */
26
+ sql: string;
27
+ /** 执行时的生效只读标记(写成功恒为 false,保留字段以备语义扩展) */
28
+ readonly: boolean;
29
+ }
30
+
31
+ /** 本地日期 → YYYY-MM-DD(审计文件按本地天轮转) */
32
+ export function localDateStr(d: Date): string {
33
+ const y = d.getFullYear();
34
+ const m = String(d.getMonth() + 1).padStart(2, "0");
35
+ const day = String(d.getDate()).padStart(2, "0");
36
+ return `${y}-${m}-${day}`;
37
+ }
38
+
39
+ /** 审计文件路径:<dir>/<YYYY-MM-DD>.jsonl(按 time 的本地日期取天) */
40
+ export function auditFilePath(time: Date, dir: string = AUDIT_DIR): string {
41
+ return join(dir, `${localDateStr(time)}.jsonl`);
42
+ }
43
+
44
+ // 目录 mkdir / 文件 chmod 仅进程生命周期内首次执行(后续每次追加只剩 1 次 appendFileSync syscall)
45
+ const ensuredDirs = new Set<string>();
46
+ const chmoddedFiles = new Set<string>();
47
+
48
+ /**
49
+ * 追加一条审计记录到按天轮转的 JSONL 文件(一行一条)。
50
+ * 同步写入保证返回结果前已落盘(单次开销亚毫秒级);失败静默降级不阻断主流程。
51
+ * @param entry 审计条目
52
+ * @param dir 审计目录,默认 AUDIT_DIR(测试可注入临时目录)
53
+ */
54
+ export function appendAuditLog(entry: AuditEntry, dir: string = AUDIT_DIR): void {
55
+ try {
56
+ if (!ensuredDirs.has(dir)) {
57
+ mkdirSync(dir, { recursive: true });
58
+ ensuredDirs.add(dir);
59
+ }
60
+ const file = auditFilePath(new Date(entry.time), dir);
61
+ appendFileSync(file, JSON.stringify(entry) + "\n", "utf-8");
62
+ if (!chmoddedFiles.has(file)) {
63
+ try { chmodSync(file, 0o600); } catch { /* 平台不支持等场景容错 */ }
64
+ chmoddedFiles.add(file);
65
+ }
66
+ } catch { /* 审计失败不影响主流程 */ }
67
+ }
@@ -9,3 +9,16 @@ export function decide(verdict: Verdict, _readonly: boolean, confirm: ConfirmMod
9
9
  if (confirm === "write" && verdict.isWrite) return "confirm";
10
10
  return "run";
11
11
  }
12
+
13
+ /** 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3;isAllowed/ExecOpts 均用此值) */
14
+ export function effectiveReadonly(aiReadonly: boolean, config: { forceReadonly?: boolean }): boolean {
15
+ return aiReadonly || config.forceReadonly === true;
16
+ }
17
+
18
+ /**
19
+ * 写操作是否缺失执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦,isWrite 即要求)。
20
+ * 命中时工具层应拒绝执行并引导 AI 补充 reason(动机+影响范围)。
21
+ */
22
+ export function writeRequiresReason(verdict: Verdict, reason?: string): boolean {
23
+ return verdict.ok === true && verdict.isWrite === true && !(typeof reason === "string" && reason.trim() !== "");
24
+ }
@@ -20,6 +20,7 @@ interface FieldBag {
20
20
  username?: string; password?: string;
21
21
  database?: string; dbIndex?: number;
22
22
  ssl?: boolean;
23
+ options?: Record<string, string>;
23
24
  url?: string;
24
25
  }
25
26
 
@@ -41,6 +42,7 @@ const REQUIRED: Record<DbTypeId, string[]> = {
41
42
  spark: ["host", "port", "database", "username"],
42
43
  redis: ["host", "port", "password"],
43
44
  elasticsearch: ["host", "port"],
45
+ mongodb: ["host", "port"], // 账号/工作库可选(本地无认证常见)
44
46
  };
45
47
 
46
48
  // ── 工具函数 ──────────────────────────────────────
@@ -78,6 +80,7 @@ function dialectFromImage(image: string): DbTypeId | null {
78
80
  const i = image.toLowerCase();
79
81
  if (/postgres/.test(i)) return "postgresql";
80
82
  if (/(^|\/)(mysql|mariadb)/.test(i)) return "mysql";
83
+ if (/(^|\/)mongo/.test(i)) return "mongodb"; // mongo / mongodb 镜像(mongo-express 误报可忍变)
81
84
  if (/redis/.test(i)) return "redis";
82
85
  if (/elasticsearch/.test(i)) return "elasticsearch";
83
86
  if (/dm8|dameng/.test(i)) return "dm";
@@ -109,7 +112,9 @@ export async function scanProject(
109
112
  const dirEnvCache = new Map<string, Record<string, string> | undefined>();
110
113
 
111
114
  const pushUrl = (url: string, file: string, profile: string, confidence: number, keyFields?: Partial<RawDbConfig>): void => {
112
- const parsed = parseUrlViaRegistry(stripQuery(url));
115
+ // 先用完整 URL 路由(MongoDB 的 authSource/replicaSet 等在 query 里,不能剥);
116
+ // 失败再回退剥离 query 的旧路径(兼容 JDBC 带查询参数时各 parseUrl 的锚点匹配)
117
+ const parsed = parseUrlViaRegistry(url) ?? parseUrlViaRegistry(stripQuery(url));
113
118
  if (!parsed) return;
114
119
  raws.push({
115
120
  dialectId: parsed.dialectId,
@@ -123,6 +128,7 @@ export async function scanProject(
123
128
  database: parsed.database,
124
129
  dbIndex: parsed.dbIndex,
125
130
  ssl: parsed.ssl,
131
+ options: parsed.options,
126
132
  },
127
133
  file, profile, confidence,
128
134
  });
@@ -160,7 +166,7 @@ export async function scanProject(
160
166
  if (base === ".env" || base.startsWith(".env.")) {
161
167
  const env = parseEnv(text);
162
168
  for (const [k, v] of Object.entries(env)) {
163
- if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
169
+ if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|MONGODB_URI|MONGO_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
164
170
  pushUrl(v, file, profile, weight);
165
171
  }
166
172
  }
@@ -175,9 +181,9 @@ export async function scanProject(
175
181
  const bag: FieldBag = {
176
182
  host: svc.name, // compose 网络内服务名即主机名
177
183
  port: hostPortOf(svc.ports),
178
- username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"],
179
- password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"],
180
- database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"],
184
+ username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"] ?? svc.env["MONGO_INITDB_ROOT_USERNAME"],
185
+ password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"] ?? svc.env["MONGO_INITDB_ROOT_PASSWORD"],
186
+ database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"] ?? svc.env["MONGO_INITDB_DATABASE"],
181
187
  };
182
188
  raws.push({ dialectId, bag, file, profile, confidence: weight });
183
189
  // compose 里也可能带完整 Spring URL
@@ -138,7 +138,7 @@ export function parseCompose(text: string): ComposeService[] {
138
138
 
139
139
  // ── 通用 URL 正则(全文件扫描兜底)──────────────────
140
140
 
141
- const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
141
+ const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|mongodb\+srv|mongodb|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
142
142
 
143
143
  export function extractUrls(text: string): string[] {
144
144
  const out = new Set<string>();
@@ -1,7 +1,7 @@
1
- // scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch 键映射 + profile 分组
1
+ // scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch / data.mongodb 键映射 + profile 分组
2
2
  import type { DbTypeId } from "../types.js";
3
3
 
4
- export type SpringGroup = "datasource" | "redis" | "es";
4
+ export type SpringGroup = "datasource" | "redis" | "es" | "mongo";
5
5
 
6
6
  export interface RawDbConfig {
7
7
  group: SpringGroup;
@@ -45,11 +45,14 @@ const SPRING_KEYS: Array<[string, Field]> = [
45
45
  ["spring.data.elasticsearch.uris", "url"],
46
46
  ["spring.data.elasticsearch.username", "username"],
47
47
  ["spring.data.elasticsearch.password", "password"],
48
+ ["spring.data.mongodb.uri", "url"], // Spring Boot 2.x+(含 3.x)
49
+ ["spring.mongodb.uri", "url"], // Spring Boot 1.x 旧前缀
48
50
  ];
49
51
 
50
52
  function groupOf(key: string): SpringGroup {
51
53
  if (key.includes("redis")) return "redis";
52
54
  if (key.includes("elasticsearch")) return "es";
55
+ if (key.includes("mongodb")) return "mongo"; // 独立分组:避免与 datasource 的 url 字段互相覆盖
53
56
  return "datasource";
54
57
  }
55
58
 
package/src/core/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // core/types.ts
2
2
  export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
3
- | "redis" | "elasticsearch" | "hive" | "spark";
4
- export type DbFamily = "relational" | "kv" | "search" | "bigdata";
3
+ | "redis" | "elasticsearch" | "mongodb" | "hive" | "spark";
4
+ export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata";
5
5
 
6
6
  export interface ConnConfig {
7
7
  id: string; name: string; type: DbTypeId;
@@ -13,7 +13,16 @@ export interface ConnConfig {
13
13
  dbIndex?: number; // Redis 库号
14
14
  apiKey?: string; // ES 预留(二期)
15
15
  options?: Record<string, string>; // Hive/Spark 会话变量等(旧文件 extraParams 读取时并入此字段)
16
+ // MongoDB: authSource/replicaSet/tls/authMechanism 等(options.srv=true 标记 SRV 连接)
16
17
  isDefault?: boolean; // 默认连接标记(§11.1,G 阶段接线)
18
+ /** 环境标签(v1.1 UX 共识 Q3):dev/test/prod;prod 建议配 forceReadonly */
19
+ envTag?: "dev" | "test" | "prod";
20
+ /** 连接级强制只读:无视全局 ai_readonly,该连接永远只接受查询 */
21
+ forceReadonly?: boolean;
22
+ /** 最近一次成功使用时间(ISO);工具/菜单查询成功后回写 */
23
+ lastUsedAt?: string;
24
+ /** 最近一次测试连接结果(菜单/向导/编辑后自动测试时回写) */
25
+ lastTest?: { ok: boolean; latency?: string; version?: string; at: string };
17
26
  createdAt: string;
18
27
  }
19
28
 
@@ -21,6 +30,8 @@ export interface ParsedTarget {
21
30
  host: string; port: number;
22
31
  username?: string; password?: string;
23
32
  database?: string; dbIndex?: number; ssl?: boolean;
33
+ /** URI 查询参数(MongoDB: authSource/replicaSet/tls...),由 parseUrl 解出、建连时回填 */
34
+ options?: Record<string, string>;
24
35
  }
25
36
 
26
37
  export interface DbConnection { type: DbTypeId; client: unknown; close(): Promise<void>; }
@@ -0,0 +1,273 @@
1
+ // dialects/document-dialect.ts —— 文档家族基类(JSON 命令信封解析、读写分类、limit 注入、结果拍平)
2
+ // 注:交互语义是"发 JSON 命令文档、拿文档结果"(db.runCommand 形态),与关系型/KV/搜索的
3
+ // 语句·命令·DSL 语义均不同,独立成基类。首个实现为 MongoDB(Spec 共识:Q1 选 JSON 信封)。
4
+
5
+ import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
6
+ QueryResult } from "../core/types.js";
7
+ import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
8
+
9
+ // ── 信封分类(读写管控核心,Spec 共识 Q3)──────────────
10
+ // 读白名单 + 写需确认 + 管理/DDL/服务端 JS 恒拒 + 未知命令保守按写(ES parseDsl 同款兜底)
11
+
12
+ /** 读命令白名单(小写;find/count/distinct/aggregate 分类前会先过 JS/写回深扫) */
13
+ const READ_COMMANDS: ReadonlySet<string> = new Set([
14
+ "find", "count", "distinct", "aggregate",
15
+ "collstats", "dbstats", "listcollections", "listindexes", "dataSize",
16
+ "ping", "buildinfo", "hello", "ismaster", "isdbgrid", "serverstatus",
17
+ ]);
18
+
19
+ /** 恒拒命令前缀(小写匹配;与只读开关无关):DDL/复制集/分片/运维一刀切 */
20
+ const DENY_PREFIXES: readonly string[] = [
21
+ "drop", // drop / dropDatabase / dropIndexes / dropUsers ...
22
+ "create", // create / createIndexes / createUser / createSearchIndexes ...
23
+ "replset", // replSetInitiate / replSetReconfig / replSetStepDown ...
24
+ "addshard", "removeshard", "balancer",
25
+ "eval", // eval / $eval 不走前缀也命中 DENY_COMMANDS,此处兜底变体
26
+ "shutdown", "kill", "fsync", "repair", "compact", "configurefailpoint",
27
+ ];
28
+
29
+ /** 恒拒命令(精确小写匹配) */
30
+ const DENY_COMMANDS: ReadonlySet<string> = new Set([
31
+ "collmod", "renamecollection", "converttocapped", "clonecollectionascapped",
32
+ "reindex", "setparameter", "setfeaturecompatibilityversion", "logrotate",
33
+ "enablesharding", "shardcollection", "movechunk", "moveprimary", "split",
34
+ "applyops", "currentop", "clone", "copydb", "clonecollection", "$eval",
35
+ ]);
36
+
37
+ /** 服务端 JS 执行(任意深度出现即恒拒,Q3 共识) */
38
+ const JS_KEYS: ReadonlySet<string> = new Set(["$where", "$function", "$accumulator"]);
39
+ /** aggregate 写回管道阶段(任意深度出现 → 整条按写分类) */
40
+ const AGG_WRITE_KEYS: ReadonlySet<string> = new Set(["$out", "$merge"]);
41
+
42
+ /** 深度扫描:任意层的对象 key(小写)命中 targets 即 true */
43
+ export function hasAnyKey(node: unknown, targets: ReadonlySet<string>): boolean {
44
+ if (Array.isArray(node)) return node.some((n) => hasAnyKey(n, targets));
45
+ if (node !== null && typeof node === "object") {
46
+ return Object.entries(node as Record<string, unknown>).some(([k, v]) =>
47
+ targets.has(k.toLowerCase()) || hasAnyKey(v, targets));
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export interface EnvelopeClassified {
53
+ envelope?: Record<string, unknown>;
54
+ verdict: Verdict;
55
+ }
56
+
57
+ /** 解析 + 分类一条命令信封(isAllowed 与 executeOn 共用,保证裁决与执行一致) */
58
+ export function classifyEnvelope(sql: string): EnvelopeClassified {
59
+ let body: unknown;
60
+ try {
61
+ body = JSON.parse(sql.trim());
62
+ } catch {
63
+ return {
64
+ verdict: {
65
+ ok: false,
66
+ reason: "MongoDB 命令信封必须是合法 JSON 对象,如 {\"find\":\"users\",\"filter\":{}}(单命令一次执行,不支持多语句)",
67
+ },
68
+ };
69
+ }
70
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
71
+ return {
72
+ verdict: { ok: false, reason: "命令信封必须是 JSON 对象(顶层 key 为命令名),如 {\"find\":\"users\"}" },
73
+ };
74
+ }
75
+ const envelope = body as Record<string, unknown>;
76
+ const cmd = Object.keys(envelope)[0] ?? "";
77
+ const target = envelope[cmd];
78
+ const summary = `${cmd}${typeof target === "string" ? " " + target.slice(0, 40) : ""}`;
79
+
80
+ // 1) 服务端 JS 恒拒(比命令分类优先:$match 里夹带 $where 也拦)
81
+ if (hasAnyKey(envelope, JS_KEYS)) {
82
+ return {
83
+ envelope,
84
+ verdict: {
85
+ ok: false,
86
+ reason: "禁止服务端 JS 执行($where / $function / $accumulator),与只读开关无关",
87
+ isWrite: true,
88
+ summary: `${summary}(硬限制)`,
89
+ },
90
+ };
91
+ }
92
+ // 2) 管理/DDL 恒拒(前缀 + 精确名单)
93
+ const cmdLower = cmd.toLowerCase();
94
+ if (DENY_PREFIXES.some((p) => cmdLower.startsWith(p)) || DENY_COMMANDS.has(cmdLower)) {
95
+ return {
96
+ envelope,
97
+ verdict: {
98
+ ok: false,
99
+ reason: `禁止执行管理/DDL 命令:${cmd}(硬限制)`,
100
+ isWrite: true,
101
+ summary: `${summary}(硬限制)`,
102
+ },
103
+ };
104
+ }
105
+ // 3) aggregate 含 $out/$merge → 整条按写(readonly 由 isAllowed 统一裁决)
106
+ if (cmdLower === "aggregate" && hasAnyKey(envelope, AGG_WRITE_KEYS)) {
107
+ return { envelope, verdict: { ok: true, isWrite: true, summary: `${summary}($out/$merge 写回)` } };
108
+ }
109
+ // 4) 读白名单
110
+ if (READ_COMMANDS.has(cmdLower)) {
111
+ return { envelope, verdict: { ok: true, isWrite: false, summary } };
112
+ }
113
+ // 5) 已知写命令与其余未知命令一律按写(未知写意图不可排除)
114
+ return { envelope, verdict: { ok: true, isWrite: true, summary } };
115
+ }
116
+
117
+ // ── limit 注入(Spec 共识 Q8:无界查询自动封顶,bulk 上限 1000)───
118
+
119
+ /** 单条写命令文档数组上限(防手滑不防恶意;writable 模式另有确认框兜底) */
120
+ export const MAX_BULK_DOCS = 1000;
121
+
122
+ export function injectLimits(envelope: Record<string, unknown>, maxRows: number): void {
123
+ const cmd = (Object.keys(envelope)[0] ?? "").toLowerCase();
124
+ if (cmd === "find") {
125
+ // Mongo 语义:limit ≤ 0 等价于“不限”,与未提供同等对待,一并收敛到 maxRows
126
+ const userLimit = typeof envelope.limit === "number" && Number.isFinite(envelope.limit) && envelope.limit > 0
127
+ ? envelope.limit : undefined;
128
+ const limit = Math.min(Math.max(1, Math.trunc(userLimit ?? maxRows)), maxRows);
129
+ envelope.limit = limit;
130
+ envelope.batchSize = limit; // db.command 走 firstBatch,batchSize 决定单批返回量
131
+ return;
132
+ }
133
+ if (cmd === "aggregate" && Array.isArray(envelope.pipeline)) {
134
+ // 空 pipeline 同样需要封顶(全集合扫描)
135
+ const last = envelope.pipeline.length > 0
136
+ ? envelope.pipeline[envelope.pipeline.length - 1] as Record<string, unknown> | null
137
+ : null;
138
+ const hasTailLimit = last !== null && typeof last === "object"
139
+ && ("$limit" in last || "$count" in last);
140
+ if (!hasTailLimit) {
141
+ envelope.pipeline = [...(envelope.pipeline as unknown[]), { $limit: maxRows }];
142
+ }
143
+ const cursor = (envelope.cursor !== null && typeof envelope.cursor === "object"
144
+ ? { ...(envelope.cursor as Record<string, unknown>) }
145
+ : {}) as Record<string, unknown>;
146
+ cursor.batchSize = maxRows;
147
+ envelope.cursor = cursor;
148
+ return;
149
+ }
150
+ // 写命令文档数组封顶(insert/update/delete 的 documents 字段)
151
+ for (const field of ["documents", "updates", "deletes"]) {
152
+ const arr = envelope[field];
153
+ if (Array.isArray(arr) && arr.length > MAX_BULK_DOCS) {
154
+ envelope[field] = arr.slice(0, MAX_BULK_DOCS);
155
+ }
156
+ }
157
+ }
158
+
159
+ // ── 结果拍平(Spec 共识 Q7:顶层字段并集,上限 50 列)───
160
+
161
+ export const MAX_QUERY_COLUMNS = 50;
162
+
163
+ /** BSON 值 → 展示原语:ObjectId 取 hex、其余 BSON 优先 toJSON(Binary/UUID → base64,避免 [object Object])、Date ISO、对象/数组 JSON */
164
+ function cellOf(v: unknown): unknown {
165
+ if (v === undefined) return null;
166
+ if (v === null || typeof v !== "object") return v;
167
+ const b = v as { toHexString?: () => string; _bsontype?: string; toJSON?: () => unknown };
168
+ if (typeof b.toHexString === "function") return b.toHexString();
169
+ if (b._bsontype) {
170
+ const j = typeof b.toJSON === "function" ? b.toJSON() : undefined;
171
+ return j !== undefined && j !== null && typeof j !== "object" ? j : JSON.stringify(v);
172
+ }
173
+ if (v instanceof Date) return v.toISOString();
174
+ return JSON.stringify(v);
175
+ }
176
+
177
+ export function flattenDocs(
178
+ docs: unknown[],
179
+ maxRows: number,
180
+ ): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
181
+ const shown = docs.slice(0, maxRows);
182
+ // 列 = 顶层字段并集,首现顺序,封顶 MAX_QUERY_COLUMNS
183
+ // truncated 语义对齐 bigdata 方言:结果数达 maxRows 上限即标截断(无法区分“恰好等于”)
184
+ const columns: string[] = [];
185
+ const seen = new Set<string>();
186
+ for (const d of shown) {
187
+ if (columns.length >= MAX_QUERY_COLUMNS) break;
188
+ if (d !== null && typeof d === "object" && !Array.isArray(d)) {
189
+ for (const k of Object.keys(d as Record<string, unknown>)) {
190
+ if (!seen.has(k)) {
191
+ seen.add(k);
192
+ columns.push(k);
193
+ if (columns.length >= MAX_QUERY_COLUMNS) break;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ const rows = shown.map((d) => {
199
+ if (d === null || typeof d !== "object" || Array.isArray(d)) return [cellOf(d)];
200
+ const obj = d as Record<string, unknown>;
201
+ return columns.map((c) => cellOf(obj[c]));
202
+ });
203
+ return { columns, rows, rowCount: rows.length, truncated: docs.length >= maxRows };
204
+ }
205
+
206
+ // ── 基类 ──────────────────────────────────────────
207
+
208
+ export abstract class DocumentDialect implements Dialect {
209
+ abstract id: Dialect["id"];
210
+ abstract label: string;
211
+ abstract family: Dialect["family"];
212
+ abstract defaultPort: number;
213
+ abstract fingerprints: Fingerprints;
214
+ abstract parseUrl(url: string): ParsedTarget | null;
215
+ abstract displayUrl(config: ConnConfig): string;
216
+ abstract versionQuery(conn: DbConnection): Promise<string>;
217
+ protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
218
+ /** 执行一条已过白名单的命令信封,返回驱动原始响应 */
219
+ protected abstract doCommand(client: unknown, config: ConnConfig,
220
+ envelope: Record<string, unknown>): Promise<unknown>;
221
+ /** 原始响应 → 文档数组(MongoDB 取 cursor.firstBatch / distinct.values,默认整响应单行) */
222
+ protected extractDocs(raw: unknown): unknown[] {
223
+ return [raw];
224
+ }
225
+ abstract listTables(config: ConnConfig, pattern?: string): Promise<import("../core/types.js").ListTablesResult>;
226
+ abstract describeTable(config: ConnConfig, target: string): Promise<import("../core/types.js").DescribeTableResult>;
227
+
228
+ isAllowed(sql: string, readonly: boolean): Verdict {
229
+ const { verdict } = classifyEnvelope(sql);
230
+ if (!verdict.ok) return verdict;
231
+ if (verdict.isWrite && readonly) {
232
+ return { ...verdict, ok: false, reason: `只读模式下不允许执行写命令:${verdict.summary}` };
233
+ }
234
+ return verdict;
235
+ }
236
+
237
+ async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
238
+ const start = Date.now();
239
+ const { envelope, verdict } = classifyEnvelope(sql);
240
+ if (!envelope || !verdict.ok) {
241
+ return { success: false, error: verdict.reason ?? "命令被拒绝", duration: `${Date.now() - start}ms` };
242
+ }
243
+ injectLimits(envelope, opts.maxRows);
244
+ try {
245
+ const raw = await this.withConnection(
246
+ config,
247
+ (conn) => this.doCommand(conn.client, config, envelope),
248
+ opts.timeoutSec * 1000,
249
+ );
250
+ const docs = this.extractDocs(raw);
251
+ const { columns, rows, rowCount, truncated } = flattenDocs(docs, opts.maxRows);
252
+ return { success: true, columns, rows, rowCount, truncated, duration: `${Date.now() - start}ms` };
253
+ } catch (err: unknown) {
254
+ return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
255
+ }
256
+ }
257
+
258
+ async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 10_000): Promise<T> {
259
+ const conn = await this.doConnect(config, timeoutMs);
260
+ try { return await fn(conn); }
261
+ finally { await conn.close(); }
262
+ }
263
+
264
+ async testConnection(config: ConnConfig): Promise<import("../core/types.js").TestConnectionResult> {
265
+ const start = Date.now();
266
+ try {
267
+ const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
268
+ return { success: true, version, latency: `${Date.now() - start}ms` };
269
+ } catch (err: unknown) {
270
+ return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
271
+ }
272
+ }
273
+ }