dsh-limix-data 0.0.0-stage → 0.3.11

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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * [INPUT]: 无运行时依赖(纯函数, client/server/test 均可 import)
3
+ * [OUTPUT]: 对外提供 guardSql(SQL 安全阀: 校验+注入 LIMIT) + GuardResult + PREVIEW_MAX_ROWS/MATERIALIZE_MAX_ROWS
4
+ * [POS]: lib/datasource 的防御第一闸(第二闸=驱动会话级 read-only[最后防线], 第三闸=客户只读账号) —
5
+ * 仅放行单条 SELECT/WITH, 拦截写操作/多语句/注释走私/方言歧义转义; 所有查询入口(工具/API)必须先过此阀
6
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
7
+ */
8
+
9
+ export const PREVIEW_MAX_ROWS = 100;
10
+ export const MATERIALIZE_MAX_ROWS = 500_000;
11
+
12
+ export type GuardResult =
13
+ | { ok: true; sql: string }
14
+ | { ok: false; reason: string };
15
+
16
+ // 词首关键字白名单 — 只允许查询形态
17
+ const ALLOWED_STARTS = /^(select|with)\b/i;
18
+
19
+ // 危险关键字黑名单(词边界) — 出现在任何位置一律拒绝。
20
+ // 宁可误杀也不放行; 但"仅语句起手位才危险、SELECT 里是常用函数/列名"的词不在此列:
21
+ // replace(字符串函数, REPLACE INTO 起手已被白名单拦)、comment(常见列名, COMMENT ON 起手已被拦)
22
+ const FORBIDDEN =
23
+ /\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;
24
+
25
+ // 敏感对象/函数 — information_schema 对 NL2SQL 有用不拦, 但拒绝系统管控函数与凭据表
26
+ const FORBIDDEN_OBJECTS = /\b(pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_sleep|mysql\.user|benchmark\s*\()/i;
27
+
28
+ type ScanOut = {
29
+ /** 字面量→''占位、注释→单个空格: 用于关键字/分号判定(防注释粘连骗过词边界) */
30
+ stripped: string;
31
+ /** 字面量原样保留、注释→单个空格: 用于 LIMIT 定位与最终下发(注释移除后语义不变) */
32
+ cleaned: string;
33
+ /** 引号区域内出现反斜杠 — PG(standard_conforming_strings)与 MySQL 转义语义相反, 存在解析歧义 */
34
+ backslashInLiteral: boolean;
35
+ };
36
+
37
+ /**
38
+ * 单趟扫描出两个视图。字符串按"非转义方言"(PG 默认)解析: 反斜杠是普通字符、'' 是转义引号;
39
+ * 一旦引号区内出现反斜杠即打歧义标记, 由调用方整句拒绝 — 因为同一 payload 在 MySQL(反斜杠转义)
40
+ * 与 PG 下会解析出不同的语句边界, 任何单一解析都可能被另一方言走私多语句(审查发现 F2)。
41
+ */
42
+ function scanSql(sql: string): ScanOut {
43
+ let stripped = "";
44
+ let cleaned = "";
45
+ let backslashInLiteral = false;
46
+ let i = 0;
47
+ const n = sql.length;
48
+ while (i < n) {
49
+ const ch = sql[i];
50
+ const two = sql.slice(i, i + 2);
51
+ if (two === "--" || ch === "#") {
52
+ // 行注释(-- 标准, # MySQL) → 换成单个空格保词边界(审查发现 F1: 换空串会让 DELETE/**/FROM 粘成 DELETEFROM 逃过 \b 判定)
53
+ while (i < n && sql[i] !== "\n") i++;
54
+ stripped += " ";
55
+ cleaned += " ";
56
+ continue;
57
+ }
58
+ if (two === "/*") {
59
+ const end = sql.indexOf("*/", i + 2);
60
+ i = end === -1 ? n : end + 2;
61
+ stripped += " ";
62
+ cleaned += " ";
63
+ continue;
64
+ }
65
+ if (ch === "'" || ch === '"' || ch === "`") {
66
+ const quote = ch;
67
+ let literal = quote;
68
+ i++;
69
+ while (i < n) {
70
+ if (sql[i] === "\\") backslashInLiteral = true; // 不当转义处理, 只标记歧义
71
+ if (sql[i] === quote) {
72
+ if (sql[i + 1] === quote) {
73
+ // '' 双写转义(SQL 标准) — 属于字面量内部
74
+ literal += quote + quote;
75
+ i += 2;
76
+ continue;
77
+ }
78
+ literal += quote;
79
+ i++;
80
+ break;
81
+ }
82
+ literal += sql[i];
83
+ i++;
84
+ }
85
+ stripped += quote + quote; // 判定视图: 字面量清空只留引号壳, 保词边界
86
+ cleaned += literal; // 下发视图: 字面量原样
87
+ continue;
88
+ }
89
+ stripped += ch;
90
+ cleaned += ch;
91
+ i++;
92
+ }
93
+ return { stripped, cleaned, backslashInLiteral };
94
+ }
95
+
96
+ /**
97
+ * SQL 安全阀。
98
+ * @param sql 原始 SQL(单条查询)
99
+ * @param maxRows 行数上限 — 语句无 LIMIT 时自动追加; 有 LIMIT 但超上限时收紧
100
+ */
101
+ export function guardSql(sql: string, maxRows: number): GuardResult {
102
+ const raw = (sql || "").trim().replace(/;+\s*$/, ""); // 去尾部分号
103
+ if (!raw) return { ok: false, reason: "SQL 为空" };
104
+ if (raw.length > 20_000) return { ok: false, reason: "SQL 过长(>20000 字符)" };
105
+
106
+ const { stripped, cleaned, backslashInLiteral } = scanSql(raw);
107
+
108
+ if (backslashInLiteral) {
109
+ return { ok: false, reason: "字符串字面量里含反斜杠, PG/MySQL 转义语义有歧义, 请改写(如改用 || 拼接或去掉反斜杠)" };
110
+ }
111
+
112
+ // 多语句: 剥掉字面量/注释后仍出现分号 = 拒绝
113
+ if (stripped.includes(";")) return { ok: false, reason: "只允许单条语句(检测到分号)" };
114
+
115
+ if (!ALLOWED_STARTS.test(stripped.trim())) {
116
+ return { ok: false, reason: "只允许 SELECT / WITH 查询语句" };
117
+ }
118
+ const forbidden = stripped.match(FORBIDDEN);
119
+ if (forbidden) return { ok: false, reason: `检测到禁用关键字: ${forbidden[1] || forbidden[0]}` };
120
+ const forbiddenObj = stripped.match(FORBIDDEN_OBJECTS);
121
+ if (forbiddenObj) return { ok: false, reason: `检测到禁用对象/函数: ${forbiddenObj[0]}` };
122
+
123
+ // LIMIT 处理 — 一律在 cleaned(注释已除, 字面量完整, 可直接执行)上定位与改写,
124
+ // 避免"stripped 里匹配到、raw 里因注释匹配不到"的假失败(审查发现 P2)
125
+ const base = cleaned.trim();
126
+ const cap = Math.max(1, Math.floor(maxRows));
127
+ const tail = /\blimit\s+(\d+)((\s+offset\s+\d+)?\s*)$/i.exec(base);
128
+ if (tail) {
129
+ const nLimit = parseInt(tail[1], 10);
130
+ if (nLimit > cap) {
131
+ const sqlOut = base.slice(0, tail.index) + `LIMIT ${cap}` + (tail[2] || "");
132
+ return { ok: true, sql: sqlOut };
133
+ }
134
+ return { ok: true, sql: base };
135
+ }
136
+ // 子查询内可能有 LIMIT 但外层没有 → 统一包一层再截断, 语义安全
137
+ return { ok: true, sql: `SELECT * FROM (${base}) AS _guarded LIMIT ${cap}` };
138
+ }
@@ -0,0 +1,46 @@
1
+
2
+ /**
3
+ * [INPUT]: 依赖 ./drivers 的 getDriver、./guard 的 guardSql、./store 的 toConn、lib/limix/csv 的 toCsv
4
+ * [OUTPUT]: 对外提供 previewQuery(≤100行 JSON) + materializeQuery(全量→CSV 文本, 列名已清洗)
5
+ * [POS]: lib/datasource 的取数出口 — DB 数据进入文件区(窄腰)前的最后一站;
6
+ * 落文件动作留给调用方(agent 工具走 saveAgentFile, API 走 saveSessionFile), 本层不碰存储
7
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
8
+ */
9
+ import { toCsv } from "./csv";
10
+ import { cellToText, sanitizeColumns } from "./cells";
11
+ import { getDriver } from "./drivers";
12
+ import { guardSql, MATERIALIZE_MAX_ROWS, PREVIEW_MAX_ROWS } from "./guard";
13
+ import { toConn, type StoredSource } from "./store";
14
+ import type { QueryResult } from "./types";
15
+
16
+ // 纯逻辑在 ./cells(无 server-only, 可单测); 此处再导出保持既有引用路径不变
17
+ export { cellToText, sanitizeColumns } from "./cells";
18
+
19
+ const PREVIEW_TIMEOUT_MS = 20_000;
20
+ const MATERIALIZE_TIMEOUT_MS = 50_000; // 贴 serverless maxDuration 之下
21
+
22
+ export async function previewQuery(source: StoredSource, sql: string): Promise<QueryResult> {
23
+ const g = guardSql(sql, PREVIEW_MAX_ROWS);
24
+ if (!g.ok) throw new Error(`SQL 未通过安全阀: ${g.reason}`);
25
+ const driver = getDriver(source.kind);
26
+ const res = await driver.runQuery(toConn(source), g.sql, { maxRows: PREVIEW_MAX_ROWS, timeoutMs: PREVIEW_TIMEOUT_MS });
27
+ // 单元格与 materialize 走同一套转换 —— 否则 Date 被 JSON 序列化成 UTC(16:00Z),
28
+ // 与物化 CSV 里的墙钟时间(00:00)对不上, agent 拿预览核对时会误判需要"时区纠偏"
29
+ return { ...res, columns: sanitizeColumns(res.columns), rows: res.rows.map((r) => r.map(cellToText)) };
30
+ }
31
+
32
+ export async function materializeQuery(
33
+ source: StoredSource,
34
+ sql: string,
35
+ ): Promise<{ csv: string; columns: string[]; rowCount: number; truncated: boolean }> {
36
+ const g = guardSql(sql, MATERIALIZE_MAX_ROWS);
37
+ if (!g.ok) throw new Error(`SQL 未通过安全阀: ${g.reason}`);
38
+ const driver = getDriver(source.kind);
39
+ const res = await driver.runQuery(toConn(source), g.sql, {
40
+ maxRows: MATERIALIZE_MAX_ROWS,
41
+ timeoutMs: MATERIALIZE_TIMEOUT_MS,
42
+ });
43
+ const columns = sanitizeColumns(res.columns);
44
+ const rows = res.rows.map((r) => r.map(cellToText));
45
+ return { csv: toCsv(columns, rows), columns, rowCount: res.rowCount, truncated: res.truncated };
46
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * [INPUT]: 依赖 node:fs/path/crypto 与 DSH_HOME 环境变量;类型对齐 ./types
3
+ * [OUTPUT]: 对外提供 readConfig/writeConfig/listSources/getSource/toConn —— JSON 配置文件版数据源清单
4
+ * [POS]: core 的存储层(DSH 形态重写:原版是 Postgres 表 + AES-GCM 密文,此处为
5
+ * $DSH_HOME/limix-datasource.json 明文配置 + 0600 权限——本地单用户演示环境的诚实取舍,
6
+ * 对外网生产库应配只读账号(三闸模型第三闸)。保持 toConn 出参形状不变,drivers/materialize 零改动)
7
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
8
+ */
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
+ import { dirname, join } from "node:path";
12
+ import type { DataSourceConn, DataSourceKind } from "./types";
13
+
14
+ /** 配置文件里的一条数据源(密码明文;文件 0600) */
15
+ export type StoredSource = {
16
+ id: string;
17
+ name: string;
18
+ kind: DataSourceKind;
19
+ host: string;
20
+ port: number;
21
+ database: string;
22
+ schema?: string | null;
23
+ username: string;
24
+ password: string;
25
+ isDemo?: boolean;
26
+ };
27
+
28
+ export type DatasourceConfig = { sources: StoredSource[] };
29
+
30
+ export function configPath(): string {
31
+ const home = process.env.DSH_HOME || join(process.env.HOME || "~", ".dsh");
32
+ return join(home, "limix-datasource.json");
33
+ }
34
+
35
+ export function readConfig(): DatasourceConfig {
36
+ try {
37
+ const raw = readFileSync(configPath(), "utf8");
38
+ const v = JSON.parse(raw);
39
+ return { sources: Array.isArray(v?.sources) ? v.sources : [] };
40
+ } catch {
41
+ return { sources: [] };
42
+ }
43
+ }
44
+
45
+ /** 原子写(tmp+rename)+ 0600 */
46
+ export function writeConfig(cfg: DatasourceConfig): void {
47
+ const p = configPath();
48
+ mkdirSync(dirname(p), { recursive: true });
49
+ const tmp = p + ".tmp";
50
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
51
+ chmodSync(tmp, 0o600);
52
+ renameSync(tmp, p);
53
+ }
54
+
55
+ export function listSources(): StoredSource[] {
56
+ return readConfig().sources;
57
+ }
58
+
59
+ export function getSource(id: string): StoredSource | null {
60
+ return readConfig().sources.find((s) => s.id === id) ?? null;
61
+ }
62
+
63
+ export function upsertSource(s: Omit<StoredSource, "id"> & { id?: string }): StoredSource {
64
+ const cfg = readConfig();
65
+ const row: StoredSource = { ...s, id: s.id || randomUUID() };
66
+ const i = cfg.sources.findIndex((x) => x.id === row.id);
67
+ if (i >= 0) cfg.sources[i] = row;
68
+ else cfg.sources.push(row);
69
+ writeConfig(cfg);
70
+ return row;
71
+ }
72
+
73
+ export function deleteSource(id: string): boolean {
74
+ const cfg = readConfig();
75
+ const n = cfg.sources.length;
76
+ cfg.sources = cfg.sources.filter((x) => x.id !== id);
77
+ if (cfg.sources.length === n) return false;
78
+ writeConfig(cfg);
79
+ return true;
80
+ }
81
+
82
+ /** 行 → 驱动连接参数(形状与原版一致,drivers/materialize 零改动消费) */
83
+ export function toConn(row: StoredSource): DataSourceConn {
84
+ return {
85
+ kind: row.kind,
86
+ host: row.host,
87
+ port: row.port,
88
+ database: row.database,
89
+ schema: row.schema ?? null,
90
+ username: row.username,
91
+ password: row.password,
92
+ };
93
+ }
94
+
95
+ export function ensureConfigFile(): void {
96
+ if (!existsSync(configPath())) writeConfig({ sources: [] });
97
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * [INPUT]: 无运行时依赖(纯类型)
3
+ * [OUTPUT]: 对外提供 DataSourceKind/DataSourceRow/DataSourceConn/TableInfo/ColumnInfo/QueryResult/DatasourceDriver 类型
4
+ * [POS]: lib/datasource 的类型真相源, 被 crypto/guard/drivers/store/materialize 与 mastra 工具消费
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
6
+ */
7
+
8
+ /** P0 支持 postgres(兼容金仓)/mysql; P1 追加 dm/oracle/mssql — 枚举先留位 */
9
+ export type DataSourceKind = "postgres" | "mysql";
10
+
11
+ export const DATA_SOURCE_KINDS: DataSourceKind[] = ["postgres", "mysql"];
12
+
13
+ /** data_sources 表一行(凭据只存密文, 明文永不出 lib/datasource) */
14
+ export type DataSourceRow = {
15
+ id: string;
16
+ ownerId: string | null;
17
+ name: string;
18
+ kind: DataSourceKind;
19
+ host: string;
20
+ port: number;
21
+ database: string;
22
+ /** postgres 专用: 限定 schema(演示库用 demo_source), 空=默认 public */
23
+ schema: string | null;
24
+ username: string;
25
+ secretEncrypted: string;
26
+ isDemo: boolean;
27
+ createdAt: string;
28
+ };
29
+
30
+ /** 驱动层连接参数(解密后, 仅存在于单次调用栈上) */
31
+ export type DataSourceConn = {
32
+ kind: DataSourceKind;
33
+ host: string;
34
+ port: number;
35
+ database: string;
36
+ schema?: string | null;
37
+ username: string;
38
+ password: string;
39
+ };
40
+
41
+ export type ColumnInfo = {
42
+ name: string;
43
+ dataType: string;
44
+ comment: string | null;
45
+ };
46
+
47
+ export type TableInfo = {
48
+ name: string;
49
+ comment: string | null;
50
+ columns: ColumnInfo[];
51
+ sampleRows: Record<string, unknown>[];
52
+ };
53
+
54
+ export type QueryResult = {
55
+ columns: string[];
56
+ rows: unknown[][];
57
+ rowCount: number;
58
+ /** 行数是否被 maxRows 截断 */
59
+ truncated: boolean;
60
+ };
61
+
62
+ /** 统一驱动接口 — 每种数据库实现一份; 全部只读语义(会话级 read-only + guard 双闸) */
63
+ export type DatasourceDriver = {
64
+ testConnection(conn: DataSourceConn): Promise<{ ok: boolean; version?: string; error?: string }>;
65
+ listTables(conn: DataSourceConn): Promise<{ name: string; comment: string | null }[]>;
66
+ describeTable(conn: DataSourceConn, table: string, sampleN?: number): Promise<TableInfo>;
67
+ runQuery(conn: DataSourceConn, sql: string, opts: { maxRows: number; timeoutMs: number }): Promise<QueryResult>;
68
+ };
@@ -0,0 +1,11 @@
1
+ # packages/data/src/host/
2
+
3
+ > L2 | 父级: ../CLAUDE.md
4
+
5
+ define-tool.ts - 将 DSH 参数字段表包成 object schema;保留执行与渲染回调。
6
+
7
+ parameters 是字段表;output.schema 已含根类型。
8
+ 两者进入递归转换前采用不同入口。
9
+ 字段上的 required 布尔值只进入父节点的 required 数组。
10
+
11
+ [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
@@ -0,0 +1,38 @@
1
+ /**
2
+ * [INPUT]: 接收 DSH 参数字段表与输出 schema DSL;不依赖宿主内部包。
3
+ * [OUTPUT]: defineTool 返回工具注册定义,参数根节点固定为 object。
4
+ * [POS]: data 的宿主适配层;将字段 required 标记提升到父节点数组。
5
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
6
+ */
7
+ type Spec = Record<string, any>
8
+
9
+ function toJsonSchema(spec: Spec): any {
10
+ if (spec && spec.type === 'object' && spec.properties) {
11
+ return {
12
+ type: 'object',
13
+ additionalProperties: spec.additionalProperties ?? false,
14
+ required: spec.required ?? Object.keys(spec.properties).filter((k) => spec.properties[k]?.required),
15
+ properties: Object.fromEntries(Object.entries(spec.properties).map(([k, v]) => [k, toJsonSchema(v as Spec)])),
16
+ }
17
+ }
18
+ const out: Spec = {}
19
+ if (spec?.type) out.type = spec.type
20
+ if (spec?.description) out.description = spec.description
21
+ if (spec?.enum) out.enum = spec.enum
22
+ if (spec?.items) out.items = toJsonSchema(spec.items)
23
+ if (spec?.properties) out.properties = toJsonSchema({ type: 'object', properties: spec.properties })
24
+ if (spec?.additionalProperties !== undefined) out.additionalProperties = spec.additionalProperties
25
+ return out
26
+ }
27
+
28
+ export function defineTool(spec: any) {
29
+ return {
30
+ name: spec.name,
31
+ description: spec.description,
32
+ // parameters 是字段表,先补 object 根节点再递归转换。
33
+ parameters: toJsonSchema({ type: 'object', properties: spec.parameters ?? {} }),
34
+ output: { schema: toJsonSchema(spec.output?.schema ?? {}), render: spec.output.render },
35
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
36
+ execute: spec.execute,
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * [INPUT]: 依赖全局 webServer;注册工厂接收 preset 的 tools 上下文;核心逻辑在 ./core/*
3
+ * (guard 安全阀 / drivers 双驱动 / materialize 取数出口——自 Next.js 版逐字迁移)
4
+ * [OUTPUT]: ① 发布 limixDatasource.register;只在调用方上下文注册 datasource 工具
5
+ * ② 同源路由 /limix-datasource/config (GET/POST) 与 /limix-datasource/test (POST)——设置页后端
6
+ * [POS]: LimiX Agent 的数据源接入插件 Node 半。安全三闸:guard 语句白名单 →
7
+ * 驱动会话级只读+语句超时 → 用户侧只读账号约定。materialize 落 CSV 进会话工作目录,
8
+ * 与 limix 工具 / better-sidebar 文件区 / 时序图表通道天然衔接
9
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
10
+ */
11
+ import { writeFileSync } from "node:fs";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
+ import { defineTool } from "./host/define-tool.ts";
14
+ import { getDriver } from "./core/drivers.ts";
15
+ import { materializeQuery, previewQuery } from "./core/materialize.ts";
16
+ import {
17
+ deleteSource, ensureConfigFile, getSource, listSources, toConn, upsertSource,
18
+ type StoredSource,
19
+ } from "./core/store.ts";
20
+ import { DATA_SOURCE_KINDS } from "./core/types.ts";
21
+
22
+ export const inject = ["webServer"];
23
+
24
+ /** 无 Date.now 顾虑(Node 半普通运行时);文件名时间戳与原版口径一致 */
25
+ function stamp(): string {
26
+ return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
27
+ }
28
+
29
+ /** 面向 agent 的源身份信息(凭据永不出层——原版纪律) */
30
+ function publicSource(s: StoredSource) {
31
+ return { id: s.id, 名称: s.name, 类型: s.kind, 库名: s.database, schema: s.schema ?? null, 演示源: !!s.isDemo };
32
+ }
33
+
34
+ const DESCRIPTION = `连接客户数据库取数(数据的第二个来源,另一来源是上传/样本 CSV)。全链路只读。
35
+ 子命令:list(列出已配置数据源);inspect --source <id> [--table 表名](不传 table 回表清单,传了回列结构+中文注释+采样5行);query --source <id> --sql <单条SELECT/WITH> [--mode preview|materialize] [--file-name 名.csv] [--workdir 目录]。
36
+ 工作流(铁律):list → 对要用的每张表 inspect 看结构与注释 → **生成的 SQL 必须先在回复里用代码块展示给用户并用一句中文解释取的是什么(可见/可确认/可纠正),不要偷偷执行** → query mode=preview 自查(≤100行)→ 确认无误再 mode=materialize 落 CSV 进文件区 → 照常用 limix 工具预测。
37
+ **多表 JOIN 拼成一张宽表是本工具的核心用法**(如电价+气象+预测按省份和时间拼宽表)。只允许单条只读查询,写操作会被安全阀拒绝。materialize 上限 50 万行。`;
38
+
39
+ export function registerDatasourceTool(ctx: any): void {
40
+ // ── datasource 工具 ──────────────────────────────────────────────────────
41
+ ctx.tools.register(defineTool({
42
+ name: "datasource",
43
+ description: DESCRIPTION,
44
+ parameters: {
45
+ command: { type: "string", required: true, enum: ["list", "inspect", "query"], description: "子命令" },
46
+ source: { type: "string", description: "数据源 id(来自 list)" },
47
+ table: { type: "string", description: "inspect 用:表名;不传返回表清单" },
48
+ sql: { type: "string", description: "query 用:单条只读 SQL(SELECT/WITH),执行前必须已在回复中展示给用户" },
49
+ mode: { type: "string", enum: ["preview", "materialize"], description: "query 用:preview 自查(默认)/ materialize 落盘 CSV" },
50
+ fileName: { type: "string", description: "materialize 落盘文件名(.csv 可省略),不传自动命名" },
51
+ workdir: { type: "string", description: "materialize 落盘目录(默认:调用方会话的工作目录)" },
52
+ },
53
+ output: {
54
+ schema: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: {
58
+ command: { type: "string", required: true },
59
+ ok: { type: "boolean", required: true },
60
+ text: { type: "string", required: true },
61
+ },
62
+ },
63
+ render: (_args: unknown, value: { text: string }) => [{ type: "text", text: value.text }],
64
+ },
65
+ async execute(args: {
66
+ command: string; source?: string; table?: string; sql?: string;
67
+ mode?: string; fileName?: string; workdir?: string;
68
+ }) {
69
+ const J = (v: unknown) => JSON.stringify(v, null, 2);
70
+ const fail = (reason: string) => ({ command: args.command, ok: false, text: J({ 错误: reason }) });
71
+ try {
72
+ if (args.command === "list") {
73
+ const sources = listSources();
74
+ if (!sources.length) {
75
+ return { command: "list", ok: true, text: J({ 数据源: [], 提示: "当前没有可用的数据源。请用户到 设置 → 数据源 里添加连接(支持 postgres/mysql),配置只读账号。" }) };
76
+ }
77
+ return { command: "list", ok: true, text: J({ 数量: sources.length, 数据源: sources.map(publicSource) }) };
78
+ }
79
+
80
+ const src = args.source ? getSource(args.source) : null;
81
+ if (!src) return fail(`未找到数据源: ${args.source ?? "(未传 source)"}——先用 list 子命令看可用清单`);
82
+
83
+ if (args.command === "inspect") {
84
+ const driver = getDriver(src.kind);
85
+ if (!args.table) {
86
+ const tables = await driver.listTables(toConn(src));
87
+ return { command: "inspect", ok: true, text: J({
88
+ 数据源: src.name, 库名: src.database, 表数量: tables.length,
89
+ 表清单: tables.map((t) => ({ 表名: t.name, 注释: t.comment })),
90
+ 下一步: "对要用到的每张表分别调用 inspect(source, table=<表名>) 看列结构与采样,再写 SQL。",
91
+ }) };
92
+ }
93
+ const info = await driver.describeTable(toConn(src), args.table, 5);
94
+ return { command: "inspect", ok: true, text: J({
95
+ 数据源: src.name, 表名: info.name, 表注释: info.comment,
96
+ 列: info.columns.map((c) => ({ 列名: c.name, 类型: c.dataType, 注释: c.comment })),
97
+ 采样5行: info.sampleRows,
98
+ }) };
99
+ }
100
+
101
+ if (args.command === "query") {
102
+ if (!args.sql) return fail("query 需要 sql 参数(单条 SELECT/WITH)");
103
+ const mode = args.mode === "materialize" ? "materialize" : "preview";
104
+ if (mode === "preview") {
105
+ const r = await previewQuery(src, args.sql);
106
+ return { command: "query", ok: true, text: J({
107
+ 模式: "preview", 数据源: src.name, 列: r.columns, 行数: r.rowCount, 已截断: r.truncated,
108
+ 数据: r.rows,
109
+ 下一步: "与用户确认数据无误后,用 mode=materialize 落盘成 CSV,再用 limix 工具预测。",
110
+ }) };
111
+ }
112
+ const r = await materializeQuery(src, args.sql);
113
+ const name = args.fileName
114
+ ? (/\.csv$/i.test(args.fileName) ? args.fileName : `${args.fileName}.csv`)
115
+ : `ds_查询结果_${stamp()}.csv`;
116
+ const dir = args.workdir && isAbsolute(args.workdir) ? args.workdir : resolve(process.cwd(), args.workdir ?? ".");
117
+ const full = join(dir, name);
118
+ writeFileSync(full, r.csv, "utf8");
119
+ return { command: "query", ok: true, text: J({
120
+ 模式: "materialize", 数据源: src.name, 文件: full, 列: r.columns, 行数: r.rowCount, 已截断: r.truncated,
121
+ 下一步: "宽表已落文件区。用 limix 工具(forecast/classify/regress)对该文件预测,或先剖析数据。",
122
+ }) };
123
+ }
124
+ return fail(`未知子命令: ${args.command}`);
125
+ } catch (e: any) {
126
+ return fail(String(e?.message ?? e));
127
+ }
128
+ },
129
+ }));
130
+
131
+ }
132
+
133
+ export function apply(ctx: any): void {
134
+ ensureConfigFile();
135
+ // 全局只发布工厂;由 preset 的工具插件传入其会话上下文。
136
+ ctx.provide("limixDatasource", { register: registerDatasourceTool });
137
+
138
+ // ── 设置页后端:同源只读/写路由(与 /limix-ui/csv 同款手法) ─────────────
139
+ const readBody = (req: any): Promise<string> => new Promise((res, rej) => {
140
+ let b = ""; req.on("data", (c: any) => { b += c }); req.on("end", () => res(b)); req.on("error", rej);
141
+ });
142
+
143
+ ctx.effect(() => ctx.webServer.register({
144
+ kind: "exact", path: "/limix-datasource/config",
145
+ handler: async (req: any, res: any) => {
146
+ try {
147
+ if (req.method === "GET") {
148
+ res.writeHead(200, { "content-type": "application/json" });
149
+ res.end(JSON.stringify({ sources: listSources(), kinds: DATA_SOURCE_KINDS }));
150
+ return;
151
+ }
152
+ if (req.method === "POST") {
153
+ const body = JSON.parse(await readBody(req));
154
+ // 全量替换语义:settings UI 每次提交完整清单;逐条校验后 upsert,缺席的删除
155
+ const incoming: StoredSource[] = Array.isArray(body?.sources) ? body.sources : [];
156
+ for (const s of incoming) {
157
+ if (!DATA_SOURCE_KINDS.includes(s.kind)) throw new Error(`不支持的类型: ${s.kind}`);
158
+ if (!s.name || !s.host || !s.database || !s.username) throw new Error("name/host/database/username 必填");
159
+ const port = Number(s.port);
160
+ if (!(port >= 1 && port <= 65535)) throw new Error(`端口非法: ${s.port}`);
161
+ }
162
+ const keep = new Set<string>();
163
+ for (const s of incoming) keep.add(upsertSource(s).id);
164
+ for (const s of listSources()) if (!keep.has(s.id)) deleteSource(s.id);
165
+ res.writeHead(200, { "content-type": "application/json" });
166
+ res.end(JSON.stringify({ ok: true, sources: listSources() }));
167
+ return;
168
+ }
169
+ res.writeHead(405); res.end();
170
+ } catch (e: any) {
171
+ res.writeHead(400, { "content-type": "application/json" });
172
+ res.end(JSON.stringify({ ok: false, error: String(e?.message ?? e) }));
173
+ }
174
+ },
175
+ }), "limix-datasource: config 路由");
176
+
177
+ ctx.effect(() => ctx.webServer.register({
178
+ kind: "exact", path: "/limix-datasource/test",
179
+ handler: async (req: any, res: any) => {
180
+ try {
181
+ if (req.method !== "POST") { res.writeHead(405); res.end(); return }
182
+ const body = JSON.parse(await readBody(req));
183
+ const s: StoredSource = body?.source;
184
+ const r = await getDriver(s.kind).testConnection(toConn(s));
185
+ res.writeHead(200, { "content-type": "application/json" });
186
+ res.end(JSON.stringify(r));
187
+ } catch (e: any) {
188
+ res.writeHead(200, { "content-type": "application/json" });
189
+ res.end(JSON.stringify({ ok: false, error: String(e?.message ?? e) }));
190
+ }
191
+ },
192
+ }), "limix-datasource: 测试连接路由");
193
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * [INPUT]: 依赖 tsdown;构建契约照抄 packages/limix-ui/tsdown.config.ts(本仓开发法则第 3 条)
3
+ * [OUTPUT]: lib/index.js(Node 半 ESM;pg/mysql2 external 留给运行时动态 import)
4
+ * + lib/client.js(浏览器半 CJS 闭包工厂)
5
+ * [POS]: limix-datasource 的构建入口
6
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
7
+ */
8
+ import { defineConfig } from 'tsdown'
9
+
10
+ const ID = 'dsh-limix-data'
11
+
12
+ const CLIENT_EXTERNALS = [
13
+ 'react',
14
+ 'react/jsx-runtime',
15
+ 'react-dom',
16
+ 'react-dom/client',
17
+ '@deepseek-ai/cordis',
18
+ '@deepseek-ai/dsh-client-ui-slots',
19
+ '@deepseek-ai/dsh-client-web-react',
20
+ '@deepseek-ai/dsh-client-ui-primitives',
21
+ '@deepseek-ai/dsh-client-ui-attachment',
22
+ '@deepseek-ai/dsh-client-schema-form',
23
+ '@deepseek-ai/dsh-client-runtime/client',
24
+ ]
25
+
26
+ export default defineConfig([
27
+ {
28
+ // ── Node 半:工具 + 路由;数据库驱动运行时动态 import,不打包 ──
29
+ entry: { index: 'src/index.ts' },
30
+ outDir: 'lib',
31
+ format: ['esm'],
32
+ platform: 'node',
33
+ dts: false,
34
+ clean: false,
35
+ fixedExtension: false,
36
+ external: ['pg', 'mysql2', 'mysql2/promise'],
37
+ },
38
+ {
39
+ // ── 浏览器半:CJS 闭包工厂(宿主 ModuleLoader 契约)──
40
+ entry: { client: 'src/client/index.tsx' },
41
+ outDir: 'lib',
42
+ format: ['cjs'],
43
+ platform: 'browser',
44
+ dts: false,
45
+ clean: false,
46
+ sourcemap: false,
47
+ fixedExtension: false,
48
+ external: CLIENT_EXTERNALS,
49
+ noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
50
+ define: {
51
+ 'process.env.NODE_ENV': JSON.stringify('production'),
52
+ 'import.meta.env.MODE': JSON.stringify('production'),
53
+ 'import.meta.env': JSON.stringify({ MODE: 'production' }),
54
+ },
55
+ outputOptions: {
56
+ entryFileNames: 'client.js',
57
+ banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: (require) => {`,
58
+ footer: 'return module.exports; } });',
59
+ intro: 'var module = { exports: {} }; var exports = module.exports;',
60
+ },
61
+ },
62
+ ])
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.