dsh-limix-data 0.0.0-stage → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,841 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ //#region src/host/define-tool.ts
6
+ function toJsonSchema(spec) {
7
+ if (spec && spec.type === "object" && spec.properties) return {
8
+ type: "object",
9
+ additionalProperties: spec.additionalProperties ?? false,
10
+ required: spec.required ?? Object.keys(spec.properties).filter((k) => spec.properties[k]?.required),
11
+ properties: Object.fromEntries(Object.entries(spec.properties).map(([k, v]) => [k, toJsonSchema(v)]))
12
+ };
13
+ const out = {};
14
+ if (spec?.type) out.type = spec.type;
15
+ if (spec?.description) out.description = spec.description;
16
+ if (spec?.enum) out.enum = spec.enum;
17
+ if (spec?.items) out.items = toJsonSchema(spec.items);
18
+ if (spec?.properties) out.properties = toJsonSchema({
19
+ type: "object",
20
+ properties: spec.properties
21
+ });
22
+ if (spec?.additionalProperties !== void 0) out.additionalProperties = spec.additionalProperties;
23
+ return out;
24
+ }
25
+ function defineTool(spec) {
26
+ return {
27
+ name: spec.name,
28
+ description: spec.description,
29
+ parameters: toJsonSchema({
30
+ type: "object",
31
+ properties: spec.parameters ?? {}
32
+ }),
33
+ output: {
34
+ schema: toJsonSchema(spec.output?.schema ?? {}),
35
+ render: spec.output.render
36
+ },
37
+ ...spec.timeoutMs !== void 0 ? { timeoutMs: spec.timeoutMs } : {},
38
+ execute: spec.execute
39
+ };
40
+ }
41
+
42
+ //#endregion
43
+ //#region src/core/drivers.ts
44
+ const CONNECT_TIMEOUT_MS = 8e3;
45
+ const postgresDriver = {
46
+ async testConnection(conn) {
47
+ try {
48
+ return {
49
+ ok: true,
50
+ version: await withPgClient(conn, 5e3, async (c) => {
51
+ const r = await c.query("SELECT version() AS v");
52
+ return String(r.rows[0]?.v || "");
53
+ })
54
+ };
55
+ } catch (e) {
56
+ return {
57
+ ok: false,
58
+ error: e instanceof Error ? e.message : String(e)
59
+ };
60
+ }
61
+ },
62
+ async listTables(conn) {
63
+ return withPgClient(conn, 15e3, async (c) => {
64
+ const schema = conn.schema || "public";
65
+ return (await c.query(`SELECT t.table_name AS name, obj_description(pc.oid) AS comment
66
+ FROM information_schema.tables t
67
+ JOIN pg_class pc ON pc.relname = t.table_name
68
+ JOIN pg_namespace pn ON pn.oid = pc.relnamespace AND pn.nspname = t.table_schema
69
+ WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE'
70
+ ORDER BY t.table_name`, [schema])).rows.map((row) => ({
71
+ name: String(row.name),
72
+ comment: row.comment ? String(row.comment) : null
73
+ }));
74
+ });
75
+ },
76
+ async describeTable(conn, table, sampleN = 5) {
77
+ assertIdent(table);
78
+ return withPgClient(conn, 2e4, async (c) => {
79
+ const qualified = `${qIdent(conn.schema || "public")}.${qIdent(table)}`;
80
+ const n = Math.min(sampleN, 20);
81
+ const row = (await c.query(`SELECT
82
+ (SELECT json_agg(x ORDER BY x.attnum) FROM (
83
+ SELECT a.attnum,
84
+ a.attname AS name,
85
+ format_type(a.atttypid, a.atttypmod) AS "dataType",
86
+ col_description(a.attrelid, a.attnum) AS comment
87
+ FROM pg_attribute a
88
+ WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped
89
+ ) x) AS columns,
90
+ obj_description($1::regclass) AS comment,
91
+ (SELECT json_agg(s) FROM (SELECT * FROM ${qualified} LIMIT ${n}) s) AS sample`, [qualified])).rows[0] || {};
92
+ const columns = (row.columns ?? []).map((x) => ({
93
+ name: String(x.name),
94
+ dataType: String(x.dataType),
95
+ comment: x.comment ? String(x.comment) : null
96
+ }));
97
+ return {
98
+ name: table,
99
+ comment: row.comment ? String(row.comment) : null,
100
+ columns,
101
+ sampleRows: row.sample ?? []
102
+ };
103
+ });
104
+ },
105
+ async runQuery(conn, sql, opts) {
106
+ return withPgClient(conn, opts.timeoutMs, async (c) => {
107
+ const r = await c.query({
108
+ text: sql,
109
+ rowMode: "array"
110
+ });
111
+ const columns = (r.fields || []).map((f) => f.name);
112
+ const rows = r.rows || [];
113
+ const truncated = rows.length >= opts.maxRows;
114
+ return {
115
+ columns,
116
+ rows: rows.slice(0, opts.maxRows),
117
+ rowCount: Math.min(rows.length, opts.maxRows),
118
+ truncated
119
+ };
120
+ });
121
+ }
122
+ };
123
+ async function withPgClient(conn, timeoutMs, fn) {
124
+ const { Client } = await import("pg");
125
+ const startupOptions = conn.schema ? `-c search_path=${assertIdentAndReturn(conn.schema)},public` : void 0;
126
+ const client = new Client({
127
+ host: conn.host,
128
+ port: conn.port,
129
+ database: conn.database,
130
+ user: conn.username,
131
+ password: conn.password,
132
+ connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
133
+ options: startupOptions,
134
+ ssl: isLocalHost(conn.host) ? void 0 : { rejectUnauthorized: false }
135
+ });
136
+ await client.connect();
137
+ try {
138
+ await client.query(`SET default_transaction_read_only = on; SET statement_timeout = ${Math.max(1e3, Math.floor(timeoutMs))}`);
139
+ return await fn(client);
140
+ } finally {
141
+ client.end().catch(() => {});
142
+ }
143
+ }
144
+ const mysqlDriver = {
145
+ async testConnection(conn) {
146
+ try {
147
+ return {
148
+ ok: true,
149
+ version: await withMysql(conn, 5e3, async (c) => {
150
+ const [rows] = await c.query("SELECT VERSION() AS v");
151
+ return String(rows[0]?.v || "");
152
+ })
153
+ };
154
+ } catch (e) {
155
+ return {
156
+ ok: false,
157
+ error: e instanceof Error ? e.message : String(e)
158
+ };
159
+ }
160
+ },
161
+ async listTables(conn) {
162
+ return withMysql(conn, 15e3, async (c) => {
163
+ const [rows] = await c.query(`SELECT TABLE_NAME AS name, TABLE_COMMENT AS comment
164
+ FROM information_schema.TABLES
165
+ WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
166
+ ORDER BY TABLE_NAME`, [conn.database]);
167
+ return rows.map((r) => ({
168
+ name: String(r.name),
169
+ comment: r.comment ? String(r.comment) : null
170
+ }));
171
+ });
172
+ },
173
+ async describeTable(conn, table, sampleN = 5) {
174
+ assertIdent(table);
175
+ return withMysql(conn, 2e4, async (c) => {
176
+ const [cols] = await c.query(`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS data_type, COLUMN_COMMENT AS comment
177
+ FROM information_schema.COLUMNS
178
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
179
+ ORDER BY ORDINAL_POSITION`, [conn.database, table]);
180
+ const columns = cols.map((r) => ({
181
+ name: String(r.name),
182
+ dataType: String(r.data_type),
183
+ comment: r.comment ? String(r.comment) : null
184
+ }));
185
+ const [tc] = await c.query(`SELECT TABLE_COMMENT AS comment FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [conn.database, table]);
186
+ const tcRow = tc[0];
187
+ const [sample] = await c.query(`SELECT * FROM \`${table}\` LIMIT ${Math.min(sampleN, 20)}`);
188
+ return {
189
+ name: table,
190
+ comment: tcRow?.comment ? String(tcRow.comment) : null,
191
+ columns,
192
+ sampleRows: sample
193
+ };
194
+ });
195
+ },
196
+ async runQuery(conn, sql, opts) {
197
+ return withMysql(conn, opts.timeoutMs, async (c) => {
198
+ const [rows, fields] = await c.query({
199
+ sql,
200
+ rowsAsArray: true,
201
+ timeout: opts.timeoutMs
202
+ });
203
+ const columns = (fields || []).map((f) => f.name);
204
+ const arr = rows;
205
+ const truncated = arr.length >= opts.maxRows;
206
+ return {
207
+ columns,
208
+ rows: arr.slice(0, opts.maxRows),
209
+ rowCount: Math.min(arr.length, opts.maxRows),
210
+ truncated
211
+ };
212
+ });
213
+ }
214
+ };
215
+ async function withMysql(conn, timeoutMs, fn) {
216
+ const c = await (await import("mysql2/promise")).createConnection({
217
+ host: conn.host,
218
+ port: conn.port,
219
+ database: conn.database,
220
+ user: conn.username,
221
+ password: conn.password,
222
+ connectTimeout: CONNECT_TIMEOUT_MS,
223
+ dateStrings: ["DATE"],
224
+ ssl: isLocalHost(conn.host) ? void 0 : { rejectUnauthorized: false }
225
+ });
226
+ try {
227
+ await c.query("SET SESSION TRANSACTION READ ONLY");
228
+ await c.query(`SET SESSION MAX_EXECUTION_TIME = ${Math.max(1e3, Math.floor(timeoutMs))}`);
229
+ return await fn(c);
230
+ } finally {
231
+ c.end().catch(() => {});
232
+ }
233
+ }
234
+ function isLocalHost(host) {
235
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "host.docker.internal";
236
+ }
237
+ /** 标识符白名单(表名/schema名): 字母数字下划线中文, 防注入拼接 */
238
+ function assertIdent(name) {
239
+ if (!/^[\w一-龥]+$/.test(name)) throw new Error(`非法标识符: ${name}`);
240
+ }
241
+ /** 同上, 校验通过即回传 —— 供拼接处一行完成"校验+使用", 免漏校验 */
242
+ function assertIdentAndReturn(name) {
243
+ assertIdent(name);
244
+ return name;
245
+ }
246
+ function qIdent(name) {
247
+ assertIdent(name);
248
+ return `"${name}"`;
249
+ }
250
+ function getDriver(kind) {
251
+ if (kind === "postgres") return postgresDriver;
252
+ if (kind === "mysql") return mysqlDriver;
253
+ throw new Error(`不支持的数据源类型: ${kind}`);
254
+ }
255
+
256
+ //#endregion
257
+ //#region src/core/csv.ts
258
+ /**
259
+ * [INPUT]: 无依赖(自 Next.js 版 lib/limix/csv.ts 摘取 toCsv 一个函数)
260
+ * [OUTPUT]: 对外提供 toCsv(含引号/逗号/换行转义)
261
+ * [POS]: core 的 CSV 序列化原子,materialize 消费
262
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
263
+ */
264
+ function toCsv(columns, rows) {
265
+ const esc = (v) => {
266
+ const s = v == null ? "" : String(v);
267
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, "\"\"")}"` : s;
268
+ };
269
+ return [columns.map(esc).join(","), ...rows.map((r) => r.map(esc).join(","))].join("\n");
270
+ }
271
+
272
+ //#endregion
273
+ //#region src/core/cells.ts
274
+ /**
275
+ * [INPUT]: 无运行时依赖(纯函数, client/server/test 均可 import — 刻意不带 server-only)
276
+ * [OUTPUT]: 对外提供 sanitizeColumns(列名清洗)/cellToText(单元格→CSV 值)
277
+ * [POS]: lib/datasource 的物化纯逻辑层, 被 materialize.ts 消费并再导出;
278
+ * 与 guard.ts 同属"可单测的纯函数"一档 —— 三处审查修复(时区/撞名/公式注入)的行为契约锁在 cells.test.ts
279
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
280
+ */
281
+ /** 列名清洗: 去首尾空白、内部空白/特殊字符换下划线 — LimiX/CSV 下游友好 */
282
+ function sanitizeColumns(columns) {
283
+ const seen = /* @__PURE__ */ new Set();
284
+ return columns.map((c, i) => {
285
+ let name = (c || `col_${i + 1}`).trim().replace(/[\s,;"'`]+/g, "_");
286
+ if (!name) name = `col_${i + 1}`;
287
+ if (seen.has(name)) {
288
+ let k = 2;
289
+ while (seen.has(`${name}_${k}`)) k++;
290
+ name = `${name}_${k}`;
291
+ }
292
+ seen.add(name);
293
+ return name;
294
+ });
295
+ }
296
+ /** 两位补零 */
297
+ function p2(n) {
298
+ return n < 10 ? `0${n}` : String(n);
299
+ }
300
+ /** 合法数字字面量(含负号/正号/小数/科学计数) — 这类必须原样, 否则毁掉下游数值解析 */
301
+ const NUMERIC_LITERAL = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
302
+ /**
303
+ * CSV 公式注入中和(审查发现 F6): Excel/WPS 打开 CSV 时会把 = + - @ 开头(及前导 Tab/CR)的单元格当公式执行。
304
+ * 数据来自客户库、理论上可被上游污染 → 加前导单引号让表格软件当纯文本。
305
+ * ⚠️ 关键例外: **负数必须放行** —— PG 的 numeric/decimal 经驱动返回的是字符串(保精度),
306
+ * 若把 "-5.5" 变成 "'-5.5", LimiX 侧数值解析直接崩(与时区 bug 同类的静默数据污染)。
307
+ */
308
+ function neutralizeFormula(s) {
309
+ if (!/^[=+\-@\t\r]/.test(s)) return s;
310
+ if (NUMERIC_LITERAL.test(s)) return s;
311
+ return `'${s}`;
312
+ }
313
+ /**
314
+ * 单元格 → CSV 值。
315
+ * 时间戳按**本地字段**拼串: 驱动把 DB 的 timestamp/DATETIME(无时区语义)按进程本地时区解析成 Date,
316
+ * 若再 toISOString() 转 UTC 会平移时区差(实测东八区平移 8 小时, 时序预测的时间轴整体错位)。
317
+ * 取本地字段即把驱动的解析原样还原成 DB 里的墙钟时间(审查发现 P0)。
318
+ */
319
+ function cellToText(v) {
320
+ if (v === null || v === void 0) return "";
321
+ if (typeof v === "number") return v;
322
+ if (typeof v === "bigint") return v.toString();
323
+ if (typeof v === "boolean") return v ? "true" : "false";
324
+ if (v instanceof Date) {
325
+ if (Number.isNaN(v.getTime())) return "";
326
+ return `${`${v.getFullYear()}-${p2(v.getMonth() + 1)}-${p2(v.getDate())}`} ${`${p2(v.getHours())}:${p2(v.getMinutes())}:${p2(v.getSeconds())}`}`;
327
+ }
328
+ if (v instanceof Uint8Array) return `0x${Buffer.from(v).toString("hex")}`;
329
+ if (typeof v === "object") return neutralizeFormula(JSON.stringify(v));
330
+ return neutralizeFormula(String(v));
331
+ }
332
+
333
+ //#endregion
334
+ //#region src/core/guard.ts
335
+ /**
336
+ * [INPUT]: 无运行时依赖(纯函数, client/server/test 均可 import)
337
+ * [OUTPUT]: 对外提供 guardSql(SQL 安全阀: 校验+注入 LIMIT) + GuardResult + PREVIEW_MAX_ROWS/MATERIALIZE_MAX_ROWS
338
+ * [POS]: lib/datasource 的防御第一闸(第二闸=驱动会话级 read-only[最后防线], 第三闸=客户只读账号) —
339
+ * 仅放行单条 SELECT/WITH, 拦截写操作/多语句/注释走私/方言歧义转义; 所有查询入口(工具/API)必须先过此阀
340
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
341
+ */
342
+ const PREVIEW_MAX_ROWS = 100;
343
+ const MATERIALIZE_MAX_ROWS = 5e5;
344
+ const ALLOWED_STARTS = /^(select|with)\b/i;
345
+ const FORBIDDEN = /\b(insert|update|delete|drop|create|alter|truncate|grant|revoke|merge|call|execute|exec|copy|vacuum|analyze|lock|rename|set|use|do|load|handler|import|install|reset|purge|kill|shutdown|prepare|deallocate|declare|fetch|listen|notify|refresh|reindex|cluster|checkpoint|discard|security|into\s+outfile|into\s+dumpfile)\b/i;
346
+ const FORBIDDEN_OBJECTS = /\b(pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_sleep|mysql\.user|benchmark\s*\()/i;
347
+ /**
348
+ * 单趟扫描出两个视图。字符串按"非转义方言"(PG 默认)解析: 反斜杠是普通字符、'' 是转义引号;
349
+ * 一旦引号区内出现反斜杠即打歧义标记, 由调用方整句拒绝 — 因为同一 payload 在 MySQL(反斜杠转义)
350
+ * 与 PG 下会解析出不同的语句边界, 任何单一解析都可能被另一方言走私多语句(审查发现 F2)。
351
+ */
352
+ function scanSql(sql) {
353
+ let stripped = "";
354
+ let cleaned = "";
355
+ let backslashInLiteral = false;
356
+ let i = 0;
357
+ const n = sql.length;
358
+ while (i < n) {
359
+ const ch = sql[i];
360
+ const two = sql.slice(i, i + 2);
361
+ if (two === "--" || ch === "#") {
362
+ while (i < n && sql[i] !== "\n") i++;
363
+ stripped += " ";
364
+ cleaned += " ";
365
+ continue;
366
+ }
367
+ if (two === "/*") {
368
+ const end = sql.indexOf("*/", i + 2);
369
+ i = end === -1 ? n : end + 2;
370
+ stripped += " ";
371
+ cleaned += " ";
372
+ continue;
373
+ }
374
+ if (ch === "'" || ch === "\"" || ch === "`") {
375
+ const quote = ch;
376
+ let literal = quote;
377
+ i++;
378
+ while (i < n) {
379
+ if (sql[i] === "\\") backslashInLiteral = true;
380
+ if (sql[i] === quote) {
381
+ if (sql[i + 1] === quote) {
382
+ literal += quote + quote;
383
+ i += 2;
384
+ continue;
385
+ }
386
+ literal += quote;
387
+ i++;
388
+ break;
389
+ }
390
+ literal += sql[i];
391
+ i++;
392
+ }
393
+ stripped += quote + quote;
394
+ cleaned += literal;
395
+ continue;
396
+ }
397
+ stripped += ch;
398
+ cleaned += ch;
399
+ i++;
400
+ }
401
+ return {
402
+ stripped,
403
+ cleaned,
404
+ backslashInLiteral
405
+ };
406
+ }
407
+ /**
408
+ * SQL 安全阀。
409
+ * @param sql 原始 SQL(单条查询)
410
+ * @param maxRows 行数上限 — 语句无 LIMIT 时自动追加; 有 LIMIT 但超上限时收紧
411
+ */
412
+ function guardSql(sql, maxRows) {
413
+ const raw = (sql || "").trim().replace(/;+\s*$/, "");
414
+ if (!raw) return {
415
+ ok: false,
416
+ reason: "SQL 为空"
417
+ };
418
+ if (raw.length > 2e4) return {
419
+ ok: false,
420
+ reason: "SQL 过长(>20000 字符)"
421
+ };
422
+ const { stripped, cleaned, backslashInLiteral } = scanSql(raw);
423
+ if (backslashInLiteral) return {
424
+ ok: false,
425
+ reason: "字符串字面量里含反斜杠, PG/MySQL 转义语义有歧义, 请改写(如改用 || 拼接或去掉反斜杠)"
426
+ };
427
+ if (stripped.includes(";")) return {
428
+ ok: false,
429
+ reason: "只允许单条语句(检测到分号)"
430
+ };
431
+ if (!ALLOWED_STARTS.test(stripped.trim())) return {
432
+ ok: false,
433
+ reason: "只允许 SELECT / WITH 查询语句"
434
+ };
435
+ const forbidden = stripped.match(FORBIDDEN);
436
+ if (forbidden) return {
437
+ ok: false,
438
+ reason: `检测到禁用关键字: ${forbidden[1] || forbidden[0]}`
439
+ };
440
+ const forbiddenObj = stripped.match(FORBIDDEN_OBJECTS);
441
+ if (forbiddenObj) return {
442
+ ok: false,
443
+ reason: `检测到禁用对象/函数: ${forbiddenObj[0]}`
444
+ };
445
+ const base = cleaned.trim();
446
+ const cap = Math.max(1, Math.floor(maxRows));
447
+ const tail = /\blimit\s+(\d+)((\s+offset\s+\d+)?\s*)$/i.exec(base);
448
+ if (tail) {
449
+ if (parseInt(tail[1], 10) > cap) return {
450
+ ok: true,
451
+ sql: base.slice(0, tail.index) + `LIMIT ${cap}` + (tail[2] || "")
452
+ };
453
+ return {
454
+ ok: true,
455
+ sql: base
456
+ };
457
+ }
458
+ return {
459
+ ok: true,
460
+ sql: `SELECT * FROM (${base}) AS _guarded LIMIT ${cap}`
461
+ };
462
+ }
463
+
464
+ //#endregion
465
+ //#region src/core/store.ts
466
+ function configPath() {
467
+ return join(process.env.DSH_HOME || join(process.env.HOME || "~", ".dsh"), "limix-datasource.json");
468
+ }
469
+ function readConfig() {
470
+ try {
471
+ const raw = readFileSync(configPath(), "utf8");
472
+ const v = JSON.parse(raw);
473
+ return { sources: Array.isArray(v?.sources) ? v.sources : [] };
474
+ } catch {
475
+ return { sources: [] };
476
+ }
477
+ }
478
+ /** 原子写(tmp+rename)+ 0600 */
479
+ function writeConfig(cfg) {
480
+ const p = configPath();
481
+ mkdirSync(dirname(p), { recursive: true });
482
+ const tmp = p + ".tmp";
483
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
484
+ chmodSync(tmp, 384);
485
+ renameSync(tmp, p);
486
+ }
487
+ function listSources() {
488
+ return readConfig().sources;
489
+ }
490
+ function getSource(id) {
491
+ return readConfig().sources.find((s) => s.id === id) ?? null;
492
+ }
493
+ function upsertSource(s) {
494
+ const cfg = readConfig();
495
+ const row = {
496
+ ...s,
497
+ id: s.id || randomUUID()
498
+ };
499
+ const i = cfg.sources.findIndex((x) => x.id === row.id);
500
+ if (i >= 0) cfg.sources[i] = row;
501
+ else cfg.sources.push(row);
502
+ writeConfig(cfg);
503
+ return row;
504
+ }
505
+ function deleteSource(id) {
506
+ const cfg = readConfig();
507
+ const n = cfg.sources.length;
508
+ cfg.sources = cfg.sources.filter((x) => x.id !== id);
509
+ if (cfg.sources.length === n) return false;
510
+ writeConfig(cfg);
511
+ return true;
512
+ }
513
+ /** 行 → 驱动连接参数(形状与原版一致,drivers/materialize 零改动消费) */
514
+ function toConn(row) {
515
+ return {
516
+ kind: row.kind,
517
+ host: row.host,
518
+ port: row.port,
519
+ database: row.database,
520
+ schema: row.schema ?? null,
521
+ username: row.username,
522
+ password: row.password
523
+ };
524
+ }
525
+ function ensureConfigFile() {
526
+ if (!existsSync(configPath())) writeConfig({ sources: [] });
527
+ }
528
+
529
+ //#endregion
530
+ //#region src/core/materialize.ts
531
+ const PREVIEW_TIMEOUT_MS = 2e4;
532
+ const MATERIALIZE_TIMEOUT_MS = 5e4;
533
+ async function previewQuery(source, sql) {
534
+ const g = guardSql(sql, PREVIEW_MAX_ROWS);
535
+ if (!g.ok) throw new Error(`SQL 未通过安全阀: ${g.reason}`);
536
+ const res = await getDriver(source.kind).runQuery(toConn(source), g.sql, {
537
+ maxRows: PREVIEW_MAX_ROWS,
538
+ timeoutMs: PREVIEW_TIMEOUT_MS
539
+ });
540
+ return {
541
+ ...res,
542
+ columns: sanitizeColumns(res.columns),
543
+ rows: res.rows.map((r) => r.map(cellToText))
544
+ };
545
+ }
546
+ async function materializeQuery(source, sql) {
547
+ const g = guardSql(sql, MATERIALIZE_MAX_ROWS);
548
+ if (!g.ok) throw new Error(`SQL 未通过安全阀: ${g.reason}`);
549
+ const res = await getDriver(source.kind).runQuery(toConn(source), g.sql, {
550
+ maxRows: MATERIALIZE_MAX_ROWS,
551
+ timeoutMs: MATERIALIZE_TIMEOUT_MS
552
+ });
553
+ const columns = sanitizeColumns(res.columns);
554
+ return {
555
+ csv: toCsv(columns, res.rows.map((r) => r.map(cellToText))),
556
+ columns,
557
+ rowCount: res.rowCount,
558
+ truncated: res.truncated
559
+ };
560
+ }
561
+
562
+ //#endregion
563
+ //#region src/core/types.ts
564
+ const DATA_SOURCE_KINDS = ["postgres", "mysql"];
565
+
566
+ //#endregion
567
+ //#region src/index.ts
568
+ const inject = ["webServer"];
569
+ /** 无 Date.now 顾虑(Node 半普通运行时);文件名时间戳与原版口径一致 */
570
+ function stamp() {
571
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
572
+ }
573
+ /** 面向 agent 的源身份信息(凭据永不出层——原版纪律) */
574
+ function publicSource(s) {
575
+ return {
576
+ id: s.id,
577
+ 名称: s.name,
578
+ 类型: s.kind,
579
+ 库名: s.database,
580
+ schema: s.schema ?? null,
581
+ 演示源: !!s.isDemo
582
+ };
583
+ }
584
+ const DESCRIPTION = `连接客户数据库取数(数据的第二个来源,另一来源是上传/样本 CSV)。全链路只读。
585
+ 子命令:list(列出已配置数据源);inspect --source <id> [--table 表名](不传 table 回表清单,传了回列结构+中文注释+采样5行);query --source <id> --sql <单条SELECT/WITH> [--mode preview|materialize] [--file-name 名.csv] [--workdir 目录]。
586
+ 工作流(铁律):list → 对要用的每张表 inspect 看结构与注释 → **生成的 SQL 必须先在回复里用代码块展示给用户并用一句中文解释取的是什么(可见/可确认/可纠正),不要偷偷执行** → query mode=preview 自查(≤100行)→ 确认无误再 mode=materialize 落 CSV 进文件区 → 照常用 limix 工具预测。
587
+ **多表 JOIN 拼成一张宽表是本工具的核心用法**(如电价+气象+预测按省份和时间拼宽表)。只允许单条只读查询,写操作会被安全阀拒绝。materialize 上限 50 万行。`;
588
+ function registerDatasourceTool(ctx) {
589
+ ctx.tools.register(defineTool({
590
+ name: "datasource",
591
+ description: DESCRIPTION,
592
+ parameters: {
593
+ command: {
594
+ type: "string",
595
+ required: true,
596
+ enum: [
597
+ "list",
598
+ "inspect",
599
+ "query"
600
+ ],
601
+ description: "子命令"
602
+ },
603
+ source: {
604
+ type: "string",
605
+ description: "数据源 id(来自 list)"
606
+ },
607
+ table: {
608
+ type: "string",
609
+ description: "inspect 用:表名;不传返回表清单"
610
+ },
611
+ sql: {
612
+ type: "string",
613
+ description: "query 用:单条只读 SQL(SELECT/WITH),执行前必须已在回复中展示给用户"
614
+ },
615
+ mode: {
616
+ type: "string",
617
+ enum: ["preview", "materialize"],
618
+ description: "query 用:preview 自查(默认)/ materialize 落盘 CSV"
619
+ },
620
+ fileName: {
621
+ type: "string",
622
+ description: "materialize 落盘文件名(.csv 可省略),不传自动命名"
623
+ },
624
+ workdir: {
625
+ type: "string",
626
+ description: "materialize 落盘目录(默认:调用方会话的工作目录)"
627
+ }
628
+ },
629
+ output: {
630
+ schema: {
631
+ type: "object",
632
+ additionalProperties: false,
633
+ properties: {
634
+ command: {
635
+ type: "string",
636
+ required: true
637
+ },
638
+ ok: {
639
+ type: "boolean",
640
+ required: true
641
+ },
642
+ text: {
643
+ type: "string",
644
+ required: true
645
+ }
646
+ }
647
+ },
648
+ render: (_args, value) => [{
649
+ type: "text",
650
+ text: value.text
651
+ }]
652
+ },
653
+ async execute(args) {
654
+ const J = (v) => JSON.stringify(v, null, 2);
655
+ const fail = (reason) => ({
656
+ command: args.command,
657
+ ok: false,
658
+ text: J({ 错误: reason })
659
+ });
660
+ try {
661
+ if (args.command === "list") {
662
+ const sources = listSources();
663
+ if (!sources.length) return {
664
+ command: "list",
665
+ ok: true,
666
+ text: J({
667
+ 数据源: [],
668
+ 提示: "当前没有可用的数据源。请用户到 设置 → 数据源 里添加连接(支持 postgres/mysql),配置只读账号。"
669
+ })
670
+ };
671
+ return {
672
+ command: "list",
673
+ ok: true,
674
+ text: J({
675
+ 数量: sources.length,
676
+ 数据源: sources.map(publicSource)
677
+ })
678
+ };
679
+ }
680
+ const src = args.source ? getSource(args.source) : null;
681
+ if (!src) return fail(`未找到数据源: ${args.source ?? "(未传 source)"}——先用 list 子命令看可用清单`);
682
+ if (args.command === "inspect") {
683
+ const driver = getDriver(src.kind);
684
+ if (!args.table) {
685
+ const tables = await driver.listTables(toConn(src));
686
+ return {
687
+ command: "inspect",
688
+ ok: true,
689
+ text: J({
690
+ 数据源: src.name,
691
+ 库名: src.database,
692
+ 表数量: tables.length,
693
+ 表清单: tables.map((t) => ({
694
+ 表名: t.name,
695
+ 注释: t.comment
696
+ })),
697
+ 下一步: "对要用到的每张表分别调用 inspect(source, table=<表名>) 看列结构与采样,再写 SQL。"
698
+ })
699
+ };
700
+ }
701
+ const info = await driver.describeTable(toConn(src), args.table, 5);
702
+ return {
703
+ command: "inspect",
704
+ ok: true,
705
+ text: J({
706
+ 数据源: src.name,
707
+ 表名: info.name,
708
+ 表注释: info.comment,
709
+ 列: info.columns.map((c) => ({
710
+ 列名: c.name,
711
+ 类型: c.dataType,
712
+ 注释: c.comment
713
+ })),
714
+ 采样5行: info.sampleRows
715
+ })
716
+ };
717
+ }
718
+ if (args.command === "query") {
719
+ if (!args.sql) return fail("query 需要 sql 参数(单条 SELECT/WITH)");
720
+ if ((args.mode === "materialize" ? "materialize" : "preview") === "preview") {
721
+ const r$1 = await previewQuery(src, args.sql);
722
+ return {
723
+ command: "query",
724
+ ok: true,
725
+ text: J({
726
+ 模式: "preview",
727
+ 数据源: src.name,
728
+ 列: r$1.columns,
729
+ 行数: r$1.rowCount,
730
+ 已截断: r$1.truncated,
731
+ 数据: r$1.rows,
732
+ 下一步: "与用户确认数据无误后,用 mode=materialize 落盘成 CSV,再用 limix 工具预测。"
733
+ })
734
+ };
735
+ }
736
+ const r = await materializeQuery(src, args.sql);
737
+ const name = args.fileName ? /\.csv$/i.test(args.fileName) ? args.fileName : `${args.fileName}.csv` : `ds_查询结果_${stamp()}.csv`;
738
+ const full = join(args.workdir && isAbsolute(args.workdir) ? args.workdir : resolve(process.cwd(), args.workdir ?? "."), name);
739
+ writeFileSync(full, r.csv, "utf8");
740
+ return {
741
+ command: "query",
742
+ ok: true,
743
+ text: J({
744
+ 模式: "materialize",
745
+ 数据源: src.name,
746
+ 文件: full,
747
+ 列: r.columns,
748
+ 行数: r.rowCount,
749
+ 已截断: r.truncated,
750
+ 下一步: "宽表已落文件区。用 limix 工具(forecast/classify/regress)对该文件预测,或先剖析数据。"
751
+ })
752
+ };
753
+ }
754
+ return fail(`未知子命令: ${args.command}`);
755
+ } catch (e) {
756
+ return fail(String(e?.message ?? e));
757
+ }
758
+ }
759
+ }));
760
+ }
761
+ function apply(ctx) {
762
+ ensureConfigFile();
763
+ ctx.provide("limixDatasource", { register: registerDatasourceTool });
764
+ const readBody = (req) => new Promise((res, rej) => {
765
+ let b = "";
766
+ req.on("data", (c) => {
767
+ b += c;
768
+ });
769
+ req.on("end", () => res(b));
770
+ req.on("error", rej);
771
+ });
772
+ ctx.effect(() => ctx.webServer.register({
773
+ kind: "exact",
774
+ path: "/limix-datasource/config",
775
+ handler: async (req, res) => {
776
+ try {
777
+ if (req.method === "GET") {
778
+ res.writeHead(200, { "content-type": "application/json" });
779
+ res.end(JSON.stringify({
780
+ sources: listSources(),
781
+ kinds: DATA_SOURCE_KINDS
782
+ }));
783
+ return;
784
+ }
785
+ if (req.method === "POST") {
786
+ const body = JSON.parse(await readBody(req));
787
+ const incoming = Array.isArray(body?.sources) ? body.sources : [];
788
+ for (const s of incoming) {
789
+ if (!DATA_SOURCE_KINDS.includes(s.kind)) throw new Error(`不支持的类型: ${s.kind}`);
790
+ if (!s.name || !s.host || !s.database || !s.username) throw new Error("name/host/database/username 必填");
791
+ const port = Number(s.port);
792
+ if (!(port >= 1 && port <= 65535)) throw new Error(`端口非法: ${s.port}`);
793
+ }
794
+ const keep = /* @__PURE__ */ new Set();
795
+ for (const s of incoming) keep.add(upsertSource(s).id);
796
+ for (const s of listSources()) if (!keep.has(s.id)) deleteSource(s.id);
797
+ res.writeHead(200, { "content-type": "application/json" });
798
+ res.end(JSON.stringify({
799
+ ok: true,
800
+ sources: listSources()
801
+ }));
802
+ return;
803
+ }
804
+ res.writeHead(405);
805
+ res.end();
806
+ } catch (e) {
807
+ res.writeHead(400, { "content-type": "application/json" });
808
+ res.end(JSON.stringify({
809
+ ok: false,
810
+ error: String(e?.message ?? e)
811
+ }));
812
+ }
813
+ }
814
+ }), "limix-datasource: config 路由");
815
+ ctx.effect(() => ctx.webServer.register({
816
+ kind: "exact",
817
+ path: "/limix-datasource/test",
818
+ handler: async (req, res) => {
819
+ try {
820
+ if (req.method !== "POST") {
821
+ res.writeHead(405);
822
+ res.end();
823
+ return;
824
+ }
825
+ const s = JSON.parse(await readBody(req))?.source;
826
+ const r = await getDriver(s.kind).testConnection(toConn(s));
827
+ res.writeHead(200, { "content-type": "application/json" });
828
+ res.end(JSON.stringify(r));
829
+ } catch (e) {
830
+ res.writeHead(200, { "content-type": "application/json" });
831
+ res.end(JSON.stringify({
832
+ ok: false,
833
+ error: String(e?.message ?? e)
834
+ }));
835
+ }
836
+ }
837
+ }), "limix-datasource: 测试连接路由");
838
+ }
839
+
840
+ //#endregion
841
+ export { apply, inject, registerDatasourceTool };