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/index.ts ADDED
@@ -0,0 +1,486 @@
1
+ /**
2
+ * 插件入口:`name` / `inject` / `Config` / `apply`。
3
+ *
4
+ * 装载链路与 `dsh-lh-judge` 完全一致:
5
+ * `pnpm run build` → `pnpm dsh plugin --profile web add D:\fastwork\projects\node\dsh-lh-data`
6
+ *
7
+ * 职责(设计文档 §7.2 / §9):
8
+ * - 注册八个 `dataset_*` 工具(句柄化,物理表名不外泄);
9
+ * - `tools/pre-execute` 给写工具加 `ask` 门禁(无审批通道即拒绝,fail-closed);
10
+ * - `ctx.tools.guard()` 单调兜底:写工具缺少归属句柄一律拒绝;
11
+ * - `ctx.systemPrompt.section()` 注入使用引导(可选依赖);
12
+ * - `ctx.effect()` 在 HMR / 卸载时关闭 libSQL 连接。
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import Schema from '@deepseek-ai/schemastery'
17
+ import { closeAllConnectors } from './datasource/connection'
18
+ import { usingDefaultEncryptKey } from './datasource/crypto'
19
+ import { createSourceStore } from './datasource/source-store'
20
+ import { closeAllDatabases } from './db'
21
+ import { resolveScope } from './scope'
22
+ import { createStore, type DataConfig, type DataServices } from './store'
23
+ import { createToolDefinitions, WRITE_TOOLS } from './tools/registry'
24
+ import { createViewRegistry, type ViewRegistry } from './view'
25
+ import { registerViewRoutes } from './http'
26
+ import { registerAdminRoutes } from './admin-http'
27
+ import { ADMIN_API_BASE } from './admin-contract'
28
+ import { PLUGIN_NAME, type PreToolDecision, type ToolDefinition, type ToolExec, type ToolRegistry } from './tooling'
29
+
30
+ /** cordis 用于加载/去重的唯一标识。 */
31
+ export const name: string = PLUGIN_NAME
32
+
33
+ /** 等 `ctx.tools` 就绪后才 apply。 */
34
+ export const inject: string[] = ['tools']
35
+
36
+ /** 插件与 dsh 运行时交互的最小上下文(duck-typed:只声明实际用到的成员)。 */
37
+ export interface PluginContext {
38
+ tools: ToolRegistry
39
+ // cordis 的中间件签名随事件变化,这里只保留插件需要的通用形状。
40
+ on(event: string, handler: (...args: any[]) => any): void
41
+ effect?(setup: () => (() => void) | void): () => void
42
+ inject?(deps: string[], callback: (injected: unknown) => void): unknown
43
+ logger?: { info?(message: string): void; warn?(message: string): void }
44
+ }
45
+
46
+ /** 系统提示词段落(可选依赖 `systemPrompt`)。 */
47
+ interface PromptSection {
48
+ name: string
49
+ order: number
50
+ text: string
51
+ }
52
+
53
+ /**
54
+ * 插件配置。类型与 `store.ts` 的 DataConfig 同源(在此只做具名导出),
55
+ * 避免 tools → index 的类型循环。
56
+ */
57
+ export interface Config extends DataConfig {}
58
+
59
+ // Config 必须是 schemastery(Standard Schema)实例,否则 cordis 校验阶段抛
60
+ // `Config.validate is undefined`(见 dsh-lh-judge/src/index.ts 的记录)。
61
+ export const Config: Schema<Config> = Schema.object({
62
+ dbPath: Schema.string().default('').description('libSQL 库路径;空 → $DSH_HOME/lh-data/data.db'),
63
+ dbUrl: Schema.string().default('').description('非空则覆盖 dbPath,可为 file: 或 libsql://(远程 Turso)'),
64
+ authToken: Schema.string().default('').description('远程 Turso token;建议留空并走环境变量 TURSO_AUTH_TOKEN'),
65
+ perWorkspace: Schema.boolean().default(false).description('true 时 scope 用 WorkspaceId 且每个工作区独立库文件'),
66
+ requireApprovalForWrites: Schema.boolean().default(true).description('写类工具是否走 ctx.approval 人工确认'),
67
+ allowRawSql: Schema.boolean().default(true).description('dataset_query 是否接受原始 sql;关闭后仅结构化查询'),
68
+ maxFileBytes: Schema.number().default(209715200).description('单个导入文件的字节上限'),
69
+ maxInsertRows: Schema.number().default(500).description('dataset_insert 单次插入行数上限'),
70
+ maxQueryRows: Schema.number().default(200).description('dataset_query 可服务行数的上限'),
71
+ batchSize: Schema.number().default(100).description('导入批量插入的批次大小'),
72
+ backgroundThresholdRows: Schema.number().default(20000).description('超过该行数自动转后台导入'),
73
+ previewSampleRows: Schema.number().default(100).description('类型推断的采样行数'),
74
+ readOnly: Schema.boolean().default(false).description('只读模式:写类工具直接 deny'),
75
+
76
+ // 结果视图(设计文档 §8):模型只拿片段,完整结果由前端分页拉取。
77
+ viewMode: Schema.union([
78
+ Schema.const('auto' as const),
79
+ Schema.const('always' as const),
80
+ Schema.const('never' as const),
81
+ ]).default('auto').description('结果视图模式:auto(超阈值才建视图)/ always / never'),
82
+ viewThresholdRows: Schema.number().default(20).description('超过该行数启用视图(auto 模式)'),
83
+ viewThresholdBytes: Schema.number().default(4096).description('片段字节数超过该值启用视图(auto 模式)'),
84
+ previewRows: Schema.number().default(5).description('模型可见的预览行数'),
85
+ previewStrategy: Schema.union([
86
+ Schema.const('head' as const),
87
+ Schema.const('head-tail' as const),
88
+ ]).default('head').description('预览行取法:head(前 N 行)/ head-tail(头尾各取)'),
89
+ previewCellChars: Schema.number().default(40).description('预览单元格截断长度'),
90
+ previewColumns: Schema.number().default(12).description('预览展示的列数上限'),
91
+ summaryEnabled: Schema.boolean().default(true).description('是否生成列统计摘要'),
92
+ summaryMaxColumns: Schema.number().default(24).description('参与摘要的列数上限'),
93
+ summaryMaxTextColumns: Schema.number().default(3).description('参与取值分布的文本列数上限'),
94
+ defaultPageSize: Schema.number().default(100).description('前端视图首页行数'),
95
+ maxPageSize: Schema.number().default(500).description('前端视图单页行数上限'),
96
+ maxViewRows: Schema.number().default(50000).description('单个视图可翻到的最大行数'),
97
+ viewRoutePrefix: Schema.string().default('/api/lh-data').description('前端分页接口的路由前缀'),
98
+
99
+ // 设置页管理接口(新增)
100
+ adminEnabled: Schema.boolean().default(true).description('是否挂载设置页管理接口(关闭后路由不注册,前端显示不可用)'),
101
+ adminMaxBodyBytes: Schema.number().default(65536).description('管理接口请求体字节上限'),
102
+ adminMaxDatasets: Schema.number().default(500).description('聚合列表扫描的数据集上限,超限截断并提示'),
103
+
104
+ // 数据源(关系型数据库)
105
+ datasourceEnabled: Schema.boolean().default(true).description('是否启用数据源(关闭后不注册 datasource_* 工具与 /sources 接口)'),
106
+ datasourceFetchBatchSize: Schema.number().default(1000).description('远端表分块拉取的行数'),
107
+ datasourceConnectTimeoutMs: Schema.number().default(10000).description('数据源连接 / 连通性测试的超时毫秒数'),
108
+ datasourceMaxImportRows: Schema.number().default(0).description('单次从远端表导入的行数上限,0 表示不限'),
109
+ datasourceEncryptKey: Schema.string().default('').description('数据源密码的加密密钥;留空则回落到环境变量 LH_DATA_ENCRYPT_KEY'),
110
+ })
111
+
112
+ /** 注入系统提示词的使用引导(替代 v1 的 SKILL.md)。 */
113
+ function usageSection(datasourceEnabled: boolean): string {
114
+ const base = [
115
+ '## 本地表格库(dsh-lh-data)',
116
+ '',
117
+ '- `dataset_import` 把工作区内的 .xlsx / .xls / .csv 落库;大文件自动转后台任务。',
118
+ '- 先 `dataset_list` 确认目标,再 `dataset_schema` 看列名与类型,然后 `dataset_query` 查询。',
119
+ '- 查询优先用结构化参数(columns / where / orderBy / limit);只有在需要聚合或连接时才传 `sql`。',
120
+ '- `dataset_query` 只返回少量预览行与全量统计摘要;结果较大时完整数据由前端表格展示,不要逐页读取全量。',
121
+ '- `dataset_insert` / `dataset_update` / `dataset_delete` / `dataset_drop` 是写操作,会触发人工确认。',
122
+ '- 所有工具用 datasetId 或登记名指代数据集;不要猜测或拼接物理表名。',
123
+ ]
124
+ if (!datasourceEnabled) return base.join('\n')
125
+ return [
126
+ ...base,
127
+ '- 还可以从数据库取数:`datasource_list` 看已登记的数据源 → `datasource_tables` 浏览远端表 →',
128
+ ' `datasource_import` 把某张表导入当前工作区,之后一律用 `dataset_*` 工具操作。',
129
+ ].join('\n')
130
+ }
131
+
132
+ const USAGE_SECTION_ORDER = 900
133
+
134
+ /** 默认加密密钥的告警一个进程只发一次。 */
135
+ let warnedDefaultKey = false
136
+
137
+ /**
138
+ * 非 cordis 环境(如 `examples/run-import.mjs` 直接调用 apply)的轻量校验/默认值合并。
139
+ * cordis 装载时配置已由 `Config` schema 校验并合并。
140
+ */
141
+ /** 从 schema 读取默认值(schema 的 default() 是唯一真源,避免两处漂移)。 */
142
+ function schemaDefaults(): Config {
143
+ const permissive = Config as unknown as (data?: unknown) => Config
144
+ return permissive(undefined)
145
+ }
146
+
147
+ export function validateConfig(cfg: Partial<DataConfig> = {}): Config {
148
+ if (cfg === null || typeof cfg !== 'object') throw new Error('config must be an object')
149
+ const source = cfg as Partial<DataConfig>
150
+ const merged: Config = { ...schemaDefaults(), ...source }
151
+ for (const key of [
152
+ 'maxFileBytes', 'maxInsertRows', 'maxQueryRows', 'batchSize', 'backgroundThresholdRows', 'previewSampleRows',
153
+ 'viewThresholdRows', 'viewThresholdBytes', 'previewRows', 'previewCellChars', 'previewColumns',
154
+ 'summaryMaxColumns', 'summaryMaxTextColumns', 'defaultPageSize', 'maxPageSize', 'maxViewRows',
155
+ 'adminMaxBodyBytes', 'adminMaxDatasets',
156
+ 'datasourceFetchBatchSize', 'datasourceConnectTimeoutMs', 'datasourceMaxImportRows',
157
+ ] as const) {
158
+ const value = merged[key]
159
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
160
+ throw new Error(`${key} must be a non-negative number`)
161
+ }
162
+ }
163
+ for (const key of [
164
+ 'perWorkspace', 'requireApprovalForWrites', 'allowRawSql', 'readOnly', 'summaryEnabled', 'datasourceEnabled',
165
+ ] as const) {
166
+ if (typeof merged[key] !== 'boolean') throw new Error(`${key} must be a boolean`)
167
+ }
168
+ for (const key of ['dbPath', 'dbUrl', 'authToken', 'datasourceEncryptKey'] as const) {
169
+ if (typeof merged[key] !== 'string') throw new Error(`${key} must be a string`)
170
+ }
171
+ if (!['auto', 'always', 'never'].includes(merged.viewMode)) {
172
+ throw new Error('viewMode must be one of auto / always / never')
173
+ }
174
+ if (!['head', 'head-tail'].includes(merged.previewStrategy)) {
175
+ throw new Error('previewStrategy must be one of head / head-tail')
176
+ }
177
+ if (typeof merged.viewRoutePrefix !== 'string' || !merged.viewRoutePrefix.startsWith('/')) {
178
+ throw new Error('viewRoutePrefix must be an absolute path starting with "/"')
179
+ }
180
+ return merged
181
+ }
182
+
183
+ /** 从一次写调用中提取归属句柄,供审批理由与单调守卫使用。 */
184
+ function targetOf(exec: ToolExec): string {
185
+ const args: unknown = exec.arguments
186
+ if (typeof args !== 'object' || args === null) return ''
187
+ const record = args as Record<string, unknown>
188
+ if (typeof record.dataset === 'string' && record.dataset.trim().length > 0) return record.dataset.trim()
189
+ if (typeof record.path === 'string' && record.path.trim().length > 0) return record.path.trim()
190
+ if (typeof record.source === 'string' && record.source.trim().length > 0) return record.source.trim()
191
+ return ''
192
+ }
193
+
194
+ /** 写工具缺归属句柄时的提示文案(各工具的必填参数名不同)。 */
195
+ function requiredHandleOf(name: string): string {
196
+ if (name === 'dataset_import') return 'path'
197
+ if (name === 'datasource_import') return 'source'
198
+ return 'dataset'
199
+ }
200
+
201
+ /** `tools/pre-execute` 的 next() 兜底:缺失或返回空值都按 allow 处理。 */
202
+ async function delegate(next: (() => Promise<PreToolDecision>) | undefined): Promise<PreToolDecision> {
203
+ if (typeof next !== 'function') return { kind: 'allow' }
204
+ return (await next()) ?? { kind: 'allow' }
205
+ }
206
+
207
+ // ── 工具调用日志 ──────────────────────────────────────────────────────────
208
+
209
+ /** 单个字符串参数的预览上限(dataset_insert 的 rows 可能很大,禁止整包进日志)。 */
210
+ const MAX_ARG_PREVIEW = 80
211
+
212
+ function previewText(value: string): string {
213
+ return value.length > MAX_ARG_PREVIEW ? `${value.slice(0, MAX_ARG_PREVIEW)}…(${value.length})` : value
214
+ }
215
+
216
+ /** 参数摘要:只记键名与规模,避免把整表数据写进日志。 */
217
+ function summarizeArgs(args: unknown): string {
218
+ if (typeof args !== 'object' || args === null) return String(args)
219
+ const record = args as Record<string, unknown>
220
+ const parts = Object.keys(record).map(key => {
221
+ const value = record[key]
222
+ if (typeof value === 'string') return `${key}=${previewText(value)}`
223
+ if (Array.isArray(value)) return `${key}[${value.length}]`
224
+ if (typeof value === 'object' && value !== null) return `${key}{${Object.keys(value).length}}`
225
+ return `${key}=${String(value)}`
226
+ })
227
+ return parts.length > 0 ? parts.join(' ') : '(no args)'
228
+ }
229
+
230
+ function errorMessage(error: unknown): string {
231
+ if (error instanceof Error) return `${error.name}: ${error.message}`
232
+ return String(error)
233
+ }
234
+
235
+ /**
236
+ * 给工具的 `execute` 包一层开始/结束日志(成功、失败、取消各一条,含耗时)。
237
+ * 只观测不介入:异常原样向上抛,返回值原样传出。
238
+ */
239
+ function withCallLogging(
240
+ definition: ToolDefinition,
241
+ log: (message: string) => void,
242
+ warn: (message: string) => void,
243
+ ): ToolDefinition {
244
+ const execute = definition.execute
245
+ return {
246
+ ...definition,
247
+ async execute(args: unknown, exec: ToolExec): Promise<unknown> {
248
+ const startedAt = Date.now()
249
+ log(`${definition.name} start ${summarizeArgs(args)}`)
250
+ try {
251
+ const value = await execute(args, exec)
252
+ const elapsed = Date.now() - startedAt
253
+ if (exec.signal?.aborted === true) warn(`${definition.name} end aborted in ${elapsed}ms`)
254
+ else log(`${definition.name} end ok in ${elapsed}ms`)
255
+ return value
256
+ } catch (error) {
257
+ warn(`${definition.name} end error in ${Date.now() - startedAt}ms: ${errorMessage(error)}`)
258
+ throw error
259
+ }
260
+ },
261
+ }
262
+ }
263
+
264
+ /**
265
+ * 装载前端分页路由(设计文档 §5.5 / §10)。
266
+ *
267
+ * - `webServer` 或 `connection` 任一缺失(CLI / TUI 剖面)→ 不注册、不启用视图;
268
+ * - 路由前缀冲突(webserver 契约抛错)→ 记 warn 并降级,插件其余能力不受影响;
269
+ * - 注册成功后才把 `views` 挂到 services,避免产生前端取不到的视图。
270
+ */
271
+ function mountViewRoutes(
272
+ rt: PluginContext,
273
+ services: DataServices,
274
+ views: ViewRegistry,
275
+ cfg: Config,
276
+ log: (message: string) => void,
277
+ warn: (message: string) => void,
278
+ ): void {
279
+ if (typeof rt.inject !== 'function') return
280
+ if (cfg.viewMode === 'never') {
281
+ log(`${PLUGIN_NAME}: 结果视图已关闭(viewMode=never)`)
282
+ return
283
+ }
284
+ rt.inject(['webServer', 'connection'], (injected: unknown) => {
285
+ const host = injected as { webServer?: DataServices['webServer']; connection?: DataServices['connection'] } | null | undefined
286
+ const { webServer, connection } = host ?? {}
287
+ if (webServer === undefined || connection === undefined) {
288
+ log(`${PLUGIN_NAME}: 未检测到 webServer/connection,结果视图降级为纯文本片段`)
289
+ return
290
+ }
291
+ services.webServer = webServer
292
+ services.connection = connection
293
+ services.views = views
294
+
295
+ let dispose: (() => void) | undefined
296
+ try {
297
+ dispose = registerViewRoutes(services)
298
+ } catch (error) {
299
+ services.views = undefined
300
+ warn(`${PLUGIN_NAME}: 结果视图路由注册失败(${errorMessage(error)}):已降级为纯文本片段`)
301
+ return
302
+ }
303
+ if (dispose === undefined) {
304
+ services.views = undefined
305
+ return
306
+ }
307
+
308
+ const release = dispose
309
+ if (typeof rt.effect === 'function') {
310
+ rt.effect(() => () => {
311
+ release()
312
+ views.clear()
313
+ })
314
+ }
315
+ log(`${PLUGIN_NAME}: 结果视图路由已挂载 ${cfg.viewRoutePrefix}/views(viewMode=${cfg.viewMode})`)
316
+
317
+ // 路由就绪后后台预载持久化视图(从各 scope 库读 lh_views),不阻塞路由挂载;
318
+ // 预载完成前若有极早期请求会因内存缓存为空而 404,但本地库预载通常远早于首个请求。
319
+ void views.loadAll().catch((error: unknown) => {
320
+ warn(`${PLUGIN_NAME}: 结果视图预载失败(${errorMessage(error)}):新视图仍可创建,重启前的历史视图需下次预载`)
321
+ })
322
+ })
323
+ }
324
+
325
+ /**
326
+ * 设置页管理接口路由挂载。与视图路由**相互独立**:
327
+ * - `adminEnabled=false` → 不注册,前端显示「接口不可用」;
328
+ * - `webServer` / `connection` 缺失 → 降级,不影响装载;
329
+ * - 注册抛错(如路径冲突)→ 记 warn 并降级,不拖垮视图路由。
330
+ */
331
+ function mountAdminRoutes(
332
+ rt: PluginContext,
333
+ services: DataServices,
334
+ cfg: Config,
335
+ log: (message: string) => void,
336
+ warn: (message: string) => void,
337
+ ): void {
338
+ if (typeof rt.inject !== 'function') return
339
+ if (cfg.adminEnabled === false) {
340
+ log(`${PLUGIN_NAME}: 设置页管理接口已关闭(adminEnabled=false)`)
341
+ return
342
+ }
343
+ rt.inject(['webServer', 'connection'], (injected: unknown) => {
344
+ const host = injected as { webServer?: DataServices['webServer']; connection?: DataServices['connection'] } | null | undefined
345
+ const { webServer, connection } = host ?? {}
346
+ if (webServer === undefined || connection === undefined) {
347
+ log(`${PLUGIN_NAME}: 未检测到 webServer/connection,设置页管理接口降级`)
348
+ return
349
+ }
350
+ services.webServer = webServer
351
+ services.connection = connection
352
+
353
+ let dispose: (() => void) | undefined
354
+ try {
355
+ dispose = registerAdminRoutes(services)
356
+ } catch (error) {
357
+ warn(`${PLUGIN_NAME}: 设置页管理接口路由注册失败(${errorMessage(error)}):已降级`)
358
+ return
359
+ }
360
+ if (dispose === undefined) return
361
+
362
+ const release = dispose
363
+ if (typeof rt.effect === 'function') rt.effect(() => () => release())
364
+ log(`${PLUGIN_NAME}: 设置页管理接口已挂载 ${ADMIN_API_BASE}`)
365
+ })
366
+ }
367
+
368
+ export function apply(ctx: Context, config: Config): void {
369
+ const rt = ctx as unknown as PluginContext
370
+ const cfg = config
371
+ const log = (message: string): void => {
372
+ rt.logger?.info?.(message)
373
+ }
374
+ const warn = (message: string): void => {
375
+ rt.logger?.warn?.(message)
376
+ }
377
+
378
+ const services: DataServices = {
379
+ cfg,
380
+ store: createStore(cfg),
381
+ sources: createSourceStore(cfg),
382
+ scopeOf: exec => resolveScope(rt, cfg, exec),
383
+ }
384
+
385
+ // 结果视图注册中心(设计文档 §6)。只有 HTTP 路由注册成功才挂到 services 上,
386
+ // 这样无浏览器剖面(CLI / TUI)的查询会自动降级为纯文本片段,不会产生取不到的视图。
387
+ const views = createViewRegistry({
388
+ routePrefix: cfg.viewRoutePrefix.replace(/\/+$/, ''),
389
+ defaultPageSize: cfg.defaultPageSize,
390
+ maxPageSize: cfg.maxPageSize,
391
+ maxViewRows: cfg.maxViewRows,
392
+ }, {
393
+ scopes: services.store.scopes,
394
+ database: (scopeKey: string) => services.store.database(scopeKey),
395
+ })
396
+
397
+ // 可选依赖:jobs(后台导入)、systemPrompt(使用引导)、webServer + connection(前端分页)。
398
+ // 缺失即降级,不影响装载。
399
+ if (typeof rt.inject === 'function') {
400
+ rt.inject(['jobs'], (injected: unknown) => {
401
+ const jobs = (injected as { jobs?: DataServices['jobs'] } | null | undefined)?.jobs
402
+ if (jobs !== undefined) services.jobs = jobs
403
+ })
404
+ mountViewRoutes(rt, services, views, cfg, log, warn)
405
+ mountAdminRoutes(rt, services, cfg, log, warn)
406
+ rt.inject(['systemPrompt'], (injected: unknown) => {
407
+ const systemPrompt = (injected as {
408
+ systemPrompt?: { section?(section: PromptSection): () => void }
409
+ } | null | undefined)?.systemPrompt
410
+ systemPrompt?.section?.({
411
+ name: `${PLUGIN_NAME}:usage`,
412
+ order: USAGE_SECTION_ORDER,
413
+ text: usageSection(cfg.datasourceEnabled),
414
+ })
415
+ })
416
+ }
417
+
418
+ // 注册即副作用:把 disposer 挂到插件 fiber,保证 HMR / 卸载时注销工具。
419
+ const unregister = createToolDefinitions(services)
420
+ .map(definition => withCallLogging(definition, log, warn))
421
+ .map(definition => rt.tools.register(definition))
422
+ if (typeof rt.effect === 'function') {
423
+ rt.effect(() => () => {
424
+ for (const dispose of unregister) dispose()
425
+ })
426
+ }
427
+
428
+ // 破坏性门禁:写工具 → ask(ctx.approval);只读模式下直接 deny。
429
+ rt.on('tools/pre-execute', async (exec: ToolExec, next?: () => Promise<PreToolDecision>): Promise<PreToolDecision> => {
430
+ if (!WRITE_TOOLS.has(exec.name)) return await delegate(next)
431
+ if (cfg.readOnly) {
432
+ return { kind: 'deny', reason: 'dsh-lh-data 处于只读模式(readOnly=true),写操作已禁用' }
433
+ }
434
+ if (!cfg.requireApprovalForWrites) return await delegate(next)
435
+ const target = targetOf(exec)
436
+ return {
437
+ kind: 'ask',
438
+ reason: `${exec.name} 会修改本地表格库${target.length > 0 ? `(目标:${target})` : ''}`,
439
+ }
440
+ })
441
+
442
+ // 单调守卫:写工具缺少归属句柄一律拒绝(后续监听器无法撤销)。
443
+ const unguard = rt.tools.guard?.(exec => {
444
+ if (!WRITE_TOOLS.has(exec.name)) return undefined
445
+ const target = targetOf(exec)
446
+ if (target.length === 0) {
447
+ return `${exec.name} 必须指定 ${requiredHandleOf(exec.name)}(datasetId / sourceId 或登记名)`
448
+ }
449
+ return undefined
450
+ })
451
+ if (unguard !== undefined && typeof rt.effect === 'function') rt.effect(() => () => unguard())
452
+
453
+ // 非 cordis 管理的资源挂到插件 fiber,卸载时确定性释放。
454
+ if (typeof rt.effect === 'function') {
455
+ rt.effect(() => () => {
456
+ closeAllDatabases()
457
+ void closeAllConnectors()
458
+ })
459
+ }
460
+
461
+ // 每次装载都提示会很吵(HMR / 多剖面会反复 apply),一个进程只说一次。
462
+ if (cfg.datasourceEnabled && !warnedDefaultKey && usingDefaultEncryptKey(cfg.datasourceEncryptKey)) {
463
+ warnedDefaultKey = true
464
+ warn(`${PLUGIN_NAME}: 未配置 datasourceEncryptKey / LH_DATA_ENCRYPT_KEY,数据源密码使用内置默认密钥(生产环境不安全)`)
465
+ }
466
+
467
+ log(`${PLUGIN_NAME} applied (readOnly=${String(cfg.readOnly)}, approval=${String(cfg.requireApprovalForWrites)})`)
468
+ }
469
+
470
+ // ── 测试/调试导出(不参与插件装载) ────────────────────────────────────────
471
+ export { closeAllDatabases } from './db'
472
+ export { buildColumns, fillSamples, mapRowKeys } from './datasource/columns'
473
+ export { decryptPassword, encryptPassword, isValidEncryptedFormat, resolveEncryptKey } from './datasource/crypto'
474
+ export { closeAllConnectors } from './datasource/connection'
475
+ export {
476
+ inferColumnType,
477
+ isCodeOrIdField,
478
+ isLargeNumber,
479
+ parseCSV,
480
+ parseXLSX,
481
+ sanitizeColumnName,
482
+ } from './parse'
483
+ export { canonicalDirectory, isInside, normalizeDirectory, resolveInputFile } from './scope'
484
+ export { SqlError, buildStructuredQuery, substituteDatasetAlias, validateReadOnlyQuery } from './sql'
485
+ export { PHYSICAL_TABLE_PATTERN, assertPhysicalTableName, generateTableName } from './store'
486
+ export { PLUGIN_NAME } from './tooling'