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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `datasource_*`:数据源的列表 / 连通性测试 / 远端表浏览 / 导入。
|
|
3
|
+
*
|
|
4
|
+
* 与 `dataset_*` 的关系:数据源只是**数据来源**的另一种形态——导入完成后的数据集
|
|
5
|
+
* 与 Excel 导入的完全同权,之后一律用 `dataset_*` 工具操作。
|
|
6
|
+
*
|
|
7
|
+
* 脱敏红线:四个工具的返回值与渲染文本里都不出现密码、连接串与本地物理表名。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { connectSource, testSource } from '../datasource/connection'
|
|
11
|
+
import { isDataSourceError } from '../datasource/errors'
|
|
12
|
+
import { importRemoteTable, type RemoteImportResult } from '../datasource/importer'
|
|
13
|
+
import type { DataSourceRecord, ConnectionTestResult } from '../datasource/types'
|
|
14
|
+
import {
|
|
15
|
+
renderColumns,
|
|
16
|
+
renderConnectionTest,
|
|
17
|
+
renderSourceList,
|
|
18
|
+
renderSourceTables,
|
|
19
|
+
type SourceListItem,
|
|
20
|
+
type SourceTableItem,
|
|
21
|
+
} from '../render'
|
|
22
|
+
import type { DataServices } from '../store'
|
|
23
|
+
import { toolDef, ToolError, type ToolDefinition, type ToolExec } from '../tooling'
|
|
24
|
+
|
|
25
|
+
const SOURCE_PARAM = {
|
|
26
|
+
type: 'string',
|
|
27
|
+
required: true,
|
|
28
|
+
description: '数据源的 sourceId 或登记名(来自 datasource_list)。',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
/** `DataSourceError` → `ToolError`(保留 code),其余错误原样上抛。 */
|
|
32
|
+
function asToolFailure(error: unknown): unknown {
|
|
33
|
+
return isDataSourceError(error) ? new ToolError(error.message, error.code) : error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toListItem(record: DataSourceRecord): SourceListItem {
|
|
37
|
+
return {
|
|
38
|
+
id: record.id,
|
|
39
|
+
name: record.name,
|
|
40
|
+
type: record.type,
|
|
41
|
+
host: record.host,
|
|
42
|
+
port: record.port,
|
|
43
|
+
database: record.database,
|
|
44
|
+
status: record.status,
|
|
45
|
+
lastError: record.lastError,
|
|
46
|
+
lastCheckedAt: record.lastCheckedAt,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── datasource_list ──────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const COLUMN_SCHEMA = {
|
|
53
|
+
type: 'array',
|
|
54
|
+
items: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
name: { type: 'string' },
|
|
59
|
+
type: { type: 'string' },
|
|
60
|
+
nullable: { type: 'boolean' },
|
|
61
|
+
description: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} as const
|
|
65
|
+
|
|
66
|
+
export function createSourceListTool(services: DataServices): ToolDefinition {
|
|
67
|
+
return toolDef({
|
|
68
|
+
name: 'datasource_list',
|
|
69
|
+
description: [
|
|
70
|
+
'列出已登记的数据库数据源:sourceId、登记名、类型(mysql / postgresql)、地址、库名、连通状态。',
|
|
71
|
+
'数据源是全局登记的,不按工作区隔离。返回内容不含密码等凭据。',
|
|
72
|
+
'只读工具,不修改任何数据。后续 datasource_* 工具都用这里的 sourceId 或登记名指代数据源。',
|
|
73
|
+
].join(' '),
|
|
74
|
+
parameters: {},
|
|
75
|
+
output: {
|
|
76
|
+
schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
properties: {
|
|
80
|
+
count: { type: 'integer', description: '数据源数量' },
|
|
81
|
+
sources: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
items: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
properties: {
|
|
87
|
+
id: { type: 'string' },
|
|
88
|
+
name: { type: 'string' },
|
|
89
|
+
type: { type: 'string' },
|
|
90
|
+
host: { type: 'string' },
|
|
91
|
+
port: { type: 'integer' },
|
|
92
|
+
database: { type: 'string' },
|
|
93
|
+
status: { type: 'string', description: 'unknown / connected / error' },
|
|
94
|
+
lastError: { type: 'string' },
|
|
95
|
+
lastCheckedAt: { type: 'integer' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
render: (_args, value: { count: number; sources: SourceListItem[] }) => [
|
|
102
|
+
{ type: 'text', text: renderSourceList(value.sources) },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
execute: async (): Promise<{ count: number; sources: SourceListItem[] }> => {
|
|
106
|
+
const records = await services.sources.list()
|
|
107
|
+
return { count: records.length, sources: records.map(toListItem) }
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── datasource_test ──────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
interface TestOutput {
|
|
115
|
+
sourceId: string
|
|
116
|
+
name: string
|
|
117
|
+
type: string
|
|
118
|
+
success: boolean
|
|
119
|
+
latency: number
|
|
120
|
+
version: string | null
|
|
121
|
+
error: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createSourceTestTool(services: DataServices): ToolDefinition {
|
|
125
|
+
return toolDef({
|
|
126
|
+
name: 'datasource_test',
|
|
127
|
+
description: [
|
|
128
|
+
'测试一个已登记数据源的连通性,返回是否成功、延迟毫秒数与数据库版本。',
|
|
129
|
+
'失败时给出脱敏后的错误原因;检测结果会写回数据源的 status / lastError。',
|
|
130
|
+
'只读工具(不读业务数据),但会真正发起一次连接。',
|
|
131
|
+
].join(' '),
|
|
132
|
+
parameters: { source: SOURCE_PARAM },
|
|
133
|
+
output: {
|
|
134
|
+
schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
properties: {
|
|
138
|
+
sourceId: { type: 'string' },
|
|
139
|
+
name: { type: 'string' },
|
|
140
|
+
type: { type: 'string' },
|
|
141
|
+
success: { type: 'boolean' },
|
|
142
|
+
latency: { type: 'integer', description: '毫秒' },
|
|
143
|
+
version: { type: 'string' },
|
|
144
|
+
error: { type: 'string' },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
render: (_args, value: TestOutput) => [
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: renderConnectionTest({
|
|
151
|
+
name: value.name,
|
|
152
|
+
success: value.success,
|
|
153
|
+
latency: value.latency,
|
|
154
|
+
version: value.version,
|
|
155
|
+
error: value.error,
|
|
156
|
+
}),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
execute: async (args): Promise<TestOutput> => {
|
|
161
|
+
try {
|
|
162
|
+
const record = await services.sources.require(args.source)
|
|
163
|
+
const result: ConnectionTestResult = await testSource(services.cfg, record)
|
|
164
|
+
await services.sources.recordCheck(record.id, result.success, result.error)
|
|
165
|
+
return {
|
|
166
|
+
sourceId: record.id,
|
|
167
|
+
name: record.name,
|
|
168
|
+
type: record.type,
|
|
169
|
+
success: result.success,
|
|
170
|
+
latency: result.latency,
|
|
171
|
+
version: result.version,
|
|
172
|
+
error: result.error,
|
|
173
|
+
}
|
|
174
|
+
} catch (error: unknown) {
|
|
175
|
+
throw asToolFailure(error)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── datasource_tables ────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
interface TablesOutput {
|
|
184
|
+
sourceId: string
|
|
185
|
+
name: string
|
|
186
|
+
schema: string | null
|
|
187
|
+
count: number
|
|
188
|
+
tables: {
|
|
189
|
+
tableName: string
|
|
190
|
+
schemaName: string | null
|
|
191
|
+
rowCount: number
|
|
192
|
+
primaryKey: string | null
|
|
193
|
+
columns: { name: string; type: string; nullable: boolean; description: string | null }[]
|
|
194
|
+
}[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function createSourceTablesTool(services: DataServices): ToolDefinition {
|
|
198
|
+
return toolDef({
|
|
199
|
+
name: 'datasource_tables',
|
|
200
|
+
description: [
|
|
201
|
+
'浏览一个数据源里可用的表:表名、估计行数、主键与列结构(列名 / 推断类型 / 是否可空 / 注释)。',
|
|
202
|
+
'可选 schema(PostgreSQL 默认 public,MySQL 无 schema 概念);可选 q 按表名关键字过滤。',
|
|
203
|
+
'只读工具。看中某张表后用 datasource_import 把它导入当前工作区。',
|
|
204
|
+
].join(' '),
|
|
205
|
+
parameters: {
|
|
206
|
+
source: SOURCE_PARAM,
|
|
207
|
+
schema: { type: 'string', description: '可选:schema 名;PostgreSQL 默认 public。' },
|
|
208
|
+
q: { type: 'string', description: '可选:按表名关键字过滤。' },
|
|
209
|
+
},
|
|
210
|
+
output: {
|
|
211
|
+
schema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
properties: {
|
|
215
|
+
sourceId: { type: 'string' },
|
|
216
|
+
name: { type: 'string' },
|
|
217
|
+
schema: { type: 'string' },
|
|
218
|
+
count: { type: 'integer' },
|
|
219
|
+
tables: {
|
|
220
|
+
type: 'array',
|
|
221
|
+
items: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
properties: {
|
|
225
|
+
tableName: { type: 'string' },
|
|
226
|
+
schemaName: { type: 'string' },
|
|
227
|
+
rowCount: { type: 'integer', description: '估计行数;-1 表示未知' },
|
|
228
|
+
primaryKey: { type: 'string' },
|
|
229
|
+
columns: COLUMN_SCHEMA,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
render: (_args, value: TablesOutput) => {
|
|
236
|
+
const items: SourceTableItem[] = value.tables.map(table => ({
|
|
237
|
+
tableName: table.tableName,
|
|
238
|
+
schemaName: table.schemaName,
|
|
239
|
+
rowCount: table.rowCount,
|
|
240
|
+
columnCount: table.columns.length,
|
|
241
|
+
primaryKey: table.primaryKey,
|
|
242
|
+
}))
|
|
243
|
+
return [{ type: 'text', text: renderSourceTables(value.name, value.schema, items) }]
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
execute: async (args): Promise<TablesOutput> => {
|
|
247
|
+
try {
|
|
248
|
+
const record = await services.sources.require(args.source)
|
|
249
|
+
const connector = await connectSource(services.cfg, record)
|
|
250
|
+
const tables = await connector.getTables(args.schema)
|
|
251
|
+
const keyword = (args.q ?? '').trim().toLowerCase()
|
|
252
|
+
const matched = keyword.length === 0
|
|
253
|
+
? tables
|
|
254
|
+
: tables.filter(table => table.tableName.toLowerCase().includes(keyword))
|
|
255
|
+
return {
|
|
256
|
+
sourceId: record.id,
|
|
257
|
+
name: record.name,
|
|
258
|
+
schema: args.schema ?? null,
|
|
259
|
+
count: matched.length,
|
|
260
|
+
tables: matched.map(table => ({
|
|
261
|
+
tableName: table.tableName,
|
|
262
|
+
schemaName: table.schemaName,
|
|
263
|
+
rowCount: table.rowCount,
|
|
264
|
+
primaryKey: table.primaryKey,
|
|
265
|
+
columns: table.columns.map(column => ({
|
|
266
|
+
name: column.name,
|
|
267
|
+
type: column.nativeType,
|
|
268
|
+
nullable: column.nullable,
|
|
269
|
+
description: column.comment,
|
|
270
|
+
})),
|
|
271
|
+
})),
|
|
272
|
+
}
|
|
273
|
+
} catch (error: unknown) {
|
|
274
|
+
throw asToolFailure(error)
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── datasource_import ────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
type ImportOutput = RemoteImportResult
|
|
283
|
+
|
|
284
|
+
export function createSourceImportTool(services: DataServices): ToolDefinition {
|
|
285
|
+
return toolDef({
|
|
286
|
+
name: 'datasource_import',
|
|
287
|
+
description: [
|
|
288
|
+
'把远端数据源里的一张表全量导入当前工作区,产出标准数据集(之后用 dataset_* 工具操作)。',
|
|
289
|
+
'source 指定数据源,table 指定远端表名(PostgreSQL 可再加 schema);name 指定登记名,缺省取表名。',
|
|
290
|
+
'limit 只导入前 N 行;大表会自动转后台任务并返回 jobId,完成后回注通知。',
|
|
291
|
+
'导入后的数据集与 Excel 导入的完全同权:dataset_query / dataset_insert 等照常使用。',
|
|
292
|
+
'这是写操作,会触发人工确认。',
|
|
293
|
+
].join(' '),
|
|
294
|
+
parameters: {
|
|
295
|
+
source: SOURCE_PARAM,
|
|
296
|
+
table: { type: 'string', required: true, description: '远端表名(先用 datasource_tables 确认)。' },
|
|
297
|
+
schema: { type: 'string', description: '可选:PostgreSQL 的 schema 名。' },
|
|
298
|
+
name: { type: 'string', description: '可选:数据集登记名;默认取远端表名。' },
|
|
299
|
+
limit: { type: 'integer', description: '可选:只导入前 N 行。' },
|
|
300
|
+
background: { type: 'boolean', description: '可选:true 强制后台导入;默认大表自动转后台。' },
|
|
301
|
+
},
|
|
302
|
+
output: {
|
|
303
|
+
schema: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
additionalProperties: false,
|
|
306
|
+
properties: {
|
|
307
|
+
datasetId: { type: 'string' },
|
|
308
|
+
name: { type: 'string' },
|
|
309
|
+
rowCount: { type: 'integer' },
|
|
310
|
+
columnCount: { type: 'integer' },
|
|
311
|
+
status: { type: 'string', description: 'ready / running' },
|
|
312
|
+
jobId: { type: 'string' },
|
|
313
|
+
columns: {
|
|
314
|
+
type: 'array',
|
|
315
|
+
items: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
properties: {
|
|
319
|
+
name: { type: 'string' },
|
|
320
|
+
sanitizedName: { type: 'string' },
|
|
321
|
+
type: { type: 'string' },
|
|
322
|
+
nullable: { type: 'boolean' },
|
|
323
|
+
description: { type: 'string' },
|
|
324
|
+
sample: { type: 'array', items: { type: 'json' } },
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
render: (_args, value: ImportOutput) => {
|
|
331
|
+
const head = value.status === 'running'
|
|
332
|
+
? `已在后台开始导入 ${value.name}(datasetId: ${value.datasetId},jobId: ${value.jobId ?? '-'}),完成后会回注通知。`
|
|
333
|
+
: `已导入 ${value.name}(datasetId: ${value.datasetId}):${value.rowCount} 行 / ${value.columnCount} 列。`
|
|
334
|
+
return [{ type: 'text', text: [head, '', renderColumns(value.columns)].join('\n') }]
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
presentCall: args => ({
|
|
338
|
+
card: 'generic',
|
|
339
|
+
title: `导入远端表 ${args.table}`,
|
|
340
|
+
kind: 'write',
|
|
341
|
+
}),
|
|
342
|
+
execute: async (args, exec: ToolExec): Promise<ImportOutput> => {
|
|
343
|
+
if (services.cfg.readOnly) {
|
|
344
|
+
throw new ToolError('dsh-lh-data 处于只读模式(readOnly=true),已拒绝导入', 'READ_ONLY')
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const scope = await services.scopeOf(exec)
|
|
348
|
+
const record = await services.sources.require(args.source)
|
|
349
|
+
exec.signal.throwIfAborted()
|
|
350
|
+
const result = await importRemoteTable(services, {
|
|
351
|
+
scopeKey: scope.scopeKey,
|
|
352
|
+
source: record,
|
|
353
|
+
tableName: args.table,
|
|
354
|
+
schemaName: args.schema ?? null,
|
|
355
|
+
name: args.name ?? null,
|
|
356
|
+
limit: args.limit ?? null,
|
|
357
|
+
}, {
|
|
358
|
+
signal: exec.signal,
|
|
359
|
+
exec,
|
|
360
|
+
background: args.background === true,
|
|
361
|
+
})
|
|
362
|
+
return result
|
|
363
|
+
} catch (error: unknown) {
|
|
364
|
+
throw asToolFailure(error)
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 供 registry 聚合。 */
|
|
371
|
+
export function createDataSourceTools(services: DataServices): ToolDefinition[] {
|
|
372
|
+
return [
|
|
373
|
+
createSourceListTool(services),
|
|
374
|
+
createSourceTestTool(services),
|
|
375
|
+
createSourceTablesTool(services),
|
|
376
|
+
createSourceImportTool(services),
|
|
377
|
+
]
|
|
378
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dataset_import`:解析工作区 Excel/CSV → 建表 → 批量插入 → 登记元数据。
|
|
3
|
+
*
|
|
4
|
+
* 大文件(≥ `backgroundThresholdRows`,或显式 `background: true`)走后台分支:
|
|
5
|
+
* 立即返回 `{ status: 'running', jobId }`,插入在 `ctx.jobs` 任务里跑完后回注通知
|
|
6
|
+
* (设计文档 §5.2)。任何失败都会删掉半截的物理表并把元数据标记为 `failed`。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises'
|
|
10
|
+
import { basename, extname } from 'node:path'
|
|
11
|
+
import { shortHash } from '../db'
|
|
12
|
+
import { parseCSV, parseXLSX, type ColumnInfo, type ParsedFile } from '../parse'
|
|
13
|
+
import { renderColumns } from '../render'
|
|
14
|
+
import { assertReadableFile, resolveInputFile, type ScopeContext } from '../scope'
|
|
15
|
+
import {
|
|
16
|
+
assertPhysicalTableName,
|
|
17
|
+
generateTableName,
|
|
18
|
+
makeDatasetId,
|
|
19
|
+
type DataServices,
|
|
20
|
+
type DatasetRecord,
|
|
21
|
+
} from '../store'
|
|
22
|
+
import { countRows, createDatasetTable, dropDatasetTable, insertRows } from '../table'
|
|
23
|
+
import { ToolError, toolDef, PLUGIN_NAME, type ToolDefinition, type ToolExec } from '../tooling'
|
|
24
|
+
|
|
25
|
+
export const IMPORT_JOB_KIND = 'dataset-import'
|
|
26
|
+
|
|
27
|
+
export interface ImportOutput {
|
|
28
|
+
datasetId: string
|
|
29
|
+
name: string
|
|
30
|
+
rowCount: number
|
|
31
|
+
columnCount: number
|
|
32
|
+
status: string
|
|
33
|
+
jobId?: string
|
|
34
|
+
columns: ColumnInfo[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 后台任务的终态(对应 dsh-jobs 的 `JobOutcome`)。 */
|
|
38
|
+
interface JobOutcome {
|
|
39
|
+
status: 'completed' | 'killed' | 'failed'
|
|
40
|
+
detail?: string
|
|
41
|
+
output?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseFile(path: string, buffer: Buffer, sheet: string | undefined, sampleRows: number): ParsedFile {
|
|
45
|
+
return extname(path).toLowerCase() === '.csv'
|
|
46
|
+
? parseCSV(buffer, sampleRows)
|
|
47
|
+
: parseXLSX(buffer, sheet, sampleRows)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 失败清理:删掉半截物理表 + 元数据标记 failed(后续工具会拒绝非 ready 的数据集)。 */
|
|
51
|
+
async function cleanupFailedImport(
|
|
52
|
+
services: DataServices,
|
|
53
|
+
scope: ScopeContext,
|
|
54
|
+
record: DatasetRecord,
|
|
55
|
+
message: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
const db = await services.store.database(scope.scopeKey)
|
|
59
|
+
await dropDatasetTable(db, record.tableName)
|
|
60
|
+
} catch {
|
|
61
|
+
// 清理失败不应掩盖原始错误。
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await services.store.update(scope.scopeKey, record.id, {
|
|
65
|
+
status: 'failed',
|
|
66
|
+
error: message.slice(0, 500),
|
|
67
|
+
rowCount: 0,
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
// 同上。
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 后台导入:注册任务并返回 jobId;不可用时返回 undefined(降级为前台)。 */
|
|
75
|
+
function startBackgroundImport(
|
|
76
|
+
services: DataServices,
|
|
77
|
+
exec: ToolExec,
|
|
78
|
+
scope: ScopeContext,
|
|
79
|
+
record: DatasetRecord,
|
|
80
|
+
columns: ColumnInfo[],
|
|
81
|
+
rows: Record<string, unknown>[],
|
|
82
|
+
): string | undefined {
|
|
83
|
+
const jobs = services.jobs
|
|
84
|
+
if (jobs === undefined) return undefined
|
|
85
|
+
|
|
86
|
+
const controller = new AbortController()
|
|
87
|
+
const notify = (text: string): void => {
|
|
88
|
+
try {
|
|
89
|
+
exec.agent?.inject?.({
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: [{ type: 'text', text }],
|
|
92
|
+
source: { kind: 'plugin', plugin: PLUGIN_NAME },
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
// agent 已 dispose(会话结束)时静默跳过,不能让通知失败影响任务本身。
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return jobs.start({
|
|
101
|
+
kind: IMPORT_JOB_KIND,
|
|
102
|
+
label: `导入 ${record.name}(${rows.length} 行)`,
|
|
103
|
+
owner: exec.agent,
|
|
104
|
+
run: () => {
|
|
105
|
+
// 任务自有的取消信号:外层调用被取消不会终止已发布的工作。
|
|
106
|
+
const done = (async (): Promise<JobOutcome> => {
|
|
107
|
+
try {
|
|
108
|
+
const db = await services.store.database(scope.scopeKey)
|
|
109
|
+
const inserted = await insertRows(db, record.tableName, columns, rows, {
|
|
110
|
+
batchSize: services.cfg.batchSize,
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
})
|
|
113
|
+
await services.store.update(scope.scopeKey, record.id, { rowCount: inserted, status: 'ready' })
|
|
114
|
+
const text = `数据集导入完成:${record.name}(${inserted} 行,datasetId: ${record.id})`
|
|
115
|
+
notify(text)
|
|
116
|
+
return { status: 'completed', detail: `${inserted} rows`, output: text }
|
|
117
|
+
} catch (error: unknown) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
119
|
+
await cleanupFailedImport(services, scope, record, message)
|
|
120
|
+
const aborted = controller.signal.aborted
|
|
121
|
+
const text = `数据集导入${aborted ? '已取消' : '失败'}:${record.name} — ${message}`
|
|
122
|
+
notify(text)
|
|
123
|
+
return { status: aborted ? 'killed' : 'failed', detail: message.slice(0, 500), output: text }
|
|
124
|
+
}
|
|
125
|
+
})()
|
|
126
|
+
return {
|
|
127
|
+
cancel: (reason?: string) => controller.abort(reason),
|
|
128
|
+
done,
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
} catch {
|
|
133
|
+
// 任务注册表不可用/预检失败:降级为前台导入。
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createImportTool(services: DataServices): ToolDefinition {
|
|
139
|
+
return toolDef({
|
|
140
|
+
name: 'dataset_import',
|
|
141
|
+
description: [
|
|
142
|
+
'把工作区内的 Excel/CSV 导入本地表格库:解析文件、建表、批量插入并登记为数据集。',
|
|
143
|
+
'path 相对路径以当前工作区目录为基准,且必须位于工作区内(禁止 ../ 穿越);仅支持 .xlsx / .xls / .csv。',
|
|
144
|
+
'可选:name 指定登记名(重复会自动加后缀)、sheet 指定工作表名(默认第一个)、limit 只导入前 N 行。',
|
|
145
|
+
'大文件会自动转后台任务并立即返回 jobId,完成后回注通知;小文件同步完成并返回行数与列信息。',
|
|
146
|
+
'这是写操作,会触发人工确认。导入后用 dataset_list / dataset_schema / dataset_query 查看。',
|
|
147
|
+
].join(' '),
|
|
148
|
+
parameters: {
|
|
149
|
+
path: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
required: true,
|
|
152
|
+
description: '工作区内的文件绝对路径,或相对当前工作区目录的相对路径。',
|
|
153
|
+
},
|
|
154
|
+
name: { type: 'string', description: '可选:数据集登记名;默认取文件名(不含扩展名)。' },
|
|
155
|
+
sheet: { type: 'string', description: '可选:Excel 工作表名;默认第一个工作表。' },
|
|
156
|
+
limit: { type: 'integer', description: '可选:只导入前 N 行。' },
|
|
157
|
+
background: { type: 'boolean', description: '可选:true 强制后台导入;默认大文件自动转后台。' },
|
|
158
|
+
},
|
|
159
|
+
output: {
|
|
160
|
+
schema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
properties: {
|
|
164
|
+
datasetId: { type: 'string' },
|
|
165
|
+
name: { type: 'string' },
|
|
166
|
+
rowCount: { type: 'integer' },
|
|
167
|
+
columnCount: { type: 'integer' },
|
|
168
|
+
status: { type: 'string', description: 'ready / running' },
|
|
169
|
+
jobId: { type: 'string' },
|
|
170
|
+
columns: {
|
|
171
|
+
type: 'array',
|
|
172
|
+
items: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
properties: {
|
|
176
|
+
name: { type: 'string' },
|
|
177
|
+
sanitizedName: { type: 'string' },
|
|
178
|
+
type: { type: 'string' },
|
|
179
|
+
nullable: { type: 'boolean' },
|
|
180
|
+
description: { type: 'string' },
|
|
181
|
+
sample: { type: 'array', items: { type: 'json' } },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
render: (_args, value: ImportOutput) => {
|
|
188
|
+
const head = value.status === 'running'
|
|
189
|
+
? `已在后台开始导入 ${value.name}(datasetId: ${value.datasetId},jobId: ${value.jobId ?? '-'}),完成后会回注通知。`
|
|
190
|
+
: `已导入 ${value.name}(datasetId: ${value.datasetId}):${value.rowCount} 行 / ${value.columnCount} 列。`
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
type: 'text',
|
|
194
|
+
text: [head, '', renderColumns(value.columns)].join('\n'),
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
presentCall: args => ({
|
|
200
|
+
card: 'generic',
|
|
201
|
+
title: `导入 ${basename(args.path)}`,
|
|
202
|
+
kind: 'write',
|
|
203
|
+
locations: [{ path: args.path }],
|
|
204
|
+
}),
|
|
205
|
+
execute: async (args, exec: ToolExec): Promise<ImportOutput> => {
|
|
206
|
+
if (services.cfg.readOnly) {
|
|
207
|
+
throw new ToolError('dsh-lh-data 处于只读模式(readOnly=true),已拒绝导入', 'READ_ONLY')
|
|
208
|
+
}
|
|
209
|
+
const scope = await services.scopeOf(exec)
|
|
210
|
+
const absolutePath = resolveInputFile(scope.cwd, args.path)
|
|
211
|
+
await assertReadableFile(absolutePath, services.cfg.maxFileBytes)
|
|
212
|
+
exec.signal.throwIfAborted()
|
|
213
|
+
|
|
214
|
+
const buffer = await readFile(absolutePath)
|
|
215
|
+
exec.signal.throwIfAborted()
|
|
216
|
+
const parsed = parseFile(absolutePath, buffer, args.sheet, services.cfg.previewSampleRows)
|
|
217
|
+
if (parsed.columns.length === 0) throw new ToolError('文件没有可识别的列(表头为空?)', 'EMPTY_COLUMNS')
|
|
218
|
+
const rows = args.limit !== undefined && args.limit > 0 ? parsed.rows.slice(0, args.limit) : parsed.rows
|
|
219
|
+
|
|
220
|
+
const base = (args.name?.trim().length ?? 0) > 0 ? args.name!.trim() : basename(absolutePath, extname(absolutePath))
|
|
221
|
+
const name = await services.store.uniqueName(scope.scopeKey, base)
|
|
222
|
+
const tableName = generateTableName(base, shortHash(scope.scopeKey))
|
|
223
|
+
assertPhysicalTableName(tableName)
|
|
224
|
+
|
|
225
|
+
const now = Date.now()
|
|
226
|
+
const record: DatasetRecord = {
|
|
227
|
+
id: makeDatasetId(),
|
|
228
|
+
scopeKey: scope.scopeKey,
|
|
229
|
+
name,
|
|
230
|
+
tableName,
|
|
231
|
+
sourcePath: absolutePath,
|
|
232
|
+
sourceId: null,
|
|
233
|
+
sourceRef: null,
|
|
234
|
+
description: null,
|
|
235
|
+
rowCount: 0,
|
|
236
|
+
columns: parsed.columns,
|
|
237
|
+
status: 'importing',
|
|
238
|
+
error: null,
|
|
239
|
+
createdAt: now,
|
|
240
|
+
updatedAt: now,
|
|
241
|
+
}
|
|
242
|
+
await services.store.create(record)
|
|
243
|
+
const db = await services.store.database(scope.scopeKey)
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await createDatasetTable(db, tableName, parsed.columns)
|
|
247
|
+
const wantsBackground = args.background === true || rows.length >= services.cfg.backgroundThresholdRows
|
|
248
|
+
if (wantsBackground) {
|
|
249
|
+
const jobId = startBackgroundImport(services, exec, scope, record, parsed.columns, rows)
|
|
250
|
+
if (jobId !== undefined) {
|
|
251
|
+
return {
|
|
252
|
+
datasetId: record.id,
|
|
253
|
+
name,
|
|
254
|
+
rowCount: 0,
|
|
255
|
+
columnCount: parsed.columns.length,
|
|
256
|
+
status: 'running',
|
|
257
|
+
jobId,
|
|
258
|
+
columns: parsed.columns,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const inserted = await insertRows(db, tableName, parsed.columns, rows, {
|
|
263
|
+
batchSize: services.cfg.batchSize,
|
|
264
|
+
signal: exec.signal,
|
|
265
|
+
})
|
|
266
|
+
const rowCount = await countRows(db, tableName)
|
|
267
|
+
await services.store.update(scope.scopeKey, record.id, { rowCount: inserted, status: 'ready' })
|
|
268
|
+
return {
|
|
269
|
+
datasetId: record.id,
|
|
270
|
+
name,
|
|
271
|
+
rowCount: Number.isFinite(rowCount) ? rowCount : inserted,
|
|
272
|
+
columnCount: parsed.columns.length,
|
|
273
|
+
status: 'ready',
|
|
274
|
+
columns: parsed.columns,
|
|
275
|
+
}
|
|
276
|
+
} catch (error: unknown) {
|
|
277
|
+
await cleanupFailedImport(services, scope, record, error instanceof Error ? error.message : String(error))
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
}
|