dsh-lh-data 0.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.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. package/src/view.ts +371 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * PostgreSQL 连接器。
3
+ *
4
+ * 元数据走 `pg_catalog`:一次查询拿到 schema 内所有表的列、类型、注释与主键,
5
+ * 行数取 `reltuples`(规划器估计值,未分析过的表为 -1)。
6
+ */
7
+
8
+ import { loadPgDriver, type PgPoolLike } from '../driver'
9
+ import { DataSourceError, wrapConnectionError } from '../errors'
10
+ import type { ColumnType } from '../../parse'
11
+ import type {
12
+ ConnectionConfig,
13
+ ConnectionTestResult,
14
+ RemoteColumn,
15
+ RemoteTable,
16
+ SchemaSummary,
17
+ } from '../types'
18
+ import { DatabaseConnector } from './base'
19
+
20
+ interface ColumnRow {
21
+ table_name: string
22
+ estimate: number | string | null
23
+ column_name: string | null
24
+ column_type: string | null
25
+ is_nullable: boolean | null
26
+ column_comment: string | null
27
+ is_primary: boolean | null
28
+ }
29
+
30
+ /** 只取普通表 / 分区表 / 物化视图:排除系统目录与索引。 */
31
+ const RELKINDS = "'r','p','m','v','f'"
32
+
33
+ const TABLE_COLUMNS_SQL = `
34
+ SELECT c.relname AS table_name,
35
+ c.reltuples::bigint AS estimate,
36
+ a.attname AS column_name,
37
+ format_type(a.atttypid, a.atttypmod) AS column_type,
38
+ NOT a.attnotnull AS is_nullable,
39
+ col_description(a.attrelid, a.attnum) AS column_comment,
40
+ pk.attnum IS NOT NULL AS is_primary
41
+ FROM pg_class c
42
+ JOIN pg_namespace n ON n.oid = c.relnamespace
43
+ LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
44
+ LEFT JOIN (SELECT i.indrelid, i.indkey[0] AS attnum FROM pg_index i WHERE i.indisprimary) pk
45
+ ON pk.indrelid = c.oid AND pk.attnum = a.attnum
46
+ WHERE n.nspname = $1 AND c.relkind IN (${RELKINDS})
47
+ ORDER BY c.relname, a.attnum`
48
+
49
+ const ONE_TABLE_COLUMNS_SQL = `
50
+ SELECT a.attname AS column_name,
51
+ format_type(a.atttypid, a.atttypmod) AS column_type,
52
+ NOT a.attnotnull AS is_nullable,
53
+ col_description(a.attrelid, a.attnum) AS column_comment,
54
+ pk.attnum IS NOT NULL AS is_primary
55
+ FROM pg_class c
56
+ JOIN pg_namespace n ON n.oid = c.relnamespace
57
+ LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
58
+ LEFT JOIN (SELECT i.indrelid, i.indkey[0] AS attnum FROM pg_index i WHERE i.indisprimary) pk
59
+ ON pk.indrelid = c.oid AND pk.attnum = a.attnum
60
+ WHERE n.nspname = $1 AND c.relname = $2
61
+ ORDER BY a.attnum`
62
+
63
+ function poolOptions(config: ConnectionConfig): Record<string, unknown> {
64
+ const options: Record<string, unknown> = {
65
+ host: config.host,
66
+ port: config.port,
67
+ database: config.database,
68
+ user: config.username,
69
+ password: config.password,
70
+ max: config.poolMax ?? 5,
71
+ connectionTimeoutMillis: config.connectTimeoutMs,
72
+ }
73
+ if (config.sslMode !== null && config.sslMode !== 'disable') {
74
+ options.ssl = { rejectUnauthorized: config.sslMode === 'verify-full' || config.sslMode === 'verify-ca' }
75
+ }
76
+ return options
77
+ }
78
+
79
+ /** `format_type` 的输出形如 `character varying(255)` / `timestamp with time zone`。 */
80
+ function mapPgType(formatType: string): ColumnType {
81
+ const base = formatType.split('(')[0].trim().split(' ')[0].toLowerCase()
82
+ if (base === 'bool' || base === 'boolean') return 'boolean'
83
+ if (['int2', 'int4', 'int8', 'smallint', 'int', 'integer', 'bigint', 'serial', 'bigserial'].includes(base)) return 'numeric'
84
+ if (['numeric', 'decimal', 'real', 'float', 'float4', 'float8', 'double', 'money'].includes(base)) return 'numeric'
85
+ if (['date', 'time', 'timestamp', 'timestamptz', 'interval'].includes(base)) return 'date'
86
+ return 'text'
87
+ }
88
+
89
+ function toRemoteColumn(row: ColumnRow): RemoteColumn {
90
+ const comment = row.column_comment === null || row.column_comment === undefined ? null : String(row.column_comment)
91
+ return {
92
+ name: String(row.column_name ?? ''),
93
+ nativeType: mapPgType(String(row.column_type ?? '')),
94
+ nullable: row.is_nullable === true,
95
+ comment: comment === null || comment.length === 0 ? null : comment,
96
+ }
97
+ }
98
+
99
+ function groupTables(rows: readonly ColumnRow[], schemaName: string): RemoteTable[] {
100
+ const byName = new Map<string, RemoteTable>()
101
+ for (const row of rows) {
102
+ let table = byName.get(row.table_name)
103
+ if (table === undefined) {
104
+ table = {
105
+ tableName: row.table_name,
106
+ schemaName,
107
+ rowCount: Number(row.estimate ?? -1),
108
+ primaryKey: null,
109
+ columns: [],
110
+ }
111
+ byName.set(row.table_name, table)
112
+ }
113
+ if (row.column_name !== null) {
114
+ const column = toRemoteColumn(row)
115
+ table.columns.push(column)
116
+ if (row.is_primary === true && table.primaryKey === null) table.primaryKey = column.name
117
+ }
118
+ }
119
+ return [...byName.values()]
120
+ }
121
+
122
+ export class PostgreSQLConnector extends DatabaseConnector {
123
+ private pool: PgPoolLike | null = null
124
+
125
+ async connect(): Promise<void> {
126
+ if (this.pool !== null) return
127
+ const pg = await loadPgDriver()
128
+ this.pool = new pg.Pool(poolOptions(this.config))
129
+ }
130
+
131
+ async disconnect(): Promise<void> {
132
+ const pool = this.pool
133
+ this.pool = null
134
+ if (pool !== null) await pool.end()
135
+ }
136
+
137
+ async testConnection(): Promise<ConnectionTestResult> {
138
+ const startedAt = Date.now()
139
+ let pool: PgPoolLike | null = null
140
+ try {
141
+ const pg = await loadPgDriver()
142
+ pool = new pg.Pool({ ...poolOptions(this.config), max: 1 })
143
+ const result = await pool.query('SELECT version() AS version')
144
+ const version = (result.rows[0] as { version?: unknown } | undefined)?.version
145
+ return {
146
+ success: true,
147
+ latency: Date.now() - startedAt,
148
+ version: version === undefined ? null : String(version),
149
+ error: null,
150
+ }
151
+ } catch (error) {
152
+ return { success: false, latency: Date.now() - startedAt, version: null, error: describe(error) }
153
+ } finally {
154
+ if (pool !== null) await pool.end().catch(() => undefined)
155
+ }
156
+ }
157
+
158
+ async getSchemas(): Promise<SchemaSummary[]> {
159
+ const rows = await this.query(`
160
+ SELECT n.nspname AS schema_name, COUNT(c.oid)::int AS total
161
+ FROM pg_namespace n
162
+ LEFT JOIN pg_class c ON c.relnamespace = n.oid AND c.relkind IN (${RELKINDS})
163
+ WHERE n.nspname NOT LIKE 'pg\\_%' AND n.nspname <> 'information_schema'
164
+ GROUP BY n.nspname
165
+ ORDER BY n.nspname`)
166
+ return (rows as { schema_name: string; total: number | string }[]).map(row => ({
167
+ schemaName: String(row.schema_name),
168
+ tableCount: Number(row.total ?? 0),
169
+ }))
170
+ }
171
+
172
+ async getTables(schemaName?: string): Promise<RemoteTable[]> {
173
+ const schema = schemaName === undefined || schemaName.trim().length === 0 ? 'public' : schemaName.trim()
174
+ const rows = await this.query(TABLE_COLUMNS_SQL, [schema])
175
+ return groupTables(rows as ColumnRow[], schema)
176
+ }
177
+
178
+ async getTableColumns(tableName: string, schemaName?: string): Promise<RemoteColumn[]> {
179
+ const schema = schemaName === undefined || schemaName.trim().length === 0 ? 'public' : schemaName.trim()
180
+ const rows = await this.query(ONE_TABLE_COLUMNS_SQL, [schema, tableName])
181
+ return (rows as ColumnRow[]).map(toRemoteColumn)
182
+ }
183
+
184
+ async getTableData(
185
+ tableName: string,
186
+ schemaName?: string,
187
+ range?: { limit?: number; offset?: number },
188
+ ): Promise<Record<string, unknown>[]> {
189
+ const schema = schemaName === undefined || schemaName.trim().length === 0 ? 'public' : schemaName.trim()
190
+ return await this.query(this.buildSelectQuery(tableName, schema, range)) as Record<string, unknown>[]
191
+ }
192
+
193
+ protected quoteIdent(name: string): string {
194
+ return `"${name.replace(/"/g, '""')}"`
195
+ }
196
+
197
+ private async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
198
+ const pool = this.pool
199
+ if (pool === null) throw new DataSourceError('UNREACHABLE', 'PostgreSQL 连接尚未建立,请先调用 connect()')
200
+ try {
201
+ const result = await pool.query(sql, params)
202
+ return result.rows
203
+ } catch (error: unknown) {
204
+ throw wrapConnectionError(error, this.config.host, this.config.port)
205
+ }
206
+ }
207
+ }
208
+
209
+ function describe(error: unknown): string {
210
+ const message = error instanceof Error ? error.message : String(error)
211
+ return message.replace(/(postgres(?:ql)?:\/\/)[^\s'"]*/gi, '$1***').slice(0, 300)
212
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 数据源密码的加解密:AES-256-GCM,密钥由 scryptSync 派生。
3
+ *
4
+ * 密文格式 `iv:authTag:encrypted`(十六进制)。落库只存密文,解密只在建立连接的
5
+ * 那一刻发生在内存里,任何日志 / 工具返回 / HTTP 响应都不出现明文。
6
+ */
7
+
8
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
9
+ import { DataSourceError } from './errors'
10
+
11
+ const ALGORITHM = 'aes-256-gcm'
12
+ const IV_LENGTH = 16
13
+ const SALT = 'dsh-lh-data-datasource'
14
+ const DEFAULT_KEY = 'dsh-lh-data-default-key'
15
+
16
+ /** 优先级:配置项 → 环境变量 `LH_DATA_ENCRYPT_KEY` → 内置默认值(不安全,启动时会告警)。 */
17
+ export function resolveEncryptKey(configured: string): string {
18
+ const fromConfig = configured.trim()
19
+ if (fromConfig.length > 0) return fromConfig
20
+ const fromEnv = (process.env.LH_DATA_ENCRYPT_KEY ?? '').trim()
21
+ return fromEnv.length > 0 ? fromEnv : DEFAULT_KEY
22
+ }
23
+
24
+ /** 配置与环境变量都没给密钥时为 true(调用方据此告警一次)。 */
25
+ export function usingDefaultEncryptKey(configured: string): boolean {
26
+ return resolveEncryptKey(configured) === DEFAULT_KEY
27
+ }
28
+
29
+ function deriveKey(password: string): Buffer {
30
+ return scryptSync(password, SALT, 32)
31
+ }
32
+
33
+ export function encryptPassword(password: string, key: string): string {
34
+ const iv = randomBytes(IV_LENGTH)
35
+ const cipher = createCipheriv(ALGORITHM, deriveKey(key), iv)
36
+ let encrypted = cipher.update(password, 'utf8', 'hex')
37
+ encrypted += cipher.final('hex')
38
+ return `${iv.toString('hex')}:${cipher.getAuthTag().toString('hex')}:${encrypted}`
39
+ }
40
+
41
+ export function decryptPassword(encrypted: string, key: string): string {
42
+ const parts = encrypted.split(':')
43
+ if (parts.length !== 3) {
44
+ throw new DataSourceError('BAD_REQUEST', '数据源密码格式非法(无法解密,请重新保存该数据源)')
45
+ }
46
+ const [ivHex, authTagHex, data] = parts
47
+ try {
48
+ const decipher = createDecipheriv(ALGORITHM, deriveKey(key), Buffer.from(ivHex, 'hex'))
49
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
50
+ return decipher.update(data, 'hex', 'utf8') + decipher.final('utf8')
51
+ } catch {
52
+ throw new DataSourceError('BAD_REQUEST', '数据源密码解密失败(密钥可能已变更,请重新保存该数据源)')
53
+ }
54
+ }
55
+
56
+ export function isValidEncryptedFormat(encrypted: string): boolean {
57
+ const parts = encrypted.split(':')
58
+ if (parts.length !== 3) return false
59
+ const isHex = (value: string): boolean => /^[0-9a-f]+$/i.test(value)
60
+ return isHex(parts[0]) && parts[0].length === IV_LENGTH * 2 && isHex(parts[1]) && isHex(parts[2]) && parts[2].length > 0
61
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 数据库驱动的运行时加载。
3
+ *
4
+ * `mysql2` / `pg` 已随插件默认安装(`dependencies`),但仍走**动态 import**:
5
+ * 用到哪个才加载哪个,CLI 剖面与只用文件导入的用户不会被这两个包拖慢启动。
6
+ * 万一运行环境里缺包(裁剪安装、pnpm 过滤等),抛 `DataSourceError('DRIVER_MISSING')`
7
+ * 并在消息里带上安装命令,而不是崩在裸栈上。
8
+ *
9
+ * 导入说明符必须走变量,不能用字面量——字面量会让 TypeScript 与打包器在解析期
10
+ * 就去解析这两个包,缺包时 typecheck / build 直接失败。
11
+ */
12
+
13
+ import { DataSourceError } from './errors'
14
+ import type { DataSourceType } from './types'
15
+
16
+ export const DRIVER_INSTALL_HINTS: Readonly<Record<DataSourceType, string>> = {
17
+ mysql: 'pnpm add mysql2',
18
+ postgresql: 'pnpm add pg',
19
+ }
20
+
21
+ const DRIVER_SPECS: Readonly<Record<DataSourceType, string>> = {
22
+ mysql: 'mysql2/promise',
23
+ postgresql: 'pg',
24
+ }
25
+
26
+ // ── 驱动的最小结构(只声明本模块用到的成员,不引第三方 .d.ts) ──────────────
27
+
28
+ interface MysqlLike {
29
+ createPool(options: Record<string, unknown>): MysqlPoolLike
30
+ createConnection(options: Record<string, unknown>): Promise<MysqlConnLike>
31
+ }
32
+
33
+ export interface MysqlPoolLike {
34
+ execute(sql: string, params?: unknown[]): Promise<[unknown[], unknown]>
35
+ end(): Promise<void>
36
+ }
37
+
38
+ interface MysqlConnLike {
39
+ execute(sql: string, params?: unknown[]): Promise<[unknown[], unknown]>
40
+ end(): Promise<void>
41
+ }
42
+
43
+ interface PgLike {
44
+ Pool: new (options: Record<string, unknown>) => PgPoolLike
45
+ }
46
+
47
+ export interface PgPoolLike {
48
+ query(sql: string, params?: unknown[]): Promise<{ rows: unknown[] }>
49
+ end(): Promise<void>
50
+ }
51
+
52
+ // ── 加载 ─────────────────────────────────────────────────────────────────────
53
+
54
+ const cache = new Map<DataSourceType, unknown>()
55
+
56
+ const MODULE_MISSING_CODES: readonly string[] = [
57
+ 'ERR_MODULE_NOT_FOUND',
58
+ 'MODULE_NOT_FOUND',
59
+ 'ERR_PACKAGE_PATH_NOT_EXPORTED',
60
+ ]
61
+
62
+ function isModuleMissing(error: unknown): boolean {
63
+ const code = (error as { code?: unknown } | null)?.code
64
+ return typeof code === 'string' && MODULE_MISSING_CODES.includes(code)
65
+ }
66
+
67
+ /** CJS 互操作:`await import('pg')` 拿到的是带 `default` 的命名空间。 */
68
+ function unwrapDefault(loaded: unknown): Record<string, unknown> {
69
+ if (typeof loaded === 'object' && loaded !== null && 'default' in loaded) {
70
+ const fallback = (loaded as { default?: unknown }).default
71
+ if (typeof fallback === 'object' && fallback !== null) return fallback as Record<string, unknown>
72
+ }
73
+ return (loaded ?? {}) as Record<string, unknown>
74
+ }
75
+
76
+ async function load(type: DataSourceType): Promise<Record<string, unknown>> {
77
+ const cached = cache.get(type)
78
+ if (cached !== undefined) return cached as Record<string, unknown>
79
+ const spec = DRIVER_SPECS[type]
80
+ let loaded: unknown
81
+ try {
82
+ loaded = await import(spec)
83
+ } catch (error) {
84
+ if (isModuleMissing(error)) {
85
+ throw new DataSourceError(
86
+ 'DRIVER_MISSING',
87
+ `未安装 ${type === 'mysql' ? 'MySQL' : 'PostgreSQL'} 驱动,请执行:${DRIVER_INSTALL_HINTS[type]}`,
88
+ )
89
+ }
90
+ throw error
91
+ }
92
+ const mod = unwrapDefault(loaded)
93
+ cache.set(type, mod)
94
+ return mod
95
+ }
96
+
97
+ /** 仅供测试:清掉模块级缓存。 */
98
+ export function resetDriverCache(): void {
99
+ cache.clear()
100
+ }
101
+
102
+ export async function loadMysqlDriver(): Promise<MysqlLike> {
103
+ return (await load('mysql')) as unknown as MysqlLike
104
+ }
105
+
106
+ export async function loadPgDriver(): Promise<PgLike> {
107
+ return (await load('postgresql')) as unknown as PgLike
108
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 数据源模块的统一错误类型。
3
+ *
4
+ * 主机侧的边界(工具 / 管理接口)把它翻译成 `ToolError` / `AdminServiceError`。
5
+ * 错误文本一律脱敏:不含密码、不含本地物理表名。
6
+ */
7
+
8
+ export type DataSourceErrorCode =
9
+ | 'NOT_FOUND'
10
+ | 'DRIVER_MISSING'
11
+ | 'UNREACHABLE'
12
+ | 'BAD_REQUEST'
13
+ | 'TOO_MANY_ROWS'
14
+ | 'IMPORT_FAILED'
15
+
16
+ export class DataSourceError extends Error {
17
+ readonly code: DataSourceErrorCode
18
+
19
+ constructor(code: DataSourceErrorCode, message: string) {
20
+ super(message)
21
+ this.name = 'DataSourceError'
22
+ this.code = code
23
+ }
24
+ }
25
+
26
+ export function isDataSourceError(error: unknown): error is DataSourceError {
27
+ return error instanceof DataSourceError
28
+ }
29
+
30
+ /**
31
+ * 原生驱动错误 → 脱敏的中文提示。
32
+ * 只保留可定位的主机与端口,凭据绝不进文本。
33
+ */
34
+ export function describeCause(error: unknown): string {
35
+ const code = (error as { code?: unknown } | null)?.code
36
+ if (typeof code === 'string') {
37
+ if (code === 'ECONNREFUSED') return '连接被拒绝(主机可达,但端口未开放或服务未启动)'
38
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return '主机名无法解析'
39
+ if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH') return '连接超时(主机不可达或被防火墙拦截)'
40
+ if (code === 'ER_ACCESS_DENIED_ERROR' || code === '28P01' || code === '28000') return '用户名或密码错误'
41
+ if (code === 'ER_DBACCESS_DENIED_ERROR' || code === '3D000') return '数据库不存在,或该用户无权访问'
42
+ if (code === 'ER_NOT_SUPPORTED_AUTH_MODE') return '服务端认证协议不被驱动支持'
43
+ if (code === 'EPROTO') return 'SSL 协商失败(检查 sslMode 配置)'
44
+ }
45
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error)
46
+ return message.trim().length > 0 ? message.trim() : '未知错误'
47
+ }
48
+
49
+ /**
50
+ * 连接 / 查询异常归一为 `DataSourceError`。
51
+ * 已经是 `DataSourceError` 的(如 DRIVER_MISSING)保持原语义,其余按「连不上」处理,
52
+ * 这样工具与设置页永远不会把驱动的裸栈抛给模型 / 浏览器。
53
+ */
54
+ export function wrapConnectionError(error: unknown, host: string, port: number): unknown {
55
+ if (isDataSourceError(error)) return error
56
+ return new DataSourceError('UNREACHABLE', `连接 ${host}:${port} 失败:${describeCause(error)}`)
57
+ }
58
+
59
+ /** 错误码 → HTTP 状态码(管理接口用;工具侧另有一套映射)。 */
60
+ export function dataSourceErrorStatus(code: DataSourceErrorCode): number {
61
+ switch (code) {
62
+ case 'NOT_FOUND': return 404
63
+ case 'DRIVER_MISSING': return 503
64
+ case 'UNREACHABLE': return 502
65
+ case 'BAD_REQUEST': return 400
66
+ case 'TOO_MANY_ROWS': return 413
67
+ default: return 500
68
+ }
69
+ }