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