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/store.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据集元数据(替代参考实现的 drizzle `dataset_metadata`)+ 归属断言。
|
|
3
|
+
*
|
|
4
|
+
* 设计文档 §5.3:`datasets` 表是插件自有的元数据表,物理表名只在本模块生成与持有,
|
|
5
|
+
* 对外只暴露 `datasetId` / 登记名。所有读写都带 `scope_key` 过滤(`assertOwned`)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveDatabase, shortHash, type Database, type Row } from './db'
|
|
9
|
+
import type { DataSourceStore } from './datasource/source-store'
|
|
10
|
+
import { createScopeRegistry, type ScopeRegistry } from './scope-registry'
|
|
11
|
+
import type { ColumnInfo } from './parse'
|
|
12
|
+
import type { ScopeContext } from './scope'
|
|
13
|
+
import type { ToolExec } from './tooling'
|
|
14
|
+
import type { ViewRegistry } from './view'
|
|
15
|
+
|
|
16
|
+
/** 插件配置(结构子集由 index.ts 的 `Config` 复用,避免 tools → index 的类型循环)。 */
|
|
17
|
+
export interface DataConfig {
|
|
18
|
+
/** libSQL 库路径;空 → `$DSH_HOME/lh-data/data.db`。 */
|
|
19
|
+
dbPath: string
|
|
20
|
+
/** 非空则覆盖 dbPath,可为 `file:` 或 `libsql://`。 */
|
|
21
|
+
dbUrl: string
|
|
22
|
+
/** 远程 Turso token;建议留空并走 `TURSO_AUTH_TOKEN`。 */
|
|
23
|
+
authToken: string
|
|
24
|
+
/** true 时 scope 用 WorkspaceId 且每个工作区独立库文件。 */
|
|
25
|
+
perWorkspace: boolean
|
|
26
|
+
requireApprovalForWrites: boolean
|
|
27
|
+
allowRawSql: boolean
|
|
28
|
+
maxFileBytes: number
|
|
29
|
+
maxInsertRows: number
|
|
30
|
+
maxQueryRows: number
|
|
31
|
+
batchSize: number
|
|
32
|
+
backgroundThresholdRows: number
|
|
33
|
+
previewSampleRows: number
|
|
34
|
+
readOnly: boolean
|
|
35
|
+
|
|
36
|
+
// ── 结果视图(设计文档 §8) ──
|
|
37
|
+
/** 视图模式:auto(超阈值才建视图)/ always / never。 */
|
|
38
|
+
viewMode: 'auto' | 'always' | 'never'
|
|
39
|
+
viewThresholdRows: number
|
|
40
|
+
viewThresholdBytes: number
|
|
41
|
+
previewRows: number
|
|
42
|
+
previewStrategy: 'head' | 'head-tail'
|
|
43
|
+
previewCellChars: number
|
|
44
|
+
previewColumns: number
|
|
45
|
+
summaryEnabled: boolean
|
|
46
|
+
summaryMaxColumns: number
|
|
47
|
+
summaryMaxTextColumns: number
|
|
48
|
+
defaultPageSize: number
|
|
49
|
+
maxPageSize: number
|
|
50
|
+
maxViewRows: number
|
|
51
|
+
viewRoutePrefix: string
|
|
52
|
+
|
|
53
|
+
// ── 设置页管理接口(新增) ──
|
|
54
|
+
/** 是否挂载设置页管理接口(关闭后路由不注册,前端显示不可用)。 */
|
|
55
|
+
adminEnabled: boolean
|
|
56
|
+
/** 管理接口请求体字节上限。 */
|
|
57
|
+
adminMaxBodyBytes: number
|
|
58
|
+
/** 聚合列表扫描的数据集上限,超限即截断并提示。 */
|
|
59
|
+
adminMaxDatasets: number
|
|
60
|
+
|
|
61
|
+
// ── 数据源(新增;默认值见 index.ts 的 Config schema) ──
|
|
62
|
+
/** 是否启用数据源(关闭后不注册 datasource_* 工具与 /sources 接口)。 */
|
|
63
|
+
datasourceEnabled: boolean
|
|
64
|
+
/** 远端表分块拉取的行数。 */
|
|
65
|
+
datasourceFetchBatchSize: number
|
|
66
|
+
/** 连接 / 连通性测试的超时毫秒数。 */
|
|
67
|
+
datasourceConnectTimeoutMs: number
|
|
68
|
+
/** 单次导入的行数上限,0 表示不限。 */
|
|
69
|
+
datasourceMaxImportRows: number
|
|
70
|
+
/** 数据源密码的加密密钥;空 → 环境变量 LH_DATA_ENCRYPT_KEY。 */
|
|
71
|
+
datasourceEncryptKey: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type DatasetStatus = 'importing' | 'ready' | 'failed'
|
|
75
|
+
|
|
76
|
+
export interface DatasetRecord {
|
|
77
|
+
id: string
|
|
78
|
+
scopeKey: string
|
|
79
|
+
name: string
|
|
80
|
+
tableName: string
|
|
81
|
+
sourcePath: string | null
|
|
82
|
+
/** 来自数据源时记录数据源 id;文件导入为 null。 */
|
|
83
|
+
sourceId: string | null
|
|
84
|
+
/** 来自数据源时的定位串 `<schema>.<table>`(脱敏,不含凭据)。 */
|
|
85
|
+
sourceRef: string | null
|
|
86
|
+
/** 用户可编辑的说明(设置页「改描述」的落点);未填为 null。 */
|
|
87
|
+
description: string | null
|
|
88
|
+
rowCount: number
|
|
89
|
+
columns: ColumnInfo[]
|
|
90
|
+
status: DatasetStatus
|
|
91
|
+
error: string | null
|
|
92
|
+
createdAt: number
|
|
93
|
+
updatedAt: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DatasetError extends Error {
|
|
97
|
+
readonly code = 'DATASET_ERROR'
|
|
98
|
+
|
|
99
|
+
constructor(message: string) {
|
|
100
|
+
super(message)
|
|
101
|
+
this.name = 'DatasetError'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 物理表名形状:`d_<scopeHash8>_<base40>_<ts36>`。 */
|
|
106
|
+
export const PHYSICAL_TABLE_PATTERN: RegExp = /^d_[a-z0-9]{8}_[a-z0-9_]{1,40}_[a-z0-9]+$/
|
|
107
|
+
|
|
108
|
+
export function assertPhysicalTableName(tableName: string): void {
|
|
109
|
+
if (!PHYSICAL_TABLE_PATTERN.test(tableName)) {
|
|
110
|
+
throw new DatasetError(`非法的数据表标识:${tableName}(拒绝拼进 SQL)`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 物理表名生成:只由插件调用,去掉参考实现的 userId,改用 scope 哈希。 */
|
|
115
|
+
export function generateTableName(base: string, scopeHash: string): string {
|
|
116
|
+
const normalized = base
|
|
117
|
+
.replace(/\.(csv|xlsx|xls)$/i, '')
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
120
|
+
.replace(/_+/g, '_')
|
|
121
|
+
.replace(/^_+|_+$/g, '')
|
|
122
|
+
.slice(0, 40)
|
|
123
|
+
return `d_${scopeHash.slice(0, 8)}_${normalized.length > 0 ? normalized : 'table'}_${Date.now().toString(36)}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function makeDatasetId(): string {
|
|
127
|
+
return `ds_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SCHEMA_SQL = `
|
|
131
|
+
CREATE TABLE IF NOT EXISTS datasets (
|
|
132
|
+
id TEXT PRIMARY KEY,
|
|
133
|
+
scope_key TEXT NOT NULL,
|
|
134
|
+
name TEXT NOT NULL,
|
|
135
|
+
table_name TEXT NOT NULL UNIQUE,
|
|
136
|
+
source_path TEXT,
|
|
137
|
+
description TEXT,
|
|
138
|
+
row_count INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
columns TEXT NOT NULL DEFAULT '[]',
|
|
140
|
+
status TEXT NOT NULL DEFAULT 'importing',
|
|
141
|
+
error TEXT,
|
|
142
|
+
created_at INTEGER NOT NULL,
|
|
143
|
+
updated_at INTEGER NOT NULL
|
|
144
|
+
);
|
|
145
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_datasets_scope_name ON datasets(scope_key, name);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_datasets_scope_created ON datasets(scope_key, created_at DESC);
|
|
147
|
+
`
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 增量迁移:`CREATE TABLE IF NOT EXISTS` 建不出新列,升级前已有的库要靠 ALTER 补。
|
|
151
|
+
* 跑在已经建好表的库上时必然报 duplicate column,吞掉即可(幂等)。
|
|
152
|
+
*/
|
|
153
|
+
const MIGRATION_SQL: readonly string[] = [
|
|
154
|
+
'ALTER TABLE datasets ADD COLUMN description TEXT',
|
|
155
|
+
'ALTER TABLE datasets ADD COLUMN source_id TEXT',
|
|
156
|
+
'ALTER TABLE datasets ADD COLUMN source_ref TEXT',
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
/** 迁移失败即停:只放过「列已存在」,其余(权限、磁盘)必须暴露。 */
|
|
160
|
+
async function runMigrations(db: Database): Promise<void> {
|
|
161
|
+
for (const sql of MIGRATION_SQL) {
|
|
162
|
+
try {
|
|
163
|
+
await db.exec(sql)
|
|
164
|
+
} catch {
|
|
165
|
+
// duplicate column name —— 该库已经是新结构。
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rowToRecord(row: Row): DatasetRecord {
|
|
171
|
+
let columns: ColumnInfo[] = []
|
|
172
|
+
try {
|
|
173
|
+
const parsed: unknown = JSON.parse(String(row.columns ?? '[]'))
|
|
174
|
+
if (Array.isArray(parsed)) columns = parsed as ColumnInfo[]
|
|
175
|
+
} catch {
|
|
176
|
+
columns = []
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
id: String(row.id),
|
|
180
|
+
scopeKey: String(row.scope_key),
|
|
181
|
+
name: String(row.name),
|
|
182
|
+
tableName: String(row.table_name),
|
|
183
|
+
sourcePath: row.source_path === null || row.source_path === undefined ? null : String(row.source_path),
|
|
184
|
+
sourceId: row.source_id === null || row.source_id === undefined ? null : String(row.source_id),
|
|
185
|
+
sourceRef: row.source_ref === null || row.source_ref === undefined ? null : String(row.source_ref),
|
|
186
|
+
description: row.description === null || row.description === undefined ? null : String(row.description),
|
|
187
|
+
rowCount: Number(row.row_count ?? 0),
|
|
188
|
+
columns,
|
|
189
|
+
status: String(row.status ?? 'ready') as DatasetStatus,
|
|
190
|
+
error: row.error === null || row.error === undefined ? null : String(row.error),
|
|
191
|
+
createdAt: Number(row.created_at ?? 0),
|
|
192
|
+
updatedAt: Number(row.updated_at ?? 0),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class DatasetStore {
|
|
197
|
+
private readonly initialized = new Set<string>()
|
|
198
|
+
|
|
199
|
+
constructor(
|
|
200
|
+
private readonly cfg: DataConfig,
|
|
201
|
+
/** 工作区注册表(跨工作区聚合枚举来源);默认随配置自动创建。 */
|
|
202
|
+
public readonly scopes: ScopeRegistry = createScopeRegistry(cfg),
|
|
203
|
+
) {}
|
|
204
|
+
|
|
205
|
+
/** 该 scope 对应的库连接(幂等建表 + 增量迁移)。 */
|
|
206
|
+
async database(scopeKey: string): Promise<Database> {
|
|
207
|
+
const db = resolveDatabase(this.cfg, scopeKey)
|
|
208
|
+
if (!this.initialized.has(scopeKey)) {
|
|
209
|
+
await db.exec(SCHEMA_SQL)
|
|
210
|
+
await runMigrations(db)
|
|
211
|
+
this.initialized.add(scopeKey)
|
|
212
|
+
}
|
|
213
|
+
return db
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async list(scopeKey: string): Promise<DatasetRecord[]> {
|
|
217
|
+
const db = await this.database(scopeKey)
|
|
218
|
+
const rows = await db
|
|
219
|
+
.prepare('SELECT * FROM datasets WHERE scope_key = ? ORDER BY created_at DESC, name ASC')
|
|
220
|
+
.all(scopeKey)
|
|
221
|
+
return rows.map(rowToRecord)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 按 datasetId 或登记名查找;找不到返回 undefined(不抛错)。 */
|
|
225
|
+
async find(scopeKey: string, reference: string): Promise<DatasetRecord | undefined> {
|
|
226
|
+
const db = await this.database(scopeKey)
|
|
227
|
+
const key = typeof reference === 'string' ? reference.trim() : ''
|
|
228
|
+
if (key.length === 0) return undefined
|
|
229
|
+
const row = await db
|
|
230
|
+
.prepare('SELECT * FROM datasets WHERE scope_key = ? AND (id = ? OR name = ?) LIMIT 1')
|
|
231
|
+
.get(scopeKey, key, key)
|
|
232
|
+
return row === undefined ? undefined : rowToRecord(row)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 当前 scope 已登记的物理表名集合(dataset_query 的 sql 白名单)。 */
|
|
236
|
+
async tableNames(scopeKey: string): Promise<string[]> {
|
|
237
|
+
return (await this.list(scopeKey)).map(record => record.tableName)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 解析句柄 + 归属断言(defense-in-depth)。 */
|
|
241
|
+
async require(scopeKey: string, reference: string, options: { requireReady?: boolean } = {}): Promise<DatasetRecord> {
|
|
242
|
+
const record = await this.find(scopeKey, reference)
|
|
243
|
+
if (record === undefined) {
|
|
244
|
+
throw new DatasetError(`未找到数据集:${reference}(先用 dataset_list 查看当前工作区可用的数据集)`)
|
|
245
|
+
}
|
|
246
|
+
if (record.scopeKey !== scopeKey) throw new DatasetError(`数据集 "${record.name}" 不属于当前工作区`)
|
|
247
|
+
assertPhysicalTableName(record.tableName)
|
|
248
|
+
if (options.requireReady === true && record.status !== 'ready') {
|
|
249
|
+
throw new DatasetError(`数据集 "${record.name}" 当前状态为 ${record.status}${record.error === null ? '' : `:${record.error}`}`)
|
|
250
|
+
}
|
|
251
|
+
return record
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** scope 内唯一的名字:已存在则追加 `_2` / `_3`。 */
|
|
255
|
+
async uniqueName(scopeKey: string, name: string): Promise<string> {
|
|
256
|
+
const base = name.trim().length > 0 ? name.trim() : 'dataset'
|
|
257
|
+
const taken = new Set((await this.list(scopeKey)).map(record => record.name))
|
|
258
|
+
if (!taken.has(base)) return base
|
|
259
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
260
|
+
const candidate = `${base}_${index}`
|
|
261
|
+
if (!taken.has(candidate)) return candidate
|
|
262
|
+
}
|
|
263
|
+
throw new DatasetError(`无法为数据集生成唯一名称:${base}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async create(record: DatasetRecord): Promise<void> {
|
|
267
|
+
const db = await this.database(record.scopeKey)
|
|
268
|
+
assertPhysicalTableName(record.tableName)
|
|
269
|
+
// 先把工作区登记进注册表,设置页才能枚举到它(目录库与业务库通常同一连接)。
|
|
270
|
+
await this.scopes.record(record.scopeKey)
|
|
271
|
+
await db
|
|
272
|
+
.prepare(
|
|
273
|
+
`INSERT INTO datasets (id, scope_key, name, table_name, source_path, source_id, source_ref, description, row_count, columns, status, error, created_at, updated_at)
|
|
274
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
275
|
+
)
|
|
276
|
+
.run(
|
|
277
|
+
record.id,
|
|
278
|
+
record.scopeKey,
|
|
279
|
+
record.name,
|
|
280
|
+
record.tableName,
|
|
281
|
+
record.sourcePath,
|
|
282
|
+
record.sourceId,
|
|
283
|
+
record.sourceRef,
|
|
284
|
+
record.description,
|
|
285
|
+
record.rowCount,
|
|
286
|
+
JSON.stringify(record.columns),
|
|
287
|
+
record.status,
|
|
288
|
+
record.error,
|
|
289
|
+
record.createdAt,
|
|
290
|
+
record.updatedAt,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async update(scopeKey: string, id: string, patch: Partial<Pick<DatasetRecord,
|
|
295
|
+
'name' | 'rowCount' | 'columns' | 'status' | 'error' | 'sourcePath' | 'sourceId' | 'sourceRef' | 'description'>>): Promise<void> {
|
|
296
|
+
const db = await this.database(scopeKey)
|
|
297
|
+
const assignments: string[] = ['updated_at = ?']
|
|
298
|
+
const params: unknown[] = [Date.now()]
|
|
299
|
+
if (patch.name !== undefined) {
|
|
300
|
+
assignments.push('name = ?')
|
|
301
|
+
params.push(patch.name)
|
|
302
|
+
}
|
|
303
|
+
if (patch.rowCount !== undefined) {
|
|
304
|
+
assignments.push('row_count = ?')
|
|
305
|
+
params.push(patch.rowCount)
|
|
306
|
+
}
|
|
307
|
+
if (patch.columns !== undefined) {
|
|
308
|
+
assignments.push('columns = ?')
|
|
309
|
+
params.push(JSON.stringify(patch.columns))
|
|
310
|
+
}
|
|
311
|
+
if (patch.status !== undefined) {
|
|
312
|
+
assignments.push('status = ?')
|
|
313
|
+
params.push(patch.status)
|
|
314
|
+
}
|
|
315
|
+
if (patch.error !== undefined) {
|
|
316
|
+
assignments.push('error = ?')
|
|
317
|
+
params.push(patch.error)
|
|
318
|
+
}
|
|
319
|
+
if (patch.sourcePath !== undefined) {
|
|
320
|
+
assignments.push('source_path = ?')
|
|
321
|
+
params.push(patch.sourcePath)
|
|
322
|
+
}
|
|
323
|
+
if (patch.sourceId !== undefined) {
|
|
324
|
+
assignments.push('source_id = ?')
|
|
325
|
+
params.push(patch.sourceId)
|
|
326
|
+
}
|
|
327
|
+
if (patch.sourceRef !== undefined) {
|
|
328
|
+
assignments.push('source_ref = ?')
|
|
329
|
+
params.push(patch.sourceRef)
|
|
330
|
+
}
|
|
331
|
+
if (patch.description !== undefined) {
|
|
332
|
+
assignments.push('description = ?')
|
|
333
|
+
params.push(patch.description)
|
|
334
|
+
}
|
|
335
|
+
params.push(id, scopeKey)
|
|
336
|
+
await db.prepare(`UPDATE datasets SET ${assignments.join(', ')} WHERE id = ? AND scope_key = ?`).run(...params)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async remove(scopeKey: string, id: string): Promise<void> {
|
|
340
|
+
const db = await this.database(scopeKey)
|
|
341
|
+
await db.prepare('DELETE FROM datasets WHERE id = ? AND scope_key = ?').run(id, scopeKey)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createStore(cfg: DataConfig): DatasetStore {
|
|
346
|
+
return new DatasetStore(cfg)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 后台任务注册表的最小视图(`ctx.jobs` 可选依赖)。 */
|
|
350
|
+
export interface JobRegistryLike {
|
|
351
|
+
start(spec: {
|
|
352
|
+
kind: string
|
|
353
|
+
label: string
|
|
354
|
+
owner?: unknown
|
|
355
|
+
run(): { cancel(reason?: string): void; done: Promise<unknown> }
|
|
356
|
+
}): string
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `dsh-host-webserver` 的最小视图(duck-typed,避免依赖 dsh 运行时)。
|
|
361
|
+
* 缺失时(CLI / TUI 剖面)不注册任何路由,视图能力整体降级。
|
|
362
|
+
*/
|
|
363
|
+
export interface WebServerLike {
|
|
364
|
+
register(route: {
|
|
365
|
+
kind: 'exact' | 'prefix'
|
|
366
|
+
path: string
|
|
367
|
+
handler(request: unknown, response: unknown): void | Promise<void>
|
|
368
|
+
}): () => void
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** `dsh-client-connection` 的最小视图:给自建路由复用平台鉴权。 */
|
|
372
|
+
export interface ConnectionLike {
|
|
373
|
+
/** 通过返回 undefined;否则返回应写入的 HTTP 状态码。 */
|
|
374
|
+
requestRejection(request: unknown): 401 | 403 | undefined
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 注入给工具的运行时依赖。 */
|
|
378
|
+
export interface DataServices {
|
|
379
|
+
cfg: DataConfig
|
|
380
|
+
store: DatasetStore
|
|
381
|
+
/** 数据源登记表(catalog 库,全局共享)。 */
|
|
382
|
+
sources: DataSourceStore
|
|
383
|
+
/** 解析一次调用的工作区;拿不到会话 cwd 时会 reject(`ScopeError`)。 */
|
|
384
|
+
scopeOf(exec: ToolExec): Promise<ScopeContext>
|
|
385
|
+
/** 可选:`ctx.jobs` 不可用时后台导入降级为前台执行。 */
|
|
386
|
+
jobs?: JobRegistryLike
|
|
387
|
+
/** 结果视图注册中心(设计文档 §6)。 */
|
|
388
|
+
views?: ViewRegistry
|
|
389
|
+
/** 可选:HTTP 路由宿主;缺失即不提供前端分页。 */
|
|
390
|
+
webServer?: WebServerLike
|
|
391
|
+
/** 可选:连接服务;缺失即不注册路由(自建路由必须有鉴权手段)。 */
|
|
392
|
+
connection?: ConnectionLike
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface ResolvedDataset {
|
|
396
|
+
scope: ScopeContext
|
|
397
|
+
record: DatasetRecord
|
|
398
|
+
db: Database
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 工具的统一入口:解析 scope → 断言归属 → 拿到库连接。 */
|
|
402
|
+
export async function resolveDataset(
|
|
403
|
+
services: DataServices,
|
|
404
|
+
exec: ToolExec,
|
|
405
|
+
reference: string,
|
|
406
|
+
options: { requireReady?: boolean } = {},
|
|
407
|
+
): Promise<ResolvedDataset> {
|
|
408
|
+
const scope = await services.scopeOf(exec)
|
|
409
|
+
const record = await services.store.require(scope.scopeKey, reference, options)
|
|
410
|
+
const db = await services.store.database(scope.scopeKey)
|
|
411
|
+
return { scope, record, db }
|
|
412
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 物理表的建/插/删 —— 移植自 `agentic-data-mini` 的 `src/lib/utils/tableManager.ts`。
|
|
3
|
+
*
|
|
4
|
+
* 保留的关键行为:`_row_id` 自增主键 + 业务列 + `_uploaded_at`;批量插入 100 行/批,
|
|
5
|
+
* 并按列类型做转换(boolean→0/1、numeric→Number、date→ISO、对象/数组→JSON)。
|
|
6
|
+
* 插件侧新增:批次之间的 `signal.throwIfAborted()` 检查点。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Database, Row } from './db'
|
|
10
|
+
import type { ColumnInfo, ColumnType } from './parse'
|
|
11
|
+
import { quoteIdentifier } from './sql'
|
|
12
|
+
|
|
13
|
+
/** 系统列:插入/更新时必须剔除。 */
|
|
14
|
+
export const SYSTEM_COLUMNS = ['_row_id', '_uploaded_at'] as const
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_BATCH_SIZE = 100
|
|
17
|
+
|
|
18
|
+
export function columnTypeToSqlite(type: ColumnType): string {
|
|
19
|
+
switch (type) {
|
|
20
|
+
case 'numeric': return 'REAL'
|
|
21
|
+
case 'boolean': return 'INTEGER'
|
|
22
|
+
case 'date': return 'TEXT'
|
|
23
|
+
default: return 'TEXT'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createDatasetTable(db: Database, tableName: string, columns: ColumnInfo[]): Promise<void> {
|
|
28
|
+
const definitions = columns.map(column => ` ${quoteIdentifier(column.sanitizedName)} ${columnTypeToSqlite(column.type)}`)
|
|
29
|
+
const sql = [
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(tableName)} (`,
|
|
31
|
+
' _row_id INTEGER PRIMARY KEY AUTOINCREMENT,',
|
|
32
|
+
definitions.join(',\n'),
|
|
33
|
+
" _uploaded_at INTEGER DEFAULT (strftime('%s', 'now'))",
|
|
34
|
+
')',
|
|
35
|
+
].join('\n')
|
|
36
|
+
await db.prepare(sql).run()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 按列类型转换一个值(批量插入与单行更新共用)。 */
|
|
40
|
+
export function coerceValue(raw: unknown, type: ColumnType): unknown {
|
|
41
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
42
|
+
switch (type) {
|
|
43
|
+
case 'boolean':
|
|
44
|
+
return raw === true || raw === 'true' || raw === '1' || raw === 1 || raw === 't' || raw === 'yes' ? 1 : 0
|
|
45
|
+
case 'numeric': {
|
|
46
|
+
const n = typeof raw === 'number' ? raw : Number(raw)
|
|
47
|
+
return Number.isFinite(n) ? n : null
|
|
48
|
+
}
|
|
49
|
+
case 'date': {
|
|
50
|
+
if (raw instanceof Date) return raw.toISOString()
|
|
51
|
+
if (typeof raw === 'string') {
|
|
52
|
+
const parsed = new Date(raw)
|
|
53
|
+
return Number.isNaN(parsed.getTime()) ? raw : parsed.toISOString()
|
|
54
|
+
}
|
|
55
|
+
return String(raw)
|
|
56
|
+
}
|
|
57
|
+
default: {
|
|
58
|
+
if (raw instanceof Date) return raw.toISOString()
|
|
59
|
+
if (typeof raw === 'boolean') return raw ? 1 : 0
|
|
60
|
+
if (typeof raw === 'object') return Buffer.isBuffer(raw) ? raw : JSON.stringify(raw)
|
|
61
|
+
return String(raw)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface InsertOptions {
|
|
67
|
+
batchSize?: number
|
|
68
|
+
signal?: AbortSignal
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 批量插入,返回实际插入行数;每批之间检查取消信号。 */
|
|
72
|
+
export async function insertRows(
|
|
73
|
+
db: Database,
|
|
74
|
+
tableName: string,
|
|
75
|
+
columns: ColumnInfo[],
|
|
76
|
+
rows: Record<string, unknown>[],
|
|
77
|
+
options: InsertOptions = {},
|
|
78
|
+
): Promise<number> {
|
|
79
|
+
if (rows.length === 0) return 0
|
|
80
|
+
if (columns.length === 0) throw new Error('没有可插入的列')
|
|
81
|
+
const batchSize = Math.max(1, Math.floor(options.batchSize ?? DEFAULT_BATCH_SIZE))
|
|
82
|
+
const names = columns.map(column => quoteIdentifier(column.sanitizedName)).join(', ')
|
|
83
|
+
let inserted = 0
|
|
84
|
+
for (let offset = 0; offset < rows.length; offset += batchSize) {
|
|
85
|
+
options.signal?.throwIfAborted()
|
|
86
|
+
const batch = rows.slice(offset, offset + batchSize)
|
|
87
|
+
const placeholders: string[] = []
|
|
88
|
+
const values: unknown[] = []
|
|
89
|
+
for (const row of batch) {
|
|
90
|
+
placeholders.push(`(${columns.map(() => '?').join(', ')})`)
|
|
91
|
+
for (const column of columns) values.push(coerceValue(row[column.sanitizedName], column.type))
|
|
92
|
+
}
|
|
93
|
+
await db.prepare(`INSERT INTO ${quoteIdentifier(tableName)} (${names}) VALUES ${placeholders.join(', ')}`).run(...values)
|
|
94
|
+
inserted += batch.length
|
|
95
|
+
}
|
|
96
|
+
return inserted
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function dropDatasetTable(db: Database, tableName: string): Promise<void> {
|
|
100
|
+
await db.prepare(`DROP TABLE IF EXISTS ${quoteIdentifier(tableName)}`).run()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function countRows(db: Database, tableName: string): Promise<number> {
|
|
104
|
+
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`).get()
|
|
105
|
+
return Number(row?.count ?? 0)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 剔除系统列,并报告未登记的列(写操作要明确拒绝未知列)。 */
|
|
109
|
+
export function sanitizeRowData(
|
|
110
|
+
data: Record<string, unknown>,
|
|
111
|
+
columns: ColumnInfo[],
|
|
112
|
+
): { values: Record<string, unknown>; unknownColumns: string[] } {
|
|
113
|
+
const known = new Set(columns.map(column => column.sanitizedName))
|
|
114
|
+
const values: Record<string, unknown> = {}
|
|
115
|
+
const unknownColumns: string[] = []
|
|
116
|
+
for (const [key, value] of Object.entries(data)) {
|
|
117
|
+
if ((SYSTEM_COLUMNS as readonly string[]).includes(key)) continue
|
|
118
|
+
if (!known.has(key)) {
|
|
119
|
+
unknownColumns.push(key)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
values[key] = value
|
|
123
|
+
}
|
|
124
|
+
return { values, unknownColumns }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 单行更新:按 `_row_id` 定位,返回受影响行数。 */
|
|
128
|
+
export async function updateRow(
|
|
129
|
+
db: Database,
|
|
130
|
+
tableName: string,
|
|
131
|
+
columns: ColumnInfo[],
|
|
132
|
+
rowId: number,
|
|
133
|
+
data: Record<string, unknown>,
|
|
134
|
+
): Promise<number> {
|
|
135
|
+
const { values, unknownColumns } = sanitizeRowData(data, columns)
|
|
136
|
+
if (unknownColumns.length > 0) throw new Error(`未知列:${unknownColumns.join(', ')}`)
|
|
137
|
+
const keys = Object.keys(values)
|
|
138
|
+
if (keys.length === 0) throw new Error('没有需要更新的列')
|
|
139
|
+
const byName = new Map(columns.map(column => [column.sanitizedName, column]))
|
|
140
|
+
const assignments = keys.map(key => `${quoteIdentifier(key)} = ?`)
|
|
141
|
+
const params = keys.map(key => coerceValue(values[key], byName.get(key)!.type))
|
|
142
|
+
const result = await db
|
|
143
|
+
.prepare(`UPDATE ${quoteIdentifier(tableName)} SET ${assignments.join(', ')} WHERE _row_id = ?`)
|
|
144
|
+
.run(...params, rowId)
|
|
145
|
+
return result.changes
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 单行删除:按 `_row_id` 定位,返回受影响行数。 */
|
|
149
|
+
export async function deleteRow(db: Database, tableName: string, rowId: number): Promise<number> {
|
|
150
|
+
const result = await db
|
|
151
|
+
.prepare(`DELETE FROM ${quoteIdentifier(tableName)} WHERE _row_id = ?`)
|
|
152
|
+
.run(rowId)
|
|
153
|
+
return result.changes
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 读取查询结果(列信息从首行推导)。 */
|
|
157
|
+
export async function selectRows(db: Database, sql: string, params: unknown[] = []): Promise<{ rows: Row[]; columns: string[] }> {
|
|
158
|
+
const rows = await db.prepare(sql).all(...params)
|
|
159
|
+
return { rows, columns: rows.length > 0 ? Object.keys(rows[0]!) : [] }
|
|
160
|
+
}
|