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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.en.md +94 -0
- package/README.md +91 -2
- package/cordis.patch.yml +11 -0
- package/docs/conversation.png +0 -0
- package/docs/demo.gif +0 -0
- package/docs/panel.png +0 -0
- package/lib/client.js +2313 -0
- package/lib/drivers.js +456 -0
- package/lib/home.js +38 -0
- package/lib/index.js +481 -0
- package/lib/service.js +448 -0
- package/lib/session-store.js +223 -0
- package/lib/store.js +203 -0
- package/package.json +69 -4
package/lib/service.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DatabaseStudio — the headless half of dsh-db-studio.
|
|
3
|
+
*
|
|
4
|
+
* Owns the connection store, one cached adapter per connection, the demo
|
|
5
|
+
* database, and the operation vocabulary shared by the HTTP route and the
|
|
6
|
+
* agent tools. Nothing here imports Cordis, so the whole service is testable in
|
|
7
|
+
* a plain Node process.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-db-studio/service
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chmod, mkdir, stat } from 'node:fs/promises'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
16
|
+
import { isSingleStatement, openConnection } from './drivers.js'
|
|
17
|
+
import { resolveDshHome, studioHome } from './home.js'
|
|
18
|
+
import { ConnectionStore, connectionTarget, normalizeConnection, publicConnection } from './store.js'
|
|
19
|
+
import { SessionSelectionStore } from './session-store.js'
|
|
20
|
+
|
|
21
|
+
const DEMO_ID = 'demo'
|
|
22
|
+
const DEMO_NAME = '示例数据库'
|
|
23
|
+
const ADAPTER_IDLE_MS = 5 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CONFIG = {
|
|
26
|
+
demo: true,
|
|
27
|
+
allowWrite: true,
|
|
28
|
+
agentTools: true,
|
|
29
|
+
maxRows: 200,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Clamp a user-supplied row limit into the configured window. */
|
|
33
|
+
function clampLimit(value, fallback, max) {
|
|
34
|
+
const n = Number.isFinite(Number(value)) ? Math.floor(Number(value)) : fallback
|
|
35
|
+
return Math.min(Math.max(n, 1), max)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Display the default harness home symbolically in the sidebar. */
|
|
39
|
+
function displayHome(home) {
|
|
40
|
+
const usual = join(homedir(), '.dsh')
|
|
41
|
+
return home === usual ? '~/.dsh' : home
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Check whether an optional driver package can be imported. */
|
|
45
|
+
async function driverAvailable(name) {
|
|
46
|
+
try {
|
|
47
|
+
await import(name)
|
|
48
|
+
return true
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The seed script for the demo database. */
|
|
55
|
+
const DEMO_SQL = `
|
|
56
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
57
|
+
id INTEGER PRIMARY KEY,
|
|
58
|
+
name TEXT NOT NULL,
|
|
59
|
+
email TEXT NOT NULL UNIQUE,
|
|
60
|
+
city TEXT NOT NULL,
|
|
61
|
+
vip INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
created_at TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
65
|
+
id INTEGER PRIMARY KEY,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
category TEXT NOT NULL,
|
|
68
|
+
price REAL NOT NULL,
|
|
69
|
+
stock INTEGER NOT NULL DEFAULT 0
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
72
|
+
id INTEGER PRIMARY KEY,
|
|
73
|
+
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
74
|
+
product_id INTEGER NOT NULL REFERENCES products(id),
|
|
75
|
+
quantity INTEGER NOT NULL,
|
|
76
|
+
total REAL NOT NULL,
|
|
77
|
+
status TEXT NOT NULL,
|
|
78
|
+
created_at TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
INSERT INTO users (id, name, email, city, vip, created_at) VALUES
|
|
81
|
+
(1, '林小雨', 'xiaoyu.lin@example.com', '杭州', 1, '2026-01-12 09:20:00'),
|
|
82
|
+
(2, '陈默', 'mo.chen@example.com', '上海', 0, '2026-02-03 14:05:00'),
|
|
83
|
+
(3, '苏晚', 'wan.su@example.com', '北京', 1, '2026-02-18 18:41:00'),
|
|
84
|
+
(4, '周野', 'ye.zhou@example.com', '成都', 0, '2026-03-02 11:10:00'),
|
|
85
|
+
(5, '顾清', 'qing.gu@example.com', '深圳', 1, '2026-03-21 08:55:00'),
|
|
86
|
+
(6, '沈舟', 'zhou.shen@example.com', '南京', 0, '2026-04-09 16:30:00'),
|
|
87
|
+
(7, '许知意', 'zhiyi.xu@example.com', '武汉', 0, '2026-05-17 10:15:00'),
|
|
88
|
+
(8, '江屿', 'yu.jiang@example.com', '厦门', 1, '2026-06-08 21:00:00');
|
|
89
|
+
INSERT INTO products (id, name, category, price, stock) VALUES
|
|
90
|
+
(1, '手冲咖啡豆 · 耶加雪菲', '咖啡', 128.0, 42),
|
|
91
|
+
(2, '冷萃咖啡液', '咖啡', 69.0, 120),
|
|
92
|
+
(3, '陶瓷马克杯', '器物', 89.0, 35),
|
|
93
|
+
(4, '桌面收纳盒', '器物', 159.0, 18),
|
|
94
|
+
(5, '机械键盘 · 青轴', '数码', 399.0, 12),
|
|
95
|
+
(6, '无线鼠标', '数码', 229.0, 24),
|
|
96
|
+
(7, '雪山香薰蜡烛', '香氛', 118.0, 48),
|
|
97
|
+
(8, '旅行帆布包', '出行', 199.0, 30);
|
|
98
|
+
INSERT INTO orders (id, user_id, product_id, quantity, total, status, created_at) VALUES
|
|
99
|
+
(1, 1, 1, 2, 256.0, 'completed', '2026-07-02 10:12:00'),
|
|
100
|
+
(2, 2, 3, 1, 89.0, 'completed', '2026-07-05 15:44:00'),
|
|
101
|
+
(3, 3, 5, 1, 399.0, 'completed', '2026-07-09 09:03:00'),
|
|
102
|
+
(4, 1, 7, 1, 118.0, 'completed', '2026-07-15 20:21:00'),
|
|
103
|
+
(5, 4, 2, 3, 207.0, 'completed', '2026-07-21 13:38:00'),
|
|
104
|
+
(6, 5, 6, 1, 229.0, 'paid', '2026-08-01 11:07:00'),
|
|
105
|
+
(7, 6, 4, 1, 159.0, 'paid', '2026-08-06 17:52:00'),
|
|
106
|
+
(8, 7, 8, 1, 199.0, 'paid', '2026-08-14 08:26:00'),
|
|
107
|
+
(9, 8, 1, 1, 128.0, 'completed', '2026-08-20 19:45:00'),
|
|
108
|
+
(10, 3, 2, 2, 138.0, 'cancelled', '2026-08-24 12:11:00'),
|
|
109
|
+
(11, 5, 7, 2, 236.0, 'completed', '2026-09-02 16:02:00'),
|
|
110
|
+
(12, 2, 8, 1, 199.0, 'paid', '2026-09-09 10:33:00'),
|
|
111
|
+
(13, 6, 1, 2, 256.0, 'completed', '2026-09-15 21:18:00'),
|
|
112
|
+
(14, 4, 6, 1, 229.0, 'paid', '2026-09-23 14:50:00'),
|
|
113
|
+
(15, 7, 3, 2, 178.0, 'completed', '2026-10-01 09:41:00'),
|
|
114
|
+
(16, 8, 4, 1, 159.0, 'paid', '2026-10-06 18:07:00'),
|
|
115
|
+
(17, 1, 8, 1, 199.0, 'completed', '2026-10-12 13:22:00'),
|
|
116
|
+
(18, 3, 1, 3, 384.0, 'paid', '2026-10-17 11:56:00');
|
|
117
|
+
`
|
|
118
|
+
|
|
119
|
+
export class DatabaseStudio {
|
|
120
|
+
constructor(options = {}) {
|
|
121
|
+
this.config = { ...DEFAULT_CONFIG, ...(options.config ?? {}) }
|
|
122
|
+
this.home = resolveDshHome(options.dshHome, options.env ?? process.env)
|
|
123
|
+
this.dir = studioHome(this.home)
|
|
124
|
+
this.demoFile = join(this.dir, 'demo.db')
|
|
125
|
+
this.store = new ConnectionStore(this.home)
|
|
126
|
+
this.selections = new SessionSelectionStore(this.home)
|
|
127
|
+
this.adapters = new Map()
|
|
128
|
+
this.writeQueues = new Map()
|
|
129
|
+
this.initPromise = null
|
|
130
|
+
this.driverCache = null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Load the store and seed the demo connection on first use. */
|
|
134
|
+
ready() {
|
|
135
|
+
this.initPromise ??= this.#init()
|
|
136
|
+
return this.initPromise
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #init() {
|
|
140
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
141
|
+
// Tighten an existing demo file too: older builds let node:sqlite create it
|
|
142
|
+
// with the process umask.
|
|
143
|
+
await chmod(this.demoFile, 0o600).catch(() => {})
|
|
144
|
+
await this.store.load()
|
|
145
|
+
await this.selections.load()
|
|
146
|
+
if (this.config.demo && this.store.list().length === 0) {
|
|
147
|
+
try {
|
|
148
|
+
await this.#ensureDemo()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.warn('dsh-db-studio: demo database could not be created —', error?.message ?? error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Create and seed the demo SQLite database, then register its connection. */
|
|
156
|
+
async #ensureDemo() {
|
|
157
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
158
|
+
let exists = false
|
|
159
|
+
try {
|
|
160
|
+
exists = (await stat(this.demoFile)).isFile()
|
|
161
|
+
} catch {
|
|
162
|
+
exists = false
|
|
163
|
+
}
|
|
164
|
+
const db = new DatabaseSync(this.demoFile)
|
|
165
|
+
try {
|
|
166
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('users','products','orders')").all()
|
|
167
|
+
if (!exists || tables.length < 3) db.exec(DEMO_SQL)
|
|
168
|
+
} finally {
|
|
169
|
+
db.close()
|
|
170
|
+
}
|
|
171
|
+
// Node's SQLite creates the file with the process umask. The demo may hold
|
|
172
|
+
// sample rows a user asked to keep, so give it the same private mode as the
|
|
173
|
+
// connection store.
|
|
174
|
+
await chmod(this.demoFile, 0o600).catch(() => {})
|
|
175
|
+
await this.store.replaceAll([
|
|
176
|
+
{
|
|
177
|
+
id: DEMO_ID,
|
|
178
|
+
name: DEMO_NAME,
|
|
179
|
+
kind: 'sqlite',
|
|
180
|
+
file: this.demoFile,
|
|
181
|
+
readOnly: false,
|
|
182
|
+
demo: true,
|
|
183
|
+
createdAt: new Date().toISOString(),
|
|
184
|
+
updatedAt: new Date().toISOString(),
|
|
185
|
+
},
|
|
186
|
+
])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The browser-safe overview the panel and the list tool return. */
|
|
190
|
+
async overview() {
|
|
191
|
+
await this.ready()
|
|
192
|
+
this.driverCache ??= Promise.all([
|
|
193
|
+
driverAvailable('pg'),
|
|
194
|
+
driverAvailable('mysql2'),
|
|
195
|
+
]).then(([postgres, mysql]) => ({ sqlite: true, postgres, mysql }))
|
|
196
|
+
return {
|
|
197
|
+
connections: this.store.list(),
|
|
198
|
+
drivers: await this.driverCache,
|
|
199
|
+
home: displayHome(this.home),
|
|
200
|
+
demoFile: this.demoFile,
|
|
201
|
+
allowWrite: this.config.allowWrite !== false,
|
|
202
|
+
config: { demo: this.config.demo !== false, maxRows: this.config.maxRows },
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The store, loaded. */
|
|
207
|
+
async connection(id) {
|
|
208
|
+
await this.ready()
|
|
209
|
+
const connection = id ? this.store.get(id) : this.store.first()
|
|
210
|
+
if (!connection) {
|
|
211
|
+
if (this.store.list().length === 0) throw new Error('还没有数据库连接,请先在「数据库」面板新建连接 / no database connection configured')
|
|
212
|
+
throw new Error('连接不存在 / connection not found')
|
|
213
|
+
}
|
|
214
|
+
return connection
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async listConnections() {
|
|
218
|
+
return this.overview()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The per-conversation state shared by the picker, the conversation tab and
|
|
223
|
+
* the status strip. `ensure()` supplies the default selection (recently used
|
|
224
|
+
* connections in the Session's workspace) the first time a Session asks.
|
|
225
|
+
*/
|
|
226
|
+
async sessionState(sessionId, cwd) {
|
|
227
|
+
await this.ready()
|
|
228
|
+
if (!sessionId) throw new Error('缺少会话 id / missing session id')
|
|
229
|
+
const connections = this.store.list()
|
|
230
|
+
const selectedIds = await this.selections.ensure(sessionId, connections, cwd)
|
|
231
|
+
const selected = selectedIds
|
|
232
|
+
.map((id) => connections.find((connection) => connection.id === id))
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
return { sessionId, cwd: cwd ?? null, selectedIds, selected, connections }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Replace one Session's selection (empty is valid). */
|
|
238
|
+
async setSessionSelection(sessionId, ids, cwd) {
|
|
239
|
+
await this.ready()
|
|
240
|
+
if (!sessionId) throw new Error('缺少会话 id / missing session id')
|
|
241
|
+
const valid = new Set(this.store.list().map((connection) => connection.id))
|
|
242
|
+
const chosen = (Array.isArray(ids) ? ids : []).filter((id) => valid.has(id))
|
|
243
|
+
await this.selections.set(sessionId, chosen, cwd)
|
|
244
|
+
return this.sessionState(sessionId, cwd)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Ping every selected connection and report table counts for the status strip. */
|
|
248
|
+
async sessionStatus(sessionId, cwd) {
|
|
249
|
+
const state = await this.sessionState(sessionId, cwd)
|
|
250
|
+
const rows = await Promise.all(state.selected.map(async (publicRow) => {
|
|
251
|
+
const connection = this.store.get(publicRow.id)
|
|
252
|
+
if (!connection) return { ...publicRow, ok: false, error: '连接不存在 / connection not found' }
|
|
253
|
+
const started = Date.now()
|
|
254
|
+
let timer = null
|
|
255
|
+
try {
|
|
256
|
+
const adapter = await this.#adapter(connection)
|
|
257
|
+
const timeout = new Promise((_, reject) => {
|
|
258
|
+
timer = setTimeout(() => reject(new Error('连接超时 / connection timed out')), 6000)
|
|
259
|
+
})
|
|
260
|
+
const tables = await Promise.race([adapter.listTables(), timeout])
|
|
261
|
+
return {
|
|
262
|
+
...publicRow,
|
|
263
|
+
ok: true,
|
|
264
|
+
latencyMs: Date.now() - started,
|
|
265
|
+
tableCount: Array.isArray(tables) ? tables.length : 0,
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// Drop a failed adapter so the next status check reconnects. Without
|
|
269
|
+
// this a pool refused at startup would keep failing forever.
|
|
270
|
+
await this.closeAdapter(connection.id).catch(() => {})
|
|
271
|
+
return { ...publicRow, ok: false, latencyMs: Date.now() - started, error: error?.message ?? String(error) }
|
|
272
|
+
} finally {
|
|
273
|
+
if (timer !== null) clearTimeout(timer)
|
|
274
|
+
}
|
|
275
|
+
}))
|
|
276
|
+
return { sessionId, cwd: cwd ?? null, checkedAt: Date.now(), connections: rows }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Strict per-Session authorization used by both the route and the tools. */
|
|
280
|
+
async assertSessionConnection(sessionId, connectionId, cwd) {
|
|
281
|
+
const state = await this.sessionState(sessionId, cwd)
|
|
282
|
+
if (state.selectedIds.length === 0) {
|
|
283
|
+
throw new Error('当前会话还没有选择数据库,请先在输入框旁选择数据库 / no database is selected for this conversation')
|
|
284
|
+
}
|
|
285
|
+
if (!state.selectedIds.includes(connectionId)) {
|
|
286
|
+
throw new Error('该数据库不在当前会话的选择范围内 / this database is not selected for this conversation')
|
|
287
|
+
}
|
|
288
|
+
return state
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Record a use for future default selections. */
|
|
292
|
+
touchUsage(connectionId, sessionId, cwd) {
|
|
293
|
+
this.selections.touch(connectionId, { sessionId, cwd })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async addConnection(input) {
|
|
297
|
+
await this.ready()
|
|
298
|
+
const connection = await this.store.add(input)
|
|
299
|
+
return connection
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async updateConnection(id, patch) {
|
|
303
|
+
await this.ready()
|
|
304
|
+
await this.closeAdapter(id)
|
|
305
|
+
return this.store.update(id, patch)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async removeConnection(id) {
|
|
309
|
+
await this.ready()
|
|
310
|
+
await this.closeAdapter(id)
|
|
311
|
+
const removed = await this.store.remove(id)
|
|
312
|
+
await this.selections.removeConnection(id)
|
|
313
|
+
return removed
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Test an unsaved form (or an existing connection when `id` is given). */
|
|
317
|
+
async testConnection(input = {}) {
|
|
318
|
+
await this.ready()
|
|
319
|
+
const existing = input.id ? this.store.get(input.id) : null
|
|
320
|
+
if (input.id && !existing) throw new Error('连接不存在 / connection not found')
|
|
321
|
+
// Merge the saved row under the form so editing a connection and pressing
|
|
322
|
+
// "test" does not require retyping a stored password.
|
|
323
|
+
const candidate = existing ? { ...existing, ...input } : input
|
|
324
|
+
if (existing && !candidate.password) delete candidate.password
|
|
325
|
+
// Normalize a form without persisting it: this validates fields and fills
|
|
326
|
+
// the same defaults the store would.
|
|
327
|
+
const connection = normalizeConnection(candidate, existing)
|
|
328
|
+
const adapter = await openConnection({ ...connection, readOnly: false })
|
|
329
|
+
try {
|
|
330
|
+
await adapter.ping()
|
|
331
|
+
return { ok: true, target: connectionTarget(connection) }
|
|
332
|
+
} finally {
|
|
333
|
+
await adapter.close().catch(() => {})
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Close and forget one cached adapter. */
|
|
338
|
+
async closeAdapter(id) {
|
|
339
|
+
const entry = this.adapters.get(id)
|
|
340
|
+
if (!entry) return
|
|
341
|
+
this.adapters.delete(id)
|
|
342
|
+
await entry.adapter.close().catch(() => {})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Get (or open) the cached adapter for a connection. */
|
|
346
|
+
async #adapter(connection) {
|
|
347
|
+
const now = Date.now()
|
|
348
|
+
for (const [id, entry] of this.adapters) {
|
|
349
|
+
if (now - entry.lastUsed > ADAPTER_IDLE_MS) {
|
|
350
|
+
this.adapters.delete(id)
|
|
351
|
+
entry.adapter.close().catch(() => {})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
let entry = this.adapters.get(connection.id)
|
|
355
|
+
if (!entry) {
|
|
356
|
+
const adapter = await openConnection(connection)
|
|
357
|
+
entry = { adapter, lastUsed: now }
|
|
358
|
+
this.adapters.set(connection.id, entry)
|
|
359
|
+
} else {
|
|
360
|
+
entry.lastUsed = now
|
|
361
|
+
}
|
|
362
|
+
return entry.adapter
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Serialize writes per connection (SQLite in particular dislikes overlap). */
|
|
366
|
+
#serialized(id, task) {
|
|
367
|
+
const previous = this.writeQueues.get(id) ?? Promise.resolve()
|
|
368
|
+
const next = previous.catch(() => {}).then(task)
|
|
369
|
+
const tracked = next.finally(() => {
|
|
370
|
+
if (this.writeQueues.get(id) === tracked) this.writeQueues.delete(id)
|
|
371
|
+
})
|
|
372
|
+
this.writeQueues.set(id, tracked)
|
|
373
|
+
return tracked
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** List tables, or describe one table when `table` is provided. */
|
|
377
|
+
async schema(id, table, options = {}) {
|
|
378
|
+
if (options.sessionId) await this.assertSessionConnection(options.sessionId, id, options.cwd)
|
|
379
|
+
const connection = await this.connection(id)
|
|
380
|
+
const adapter = await this.#adapter(connection)
|
|
381
|
+
const result = table
|
|
382
|
+
? await adapter.describeTable(String(table))
|
|
383
|
+
: { connection: publicConnection(connection), tables: await adapter.listTables() }
|
|
384
|
+
this.touchUsage(connection.id, options.sessionId, options.cwd)
|
|
385
|
+
return result
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One page of a table's rows. */
|
|
389
|
+
async rows(id, table, options = {}) {
|
|
390
|
+
if (options.sessionId) await this.assertSessionConnection(options.sessionId, id, options.cwd)
|
|
391
|
+
const connection = await this.connection(id)
|
|
392
|
+
if (!table) throw new Error('请选择一张表 / choose a table')
|
|
393
|
+
const limit = clampLimit(options.limit, 50, this.config.maxRows)
|
|
394
|
+
const offset = Math.max(0, Number.isFinite(Number(options.offset)) ? Math.floor(Number(options.offset)) : 0)
|
|
395
|
+
const adapter = await this.#adapter(connection)
|
|
396
|
+
const result = await adapter.fetchRows(String(table), { limit, offset })
|
|
397
|
+
this.touchUsage(connection.id, options.sessionId, options.cwd)
|
|
398
|
+
return { connection: publicConnection(connection), table: String(table), limit, offset, ...result }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Run a read-only query. */
|
|
402
|
+
async query(id, sql, options = {}) {
|
|
403
|
+
if (options.sessionId) await this.assertSessionConnection(options.sessionId, id, options.cwd)
|
|
404
|
+
const connection = await this.connection(id)
|
|
405
|
+
const limit = clampLimit(options.limit, this.config.maxRows, this.config.maxRows)
|
|
406
|
+
const adapter = await this.#adapter(connection)
|
|
407
|
+
const started = Date.now()
|
|
408
|
+
const result = await adapter.query(sql, { limit })
|
|
409
|
+
this.touchUsage(connection.id, options.sessionId, options.cwd)
|
|
410
|
+
return { connection: publicConnection(connection), elapsedMs: Date.now() - started, ...result }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Run a write statement (or any single statement) after policy checks. */
|
|
414
|
+
async execute(id, sql, options = {}) {
|
|
415
|
+
if (options.sessionId) await this.assertSessionConnection(options.sessionId, id, options.cwd)
|
|
416
|
+
const connection = await this.connection(id)
|
|
417
|
+
if (connection.readOnly) throw new Error('该连接是只读连接 / this connection is read-only')
|
|
418
|
+
if (this.config.allowWrite === false) throw new Error('插件配置已关闭写操作(allowWrite: false)/ writes are disabled by plugin config')
|
|
419
|
+
if (!isSingleStatement(sql)) throw new Error('只允许执行一条 SQL / only one statement is allowed')
|
|
420
|
+
return this.#serialized(connection.id, async () => {
|
|
421
|
+
const adapter = await this.#adapter(connection)
|
|
422
|
+
const started = Date.now()
|
|
423
|
+
const result = await adapter.execute(sql)
|
|
424
|
+
this.touchUsage(connection.id, options.sessionId, options.cwd)
|
|
425
|
+
return { connection: publicConnection(connection), elapsedMs: result.elapsedMs ?? (Date.now() - started), ...result }
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Create the demo connection on demand (empty-state button). */
|
|
430
|
+
async seedDemo() {
|
|
431
|
+
await this.ready()
|
|
432
|
+
await this.#ensureDemo()
|
|
433
|
+
return this.overview()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Close every adapter (plugin disposal). */
|
|
437
|
+
async dispose() {
|
|
438
|
+
const entries = [...this.adapters.values()]
|
|
439
|
+
this.adapters.clear()
|
|
440
|
+
await Promise.all(entries.map((entry) => entry.adapter.close().catch(() => {})))
|
|
441
|
+
await this.selections.flush().catch(() => {})
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Convenience factory used by the Cordis entry and tests. */
|
|
446
|
+
export function createStudio(options) {
|
|
447
|
+
return new DatabaseStudio(options)
|
|
448
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-conversation database selection and global usage tracking.
|
|
3
|
+
*
|
|
4
|
+
* The chat's database picker, the conversation "数据库" tab and the status
|
|
5
|
+
* strip must agree on one answer per Session, and the model-facing tools must
|
|
6
|
+
* see the same answer. That answer lives here, on the host, keyed by Session
|
|
7
|
+
* id: the browser fetches it over the plugin route and the tools read it from
|
|
8
|
+
* their execution context.
|
|
9
|
+
*
|
|
10
|
+
* Two things are persisted:
|
|
11
|
+
*
|
|
12
|
+
* - `sessions[sessionId].ids` — the databases this conversation may touch.
|
|
13
|
+
* - `usage[connectionId]` — when (and in which workspace `cwd`) a connection
|
|
14
|
+
* was last used, so a new conversation can default to the connections
|
|
15
|
+
* recently used in its workspace.
|
|
16
|
+
*
|
|
17
|
+
* The file is private (`0700` directory, `0600` file), like the connection
|
|
18
|
+
* store.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-db-studio/session-store
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { studioHome } from './home.js'
|
|
26
|
+
|
|
27
|
+
const FILE_MODE = 0o600
|
|
28
|
+
const DIR_MODE = 0o700
|
|
29
|
+
const MAX_SESSIONS = 200
|
|
30
|
+
const SAVE_DELAY_MS = 400
|
|
31
|
+
|
|
32
|
+
/** Empty state document. */
|
|
33
|
+
function emptyState() {
|
|
34
|
+
return { version: 1, sessions: {}, usage: {} }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Keep only the newest N sessions. */
|
|
38
|
+
function pruneSessions(sessions) {
|
|
39
|
+
const rows = Object.entries(sessions)
|
|
40
|
+
if (rows.length <= MAX_SESSIONS) return sessions
|
|
41
|
+
rows.sort((left, right) => (right[1]?.updatedAt ?? 0) - (left[1]?.updatedAt ?? 0))
|
|
42
|
+
return Object.fromEntries(rows.slice(0, MAX_SESSIONS))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalize one stored entry. */
|
|
46
|
+
function normalizeEntry(entry) {
|
|
47
|
+
return {
|
|
48
|
+
ids: Array.isArray(entry?.ids) ? entry.ids.filter((id) => typeof id === 'string') : [],
|
|
49
|
+
cwd: typeof entry?.cwd === 'string' ? entry.cwd : undefined,
|
|
50
|
+
updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : Date.now(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SessionSelectionStore {
|
|
55
|
+
constructor(home) {
|
|
56
|
+
this.home = home
|
|
57
|
+
this.dir = studioHome(home)
|
|
58
|
+
this.file = join(this.dir, 'sessions.json')
|
|
59
|
+
this.state = emptyState()
|
|
60
|
+
this.loaded = false
|
|
61
|
+
this.saveTimer = null
|
|
62
|
+
this.saveQueue = Promise.resolve()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read the store once. A missing or malformed file is an empty store. */
|
|
66
|
+
async load() {
|
|
67
|
+
if (this.loaded) return this
|
|
68
|
+
this.loaded = true
|
|
69
|
+
try {
|
|
70
|
+
const raw = await readFile(this.file, 'utf8')
|
|
71
|
+
const parsed = JSON.parse(raw)
|
|
72
|
+
if (parsed && typeof parsed === 'object') {
|
|
73
|
+
this.state = {
|
|
74
|
+
version: 1,
|
|
75
|
+
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
76
|
+
usage: parsed.usage && typeof parsed.usage === 'object' ? parsed.usage : {},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code !== 'ENOENT') {
|
|
81
|
+
console.warn('dsh-db-studio: ignoring unreadable session store —', error?.message ?? error)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Serialized atomic write. */
|
|
88
|
+
async save() {
|
|
89
|
+
if (this.saveTimer !== null) {
|
|
90
|
+
clearTimeout(this.saveTimer)
|
|
91
|
+
this.saveTimer = null
|
|
92
|
+
}
|
|
93
|
+
this.saveQueue = this.saveQueue.catch(() => {}).then(async () => {
|
|
94
|
+
await mkdir(this.dir, { recursive: true, mode: DIR_MODE })
|
|
95
|
+
this.state.sessions = pruneSessions(this.state.sessions)
|
|
96
|
+
const body = JSON.stringify(this.state, null, 2) + '\n'
|
|
97
|
+
const temp = `${this.file}.${process.pid}.${Date.now()}.tmp`
|
|
98
|
+
await writeFile(temp, body, { mode: FILE_MODE })
|
|
99
|
+
await rename(temp, this.file)
|
|
100
|
+
await chmod(this.file, FILE_MODE).catch(() => {})
|
|
101
|
+
})
|
|
102
|
+
return this.saveQueue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Coalesce usage writes; selections save immediately. */
|
|
106
|
+
#soon() {
|
|
107
|
+
if (this.saveTimer !== null) return
|
|
108
|
+
this.saveTimer = setTimeout(() => {
|
|
109
|
+
this.saveTimer = null
|
|
110
|
+
this.save().catch(() => {})
|
|
111
|
+
}, SAVE_DELAY_MS)
|
|
112
|
+
if (typeof this.saveTimer.unref === 'function') this.saveTimer.unref()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve the stored entry without creating one. */
|
|
116
|
+
#entry(sessionId) {
|
|
117
|
+
return this.state.sessions[sessionId]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Has this Session an explicit/default selection yet? */
|
|
121
|
+
has(sessionId) {
|
|
122
|
+
return this.#entry(sessionId) !== undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ensure a Session has a selection.
|
|
127
|
+
*
|
|
128
|
+
* Default ("最近使用的连接,工作区内优先"): every connection used in this
|
|
129
|
+
* session's workspace, newest first; if none, every connection ever used;
|
|
130
|
+
* if nothing has ever been used, every configured connection (so the demo
|
|
131
|
+
* database is selected out of the box).
|
|
132
|
+
*
|
|
133
|
+
* The computed default is persisted, which makes the picker, tab and tools
|
|
134
|
+
* agree even before the user opens the picker.
|
|
135
|
+
*/
|
|
136
|
+
async ensure(sessionId, connections, cwd) {
|
|
137
|
+
if (!sessionId) throw new Error('缺少会话 id / missing session id')
|
|
138
|
+
await this.load()
|
|
139
|
+
const existing = this.#entry(sessionId)
|
|
140
|
+
if (existing) {
|
|
141
|
+
const entry = normalizeEntry(existing)
|
|
142
|
+
entry.ids = entry.ids.filter((id) => connections.some((connection) => connection.id === id))
|
|
143
|
+
this.state.sessions[sessionId] = entry
|
|
144
|
+
return entry.ids
|
|
145
|
+
}
|
|
146
|
+
const usable = connections.filter((connection) => connection && typeof connection.id === 'string')
|
|
147
|
+
const usage = this.state.usage
|
|
148
|
+
const inWorkspace = cwd
|
|
149
|
+
? usable
|
|
150
|
+
.filter((connection) => usage[connection.id]?.cwd === cwd)
|
|
151
|
+
.sort((left, right) => (usage[right.id]?.at ?? 0) - (usage[left.id]?.at ?? 0))
|
|
152
|
+
: []
|
|
153
|
+
const everUsed = usable
|
|
154
|
+
.filter((connection) => usage[connection.id]?.at)
|
|
155
|
+
.sort((left, right) => (usage[right.id]?.at ?? 0) - (usage[left.id]?.at ?? 0))
|
|
156
|
+
const defaults = inWorkspace.length ? inWorkspace : everUsed.length ? everUsed : usable
|
|
157
|
+
const ids = defaults.map((connection) => connection.id)
|
|
158
|
+
this.state.sessions[sessionId] = { ids, cwd, updatedAt: Date.now() }
|
|
159
|
+
await this.save()
|
|
160
|
+
return ids
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Replace a Session's selection. Empty is a valid, explicit choice. */
|
|
164
|
+
async set(sessionId, ids, cwd) {
|
|
165
|
+
await this.load()
|
|
166
|
+
const unique = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string'))]
|
|
167
|
+
this.state.sessions[sessionId] = {
|
|
168
|
+
ids: unique,
|
|
169
|
+
cwd,
|
|
170
|
+
updatedAt: Date.now(),
|
|
171
|
+
}
|
|
172
|
+
await this.save()
|
|
173
|
+
return unique
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Record that a connection was used, for future default selections. */
|
|
177
|
+
touch(connectionId, { sessionId, cwd } = {}) {
|
|
178
|
+
if (!connectionId) return
|
|
179
|
+
this.state.usage[connectionId] = {
|
|
180
|
+
at: Date.now(),
|
|
181
|
+
...(cwd ? { cwd } : {}),
|
|
182
|
+
...(sessionId ? { sessionId } : {}),
|
|
183
|
+
}
|
|
184
|
+
this.#soon()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A connection was removed: drop it from every selection and usage row. */
|
|
188
|
+
async removeConnection(connectionId) {
|
|
189
|
+
await this.load()
|
|
190
|
+
delete this.state.usage[connectionId]
|
|
191
|
+
let changed = false
|
|
192
|
+
for (const entry of Object.values(this.state.sessions)) {
|
|
193
|
+
const ids = normalizeEntry(entry).ids
|
|
194
|
+
if (ids.includes(connectionId)) {
|
|
195
|
+
entry.ids = ids.filter((id) => id !== connectionId)
|
|
196
|
+
entry.updatedAt = Date.now()
|
|
197
|
+
changed = true
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (changed) await this.save()
|
|
201
|
+
else this.#soon()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Selected ids for a Session, or [] when none. */
|
|
205
|
+
async selected(sessionId) {
|
|
206
|
+
await this.load()
|
|
207
|
+
return normalizeEntry(this.#entry(sessionId)).ids
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** All usage rows (tests/debug). */
|
|
211
|
+
usageSnapshot() {
|
|
212
|
+
return { ...this.state.usage }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Flush pending usage writes. */
|
|
216
|
+
async flush() {
|
|
217
|
+
if (this.saveTimer !== null) {
|
|
218
|
+
clearTimeout(this.saveTimer)
|
|
219
|
+
this.saveTimer = null
|
|
220
|
+
}
|
|
221
|
+
await this.save()
|
|
222
|
+
}
|
|
223
|
+
}
|