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/drivers.js ADDED
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Database adapters.
3
+ *
4
+ * Three kinds share one small interface so the HTTP API, the agent tools and
5
+ * the browser half never branch on the engine:
6
+ *
7
+ * ping(), listTables(), describeTable(name), countRows(name),
8
+ * fetchRows(name, { limit, offset }), query(sql, { limit }),
9
+ * execute(sql), close()
10
+ *
11
+ * SQLite rides Node's built-in `node:sqlite` (the same module the harness's
12
+ * own session index uses), so it needs no dependency at all. PostgreSQL and
13
+ * MySQL load `pg` / `mysql2` lazily and report a friendly error if a deployment
14
+ * chose not to install them.
15
+ *
16
+ * @module dsh-db-studio/drivers
17
+ */
18
+
19
+ import { stat } from 'node:fs/promises'
20
+ import { dirname } from 'node:path'
21
+
22
+ /** Quote a SQL identifier for the given kind. */
23
+ export function quoteIdentifier(kind, name) {
24
+ const value = String(name)
25
+ if (kind === 'mysql') return '`' + value.replaceAll('`', '``') + '`'
26
+ return '"' + value.replaceAll('"', '""') + '"'
27
+ }
28
+
29
+ /** Quote a possibly schema-qualified table name. */
30
+ export function quoteTable(kind, table) {
31
+ if (kind === 'sqlite' || kind === 'mysql') {
32
+ const parts = table.split('.')
33
+ return parts.map((part) => quoteIdentifier(kind, part)).join('.')
34
+ }
35
+ return table.split('.').map((part) => quoteIdentifier(kind, part)).join('.')
36
+ }
37
+
38
+ /**
39
+ * Strip comments and string literals so keyword guards cannot be fooled by
40
+ * text inside a value. This is a guard, not a parser; the database remains the
41
+ * authority on syntax.
42
+ */
43
+ export function stripSqlNoise(sql) {
44
+ let out = ''
45
+ let i = 0
46
+ const n = sql.length
47
+ while (i < n) {
48
+ const ch = sql[i]
49
+ const next = sql[i + 1]
50
+ if (ch === '-' && next === '-') {
51
+ const end = sql.indexOf('\n', i + 2)
52
+ i = end === -1 ? n : end + 1
53
+ out += ' '
54
+ continue
55
+ }
56
+ if (ch === '/' && next === '*') {
57
+ const end = sql.indexOf('*/', i + 2)
58
+ i = end === -1 ? n : end + 2
59
+ out += ' '
60
+ continue
61
+ }
62
+ if (ch === "'" || ch === '"' || ch === '`') {
63
+ const quote = ch
64
+ i++
65
+ while (i < n) {
66
+ if (sql[i] === quote) {
67
+ if (sql[i + 1] === quote) {
68
+ i += 2
69
+ continue
70
+ }
71
+ i++
72
+ break
73
+ }
74
+ i++
75
+ }
76
+ out += " '' "
77
+ continue
78
+ }
79
+ out += ch
80
+ i++
81
+ }
82
+ return out
83
+ }
84
+
85
+ /** True when the SQL is a single statement. */
86
+ export function isSingleStatement(sql) {
87
+ const clean = stripSqlNoise(sql).trim().replace(/;+\s*$/, '')
88
+ if (clean.includes(';')) return false
89
+ return clean.length > 0
90
+ }
91
+
92
+ /** Read-only statements accepted by `db_query`. */
93
+ export function isReadOnlySql(sql) {
94
+ const clean = stripSqlNoise(sql).trim()
95
+ if (!clean) return false
96
+ const first = clean.match(/^\(?\s*([a-z]+)/i)?.[1]?.toLowerCase()
97
+ if (!['select', 'with', 'explain', 'pragma', 'show', 'describe', 'desc', 'values', 'table'].includes(first)) return false
98
+ if (first === 'pragma' && /=/.test(clean)) return false
99
+ const forbidden = /\b(insert|update|delete|merge|replace|create|alter|drop|truncate|attach|detach|vacuum|reindex|grant|revoke|call|copy|load|begin|commit|rollback|savepoint|lock|unlock)\b/i
100
+ return !forbidden.test(clean)
101
+ }
102
+
103
+ /** Make sure a SQL text is not empty. */
104
+ export function requireSql(sql) {
105
+ const value = typeof sql === 'string' ? sql.trim() : ''
106
+ if (!value) throw new Error('SQL 不能为空 / SQL is empty')
107
+ return value
108
+ }
109
+
110
+ /** Ensure the directory of a SQLite file exists? No: SQLite creates files, not dirs. */
111
+ async function assertSqliteParent(file) {
112
+ try {
113
+ const info = await stat(dirname(file))
114
+ if (!info.isDirectory()) throw new Error('不是一个目录 / not a directory')
115
+ } catch (error) {
116
+ if (error?.code === 'ENOENT') throw new Error(`SQLite 文件所在目录不存在 / parent directory does not exist: ${dirname(file)}`)
117
+ throw error
118
+ }
119
+ }
120
+
121
+ // ── SQLite ─────────────────────────────────────────────────────────────────
122
+
123
+ /** Open a SQLite database through node:sqlite. */
124
+ async function openSqlite(connection, options) {
125
+ const { DatabaseSync } = await import('node:sqlite')
126
+ await assertSqliteParent(connection.file)
127
+ const db = new DatabaseSync(connection.file, { readOnly: options.readOnly === true })
128
+ try {
129
+ db.exec('PRAGMA foreign_keys = ON')
130
+ } catch {
131
+ // A read-only handle still answers queries; the pragma is best-effort.
132
+ }
133
+
134
+ const columnsOf = (statement) => {
135
+ try {
136
+ return statement.columns().map((column) => column.name)
137
+ } catch {
138
+ return []
139
+ }
140
+ }
141
+
142
+ return {
143
+ kind: 'sqlite',
144
+ async ping() {
145
+ db.prepare('SELECT 1 AS ok').get()
146
+ return true
147
+ },
148
+ async listTables() {
149
+ const rows = db.prepare(
150
+ "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
151
+ ).all()
152
+ return rows.map((row) => ({ name: row.name, type: row.type, schema: null }))
153
+ },
154
+ async describeTable(table) {
155
+ const info = db.prepare(`PRAGMA table_info(${quoteIdentifier('sqlite', table)})`).all()
156
+ if (info.length === 0) throw new Error(`表不存在 / table not found: ${table}`)
157
+ const columns = info.map((row) => ({
158
+ name: row.name,
159
+ type: row.type || 'ANY',
160
+ notNull: Number(row.notnull) === 1,
161
+ defaultValue: row.dflt_value ?? null,
162
+ primaryKey: Number(row.pk) > 0,
163
+ extra: '',
164
+ }))
165
+ const count = await this.countRows(table)
166
+ return { table, schema: null, columns, primaryKey: columns.filter((column) => column.primaryKey).map((column) => column.name), rowCount: count }
167
+ },
168
+ async countRows(table) {
169
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier('sqlite', table)}`).get()
170
+ return Number(row?.count ?? 0)
171
+ },
172
+ async fetchRows(table, { limit = 50, offset = 0 } = {}) {
173
+ const statement = db.prepare(
174
+ `SELECT * FROM ${quoteIdentifier('sqlite', table)} LIMIT ? OFFSET ?`,
175
+ )
176
+ const rows = statement.all(limit, offset)
177
+ return { columns: columnsOf(statement), rows, total: await this.countRows(table) }
178
+ },
179
+ async query(sql, { limit = 200 } = {}) {
180
+ const value = requireSql(sql)
181
+ if (!isSingleStatement(value)) throw new Error('只允许执行一条 SQL / only one statement is allowed')
182
+ if (!isReadOnlySql(value)) throw new Error('db_query 只接受只读 SQL;写操作请使用 db_execute / db_query is read-only, use db_execute for writes')
183
+ const statement = db.prepare(value)
184
+ const columns = columnsOf(statement)
185
+ const rows = []
186
+ let truncated = false
187
+ const iterator = statement.iterate()
188
+ try {
189
+ for (const row of iterator) {
190
+ if (rows.length >= limit) {
191
+ truncated = true
192
+ break
193
+ }
194
+ rows.push(row)
195
+ }
196
+ } finally {
197
+ iterator?.return?.()
198
+ }
199
+ return { columns, rows, truncated }
200
+ },
201
+ async execute(sql) {
202
+ const value = requireSql(sql)
203
+ const started = Date.now()
204
+ const isInsert = /^\s*(insert|replace)\b/i.test(stripSqlNoise(value))
205
+ db.exec(value)
206
+ let changes = 0
207
+ let lastInsertRowid = null
208
+ try {
209
+ const info = db.prepare('SELECT changes() AS changes, last_insert_rowid() AS id').get()
210
+ changes = Number(info?.changes ?? 0)
211
+ // `last_insert_rowid()` is connection-wide and keeps the previous
212
+ // INSERT's value after an UPDATE or DDL, so only report it for an
213
+ // insert that actually changed a row.
214
+ if (isInsert && changes > 0) lastInsertRowid = info?.id ?? null
215
+ } catch {
216
+ // DDL and multi-statement scripts may leave no row to read.
217
+ }
218
+ return { changes, lastInsertRowid, elapsedMs: Date.now() - started }
219
+ },
220
+ async close() {
221
+ db.close()
222
+ },
223
+ }
224
+ }
225
+
226
+ // ── PostgreSQL ──────────────────────────────────────────────────────────────
227
+
228
+ async function openPostgres(connection) {
229
+ let pg
230
+ try {
231
+ pg = await import('pg')
232
+ } catch {
233
+ throw new Error('未安装 PostgreSQL 驱动。请在插件目录运行 npm install pg / pg driver is not installed')
234
+ }
235
+ const Pool = pg.Pool ?? pg.default?.Pool
236
+ if (!Pool) throw new Error('pg 驱动加载失败 / failed to load the pg driver')
237
+ const pool = new Pool({
238
+ host: connection.host,
239
+ port: connection.port,
240
+ user: connection.user,
241
+ password: connection.password,
242
+ database: connection.database,
243
+ ssl: connection.ssl ? { rejectUnauthorized: false } : undefined,
244
+ max: 2,
245
+ idleTimeoutMillis: 30_000,
246
+ connectionTimeoutMillis: 8_000,
247
+ })
248
+
249
+ const splitTable = (table) => {
250
+ const parts = String(table).split('.')
251
+ return parts.length > 1 ? { schema: parts[0], name: parts[1] } : { schema: 'public', name: parts[0] }
252
+ }
253
+
254
+ return {
255
+ kind: 'postgres',
256
+ async ping() {
257
+ await pool.query('SELECT 1')
258
+ return true
259
+ },
260
+ async listTables() {
261
+ const result = await pool.query(
262
+ `SELECT table_schema AS schema, table_name AS name, table_type AS type
263
+ FROM information_schema.tables
264
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
265
+ AND table_type IN ('BASE TABLE', 'VIEW')
266
+ ORDER BY table_schema, table_name`,
267
+ )
268
+ return result.rows.map((row) => ({ schema: row.schema, name: row.name, type: row.type === 'VIEW' ? 'view' : 'table' }))
269
+ },
270
+ async describeTable(table) {
271
+ const { schema, name } = splitTable(table)
272
+ const result = await pool.query(
273
+ `SELECT column_name AS name, data_type AS type, is_nullable AS nullable,
274
+ column_default AS default_value
275
+ FROM information_schema.columns
276
+ WHERE table_schema = $1 AND table_name = $2
277
+ ORDER BY ordinal_position`,
278
+ [schema, name],
279
+ )
280
+ if (result.rows.length === 0) throw new Error(`表不存在 / table not found: ${table}`)
281
+ const keys = await pool.query(
282
+ `SELECT kcu.column_name AS name
283
+ FROM information_schema.table_constraints tc
284
+ JOIN information_schema.key_column_usage kcu
285
+ ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
286
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`,
287
+ [schema, name],
288
+ )
289
+ const primaryKey = new Set(keys.rows.map((row) => row.name))
290
+ const columns = result.rows.map((row) => ({
291
+ name: row.name,
292
+ type: row.type,
293
+ notNull: row.nullable === 'NO',
294
+ defaultValue: row.default_value,
295
+ primaryKey: primaryKey.has(row.name),
296
+ extra: '',
297
+ }))
298
+ return { table, schema, columns, primaryKey: [...primaryKey], rowCount: await this.countRows(table) }
299
+ },
300
+ async countRows(table) {
301
+ const result = await pool.query(`SELECT COUNT(*)::bigint AS count FROM ${quoteTable('postgres', table)}`)
302
+ return Number(result.rows[0]?.count ?? 0)
303
+ },
304
+ async fetchRows(table, { limit = 50, offset = 0 } = {}) {
305
+ const result = await pool.query(
306
+ `SELECT * FROM ${quoteTable('postgres', table)} LIMIT $1 OFFSET $2`,
307
+ [limit, offset],
308
+ )
309
+ return {
310
+ columns: result.fields.map((field) => field.name),
311
+ rows: result.rows,
312
+ total: await this.countRows(table),
313
+ }
314
+ },
315
+ async query(sql, { limit = 200 } = {}) {
316
+ const value = requireSql(sql)
317
+ if (!isSingleStatement(value)) throw new Error('只允许执行一条 SQL / only one statement is allowed')
318
+ if (!isReadOnlySql(value)) throw new Error('db_query 只接受只读 SQL;写操作请使用 db_execute / db_query is read-only, use db_execute for writes')
319
+ const result = await pool.query(value)
320
+ const rows = result.rows.slice(0, limit)
321
+ return {
322
+ columns: result.fields?.map((field) => field.name) ?? Object.keys(rows[0] ?? {}),
323
+ rows,
324
+ truncated: result.rows.length > limit,
325
+ }
326
+ },
327
+ async execute(sql) {
328
+ const value = requireSql(sql)
329
+ const started = Date.now()
330
+ const result = await pool.query(value)
331
+ return {
332
+ changes: result.rowCount ?? 0,
333
+ lastInsertRowid: null,
334
+ elapsedMs: Date.now() - started,
335
+ command: result.command,
336
+ }
337
+ },
338
+ async close() {
339
+ await pool.end()
340
+ },
341
+ }
342
+ }
343
+
344
+ // ── MySQL ──────────────────────────────────────────────────────────────────
345
+
346
+ async function openMysql(connection) {
347
+ let mysql
348
+ try {
349
+ mysql = await import('mysql2/promise')
350
+ } catch {
351
+ throw new Error('未安装 MySQL 驱动。请在插件目录运行 npm install mysql2 / mysql2 driver is not installed')
352
+ }
353
+ const createPool = mysql.createPool ?? mysql.default?.createPool
354
+ if (typeof createPool !== 'function') throw new Error('mysql2 驱动加载失败 / failed to load the mysql2 driver')
355
+ const pool = createPool({
356
+ host: connection.host,
357
+ port: connection.port,
358
+ user: connection.user,
359
+ password: connection.password,
360
+ database: connection.database,
361
+ ssl: connection.ssl ? { rejectUnauthorized: false } : undefined,
362
+ waitForConnections: true,
363
+ connectionLimit: 2,
364
+ connectTimeout: 8_000,
365
+ dateStrings: true,
366
+ })
367
+
368
+ return {
369
+ kind: 'mysql',
370
+ async ping() {
371
+ await pool.query('SELECT 1')
372
+ return true
373
+ },
374
+ async listTables() {
375
+ const [rows] = await pool.query(
376
+ `SELECT table_name AS name, table_type AS type
377
+ FROM information_schema.tables
378
+ WHERE table_schema = DATABASE()
379
+ ORDER BY table_name`,
380
+ )
381
+ return rows.map((row) => ({ name: row.name, schema: null, type: row.type === 'VIEW' ? 'view' : 'table' }))
382
+ },
383
+ async describeTable(table) {
384
+ const name = table.includes('.') ? table.split('.').pop() : table
385
+ const [rows] = await pool.query(`SHOW FULL COLUMNS FROM ${quoteIdentifier('mysql', name)}`)
386
+ if (!rows || rows.length === 0) throw new Error(`表不存在 / table not found: ${table}`)
387
+ const columns = rows.map((row) => ({
388
+ name: row.Field,
389
+ type: row.Type,
390
+ notNull: row.Null === 'NO',
391
+ defaultValue: row.Default,
392
+ primaryKey: row.Key === 'PRI',
393
+ extra: row.Extra ?? '',
394
+ }))
395
+ return {
396
+ table,
397
+ schema: null,
398
+ columns,
399
+ primaryKey: columns.filter((column) => column.primaryKey).map((column) => column.name),
400
+ rowCount: await this.countRows(table),
401
+ }
402
+ },
403
+ async countRows(table) {
404
+ const name = table.includes('.') ? table.split('.').pop() : table
405
+ const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM ${quoteIdentifier('mysql', name)}`)
406
+ return Number(rows[0]?.count ?? 0)
407
+ },
408
+ async fetchRows(table, { limit = 50, offset = 0 } = {}) {
409
+ const name = table.includes('.') ? table.split('.').pop() : table
410
+ const [rows, fields] = await pool.query(
411
+ `SELECT * FROM ${quoteIdentifier('mysql', name)} LIMIT ? OFFSET ?`,
412
+ [limit, offset],
413
+ )
414
+ return { columns: (fields ?? []).map((field) => field.name), rows, total: await this.countRows(table) }
415
+ },
416
+ async query(sql, { limit = 200 } = {}) {
417
+ const value = requireSql(sql)
418
+ if (!isSingleStatement(value)) throw new Error('只允许执行一条 SQL / only one statement is allowed')
419
+ if (!isReadOnlySql(value)) throw new Error('db_query 只接受只读 SQL;写操作请使用 db_execute / db_query is read-only, use db_execute for writes')
420
+ const [rows, fields] = await pool.query(value)
421
+ if (!Array.isArray(rows)) throw new Error('这条语句没有返回结果 / the statement returned no result set')
422
+ return {
423
+ columns: (fields ?? []).map((field) => field.name),
424
+ rows: rows.slice(0, limit),
425
+ truncated: rows.length > limit,
426
+ }
427
+ },
428
+ async execute(sql) {
429
+ const value = requireSql(sql)
430
+ const started = Date.now()
431
+ const isInsert = /^\s*(insert|replace)\b/i.test(stripSqlNoise(value))
432
+ const [result] = await pool.query(value)
433
+ const changes = result?.affectedRows ?? 0
434
+ return {
435
+ changes,
436
+ lastInsertRowid: isInsert && changes > 0 && result?.insertId ? result.insertId : null,
437
+ elapsedMs: Date.now() - started,
438
+ }
439
+ },
440
+ async close() {
441
+ await pool.end()
442
+ },
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Open a connection. `options.readOnly` is honored where the engine supports
448
+ * it (SQLite today); every engine additionally gets the SQL guard in `query`.
449
+ */
450
+ export async function openConnection(connection, options = {}) {
451
+ if (!connection) throw new Error('连接不存在 / connection not found')
452
+ if (connection.kind === 'sqlite') return openSqlite(connection, options)
453
+ if (connection.kind === 'postgres') return openPostgres(connection, options)
454
+ if (connection.kind === 'mysql') return openMysql(connection, options)
455
+ throw new Error(`不支持的连接类型 / unsupported connection kind: ${connection.kind}`)
456
+ }
package/lib/home.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Where dsh-db-studio keeps its data.
3
+ *
4
+ * The resolution mirrors `@deepseek-ai/dsh-home-paths`: an explicit config
5
+ * wins over `$DSH_HOME`, which wins over `~/.dsh`; a blank environment value
6
+ * is treated as unset. `~` and `~/...` are expanded against the OS home.
7
+ *
8
+ * @module dsh-db-studio/home
9
+ */
10
+
11
+ import { homedir } from 'node:os'
12
+ import { isAbsolute, join, resolve } from 'node:path'
13
+
14
+ /** Expand the tilde forms the harness treats as the OS home. */
15
+ export function expandHomePath(path) {
16
+ if (typeof path !== 'string') return path
17
+ if (path === '~') return homedir()
18
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
19
+ return path
20
+ }
21
+
22
+ /** Resolve the harness home: configured, else `$DSH_HOME`, else `~/.dsh`. */
23
+ export function resolveDshHome(configured, env = process.env) {
24
+ const fromEnv = env.DSH_HOME
25
+ const chosen = configured ?? (fromEnv !== undefined && fromEnv.trim() ? fromEnv : join(homedir(), '.dsh'))
26
+ return resolve(expandHomePath(chosen))
27
+ }
28
+
29
+ /** The plugin's private directory under a harness home. */
30
+ export function studioHome(home) {
31
+ return join(home, 'dsh-db-studio')
32
+ }
33
+
34
+ /** Make a path absolute without touching the filesystem. */
35
+ export function absolutePath(path) {
36
+ const expanded = expandHomePath(path)
37
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(expanded)
38
+ }