@nsyan/db 1.0.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.
@@ -0,0 +1,149 @@
1
+ // scan/parsers.ts —— 按文件类型解析:.env / .properties / yml+yaml(Spring 结构优先)/ docker-compose.yml / 通用 URL 正则
2
+
3
+ export function parseEnv(text: string): Record<string, string> {
4
+ const out: Record<string, string> = {};
5
+ for (const line of text.split("\n")) {
6
+ const t = line.trim();
7
+ if (!t || t.startsWith("#")) continue;
8
+ const eq = t.indexOf("=");
9
+ if (eq <= 0) continue;
10
+ const key = t.slice(0, eq).trim();
11
+ let val = t.slice(eq + 1).trim();
12
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
13
+ val = val.slice(1, -1);
14
+ }
15
+ out[key] = val;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ export function parseProperties(text: string): Record<string, string> {
21
+ const out: Record<string, string> = {};
22
+ for (const line of text.split("\n")) {
23
+ const t = line.trim();
24
+ if (!t || t.startsWith("#") || t.startsWith("!")) continue;
25
+ const m = /^([^=:!]+)[=:](.*)$/.exec(t);
26
+ if (!m) continue;
27
+ out[m[1].trim()] = m[2].trim();
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * 极简 YAML 子集解析:嵌套 map + 标量(够 Spring 配置用)。
34
+ * 不支持多行标量/锚点/复杂流式结构——遇到即跳过该行。
35
+ */
36
+ export function parseSimpleYaml(text: string): Record<string, unknown> {
37
+ interface YamlLine { indent: number; key: string; value: string | null; }
38
+ const lines: YamlLine[] = [];
39
+ for (const raw of text.split("\n")) {
40
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
41
+ if (raw.trim().startsWith("- ")) continue; // 列表项:Spring 配置子树用不到,交给 compose 解析器
42
+ const indent = raw.length - raw.trimStart().length;
43
+ const content = raw.trim();
44
+ const colon = content.indexOf(":");
45
+ if (colon < 0) continue; // 多行标量等不支持,跳过
46
+ const key = content.slice(0, colon).trim().replace(/^["']|["']$/g, "");
47
+ let value: string | null = content.slice(colon + 1).trim();
48
+ if (value === "" || value === "|" || value === ">") value = null;
49
+ else value = value.replace(/^["']|["']$/g, "");
50
+ lines.push({ indent, key, value });
51
+ }
52
+ const root: Record<string, unknown> = {};
53
+ const stack: { indent: number; obj: Record<string, unknown> }[] = [{ indent: -1, obj: root }];
54
+ for (const ln of lines) {
55
+ while (stack.length > 1 && ln.indent <= stack[stack.length - 1].indent) stack.pop();
56
+ const parent = stack[stack.length - 1].obj;
57
+ if (ln.value !== null) {
58
+ parent[ln.key] = ln.value;
59
+ } else {
60
+ const child: Record<string, unknown> = {};
61
+ parent[ln.key] = child;
62
+ stack.push({ indent: ln.indent, obj: child });
63
+ }
64
+ }
65
+ return root;
66
+ }
67
+
68
+ // ── docker-compose.yml ────────────────────────────
69
+
70
+ export interface ComposeService {
71
+ name: string;
72
+ image?: string;
73
+ env: Record<string, string>;
74
+ ports: string[];
75
+ }
76
+
77
+ export function parseCompose(text: string): ComposeService[] {
78
+ const services: ComposeService[] = [];
79
+ let cur: ComposeService | null = null;
80
+ let block: { kind: "environment" | "ports"; indent: number } | null = null;
81
+ let servicesIndent = -1;
82
+
83
+ for (const raw of text.split("\n")) {
84
+ const t = raw.trim();
85
+ if (!t || t.startsWith("#")) continue;
86
+ const indent = raw.length - raw.trimStart().length;
87
+
88
+ if (t.startsWith("- ")) {
89
+ if (!cur || !block || indent <= block.indent) continue;
90
+ const item = t.slice(2).trim().replace(/^["']|["']$/g, "");
91
+ if (block.kind === "ports") {
92
+ cur.ports.push(item);
93
+ } else {
94
+ const eq = item.indexOf("=");
95
+ if (eq > 0) cur.env[item.slice(0, eq).trim()] = item.slice(eq + 1).trim();
96
+ }
97
+ continue;
98
+ }
99
+
100
+ if (block && indent <= block.indent) block = null;
101
+
102
+ const colon = t.indexOf(":");
103
+ if (colon < 0) continue;
104
+ const key = t.slice(0, colon).trim().replace(/^["']|["']$/g, "");
105
+ const value = t.slice(colon + 1).trim();
106
+
107
+ if (key === "services" && value === "") {
108
+ servicesIndent = indent;
109
+ cur = null;
110
+ block = null;
111
+ continue;
112
+ }
113
+ // service 名行:services 的直接子级(缩进 = servicesIndent + 2)且无内联值
114
+ if (servicesIndent >= 0 && indent === servicesIndent + 2 && value === "") {
115
+ cur = { name: key, image: undefined, env: {}, ports: [] };
116
+ services.push(cur);
117
+ block = null;
118
+ continue;
119
+ }
120
+ if (cur) {
121
+ if (key === "image" && value) {
122
+ cur.image = value.replace(/^["']|["']$/g, "");
123
+ } else if (key === "environment") {
124
+ if (value === "") block = { kind: "environment", indent };
125
+ else {
126
+ const eq = value.indexOf("=");
127
+ if (eq > 0) cur.env[value.slice(0, eq).trim()] = value.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
128
+ }
129
+ } else if (key === "ports") {
130
+ block = value === "" ? { kind: "ports", indent } : null;
131
+ } else if (block?.kind === "environment" && indent > block.indent && value !== "") {
132
+ cur.env[key] = value.replace(/^["']|["']$/g, "");
133
+ }
134
+ }
135
+ }
136
+ return services;
137
+ }
138
+
139
+ // ── 通用 URL 正则(全文件扫描兜底)──────────────────
140
+
141
+ const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
142
+
143
+ export function extractUrls(text: string): string[] {
144
+ const out = new Set<string>();
145
+ for (const m of text.matchAll(URL_RE)) {
146
+ out.add(m[0].replace(/[),.;\]]+$/, ""));
147
+ }
148
+ return [...out];
149
+ }
@@ -0,0 +1,24 @@
1
+ // scan/placeholders.ts —— Spring 占位符 ${KEY:default} 解析:default → 同目录 .env → 进程 env → 标「待补」
2
+ const PH_RE = /^\$\{([^:}]+)(?::((?:.|\n)*))?\}$/;
3
+
4
+ export interface PlaceholderResult {
5
+ resolved: boolean;
6
+ value?: string;
7
+ }
8
+
9
+ export function resolvePlaceholder(value: string, localEnv?: Record<string, string>): PlaceholderResult {
10
+ const m = PH_RE.exec(value.trim());
11
+ if (!m) return { resolved: true, value };
12
+ const key = m[1];
13
+ const hasDefault = m[2] !== undefined;
14
+
15
+ // 有非空 default → 直接用
16
+ if (hasDefault && m[2] !== "") return { resolved: true, value: m[2] };
17
+
18
+ // 无 default 或 default 为空串:查同目录 .env → 进程 env
19
+ const fromEnv = localEnv?.[key] ?? process.env[key];
20
+ if (fromEnv !== undefined && fromEnv !== "") return { resolved: true, value: fromEnv };
21
+
22
+ // 缺省:标待补(空串 default 同样视为待补,如 ${REDIS_PASSWORD:})
23
+ return { resolved: false };
24
+ }
@@ -0,0 +1,25 @@
1
+ // scan/scoring.ts —— 置信度:docker-compose/.env/application.yml 高权重;*test*/*example*/*.md/logs 降权或排除
2
+ // 注:排除/降权只看相对扫描根的路径——固件本身在 test/ 下不受影响。
3
+
4
+ const SOURCE_WEIGHT: Array<[RegExp, number]> = [
5
+ [/application[^/]*\.ya?ml$/i, 0.9],
6
+ [/docker-compose[^/]*\.ya?ml$/i, 0.85],
7
+ [/^docker-compose[^/]*\.ya?ml$/i, 0.85],
8
+ [/\.env(\.[^/]*)?$/i, 0.8],
9
+ [/application[^/]*\.properties$/i, 0.7],
10
+ [/\.properties$/i, 0.6],
11
+ ];
12
+
13
+ const FALLBACK_WEIGHT = 0.5; // 通用 URL 正则扫出的候选
14
+
15
+ /** 命中测试/示例/文档/日志路径的文件整体排除,不产候选。 */
16
+ export function isExcluded(relPath: string): boolean {
17
+ return /(^|\/)(test|tests|spec|specs|example|examples|docs?)(\/|$)/i.test(relPath)
18
+ || /\.md$/i.test(relPath)
19
+ || /\.(log|bak)$/i.test(relPath);
20
+ }
21
+
22
+ export function sourceWeight(relPath: string): number {
23
+ for (const [re, w] of SOURCE_WEIGHT) if (re.test(relPath)) return w;
24
+ return FALLBACK_WEIGHT;
25
+ }
@@ -0,0 +1,73 @@
1
+ // scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch 键映射 + profile 分组
2
+ import type { DbTypeId } from "../types.js";
3
+
4
+ export type SpringGroup = "datasource" | "redis" | "es";
5
+
6
+ export interface RawDbConfig {
7
+ group: SpringGroup;
8
+ dialectId?: DbTypeId; // 有 URL 时由 candidates 层经 registry 判定
9
+ url?: string;
10
+ host?: string;
11
+ port?: string;
12
+ username?: string;
13
+ password?: string;
14
+ database?: string;
15
+ dbIndex?: string;
16
+ profile: string;
17
+ file: string;
18
+ weight: number; // scoring 来源权重
19
+ }
20
+
21
+ export function profileOf(filename: string): string {
22
+ const m = /application-([A-Za-z0-9_-]+)\.(?:yml|yaml|properties)$/i.exec(filename);
23
+ return m ? m[1] : "default";
24
+ }
25
+
26
+ type Field = "url" | "username" | "password" | "host" | "port" | "database" | "dbIndex";
27
+
28
+ const SPRING_KEYS: Array<[string, Field]> = [
29
+ ["spring.datasource.url", "url"],
30
+ ["spring.datasource.jdbc-url", "url"],
31
+ ["spring.datasource.username", "username"],
32
+ ["spring.datasource.password", "password"],
33
+ ["spring.data.redis.host", "host"],
34
+ ["spring.data.redis.port", "port"],
35
+ ["spring.data.redis.password", "password"],
36
+ ["spring.data.redis.database", "dbIndex"],
37
+ ["spring.redis.host", "host"], // Spring Boot 2.x 旧前缀
38
+ ["spring.redis.port", "port"],
39
+ ["spring.redis.password", "password"],
40
+ ["spring.redis.database", "dbIndex"],
41
+ ["spring.elasticsearch.uris", "url"],
42
+ ["spring.elasticsearch.url", "url"],
43
+ ["spring.elasticsearch.username", "username"],
44
+ ["spring.elasticsearch.password", "password"],
45
+ ["spring.data.elasticsearch.uris", "url"],
46
+ ["spring.data.elasticsearch.username", "username"],
47
+ ["spring.data.elasticsearch.password", "password"],
48
+ ];
49
+
50
+ function groupOf(key: string): SpringGroup {
51
+ if (key.includes("redis")) return "redis";
52
+ if (key.includes("elasticsearch")) return "es";
53
+ return "datasource";
54
+ }
55
+
56
+ /** 扁平化 dotted 键 → 按组聚合成 RawDbConfig[] */
57
+ export function springKeysToRaw(dotted: Record<string, string>, file: string, weight: number): RawDbConfig[] {
58
+ const groups = new Map<SpringGroup, RawDbConfig>();
59
+ for (const [key, field] of SPRING_KEYS) {
60
+ const v = dotted[key];
61
+ if (v === undefined || v === "") continue;
62
+ const g = groupOf(key);
63
+ const raw = groups.get(g) ?? { group: g, profile: profileOf(basename(file)), file, weight };
64
+ (raw as Record<string, unknown>)[field] = v;
65
+ groups.set(g, raw);
66
+ }
67
+ return [...groups.values()];
68
+ }
69
+
70
+ function basename(p: string): string {
71
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
72
+ return i >= 0 ? p.slice(i + 1) : p;
73
+ }
@@ -0,0 +1,47 @@
1
+ // scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
2
+ import { readdirSync, statSync, type Dirent } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ const IGNORED_DIRS = new Set([
6
+ "node_modules", ".git", "target", "dist", "venv", "logs", "docs",
7
+ "build", "coverage", "__pycache__", ".idea", ".vscode",
8
+ ]);
9
+
10
+ const MAX_FILES = 2000;
11
+ const MAX_DEPTH = 8;
12
+
13
+ /**
14
+ * 把输入路径解析为绝对路径;越界(cwd 子树之外、`..` 上跳、cwd 本身除外)
15
+ * 直接抛 "scan path out of scope"——与「密码不进上下文」同级别的红线。
16
+ */
17
+ export function resolveRoot(input: string): string {
18
+ const cwd = process.cwd();
19
+ const abs = isAbsolute(input) ? resolve(input) : resolve(cwd, input);
20
+ const rel = relative(cwd, abs);
21
+ if (rel.startsWith("..") || isAbsolute(rel)) {
22
+ throw new Error(`scan path out of scope: ${input}`);
23
+ }
24
+ return abs;
25
+ }
26
+
27
+ /** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES)。 */
28
+ export function walk(root: string): string[] {
29
+ const out: string[] = [];
30
+ const visit = (dir: string, depth: number): void => {
31
+ if (depth > MAX_DEPTH || out.length >= MAX_FILES) return;
32
+ let entries: Dirent[];
33
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
34
+ for (const e of entries) {
35
+ if (IGNORED_DIRS.has(e.name)) continue;
36
+ if (e.isDirectory() && e.name.startsWith(".")) continue;
37
+ const p = join(dir, e.name);
38
+ if (e.isDirectory()) visit(p, depth + 1);
39
+ else if (e.isFile()) {
40
+ try { if (statSync(p).isFile()) out.push(p); } catch { /* ignore */ }
41
+ if (out.length >= MAX_FILES) return;
42
+ }
43
+ }
44
+ };
45
+ visit(root, 0);
46
+ return out;
47
+ }
@@ -0,0 +1,72 @@
1
+ // src/core/sql-text.ts
2
+ export function stripComments(sql: string): string {
3
+ let out = "";
4
+ let i = 0;
5
+ let q: string | null = null;
6
+ while (i < sql.length) {
7
+ const ch = sql[i];
8
+ if (q) {
9
+ out += ch;
10
+ if (ch === q) {
11
+ if (sql[i + 1] === q) { out += sql[i + 1]; i += 2; continue; } // '' 转义
12
+ q = null;
13
+ }
14
+ i++;
15
+ continue;
16
+ }
17
+ if (ch === "'" || ch === '"' || ch === "`") { q = ch; out += ch; i++; continue; }
18
+ if (ch === "-" && sql[i + 1] === "-") { while (i < sql.length && sql[i] !== "\n") i++; continue; }
19
+ if (ch === "/" && sql[i + 1] === "*") {
20
+ i += 2;
21
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
22
+ i += 2;
23
+ continue;
24
+ }
25
+ out += ch; i++;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ export function splitStatements(sql: string): string[] {
31
+ // 注:先 stripComments 再切分——注释文本不进入语句,注释内分号自然消失;
32
+ // 这里只处理字符串字面量(含 '' 双写转义)内的分号
33
+ const clean = stripComments(sql);
34
+ const stmts: string[] = [];
35
+ let cur = "";
36
+ let q: string | null = null;
37
+ for (let i = 0; i < clean.length; i++) {
38
+ const ch = clean[i];
39
+ const nx = clean[i + 1];
40
+ if (q) {
41
+ cur += ch;
42
+ if (ch === q) {
43
+ if (nx === q) { cur += nx; i++; continue; }
44
+ q = null;
45
+ }
46
+ continue;
47
+ }
48
+ if (ch === "'" || ch === '"' || ch === "`") { q = ch; cur += ch; continue; }
49
+ if (ch === ";") {
50
+ if (cur.trim()) stmts.push(cur.trim());
51
+ cur = "";
52
+ continue;
53
+ }
54
+ cur += ch;
55
+ }
56
+ if (cur.trim()) stmts.push(cur.trim());
57
+ return stmts;
58
+ }
59
+
60
+ const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|LOAD|MERGE|EXEC|EXECUTE|CALL|MSCK|CACHE|REFRESH)\b/i;
61
+ const WITH_DML_RE = /^\s*WITH\b[\s\S]*\b(INSERT|UPDATE|DELETE|MERGE)\b/i;
62
+ const FOR_UPDATE_RE = /\bFOR\s+UPDATE\b/i;
63
+ const DROP_TABLE_RE = /^\s*DROP\s+(TABLE|DATABASE)\b/i;
64
+
65
+ export function isWriteStatement(stmt: string): boolean {
66
+ const clean = stripComments(stmt);
67
+ return WRITE_RE.test(clean) || WITH_DML_RE.test(clean) || FOR_UPDATE_RE.test(clean);
68
+ }
69
+
70
+ export function isDropStatement(stmt: string): boolean {
71
+ return DROP_TABLE_RE.test(stripComments(stmt));
72
+ }
@@ -0,0 +1,88 @@
1
+ // core/types.ts
2
+ export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
3
+ | "redis" | "elasticsearch" | "hive" | "spark";
4
+ export type DbFamily = "relational" | "kv" | "search" | "bigdata";
5
+
6
+ export interface ConnConfig {
7
+ id: string; name: string; type: DbTypeId;
8
+ description?: string; host?: string; port?: number;
9
+ username?: string; password?: string;
10
+ /** 家族语义:关系型=库名 / DM=schema / Redis=不用(用 dbIndex)/
11
+ ES=默认 index / Hive-Spark=database 名 */
12
+ database?: string;
13
+ dbIndex?: number; // Redis 库号
14
+ apiKey?: string; // ES 预留(二期)
15
+ options?: Record<string, string>; // Hive/Spark 会话变量等(旧文件 extraParams 读取时并入此字段)
16
+ isDefault?: boolean; // 默认连接标记(§11.1,G 阶段接线)
17
+ createdAt: string;
18
+ }
19
+
20
+ export interface ParsedTarget {
21
+ host: string; port: number;
22
+ username?: string; password?: string;
23
+ database?: string; dbIndex?: number; ssl?: boolean;
24
+ }
25
+
26
+ export interface DbConnection { type: DbTypeId; client: unknown; close(): Promise<void>; }
27
+ export interface ExecOpts { readonly: boolean; maxRows: number; timeoutSec: number; }
28
+
29
+ // scan/candidates.ts
30
+ export type CandidateStatus = "ready" | "incomplete" | "encrypted" | "exists";
31
+ export interface Candidate {
32
+ status: CandidateStatus;
33
+ dialectId: DbTypeId; // 由方言 fingerprints 匹配得出
34
+ partial: Partial<ConnConfig>; // 已抽到的字段
35
+ missing: string[]; // 待补字段名(incomplete 时)
36
+ source: { file: string; profile?: string; confidence: number };
37
+ }
38
+
39
+ // ── 以下从 src/db.ts 原样搬入(字段不变) ──────────
40
+
41
+ export interface TableInfo {
42
+ schema: string;
43
+ name: string;
44
+ type: string;
45
+ description: string;
46
+ }
47
+
48
+ export interface ColumnInfo {
49
+ name: string;
50
+ type: string;
51
+ nullable: boolean;
52
+ default: string | null;
53
+ primaryKey: boolean;
54
+ comment: string;
55
+ }
56
+
57
+ export interface QueryResult {
58
+ success: boolean;
59
+ columns?: string[];
60
+ rows?: unknown[][];
61
+ rowCount?: number;
62
+ duration?: string;
63
+ error?: string;
64
+ /** 结果是否因达到 maxRows 而截断(Task 8 加法字段,Task 11 导出显示可复用) */
65
+ truncated?: boolean;
66
+ }
67
+
68
+ export interface ListTablesResult {
69
+ success: boolean;
70
+ tables?: TableInfo[];
71
+ count?: number;
72
+ error?: string;
73
+ }
74
+
75
+ export interface DescribeTableResult {
76
+ success: boolean;
77
+ columns?: ColumnInfo[];
78
+ count?: number;
79
+ error?: string;
80
+ }
81
+
82
+ export interface TestConnectionResult {
83
+ success: boolean;
84
+ version?: string;
85
+ latency?: string;
86
+ error?: string;
87
+ warning?: string; // ES 未知大版本等非致命版本警告(Task 7,Spec §12)
88
+ }
@@ -0,0 +1,4 @@
1
+ // core/whitelist.ts —— 命令白名单匹配工具(KV/搜索共用)
2
+ export function matchCommand(cmd: string, list: string[]): boolean {
3
+ return list.includes(cmd.trim().toUpperCase());
4
+ }