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
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
+ }