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