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.
- package/README.md +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
package/src/admin.ts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页管理接口的主机侧服务层(模块级函数,无类)。
|
|
3
|
+
*
|
|
4
|
+
* 负责跨工作区聚合、单条详情、新建/改名改描述改来源/删除等纯业务逻辑,
|
|
5
|
+
* 不含任何 HTTP 细节(路由在 `admin-http.ts`)。所有错误都收敛成
|
|
6
|
+
* `AdminServiceError`,由路由层映射到统一脱敏的 JSON 错误体。
|
|
7
|
+
*
|
|
8
|
+
* 关键约束:
|
|
9
|
+
* - 写操作先过 `assertWritable`(readOnly / adminEnabled 门禁);
|
|
10
|
+
* - 写与读都先校验 scope 是否「已知」(来自工作区注册表),拒绝凭空造工作区;
|
|
11
|
+
* - 物理表名一律由 `generateTableName` 生成并经 `assertPhysicalTableName` 白名单,
|
|
12
|
+
* 错误信息不回显表名与 SQL。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DataServices } from './store'
|
|
16
|
+
import { assertPhysicalTableName, generateTableName, makeDatasetId, type DatasetRecord } from './store'
|
|
17
|
+
import { shortHash } from './db'
|
|
18
|
+
import { countRows, createDatasetTable, dropDatasetTable } from './table'
|
|
19
|
+
import { quoteIdentifier } from './sql'
|
|
20
|
+
import {
|
|
21
|
+
connectionOf,
|
|
22
|
+
connectSource,
|
|
23
|
+
testDraft,
|
|
24
|
+
testSource,
|
|
25
|
+
type ConnectionDraft,
|
|
26
|
+
} from './datasource/connection'
|
|
27
|
+
import { dataSourceErrorStatus, isDataSourceError, type DataSourceErrorCode } from './datasource/errors'
|
|
28
|
+
import { importRemoteTable } from './datasource/importer'
|
|
29
|
+
import type { DataSourceRecord } from './datasource/types'
|
|
30
|
+
import {
|
|
31
|
+
ADMIN_ROW_ID_COLUMN,
|
|
32
|
+
type AdminErrorCode,
|
|
33
|
+
type AdminWarning,
|
|
34
|
+
type ConnectionTestView,
|
|
35
|
+
type CreateDatasetRequest,
|
|
36
|
+
type DataSourceView,
|
|
37
|
+
type DatasetAdminView,
|
|
38
|
+
type DatasetDetailView,
|
|
39
|
+
type DatasetRowsResult,
|
|
40
|
+
type ImportSourceTableResult,
|
|
41
|
+
type ListDatasetsResult,
|
|
42
|
+
type ListSourceTablesResult,
|
|
43
|
+
type PatchDatasetRequest,
|
|
44
|
+
type ScopeView,
|
|
45
|
+
} from './admin-contract'
|
|
46
|
+
import {
|
|
47
|
+
parseCreateDataSource,
|
|
48
|
+
parseImportRequest,
|
|
49
|
+
parseListQuery,
|
|
50
|
+
parsePatchDataSource,
|
|
51
|
+
parseRowsQuery,
|
|
52
|
+
parseTablesQuery,
|
|
53
|
+
parseTestDataSource,
|
|
54
|
+
validateColumnPatches,
|
|
55
|
+
validateColumnSpecs,
|
|
56
|
+
validateDatasetName,
|
|
57
|
+
validateDescription,
|
|
58
|
+
validateScopeKey,
|
|
59
|
+
validateSourcePath,
|
|
60
|
+
type ParsedListQuery,
|
|
61
|
+
type ParsedPatchSource,
|
|
62
|
+
type ParsedRowsQuery,
|
|
63
|
+
} from './admin-validate'
|
|
64
|
+
|
|
65
|
+
/** 业务错误:携带 HTTP 状态码与脱敏后的错误信息。 */
|
|
66
|
+
export class AdminServiceError extends Error {
|
|
67
|
+
constructor(
|
|
68
|
+
public readonly code: AdminErrorCode,
|
|
69
|
+
public readonly status: number,
|
|
70
|
+
message: string,
|
|
71
|
+
) {
|
|
72
|
+
super(message)
|
|
73
|
+
this.name = 'AdminServiceError'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 只读模式或接口关闭时拒绝写操作。 */
|
|
78
|
+
export function assertWritable(services: DataServices): void {
|
|
79
|
+
if (services.cfg.readOnly) {
|
|
80
|
+
throw new AdminServiceError('READ_ONLY', 403, '插件处于只读模式(readOnly=true),管理接口拒绝写操作')
|
|
81
|
+
}
|
|
82
|
+
if (services.cfg.adminEnabled === false) {
|
|
83
|
+
throw new AdminServiceError('ADMIN_DISABLED', 403, '管理接口已关闭(adminEnabled=false)')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function listScopes(services: DataServices): Promise<ScopeView[]> {
|
|
88
|
+
const entries = await services.store.scopes.list()
|
|
89
|
+
return entries.map(entry => ({ scopeKey: entry.scopeKey, lastSeen: entry.lastSeen }))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function recordToView(record: DatasetRecord): DatasetAdminView {
|
|
93
|
+
return {
|
|
94
|
+
id: record.id,
|
|
95
|
+
scopeKey: record.scopeKey,
|
|
96
|
+
name: record.name,
|
|
97
|
+
description: record.description,
|
|
98
|
+
sourcePath: record.sourcePath,
|
|
99
|
+
rowCount: record.rowCount,
|
|
100
|
+
columnCount: record.columns.length,
|
|
101
|
+
status: record.status,
|
|
102
|
+
error: record.error,
|
|
103
|
+
createdAt: record.createdAt,
|
|
104
|
+
updatedAt: record.updatedAt,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function recordToDetail(record: DatasetRecord): DatasetDetailView {
|
|
109
|
+
return {
|
|
110
|
+
...recordToView(record),
|
|
111
|
+
columns: record.columns.map(column => ({
|
|
112
|
+
name: column.name,
|
|
113
|
+
sanitizedName: column.sanitizedName,
|
|
114
|
+
type: column.type,
|
|
115
|
+
nullable: column.nullable,
|
|
116
|
+
description: column.description ?? '',
|
|
117
|
+
sample: column.sample,
|
|
118
|
+
})),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function matchesQuery(view: DatasetAdminView, needle: string): boolean {
|
|
123
|
+
return (
|
|
124
|
+
view.name.toLowerCase().includes(needle) ||
|
|
125
|
+
(view.sourcePath ?? '').toLowerCase().includes(needle) ||
|
|
126
|
+
view.scopeKey.toLowerCase().includes(needle) ||
|
|
127
|
+
(view.description ?? '').toLowerCase().includes(needle)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 聚合所有工作区的数据集:逐 scope 拉取后内存过滤、排序、分页。
|
|
133
|
+
* 单个工作区读取失败降级为一条 warning 而不阻断整页。
|
|
134
|
+
*/
|
|
135
|
+
export async function listDatasets(
|
|
136
|
+
services: DataServices,
|
|
137
|
+
rawQuery: Record<string, string | undefined>,
|
|
138
|
+
): Promise<ListDatasetsResult> {
|
|
139
|
+
const query: ParsedListQuery = parseListQuery(rawQuery)
|
|
140
|
+
const scopes = await services.store.scopes.list()
|
|
141
|
+
const all: DatasetAdminView[] = []
|
|
142
|
+
const warnings: AdminWarning[] = []
|
|
143
|
+
let scanned = 0
|
|
144
|
+
let truncated = false
|
|
145
|
+
|
|
146
|
+
for (const entry of scopes) {
|
|
147
|
+
try {
|
|
148
|
+
const records = await services.store.list(entry.scopeKey)
|
|
149
|
+
for (const record of records) {
|
|
150
|
+
if (scanned >= services.cfg.adminMaxDatasets) {
|
|
151
|
+
truncated = true
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
scanned += 1
|
|
155
|
+
all.push(recordToView(record))
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
warnings.push({ scopeKey: entry.scopeKey, message: `该工作区读取失败:${messageOf(error)}` })
|
|
159
|
+
}
|
|
160
|
+
if (truncated) break
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const needle = query.q.toLowerCase()
|
|
164
|
+
const filtered = needle.length === 0 ? all : all.filter(view => matchesQuery(view, needle))
|
|
165
|
+
filtered.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
166
|
+
|
|
167
|
+
const start = (query.page - 1) * query.pageSize
|
|
168
|
+
const items = filtered.slice(start, start + query.pageSize)
|
|
169
|
+
return {
|
|
170
|
+
items,
|
|
171
|
+
total: filtered.length,
|
|
172
|
+
page: query.page,
|
|
173
|
+
pageSize: query.pageSize,
|
|
174
|
+
truncated,
|
|
175
|
+
warnings,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function getDataset(
|
|
180
|
+
services: DataServices,
|
|
181
|
+
scopeKey: string,
|
|
182
|
+
id: string,
|
|
183
|
+
): Promise<DatasetDetailView> {
|
|
184
|
+
if (!(await services.store.scopes.has(scopeKey))) {
|
|
185
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${scopeKey}`)
|
|
186
|
+
}
|
|
187
|
+
const record = await services.store.find(scopeKey, id)
|
|
188
|
+
if (record === undefined) throw new AdminServiceError('NOT_FOUND', 404, `未找到数据集:${id}`)
|
|
189
|
+
return recordToDetail(record)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 只读分页读取某个数据集的物理表行。
|
|
194
|
+
*
|
|
195
|
+
* 只按 `_row_id` 升序翻页(稳定、不重不漏),列名取自元数据登记的业务列,
|
|
196
|
+
* 系统列(`_row_id` / `_uploaded_at`)不进 `columns`,但 `_row_id` 会出现在
|
|
197
|
+
* 每行里,供前端做稳定行键。页码由 `COUNT(*)` 反推后夹紧,越界返回末页。
|
|
198
|
+
*/
|
|
199
|
+
export async function listDatasetRows(
|
|
200
|
+
services: DataServices,
|
|
201
|
+
scopeKey: string,
|
|
202
|
+
id: string,
|
|
203
|
+
rawQuery: Record<string, string | undefined>,
|
|
204
|
+
): Promise<DatasetRowsResult> {
|
|
205
|
+
if (!(await services.store.scopes.has(scopeKey))) {
|
|
206
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${scopeKey}`)
|
|
207
|
+
}
|
|
208
|
+
const record = await services.store.find(scopeKey, id)
|
|
209
|
+
if (record === undefined) throw new AdminServiceError('NOT_FOUND', 404, `未找到数据集:${id}`)
|
|
210
|
+
assertPhysicalTableName(record.tableName)
|
|
211
|
+
|
|
212
|
+
const query: ParsedRowsQuery = parseRowsQuery(rawQuery)
|
|
213
|
+
const db = await services.store.database(scopeKey)
|
|
214
|
+
const total = await countRows(db, record.tableName)
|
|
215
|
+
const totalPages = Math.max(1, Math.ceil(total / query.pageSize))
|
|
216
|
+
const page = Math.min(query.page, totalPages)
|
|
217
|
+
const offset = (page - 1) * query.pageSize
|
|
218
|
+
|
|
219
|
+
const names = record.columns.map(column => column.sanitizedName)
|
|
220
|
+
const projection = names.length === 0
|
|
221
|
+
? quoteIdentifier(ADMIN_ROW_ID_COLUMN)
|
|
222
|
+
: [ADMIN_ROW_ID_COLUMN, ...names].map(quoteIdentifier).join(', ')
|
|
223
|
+
const rows = await db
|
|
224
|
+
.prepare(
|
|
225
|
+
`SELECT ${projection} FROM ${quoteIdentifier(record.tableName)}`
|
|
226
|
+
+ ` ORDER BY ${quoteIdentifier(ADMIN_ROW_ID_COLUMN)} ASC LIMIT ? OFFSET ?`,
|
|
227
|
+
)
|
|
228
|
+
.all(query.pageSize, offset)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
columns: names,
|
|
232
|
+
rows: rows.map(row => ({ ...row })),
|
|
233
|
+
page,
|
|
234
|
+
pageSize: query.pageSize,
|
|
235
|
+
total,
|
|
236
|
+
totalPages,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 新建一张空物理表并登记元数据;列结构在提交时冻结。 */
|
|
241
|
+
export async function createDataset(
|
|
242
|
+
services: DataServices,
|
|
243
|
+
raw: CreateDatasetRequest,
|
|
244
|
+
): Promise<DatasetDetailView> {
|
|
245
|
+
assertWritable(services)
|
|
246
|
+
const scopeKey = validateScopeKey(raw.scopeKey)
|
|
247
|
+
if (!(await services.store.scopes.has(scopeKey))) {
|
|
248
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${scopeKey}(请从下拉里选)`)
|
|
249
|
+
}
|
|
250
|
+
const name = validateDatasetName(raw.name)
|
|
251
|
+
const description = validateDescription(raw.description)
|
|
252
|
+
const sourcePath = validateSourcePath(raw.sourcePath)
|
|
253
|
+
const columns = validateColumnSpecs(raw.columns)
|
|
254
|
+
|
|
255
|
+
// 同工作区内名称唯一(先于建表与落库,避免触发 UNIQUE 约束变成 500)。
|
|
256
|
+
const conflict = await services.store.find(scopeKey, name)
|
|
257
|
+
if (conflict !== undefined) {
|
|
258
|
+
throw new AdminServiceError('DUPLICATE_NAME', 409, `工作区内已存在同名数据集:${name}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tableName = generateTableName(name, shortHash(scopeKey))
|
|
262
|
+
assertPhysicalTableName(tableName)
|
|
263
|
+
const now = Date.now()
|
|
264
|
+
const record: DatasetRecord = {
|
|
265
|
+
id: makeDatasetId(),
|
|
266
|
+
scopeKey,
|
|
267
|
+
name,
|
|
268
|
+
tableName,
|
|
269
|
+
sourcePath,
|
|
270
|
+
sourceId: null,
|
|
271
|
+
sourceRef: null,
|
|
272
|
+
description,
|
|
273
|
+
rowCount: 0,
|
|
274
|
+
columns,
|
|
275
|
+
status: 'ready',
|
|
276
|
+
error: null,
|
|
277
|
+
createdAt: now,
|
|
278
|
+
updatedAt: now,
|
|
279
|
+
}
|
|
280
|
+
const db = await services.store.database(scopeKey)
|
|
281
|
+
await createDatasetTable(db, tableName, columns)
|
|
282
|
+
await services.store.create(record)
|
|
283
|
+
return recordToDetail(record)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 改名 / 改描述 / 改来源 / 改列的说明与样例;未提供的字段保持不变;同工作区内名称唯一。 */
|
|
287
|
+
export async function patchDataset(
|
|
288
|
+
services: DataServices,
|
|
289
|
+
scopeKey: string,
|
|
290
|
+
id: string,
|
|
291
|
+
raw: PatchDatasetRequest,
|
|
292
|
+
): Promise<DatasetDetailView> {
|
|
293
|
+
assertWritable(services)
|
|
294
|
+
if (!(await services.store.scopes.has(scopeKey))) {
|
|
295
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${scopeKey}`)
|
|
296
|
+
}
|
|
297
|
+
const record = await services.store.find(scopeKey, id)
|
|
298
|
+
if (record === undefined) throw new AdminServiceError('NOT_FOUND', 404, `未找到数据集:${id}`)
|
|
299
|
+
|
|
300
|
+
const patch: Partial<Pick<DatasetRecord, 'name' | 'description' | 'sourcePath' | 'columns'>> = {}
|
|
301
|
+
if (raw.name !== undefined) {
|
|
302
|
+
const name = validateDatasetName(raw.name)
|
|
303
|
+
if (name !== record.name) {
|
|
304
|
+
const conflict = await services.store.find(scopeKey, name)
|
|
305
|
+
if (conflict !== undefined && conflict.id !== record.id) {
|
|
306
|
+
throw new AdminServiceError('DUPLICATE_NAME', 409, `工作区内已存在同名数据集:${name}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
patch.name = name
|
|
310
|
+
}
|
|
311
|
+
if (raw.description !== undefined) patch.description = validateDescription(raw.description)
|
|
312
|
+
if (raw.sourcePath !== undefined) patch.sourcePath = validateSourcePath(raw.sourcePath)
|
|
313
|
+
// 列的说明与样例是纯元数据,可改;列名/类型/可空性由校验层从既有列原样带出,改不了。
|
|
314
|
+
if (raw.columns !== undefined) patch.columns = validateColumnPatches(raw.columns, record.columns)
|
|
315
|
+
if (Object.keys(patch).length === 0) return recordToDetail(record)
|
|
316
|
+
|
|
317
|
+
await services.store.update(scopeKey, record.id, patch)
|
|
318
|
+
const updated = await services.store.find(scopeKey, record.id)
|
|
319
|
+
if (updated === undefined) throw new AdminServiceError('NOT_FOUND', 404, `数据集在更新后丢失:${id}`)
|
|
320
|
+
return recordToDetail(updated)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface DeleteDatasetResult {
|
|
324
|
+
id: string
|
|
325
|
+
name: string
|
|
326
|
+
dropped: boolean
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 连同物理表与元数据一起删除(等同 dataset_drop)。 */
|
|
330
|
+
export async function deleteDataset(
|
|
331
|
+
services: DataServices,
|
|
332
|
+
scopeKey: string,
|
|
333
|
+
id: string,
|
|
334
|
+
): Promise<DeleteDatasetResult> {
|
|
335
|
+
assertWritable(services)
|
|
336
|
+
if (!(await services.store.scopes.has(scopeKey))) {
|
|
337
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${scopeKey}`)
|
|
338
|
+
}
|
|
339
|
+
const record = await services.store.find(scopeKey, id)
|
|
340
|
+
if (record === undefined) throw new AdminServiceError('NOT_FOUND', 404, `未找到数据集:${id}`)
|
|
341
|
+
const db = await services.store.database(scopeKey)
|
|
342
|
+
await dropDatasetTable(db, record.tableName)
|
|
343
|
+
await services.store.remove(scopeKey, record.id)
|
|
344
|
+
return { id: record.id, name: record.name, dropped: true }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function messageOf(error: unknown): string {
|
|
348
|
+
if (error instanceof Error) return error.message.split('\n')[0]
|
|
349
|
+
return String(error)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── 数据源 ───────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/** `DataSourceError` → `AdminServiceError`(保留语义,状态码由错误码映射)。 */
|
|
355
|
+
function withSourceErrors<T>(run: () => Promise<T>): Promise<T> {
|
|
356
|
+
return run().catch((error: unknown) => {
|
|
357
|
+
if (!isDataSourceError(error)) throw error
|
|
358
|
+
throw new AdminServiceError(mapSourceCode(error.code), dataSourceErrorStatus(error.code), error.message)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function mapSourceCode(code: DataSourceErrorCode): AdminErrorCode {
|
|
363
|
+
switch (code) {
|
|
364
|
+
case 'NOT_FOUND': return 'NOT_FOUND'
|
|
365
|
+
case 'DRIVER_MISSING': return 'DRIVER_MISSING'
|
|
366
|
+
case 'UNREACHABLE': return 'SOURCE_UNREACHABLE'
|
|
367
|
+
case 'IMPORT_FAILED': return 'IMPORT_FAILED'
|
|
368
|
+
case 'TOO_MANY_ROWS': return 'PAYLOAD_TOO_LARGE'
|
|
369
|
+
default: return 'BAD_REQUEST'
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function assertSourcesEnabled(services: DataServices): void {
|
|
374
|
+
if (services.cfg.datasourceEnabled === false) {
|
|
375
|
+
throw new AdminServiceError('SOURCE_DISABLED', 403, '数据源功能已关闭(datasourceEnabled=false)')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 脱敏:只暴露「有没有密码」,密码本身永不出主机。 */
|
|
380
|
+
function toSourceView(record: DataSourceRecord): DataSourceView {
|
|
381
|
+
return {
|
|
382
|
+
id: record.id,
|
|
383
|
+
name: record.name,
|
|
384
|
+
type: record.type,
|
|
385
|
+
host: record.host,
|
|
386
|
+
port: record.port,
|
|
387
|
+
database: record.database,
|
|
388
|
+
username: record.username,
|
|
389
|
+
hasPassword: record.passwordEnc.length > 0,
|
|
390
|
+
sslMode: record.sslMode,
|
|
391
|
+
poolMax: record.poolMax,
|
|
392
|
+
description: record.description,
|
|
393
|
+
status: record.status,
|
|
394
|
+
lastError: record.lastError,
|
|
395
|
+
lastCheckedAt: record.lastCheckedAt,
|
|
396
|
+
createdAt: record.createdAt,
|
|
397
|
+
updatedAt: record.updatedAt,
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function listDataSources(services: DataServices): Promise<DataSourceView[]> {
|
|
402
|
+
assertSourcesEnabled(services)
|
|
403
|
+
return (await services.sources.list()).map(toSourceView)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function getDataSource(services: DataServices, reference: string): Promise<DataSourceView> {
|
|
407
|
+
assertSourcesEnabled(services)
|
|
408
|
+
return toSourceView(await withSourceErrors(() => services.sources.require(reference)))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function createDataSource(services: DataServices, raw: unknown): Promise<DataSourceView> {
|
|
412
|
+
assertWritable(services)
|
|
413
|
+
assertSourcesEnabled(services)
|
|
414
|
+
const input = parseCreateDataSource(raw)
|
|
415
|
+
if (input.test) {
|
|
416
|
+
const result = await withSourceErrors(() => testDraft(services.cfg, input))
|
|
417
|
+
if (!result.success) {
|
|
418
|
+
throw new AdminServiceError('SOURCE_UNREACHABLE', 502, result.error ?? '连接失败')
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const created = await services.sources.create(input)
|
|
422
|
+
if (!input.test) return toSourceView(created)
|
|
423
|
+
return toSourceView(await services.sources.update(created.id, {
|
|
424
|
+
status: 'connected',
|
|
425
|
+
lastError: null,
|
|
426
|
+
lastCheckedAt: Date.now(),
|
|
427
|
+
}))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 未落库的合并草稿:既有记录 + 补丁,用于「保存前先测连」。 */
|
|
431
|
+
function mergedDraft(services: DataServices, record: DataSourceRecord, patch: ParsedPatchSource): ConnectionDraft {
|
|
432
|
+
const base = connectionOf(services.cfg, record)
|
|
433
|
+
return {
|
|
434
|
+
type: patch.type ?? record.type,
|
|
435
|
+
host: patch.host ?? base.host,
|
|
436
|
+
port: patch.port ?? base.port,
|
|
437
|
+
database: patch.database ?? base.database,
|
|
438
|
+
username: patch.username ?? base.username,
|
|
439
|
+
password: typeof patch.password === 'string' ? patch.password : base.password,
|
|
440
|
+
sslMode: patch.sslMode !== undefined ? patch.sslMode : base.sslMode,
|
|
441
|
+
poolMax: patch.poolMax !== undefined ? patch.poolMax : base.poolMax,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function patchDataSource(
|
|
446
|
+
services: DataServices,
|
|
447
|
+
reference: string,
|
|
448
|
+
raw: unknown,
|
|
449
|
+
): Promise<DataSourceView> {
|
|
450
|
+
assertWritable(services)
|
|
451
|
+
assertSourcesEnabled(services)
|
|
452
|
+
const patch = parsePatchDataSource(raw)
|
|
453
|
+
const current = await withSourceErrors(() => services.sources.require(reference))
|
|
454
|
+
if (patch.test) {
|
|
455
|
+
const result = await withSourceErrors(() => testDraft(services.cfg, mergedDraft(services, current, patch)))
|
|
456
|
+
if (!result.success) {
|
|
457
|
+
throw new AdminServiceError('SOURCE_UNREACHABLE', 502, result.error ?? '连接失败')
|
|
458
|
+
}
|
|
459
|
+
patch.status = 'connected'
|
|
460
|
+
patch.lastError = null
|
|
461
|
+
patch.lastCheckedAt = Date.now()
|
|
462
|
+
}
|
|
463
|
+
return toSourceView(await services.sources.update(current.id, patch))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function deleteDataSource(services: DataServices, reference: string): Promise<{ deleted: boolean }> {
|
|
467
|
+
assertWritable(services)
|
|
468
|
+
assertSourcesEnabled(services)
|
|
469
|
+
const record = await withSourceErrors(() => services.sources.require(reference))
|
|
470
|
+
await services.sources.remove(record.id)
|
|
471
|
+
return { deleted: true }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 测已登记的数据源(`source`)或未保存的草稿(完整连接参数)。 */
|
|
475
|
+
export async function testDataSource(services: DataServices, raw: unknown): Promise<ConnectionTestView> {
|
|
476
|
+
assertSourcesEnabled(services)
|
|
477
|
+
const parsed = parseTestDataSource(raw)
|
|
478
|
+
const reference = parsed.reference
|
|
479
|
+
if (reference !== undefined) {
|
|
480
|
+
const record = await withSourceErrors(() => services.sources.require(reference))
|
|
481
|
+
const result = await withSourceErrors(() => testSource(services.cfg, record))
|
|
482
|
+
await services.sources.recordCheck(record.id, result.success, result.error)
|
|
483
|
+
return result
|
|
484
|
+
}
|
|
485
|
+
const draft = parsed.draft as ConnectionDraft
|
|
486
|
+
return await withSourceErrors(() => testDraft(services.cfg, draft))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function listSourceTables(
|
|
490
|
+
services: DataServices,
|
|
491
|
+
reference: string,
|
|
492
|
+
params: Record<string, string | undefined>,
|
|
493
|
+
): Promise<ListSourceTablesResult> {
|
|
494
|
+
assertSourcesEnabled(services)
|
|
495
|
+
const query = parseTablesQuery(params)
|
|
496
|
+
const record = await withSourceErrors(() => services.sources.require(reference))
|
|
497
|
+
const connector = await withSourceErrors(() => connectSource(services.cfg, record))
|
|
498
|
+
const schemas = await withSourceErrors(() => connector.getSchemas())
|
|
499
|
+
const tables = await withSourceErrors(() => connector.getTables(query.schema ?? undefined))
|
|
500
|
+
const needle = query.q.toLowerCase()
|
|
501
|
+
const matched = needle.length === 0
|
|
502
|
+
? tables
|
|
503
|
+
: tables.filter(table => table.tableName.toLowerCase().includes(needle))
|
|
504
|
+
return {
|
|
505
|
+
schema: matched[0]?.schemaName ?? null,
|
|
506
|
+
schemas: schemas.map(entry => entry.schemaName),
|
|
507
|
+
tables: matched.map(table => ({
|
|
508
|
+
tableName: table.tableName,
|
|
509
|
+
schemaName: table.schemaName,
|
|
510
|
+
rowCount: table.rowCount,
|
|
511
|
+
primaryKey: table.primaryKey,
|
|
512
|
+
columns: table.columns.map(column => ({
|
|
513
|
+
name: column.name,
|
|
514
|
+
type: column.nativeType,
|
|
515
|
+
nullable: column.nullable,
|
|
516
|
+
description: column.comment,
|
|
517
|
+
})),
|
|
518
|
+
})),
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function importSourceTable(
|
|
523
|
+
services: DataServices,
|
|
524
|
+
reference: string,
|
|
525
|
+
raw: unknown,
|
|
526
|
+
): Promise<ImportSourceTableResult> {
|
|
527
|
+
assertWritable(services)
|
|
528
|
+
assertSourcesEnabled(services)
|
|
529
|
+
const request = parseImportRequest(raw)
|
|
530
|
+
// 先定位数据源:未知数据源比未知工作区更贴近调用方的意图。
|
|
531
|
+
const record = await withSourceErrors(() => services.sources.require(reference))
|
|
532
|
+
if (!(await services.store.scopes.has(request.scopeKey))) {
|
|
533
|
+
throw new AdminServiceError('SCOPE_UNKNOWN', 404, `未知工作区:${request.scopeKey}`)
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const result = await importRemoteTable(services, {
|
|
537
|
+
scopeKey: request.scopeKey,
|
|
538
|
+
source: record,
|
|
539
|
+
tableName: request.tableName,
|
|
540
|
+
schemaName: request.schemaName,
|
|
541
|
+
name: request.name,
|
|
542
|
+
limit: request.limit,
|
|
543
|
+
}, { signal: new AbortController().signal })
|
|
544
|
+
return {
|
|
545
|
+
datasetId: result.datasetId,
|
|
546
|
+
name: result.name,
|
|
547
|
+
rowCount: result.rowCount,
|
|
548
|
+
columnCount: result.columnCount,
|
|
549
|
+
status: result.status,
|
|
550
|
+
...result.jobId === undefined ? {} : { jobId: result.jobId },
|
|
551
|
+
}
|
|
552
|
+
} catch (error: unknown) {
|
|
553
|
+
if (isDataSourceError(error)) {
|
|
554
|
+
throw new AdminServiceError(mapSourceCode(error.code), dataSourceErrorStatus(error.code), error.message)
|
|
555
|
+
}
|
|
556
|
+
throw new AdminServiceError('IMPORT_FAILED', 500, messageOf(error))
|
|
557
|
+
}
|
|
558
|
+
}
|