dsh-db-studio 0.0.0-stage → 1.0.2

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/lib/index.js ADDED
@@ -0,0 +1,481 @@
1
+ /**
2
+ * dsh-db-studio — Cordis entry (host half).
3
+ *
4
+ * Exposes two independent surfaces, both optional so a headless or non-HTTP
5
+ * assembly still gets the other:
6
+ *
7
+ * - `POST /dsh-db-studio/api` for the browser panel (overview, CRUD, rows,
8
+ * SQL).
9
+ * - model-facing tools `db_list_connections`, `db_describe_schema`,
10
+ * `db_query`, `db_execute`.
11
+ *
12
+ * The database work lives in `service.js`; this file only wires transports,
13
+ * config and disposal. Host packages (`@deepseek-ai/dsh-tools`, `webServer`)
14
+ * are probed through `optionalHostImport`, the same pattern dsh-bill uses, so
15
+ * a `link:`ed checkout resolves them from the harness entry point.
16
+ *
17
+ * @module dsh-db-studio
18
+ */
19
+
20
+ import { createStudio, DEFAULT_CONFIG } from './service.js'
21
+
22
+ const ROUTE = '/dsh-db-studio/api'
23
+ const MAX_BODY = 4 * 1024 * 1024
24
+
25
+ /** One issue in the Standard Schema config vocabulary. */
26
+ const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
27
+
28
+ /**
29
+ * Plugin config as a Standard Schema.
30
+ *
31
+ * Defaults are materialized here, before the fiber starts, so `apply` always
32
+ * receives a complete object and a typo is refused with its path named.
33
+ */
34
+ export const Config = {
35
+ '~standard': {
36
+ version: 1,
37
+ vendor: 'dsh-db-studio',
38
+ validate(input) {
39
+ if (input === undefined || input === null) return { value: { ...DEFAULT_CONFIG } }
40
+ if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
41
+ const issues = []
42
+ const value = {}
43
+ const booleans = ['demo', 'allowWrite', 'agentTools']
44
+ for (const key of booleans) {
45
+ const raw = input[key]
46
+ if (raw === undefined) value[key] = DEFAULT_CONFIG[key]
47
+ else if (typeof raw !== 'boolean') issues.push(issue('expected a boolean', key))
48
+ else value[key] = raw
49
+ }
50
+ const maxRows = input.maxRows
51
+ if (maxRows === undefined) value.maxRows = DEFAULT_CONFIG.maxRows
52
+ else if (typeof maxRows !== 'number' || !Number.isInteger(maxRows) || maxRows <= 0) issues.push(issue('expected a positive integer', 'maxRows'))
53
+ else value.maxRows = maxRows
54
+ const { dshHome } = input
55
+ if (dshHome !== undefined) {
56
+ if (typeof dshHome !== 'string' || !dshHome.trim()) issues.push(issue('expected a non-empty path', 'dshHome'))
57
+ else value.dshHome = dshHome
58
+ }
59
+ return issues.length ? { issues } : { value }
60
+ },
61
+ },
62
+ }
63
+
64
+ /** Import a package that exists only inside the harness graph. */
65
+ async function optionalHostImport(specifier) {
66
+ try {
67
+ return await import(specifier)
68
+ } catch {
69
+ const entry = process.argv[1]
70
+ if (typeof entry !== 'string' || entry === '') return null
71
+ try {
72
+ const { createRequire } = await import('node:module')
73
+ const { pathToFileURL } = await import('node:url')
74
+ const resolved = createRequire(entry).resolve(specifier)
75
+ return await import(pathToFileURL(resolved).href)
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Read a small JSON body. */
83
+ function readBody(req) {
84
+ return new Promise((resolve, reject) => {
85
+ let data = ''
86
+ req.on('data', (chunk) => {
87
+ data += chunk
88
+ if (data.length > MAX_BODY) {
89
+ req.destroy()
90
+ reject(new Error('请求体过大 / request body too large'))
91
+ }
92
+ })
93
+ req.on('end', () => {
94
+ if (!data.trim()) return resolve({})
95
+ try {
96
+ resolve(JSON.parse(data))
97
+ } catch {
98
+ reject(new Error('请求体不是合法 JSON / invalid JSON body'))
99
+ }
100
+ })
101
+ req.on('error', reject)
102
+ })
103
+ }
104
+
105
+ /** Format a cell for a markdown table. */
106
+ function cell(value) {
107
+ if (value === null || value === undefined) return 'NULL'
108
+ let text = typeof value === 'object' ? JSON.stringify(value) : String(value)
109
+ if (text === '') return ' '
110
+ text = text.replaceAll('|', '\\|').replaceAll('\n', ' ')
111
+ return text.length > 120 ? text.slice(0, 117) + '...' : text
112
+ }
113
+
114
+ /** Render columns/rows as a markdown table, capped for the transcript. */
115
+ function markdownTable(columns, rows, maxRows = 20) {
116
+ const cols = Array.isArray(columns) && columns.length ? columns : Object.keys(rows?.[0] ?? {})
117
+ if (cols.length === 0 || !rows?.length) return ''
118
+ const head = `| ${cols.map((name) => String(name).replaceAll('|', '\\|')).join(' | ')} |`
119
+ const sep = `| ${cols.map(() => '---').join(' | ')} |`
120
+ const body = rows.slice(0, maxRows).map((row) => `| ${cols.map((name) => cell(row?.[name])).join(' | ')} |`)
121
+ const more = rows.length > maxRows ? `\n_… 还有 ${rows.length - maxRows} 行未显示_` : ''
122
+ return [head, sep, ...body].join('\n') + more
123
+ }
124
+
125
+ /** The calling Session, when a tool was invoked from an agent turn. */
126
+ function sessionOf(exec) {
127
+ const session = exec?.agent?.session
128
+ return session === undefined ? null : { id: session.id, cwd: session.header?.cwd }
129
+ }
130
+
131
+ /**
132
+ * Resolve a connection id for a tool call.
133
+ *
134
+ * The conversation's selection is the authorization boundary: a Session with
135
+ * no databases selected is refused outright, and an explicit connectionId
136
+ * outside the selection is refused too. Only the IDE-side panel (no Session)
137
+ * sees every connection.
138
+ */
139
+ async function resolveConnectionId(studio, requested, session) {
140
+ const overview = await studio.listConnections()
141
+ const rows = overview.connections
142
+ const find = (id) => rows.find((row) => row.id === id)
143
+ if (!session) {
144
+ if (requested) {
145
+ const found = find(requested)
146
+ if (!found) throw new Error(`找不到连接 "${requested}"。可用连接:${rows.map((row) => `${row.id} (${row.name})`).join(', ') || '无'}`)
147
+ return found.id
148
+ }
149
+ if (rows.length === 1) return rows[0].id
150
+ if (rows.length === 0) throw new Error('还没有配置数据库连接。请让用户在「数据库」侧边栏面板新建连接后再试。')
151
+ throw new Error(`有多个数据库连接,请用 connectionId 指定:${rows.map((row) => `${row.id} (${row.name})`).join(', ')}`)
152
+ }
153
+ const state = await studio.sessionState(session.id, session.cwd)
154
+ const selected = state.selectedIds
155
+ if (selected.length === 0) {
156
+ throw new Error('当前会话还没有选择数据库。请让用户在输入框旁的多选下拉里选择数据库后再试。')
157
+ }
158
+ if (requested) {
159
+ const found = find(requested)
160
+ if (!found) throw new Error(`找不到连接 "${requested}"。当前会话可选:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
161
+ if (!selected.includes(requested)) {
162
+ throw new Error(`数据库 "${requested}" 不在当前会话的选择范围内。当前已选:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
163
+ }
164
+ return found.id
165
+ }
166
+ if (selected.length === 1) return selected[0]
167
+ throw new Error(`当前会话选中了多个数据库,请用 connectionId 指定:${selected.map((id) => `${id} (${find(id)?.name ?? id})`).join(', ')}`)
168
+ }
169
+
170
+ /** Build the four model-facing tools. */
171
+ function buildToolDefinitions(studio) {
172
+ return [
173
+ {
174
+ name: 'db_list_connections',
175
+ description: 'List the database connections configured in DSH DB Studio, with their ids, kinds and targets, and mark which ones are selected for this conversation. '
176
+ + 'Call this first when the user asks about data but you do not know which connection to use. Only selected connections may be queried. Passwords are never returned.',
177
+ parameters: {},
178
+ output: {
179
+ schema: {
180
+ type: 'object',
181
+ properties: {
182
+ summary: { type: 'string' },
183
+ connections: { type: 'array', items: { type: 'object', additionalProperties: true } },
184
+ drivers: { type: 'object', additionalProperties: true },
185
+ },
186
+ additionalProperties: true,
187
+ },
188
+ render: (_args, value) => [{ type: 'text', text: value?.summary ?? 'No connections.' }],
189
+ },
190
+ async execute(_args, exec) {
191
+ const session = sessionOf(exec)
192
+ const overview = await studio.listConnections()
193
+ const rows = overview.connections
194
+ let selectedIds = rows.map((row) => row.id)
195
+ if (session) {
196
+ const state = await studio.sessionState(session.id, session.cwd)
197
+ selectedIds = state.selectedIds
198
+ }
199
+ const marked = rows.map((row) => ({ ...row, selected: selectedIds.includes(row.id) }))
200
+ const selectedRows = marked.filter((row) => row.selected)
201
+ const summary = rows.length === 0
202
+ ? '还没有配置数据库连接。'
203
+ : (selectedRows.length === 0
204
+ ? '当前会话还没有选择数据库。请让用户先在输入框旁的数据库多选下拉里选择。\n'
205
+ : `当前会话已选择 ${selectedRows.length} 个数据库:\n` + selectedRows.map((row) => `- \`${row.id}\` · ${row.name} · ${row.kind}${row.demo ? '(示例)' : ''}`).join('\n') + '\n\n')
206
+ + `全部 ${rows.length} 个连接:\n` + marked.map((row) => `- \`${row.id}\` · ${row.name} · ${row.kind}${row.selected ? ' ✓ 已选' : ''}`).join('\n')
207
+ return { summary, connections: marked, selectedIds, drivers: overview.drivers }
208
+ },
209
+ },
210
+ {
211
+ name: 'db_describe_schema',
212
+ description: 'Inspect a database schema. Without `table`, lists every table and view. '
213
+ + 'With `table`, returns that table\'s columns (type, nullability, default, primary key) and row count. '
214
+ + 'Use it before writing SQL so column names and types are correct.',
215
+ parameters: {
216
+ connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
217
+ table: { type: 'string', description: "Table name, optionally schema-qualified (e.g. 'users' or 'public.users'). Omit to list tables." },
218
+ },
219
+ output: {
220
+ schema: {
221
+ type: 'object',
222
+ properties: {
223
+ summary: { type: 'string' },
224
+ tables: { type: 'array', items: { type: 'object', additionalProperties: true } },
225
+ table: { type: 'string' },
226
+ columns: { type: 'array', items: { type: 'object', additionalProperties: true } },
227
+ primaryKey: { type: 'array', items: { type: 'string' } },
228
+ rowCount: { type: 'number' },
229
+ },
230
+ additionalProperties: true,
231
+ },
232
+ render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
233
+ },
234
+ async execute(args, exec) {
235
+ const session = sessionOf(exec)
236
+ const id = await resolveConnectionId(studio, args?.connectionId, session)
237
+ const schema = await studio.schema(id, args?.table, session ? { sessionId: session.id, cwd: session.cwd } : {})
238
+ if (schema.tables) {
239
+ const list = schema.tables.map((row) => `- ${row.schema ? `${row.schema}.` : ''}${row.name} (${row.type})`)
240
+ return {
241
+ summary: `连接 \`${id}\` 中共有 ${schema.tables.length} 张表/视图:\n${list.join('\n') || '(空)'}`,
242
+ tables: schema.tables,
243
+ }
244
+ }
245
+ const lines = schema.columns.map((column) => {
246
+ const flags = [column.primaryKey ? 'PK' : '', column.notNull ? 'NOT NULL' : ''].filter(Boolean).join(' ')
247
+ return `- ${column.name} ${column.type}${flags ? ` — ${flags}` : ''}${column.defaultValue != null ? ` default ${column.defaultValue}` : ''}`
248
+ })
249
+ return {
250
+ summary: `表 \`${schema.table}\`(${schema.rowCount} 行)的字段:\n${lines.join('\n')}`,
251
+ table: schema.table,
252
+ columns: schema.columns,
253
+ primaryKey: schema.primaryKey,
254
+ rowCount: schema.rowCount,
255
+ }
256
+ },
257
+ },
258
+ {
259
+ name: 'db_query',
260
+ description: 'Run a READ-ONLY SQL query (SELECT / WITH / EXPLAIN / SHOW / PRAGMA) against a database selected for this conversation and return columns and rows. '
261
+ + 'Only the conversation\'s selected databases are allowed. Writes are rejected; use db_execute for INSERT/UPDATE/DELETE/DDL. '
262
+ + 'Results are capped by the plugin\'s maxRows; aggregate in SQL rather than fetching everything. '
263
+ + 'Use the exact table and column names returned by db_describe_schema.',
264
+ parameters: {
265
+ connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
266
+ sql: { type: 'string', required: true, description: 'One read-only SQL statement.' },
267
+ limit: { type: 'number', description: 'Maximum rows to return (default 200, capped by plugin config).' },
268
+ },
269
+ output: {
270
+ schema: {
271
+ type: 'object',
272
+ properties: {
273
+ summary: { type: 'string' },
274
+ columns: { type: 'array', items: { type: 'string' } },
275
+ rows: { type: 'array', items: { type: 'object', additionalProperties: true } },
276
+ rowCount: { type: 'number' },
277
+ truncated: { type: 'boolean' },
278
+ elapsedMs: { type: 'number' },
279
+ },
280
+ additionalProperties: true,
281
+ },
282
+ render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
283
+ },
284
+ async execute(args, exec) {
285
+ const session = sessionOf(exec)
286
+ const id = await resolveConnectionId(studio, args?.connectionId, session)
287
+ const result = await studio.query(id, args?.sql, { limit: args?.limit, ...(session ? { sessionId: session.id, cwd: session.cwd } : {}) })
288
+ const rows = result.rows ?? []
289
+ const summaryParts = [`查询成功,返回 ${rows.length} 行${result.truncated ? '(已截断)' : ''},耗时 ${result.elapsedMs} ms。`]
290
+ const table = markdownTable(result.columns, rows, 25)
291
+ if (table) summaryParts.push(table)
292
+ else summaryParts.push('(没有数据行)')
293
+ return {
294
+ summary: summaryParts.join('\n'),
295
+ columns: result.columns,
296
+ rows,
297
+ rowCount: rows.length,
298
+ truncated: result.truncated === true,
299
+ elapsedMs: result.elapsedMs,
300
+ }
301
+ },
302
+ },
303
+ {
304
+ name: 'db_execute',
305
+ description: 'Run a single write statement (INSERT / UPDATE / DELETE / CREATE / ALTER / DROP) against a database selected for this conversation. '
306
+ + 'Only the conversation\'s selected databases are allowed. Requires `confirm: true`; only set it after the user has clearly asked for the change. '
307
+ + 'Prefer a targeted WHERE clause, and report the affected row count afterwards. '
308
+ + 'Rejected when the connection is read-only or the plugin config disables writes.',
309
+ parameters: {
310
+ connectionId: { type: 'string', description: 'Connection id from db_list_connections. Optional when the conversation has exactly one selected database; only selected databases are allowed.' },
311
+ sql: { type: 'string', required: true, description: 'One write statement.' },
312
+ confirm: { type: 'boolean', required: true, description: 'Explicitly confirm the write. Set true only when the user asked for this change.' },
313
+ },
314
+ output: {
315
+ schema: {
316
+ type: 'object',
317
+ properties: {
318
+ summary: { type: 'string' },
319
+ changes: { type: 'number' },
320
+ lastInsertRowid: { type: 'string' },
321
+ elapsedMs: { type: 'number' },
322
+ },
323
+ additionalProperties: true,
324
+ },
325
+ render: (_args, value) => [{ type: 'text', text: value?.summary ?? '' }],
326
+ },
327
+ async execute(args, exec) {
328
+ if (args?.confirm !== true) throw new Error('写操作需要显式确认:请先在对话中得到用户同意,再以 confirm: true 重试。')
329
+ const session = sessionOf(exec)
330
+ const id = await resolveConnectionId(studio, args?.connectionId, session)
331
+ const result = await studio.execute(id, args?.sql, session ? { sessionId: session.id, cwd: session.cwd } : {})
332
+ const affected = result.changes ?? 0
333
+ return {
334
+ summary: `执行成功:影响 ${affected} 行${result.lastInsertRowid != null ? `,最后插入 id = ${result.lastInsertRowid}` : ''},耗时 ${result.elapsedMs ?? 0} ms。`,
335
+ changes: affected,
336
+ lastInsertRowid: result.lastInsertRowid == null ? '' : String(result.lastInsertRowid),
337
+ elapsedMs: result.elapsedMs ?? 0,
338
+ }
339
+ },
340
+ },
341
+ ]
342
+ }
343
+
344
+ /** Dispatch one HTTP request body. */
345
+ async function dispatch(studio, body, options = {}) {
346
+ const action = body?.action
347
+ if (!action) throw new Error('缺少 action / missing action')
348
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : undefined
349
+ const cwd = sessionId ? (body.cwd ?? options.cwdOf?.(sessionId)) : undefined
350
+ const scoped = sessionId ? { sessionId, cwd } : {}
351
+ switch (action) {
352
+ case 'overview':
353
+ case 'list':
354
+ return { ok: true, ...(await studio.listConnections()) }
355
+ case 'session-state':
356
+ return { ok: true, ...(await studio.sessionState(sessionId, cwd)) }
357
+ case 'session-set':
358
+ return { ok: true, ...(await studio.setSessionSelection(sessionId, body.ids, cwd)) }
359
+ case 'session-status':
360
+ return { ok: true, ...(await studio.sessionStatus(sessionId, cwd)) }
361
+ case 'tables':
362
+ return { ok: true, ...(await studio.schema(body.id, undefined, scoped)) }
363
+ case 'describe':
364
+ return { ok: true, ...(await studio.schema(body.id, body.table, scoped)) }
365
+ case 'rows':
366
+ return { ok: true, ...(await studio.rows(body.id, body.table, { limit: body.limit, offset: body.offset, ...scoped })) }
367
+ case 'query':
368
+ return { ok: true, ...(await studio.query(body.id, body.sql, { limit: body.limit, ...scoped })) }
369
+ case 'execute':
370
+ return { ok: true, ...(await studio.execute(body.id, body.sql, scoped)) }
371
+ case 'test':
372
+ return { ok: true, ...(await studio.testConnection(body.connection ?? body)) }
373
+ case 'add':
374
+ return { ok: true, connection: await studio.addConnection(body.connection ?? body) }
375
+ case 'update':
376
+ return { ok: true, connection: await studio.updateConnection(body.id, body.patch ?? {}) }
377
+ case 'remove':
378
+ return { ok: true, removed: await studio.removeConnection(body.id) }
379
+ case 'seed-demo':
380
+ return { ok: true, ...(await studio.seedDemo()) }
381
+ case 'ping':
382
+ return { ok: true, time: Date.now() }
383
+ default:
384
+ throw new Error(`未知操作 / unknown action: ${action}`)
385
+ }
386
+ }
387
+
388
+ /** Cordis plugin entry. */
389
+ export default {
390
+ name: 'dsh-db-studio',
391
+ Config,
392
+ inject: [],
393
+ apply(ctx, config = null) {
394
+ const cfg = config ?? { ...DEFAULT_CONFIG }
395
+ const studio = createStudio({ dshHome: cfg.dshHome, config: cfg })
396
+
397
+ // Kick the load/demo seed off now; individual requests await the same
398
+ // promise, so the first request after boot cannot race it.
399
+ studio.ready().catch((error) => {
400
+ console.warn('dsh-db-studio: initialization failed —', error?.message ?? error)
401
+ })
402
+
403
+ ctx.effect(() => () => studio.dispose().catch(() => {}), 'dsh-db-studio: adapters')
404
+
405
+ // Live Session lookup for workspace-aware defaults. `sessions` may not be
406
+ // mounted in every assembly, so this is a probe rather than an inject.
407
+ const cwdOf = (sessionId) => {
408
+ try {
409
+ const sessions = ctx.get('sessions')
410
+ return sessions?.get?.(sessionId)?.header?.cwd
411
+ } catch {
412
+ return undefined
413
+ }
414
+ }
415
+
416
+ // ── HTTP carrier for the browser panel ────────────────────────────────
417
+ ctx.inject(['webServer'], (child) => {
418
+ child.effect(() => child.webServer.register({
419
+ kind: 'exact',
420
+ path: ROUTE,
421
+ handler: async (req, res) => {
422
+ const send = (status, payload) => {
423
+ res.writeHead(status, {
424
+ 'Content-Type': 'application/json; charset=utf-8',
425
+ 'Cache-Control': 'no-store',
426
+ })
427
+ res.end(JSON.stringify(payload))
428
+ }
429
+ // Only same-origin pages may talk to this route. A cross-origin
430
+ // fetch that uses a "simple" content type (text/plain) would skip
431
+ // preflight and could otherwise write data without reading a reply.
432
+ const origin = req.headers?.origin
433
+ if (typeof origin === 'string' && origin !== '') {
434
+ let sameOrigin = false
435
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
436
+ if (!sameOrigin) {
437
+ send(403, { ok: false, error: 'cross-origin request refused' })
438
+ return
439
+ }
440
+ }
441
+ if (req.method !== 'POST') {
442
+ send(200, { ok: true, service: 'dsh-db-studio', route: ROUTE })
443
+ return
444
+ }
445
+ try {
446
+ const body = await readBody(req)
447
+ send(200, await dispatch(studio, body, { cwdOf }))
448
+ } catch (error) {
449
+ send(200, { ok: false, error: error?.message ?? String(error) })
450
+ }
451
+ },
452
+ }), 'dsh-db-studio: http api')
453
+ })
454
+
455
+ // ── model-facing tools ───────────────────────────────────────────────
456
+ if (cfg.agentTools !== false) {
457
+ ctx.inject(['tools'], (child) => {
458
+ optionalHostImport('@deepseek-ai/dsh-tools').then((mod) => {
459
+ if (!mod?.defineTool) {
460
+ console.warn('dsh-db-studio: @deepseek-ai/dsh-tools not reachable; agent tools are disabled')
461
+ return
462
+ }
463
+ try {
464
+ child.effect(() => {
465
+ const definitions = buildToolDefinitions(studio)
466
+ const disposers = definitions.map((definition) => child.tools.register(mod.defineTool(definition)))
467
+ console.info('dsh-db-studio: registered ' + definitions.length + ' agent tools (' + definitions.map((definition) => definition.name).join(', ') + ')')
468
+ return () => { for (const dispose of disposers) dispose?.() }
469
+ }, 'dsh-db-studio: agent tools')
470
+ } catch (error) {
471
+ console.warn('dsh-db-studio: agent tools not registered —', error?.message ?? error)
472
+ }
473
+ }).catch((error) => {
474
+ console.warn('dsh-db-studio: agent tools not registered —', error?.message ?? error)
475
+ })
476
+ })
477
+ }
478
+ },
479
+ }
480
+
481
+ export { buildToolDefinitions, dispatch, markdownTable, ROUTE }