@dbx-app/node-core 0.4.3
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/LICENSE +661 -0
- package/README.md +33 -0
- package/dist/backend.d.ts +12 -0
- package/dist/backend.js +16 -0
- package/dist/bridge.d.ts +9 -0
- package/dist/bridge.js +23 -0
- package/dist/connections.d.ts +43 -0
- package/dist/connections.js +179 -0
- package/dist/database.d.ts +25 -0
- package/dist/database.js +415 -0
- package/dist/diagnostics.d.ts +20 -0
- package/dist/diagnostics.js +79 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +19 -0
- package/dist/schema-context.d.ts +25 -0
- package/dist/schema-context.js +43 -0
- package/dist/sql-safety.d.ts +10 -0
- package/dist/sql-safety.js +103 -0
- package/dist/web-backend.d.ts +9 -0
- package/dist/web-backend.js +115 -0
- package/package.json +41 -0
package/dist/database.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createServer, connect as netConnect } from "node:net";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { sqlSafetyFromEnv } from "./sql-safety.js";
|
|
7
|
+
const MAX_ROWS = 100;
|
|
8
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const QUERY_TIMEOUT_MS = 30_000;
|
|
10
|
+
const pools = new Map();
|
|
11
|
+
const proxyTunnels = new Map();
|
|
12
|
+
function poolKey(config) {
|
|
13
|
+
return `${config.id}:${config.database || ""}`;
|
|
14
|
+
}
|
|
15
|
+
function evictPool(key, entry) {
|
|
16
|
+
pools.delete(key);
|
|
17
|
+
if (entry.type === "pg") {
|
|
18
|
+
entry.pool.end().catch(() => { });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
entry.pool.end().catch(() => { });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function resetIdleTimer(key, entry) {
|
|
25
|
+
clearTimeout(entry.timer);
|
|
26
|
+
entry.timer = setTimeout(() => evictPool(key, entry), IDLE_TIMEOUT_MS);
|
|
27
|
+
}
|
|
28
|
+
async function getPgPool(config) {
|
|
29
|
+
const key = poolKey(config);
|
|
30
|
+
const existing = pools.get(key);
|
|
31
|
+
if (existing?.type === "pg") {
|
|
32
|
+
resetIdleTimer(key, existing);
|
|
33
|
+
return existing.pool;
|
|
34
|
+
}
|
|
35
|
+
const pg = await import("pg");
|
|
36
|
+
const endpoint = await connectionEndpoint(config);
|
|
37
|
+
const pool = new pg.default.Pool({
|
|
38
|
+
connectionString: buildConnectionUrl(config, endpoint),
|
|
39
|
+
max: 3,
|
|
40
|
+
idleTimeoutMillis: 30_000,
|
|
41
|
+
connectionTimeoutMillis: 10_000,
|
|
42
|
+
});
|
|
43
|
+
pool.on("error", () => { });
|
|
44
|
+
const entry = { type: "pg", pool, timer: setTimeout(() => { }, 0) };
|
|
45
|
+
pools.set(key, entry);
|
|
46
|
+
resetIdleTimer(key, entry);
|
|
47
|
+
return pool;
|
|
48
|
+
}
|
|
49
|
+
async function getMysqlPool(config) {
|
|
50
|
+
const key = poolKey(config);
|
|
51
|
+
const existing = pools.get(key);
|
|
52
|
+
if (existing?.type === "mysql") {
|
|
53
|
+
resetIdleTimer(key, existing);
|
|
54
|
+
return existing.pool;
|
|
55
|
+
}
|
|
56
|
+
const mysql = await import("mysql2/promise");
|
|
57
|
+
const endpoint = await connectionEndpoint(config);
|
|
58
|
+
const pool = mysql.default.createPool({
|
|
59
|
+
uri: buildConnectionUrl(config, endpoint),
|
|
60
|
+
connectionLimit: 3,
|
|
61
|
+
idleTimeout: 30_000,
|
|
62
|
+
connectTimeout: 10_000,
|
|
63
|
+
});
|
|
64
|
+
const entry = { type: "mysql", pool, timer: setTimeout(() => { }, 0) };
|
|
65
|
+
pools.set(key, entry);
|
|
66
|
+
resetIdleTimer(key, entry);
|
|
67
|
+
return pool;
|
|
68
|
+
}
|
|
69
|
+
async function connectionEndpoint(config) {
|
|
70
|
+
if (!config.proxy_enabled || !config.proxy_host)
|
|
71
|
+
return { host: config.host, port: config.port };
|
|
72
|
+
const existing = proxyTunnels.get(config.id);
|
|
73
|
+
if (existing)
|
|
74
|
+
return { host: "127.0.0.1", port: existing.port };
|
|
75
|
+
const server = createServer((inbound) => {
|
|
76
|
+
connectViaProxy(config)
|
|
77
|
+
.then((outbound) => {
|
|
78
|
+
inbound.pipe(outbound);
|
|
79
|
+
outbound.pipe(inbound);
|
|
80
|
+
})
|
|
81
|
+
.catch(() => inbound.destroy());
|
|
82
|
+
});
|
|
83
|
+
const port = await new Promise((resolve, reject) => {
|
|
84
|
+
server.once("error", reject);
|
|
85
|
+
server.listen(0, "127.0.0.1", () => {
|
|
86
|
+
const address = server.address();
|
|
87
|
+
if (address && typeof address === "object")
|
|
88
|
+
resolve(address.port);
|
|
89
|
+
else
|
|
90
|
+
reject(new Error("Failed to bind proxy tunnel"));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
proxyTunnels.set(config.id, { server, port });
|
|
94
|
+
return { host: "127.0.0.1", port };
|
|
95
|
+
}
|
|
96
|
+
function buildConnectionUrl(config, endpoint) {
|
|
97
|
+
const db = config.database || "";
|
|
98
|
+
const params = config.url_params || "";
|
|
99
|
+
const suffix = params ? `?${params}` : "";
|
|
100
|
+
if (isMysqlType(config.db_type)) {
|
|
101
|
+
return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`;
|
|
102
|
+
}
|
|
103
|
+
return `postgres://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`;
|
|
104
|
+
}
|
|
105
|
+
function connectViaProxy(config) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const socket = netConnect(config.proxy_port || 1080, config.proxy_host || "127.0.0.1");
|
|
108
|
+
socket.once("error", reject);
|
|
109
|
+
socket.once("connect", () => {
|
|
110
|
+
if ((config.proxy_type || "socks5") === "http") {
|
|
111
|
+
httpConnect(socket, config, resolve, reject);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
socks5Connect(socket, config, resolve, reject);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function httpConnect(socket, config, resolve, reject) {
|
|
120
|
+
const target = `${config.host}:${config.port}`;
|
|
121
|
+
const lines = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`];
|
|
122
|
+
if (config.proxy_username || config.proxy_password) {
|
|
123
|
+
const token = Buffer.from(`${config.proxy_username || ""}:${config.proxy_password || ""}`).toString("base64");
|
|
124
|
+
lines.push(`Proxy-Authorization: Basic ${token}`);
|
|
125
|
+
}
|
|
126
|
+
socket.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
127
|
+
let buffer = Buffer.alloc(0);
|
|
128
|
+
socket.on("data", function onData(chunk) {
|
|
129
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
130
|
+
const end = buffer.indexOf("\r\n\r\n");
|
|
131
|
+
if (end < 0)
|
|
132
|
+
return;
|
|
133
|
+
socket.off("data", onData);
|
|
134
|
+
const head = buffer.subarray(0, end).toString("utf8");
|
|
135
|
+
if (!/^HTTP\/1\.[01] 200\b/.test(head)) {
|
|
136
|
+
reject(new Error(`HTTP proxy CONNECT failed: ${head.split("\r\n")[0] || "invalid response"}`));
|
|
137
|
+
socket.destroy();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const rest = buffer.subarray(end + 4);
|
|
141
|
+
if (rest.length)
|
|
142
|
+
socket.unshift(rest);
|
|
143
|
+
resolve(socket);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function socks5Connect(socket, config, resolve, reject) {
|
|
147
|
+
const wantsAuth = !!(config.proxy_username || config.proxy_password);
|
|
148
|
+
socket.write(Buffer.from(wantsAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00]));
|
|
149
|
+
socket.once("data", (method) => {
|
|
150
|
+
if (method.length < 2 || method[0] !== 0x05) {
|
|
151
|
+
reject(new Error("Invalid SOCKS greeting"));
|
|
152
|
+
socket.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (method[1] === 0x02) {
|
|
156
|
+
const user = Buffer.from(config.proxy_username || "");
|
|
157
|
+
const pass = Buffer.from(config.proxy_password || "");
|
|
158
|
+
socket.write(Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass]));
|
|
159
|
+
socket.once("data", (auth) => {
|
|
160
|
+
if (auth.length < 2 || auth[1] !== 0x00) {
|
|
161
|
+
reject(new Error("SOCKS proxy authentication failed"));
|
|
162
|
+
socket.destroy();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
sendSocksConnect(socket, config, resolve, reject);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else if (method[1] === 0x00) {
|
|
169
|
+
sendSocksConnect(socket, config, resolve, reject);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
reject(new Error("SOCKS proxy rejected authentication methods"));
|
|
173
|
+
socket.destroy();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function sendSocksConnect(socket, config, resolve, reject) {
|
|
178
|
+
const host = Buffer.from(config.host);
|
|
179
|
+
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), host, portBytes(config.port)]));
|
|
180
|
+
socket.once("data", (res) => {
|
|
181
|
+
if (res.length < 4 || res[0] !== 0x05 || res[1] !== 0x00) {
|
|
182
|
+
reject(new Error(`SOCKS proxy connect failed with code ${res[1] ?? "unknown"}`));
|
|
183
|
+
socket.destroy();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
resolve(socket);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function portBytes(port) {
|
|
190
|
+
const buf = Buffer.alloc(2);
|
|
191
|
+
buf.writeUInt16BE(port);
|
|
192
|
+
return buf;
|
|
193
|
+
}
|
|
194
|
+
function isMysqlType(dbType) {
|
|
195
|
+
return dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
|
|
196
|
+
}
|
|
197
|
+
function isDirectType(dbType) {
|
|
198
|
+
switch (dbType) {
|
|
199
|
+
case "postgres":
|
|
200
|
+
case "redshift":
|
|
201
|
+
case "mysql":
|
|
202
|
+
case "doris":
|
|
203
|
+
case "starrocks":
|
|
204
|
+
case "sqlite":
|
|
205
|
+
return true;
|
|
206
|
+
default:
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function bridgeAppDataDir() {
|
|
211
|
+
const home = homedir();
|
|
212
|
+
switch (platform()) {
|
|
213
|
+
case "darwin":
|
|
214
|
+
return join(home, "Library", "Application Support", "com.dbx.app");
|
|
215
|
+
case "win32":
|
|
216
|
+
return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
|
|
217
|
+
default:
|
|
218
|
+
return join(home, ".config", "com.dbx.app");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function bridgeDataRequest(path, body) {
|
|
222
|
+
let bridgeUrl;
|
|
223
|
+
try {
|
|
224
|
+
const portFile = join(bridgeAppDataDir(), "mcp-bridge-port");
|
|
225
|
+
const port = (await readFile(portFile, "utf-8")).trim();
|
|
226
|
+
bridgeUrl = `http://127.0.0.1:${port}`;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Error("DBX desktop app is not running. This database type requires DBX to be running for query execution.");
|
|
230
|
+
}
|
|
231
|
+
const res = await fetch(`${bridgeUrl}${path}`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": "application/json" },
|
|
234
|
+
body: JSON.stringify(body),
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const errBody = await res.text().catch(() => "");
|
|
238
|
+
let errorMsg;
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(errBody);
|
|
241
|
+
errorMsg = parsed.error || errBody;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
errorMsg = errBody;
|
|
245
|
+
}
|
|
246
|
+
throw new Error(errorMsg || `Bridge request failed: ${res.status}`);
|
|
247
|
+
}
|
|
248
|
+
return res.json();
|
|
249
|
+
}
|
|
250
|
+
function resolveMaxRows(options) {
|
|
251
|
+
return options?.maxRows ?? MAX_ROWS;
|
|
252
|
+
}
|
|
253
|
+
function resolveTimeoutMs(options) {
|
|
254
|
+
return options?.timeoutMs ?? QUERY_TIMEOUT_MS;
|
|
255
|
+
}
|
|
256
|
+
function convertBridgeQueryResult(result, options) {
|
|
257
|
+
const rows = result.rows.slice(0, resolveMaxRows(options)).map((row) => {
|
|
258
|
+
const obj = {};
|
|
259
|
+
result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
|
260
|
+
return obj;
|
|
261
|
+
});
|
|
262
|
+
return { columns: result.columns, rows, row_count: rows.length };
|
|
263
|
+
}
|
|
264
|
+
function withTimeout(promise, ms) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms);
|
|
267
|
+
promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function queryWithRetry(config, fn, options) {
|
|
271
|
+
const timeoutMs = resolveTimeoutMs(options);
|
|
272
|
+
try {
|
|
273
|
+
return await withTimeout(fn(), timeoutMs);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
277
|
+
const retriable = /terminating connection|Connection lost|ECONNRESET|EPIPE|connection refused/i.test(msg);
|
|
278
|
+
if (retriable) {
|
|
279
|
+
const key = poolKey(config);
|
|
280
|
+
const entry = pools.get(key);
|
|
281
|
+
if (entry)
|
|
282
|
+
evictPool(key, entry);
|
|
283
|
+
return withTimeout(fn(), timeoutMs);
|
|
284
|
+
}
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function pgQuery(config, sql, params, options) {
|
|
289
|
+
return queryWithRetry(config, async () => {
|
|
290
|
+
const pool = await getPgPool(config);
|
|
291
|
+
const result = await pool.query(sql, params);
|
|
292
|
+
const rows = (result.rows || []).slice(0, resolveMaxRows(options));
|
|
293
|
+
return { columns: result.fields?.map((f) => f.name) ?? [], rows, row_count: rows.length };
|
|
294
|
+
}, options);
|
|
295
|
+
}
|
|
296
|
+
async function mysqlQuery(config, sql, params, options) {
|
|
297
|
+
return queryWithRetry(config, async () => {
|
|
298
|
+
const pool = await getMysqlPool(config);
|
|
299
|
+
const [results, fields] = await pool.query(sql, params);
|
|
300
|
+
const rows = (Array.isArray(results) ? results : []).slice(0, resolveMaxRows(options));
|
|
301
|
+
return { columns: fields?.map((f) => f.name) ?? [], rows, row_count: rows.length };
|
|
302
|
+
}, options);
|
|
303
|
+
}
|
|
304
|
+
async function query(config, sql, params, options) {
|
|
305
|
+
if (config.db_type === "sqlite")
|
|
306
|
+
return sqliteQuery(config, sql, options);
|
|
307
|
+
if (isMysqlType(config.db_type))
|
|
308
|
+
return mysqlQuery(config, sql, params, options);
|
|
309
|
+
return pgQuery(config, sql, params, options);
|
|
310
|
+
}
|
|
311
|
+
function sqlitePath(config) {
|
|
312
|
+
return expandTilde(config.host || config.database || "");
|
|
313
|
+
}
|
|
314
|
+
function expandTilde(path) {
|
|
315
|
+
if (path === "~")
|
|
316
|
+
return homedir();
|
|
317
|
+
if (path.startsWith("~/"))
|
|
318
|
+
return join(homedir(), path.slice(2));
|
|
319
|
+
return path;
|
|
320
|
+
}
|
|
321
|
+
function quoteSqliteIdentifier(identifier) {
|
|
322
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
323
|
+
}
|
|
324
|
+
function sqliteQuery(config, sql, options) {
|
|
325
|
+
const db = new Database(sqlitePath(config), { readonly: !sqlSafetyFromEnv().allowWrites });
|
|
326
|
+
try {
|
|
327
|
+
const stmt = db.prepare(sql);
|
|
328
|
+
if (stmt.reader) {
|
|
329
|
+
const rows = stmt.all().slice(0, resolveMaxRows(options));
|
|
330
|
+
return { columns: stmt.columns().map((column) => column.name), rows, row_count: rows.length };
|
|
331
|
+
}
|
|
332
|
+
const result = stmt.run();
|
|
333
|
+
return { columns: [], rows: [], row_count: result.changes };
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
db.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
export async function executeQuery(config, sql, options) {
|
|
340
|
+
if (isDirectType(config.db_type)) {
|
|
341
|
+
return query(config, sql, undefined, options);
|
|
342
|
+
}
|
|
343
|
+
const result = await withTimeout(bridgeDataRequest("/data/execute-query", {
|
|
344
|
+
connection_name: config.name,
|
|
345
|
+
database: config.database || "",
|
|
346
|
+
sql,
|
|
347
|
+
}), resolveTimeoutMs(options));
|
|
348
|
+
return convertBridgeQueryResult(result, options);
|
|
349
|
+
}
|
|
350
|
+
export async function listTables(config, schema) {
|
|
351
|
+
if (config.db_type === "sqlite") {
|
|
352
|
+
const result = await query(config, `SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`);
|
|
353
|
+
return result.rows.map((r) => ({ name: String(r.name || ""), type: String(r.type || "table") }));
|
|
354
|
+
}
|
|
355
|
+
if (!isDirectType(config.db_type)) {
|
|
356
|
+
const tables = await bridgeDataRequest("/data/list-tables", {
|
|
357
|
+
connection_name: config.name,
|
|
358
|
+
database: config.database || "",
|
|
359
|
+
schema: schema || "",
|
|
360
|
+
});
|
|
361
|
+
return tables.map((t) => ({ name: t.name, type: t.table_type || "TABLE" }));
|
|
362
|
+
}
|
|
363
|
+
let result;
|
|
364
|
+
if (isMysqlType(config.db_type)) {
|
|
365
|
+
result = await query(config, `SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
result = await query(config, `SELECT table_name AS name, table_type AS type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name`, [schema || "public"]);
|
|
369
|
+
}
|
|
370
|
+
return result.rows.map((r) => ({ name: String(r.name || r.NAME), type: String(r.type || r.TYPE || "TABLE") }));
|
|
371
|
+
}
|
|
372
|
+
export async function describeTable(config, table, schema) {
|
|
373
|
+
if (config.db_type === "sqlite") {
|
|
374
|
+
const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`);
|
|
375
|
+
return result.rows.map((r) => ({
|
|
376
|
+
name: String(r.name || ""),
|
|
377
|
+
data_type: String(r.type || ""),
|
|
378
|
+
is_nullable: Number(r.notnull || 0) === 0,
|
|
379
|
+
column_default: r.dflt_value != null ? String(r.dflt_value) : null,
|
|
380
|
+
is_primary_key: Number(r.pk || 0) > 0,
|
|
381
|
+
comment: null,
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
if (!isDirectType(config.db_type)) {
|
|
385
|
+
const columns = await bridgeDataRequest("/data/describe-table", {
|
|
386
|
+
connection_name: config.name,
|
|
387
|
+
database: config.database || "",
|
|
388
|
+
schema: schema || "",
|
|
389
|
+
table,
|
|
390
|
+
});
|
|
391
|
+
return columns.map((c) => ({
|
|
392
|
+
name: c.name,
|
|
393
|
+
data_type: c.data_type,
|
|
394
|
+
is_nullable: c.is_nullable,
|
|
395
|
+
column_default: c.column_default,
|
|
396
|
+
is_primary_key: c.is_primary_key,
|
|
397
|
+
comment: c.comment,
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
let result;
|
|
401
|
+
if (isMysqlType(config.db_type)) {
|
|
402
|
+
result = await query(config, `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, [table]);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
result = await query(config, `SELECT c.column_name AS name, c.data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`, [schema || "public", table]);
|
|
406
|
+
}
|
|
407
|
+
return result.rows.map((r) => ({
|
|
408
|
+
name: String(r.name || ""),
|
|
409
|
+
data_type: String(r.data_type || ""),
|
|
410
|
+
is_nullable: Boolean(r.is_nullable),
|
|
411
|
+
column_default: r.column_default != null ? String(r.column_default) : null,
|
|
412
|
+
is_primary_key: Boolean(r.is_primary_key),
|
|
413
|
+
comment: r.comment != null ? String(r.comment) : null,
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const DIRECT_QUERY_TYPES: readonly ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite"];
|
|
2
|
+
export declare const BRIDGE_REQUIRED_TYPES: readonly ["redis", "mongodb", "duckdb", "clickhouse", "sqlserver", "oracle", "elasticsearch", "dameng", "kingbase", "highgo", "vastbase", "goldendb", "gaussdb", "tdengine", "h2", "snowflake", "trino", "hive", "db2", "informix", "neo4j", "cassandra", "bigquery", "kylin", "sundb", "jdbc", "access"];
|
|
3
|
+
export interface DbxDiagnostics {
|
|
4
|
+
appDataDir: string;
|
|
5
|
+
dbPath: string;
|
|
6
|
+
dbPathExists: boolean;
|
|
7
|
+
connectionsTableExists: boolean;
|
|
8
|
+
connectionSecretsTableExists?: boolean;
|
|
9
|
+
connectionRowCount: number;
|
|
10
|
+
loadConnectionsOk: boolean;
|
|
11
|
+
loadedConnectionCount: number;
|
|
12
|
+
loadConnectionsError?: string;
|
|
13
|
+
loadConnectionsHint?: string;
|
|
14
|
+
bridgePortFile: string;
|
|
15
|
+
bridgePortFileExists: boolean;
|
|
16
|
+
bridgeUrl?: string;
|
|
17
|
+
directQueryTypes: string[];
|
|
18
|
+
bridgeRequiredTypes: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function getDbxDiagnostics(): Promise<DbxDiagnostics>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js";
|
|
3
|
+
import { inspectConnectionStore } from "./connections.js";
|
|
4
|
+
export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "sqlite"];
|
|
5
|
+
export const BRIDGE_REQUIRED_TYPES = [
|
|
6
|
+
"redis",
|
|
7
|
+
"mongodb",
|
|
8
|
+
"duckdb",
|
|
9
|
+
"clickhouse",
|
|
10
|
+
"sqlserver",
|
|
11
|
+
"oracle",
|
|
12
|
+
"elasticsearch",
|
|
13
|
+
"dameng",
|
|
14
|
+
"kingbase",
|
|
15
|
+
"highgo",
|
|
16
|
+
"vastbase",
|
|
17
|
+
"goldendb",
|
|
18
|
+
"gaussdb",
|
|
19
|
+
"tdengine",
|
|
20
|
+
"h2",
|
|
21
|
+
"snowflake",
|
|
22
|
+
"trino",
|
|
23
|
+
"hive",
|
|
24
|
+
"db2",
|
|
25
|
+
"informix",
|
|
26
|
+
"neo4j",
|
|
27
|
+
"cassandra",
|
|
28
|
+
"bigquery",
|
|
29
|
+
"kylin",
|
|
30
|
+
"sundb",
|
|
31
|
+
"jdbc",
|
|
32
|
+
"access",
|
|
33
|
+
];
|
|
34
|
+
export async function getDbxDiagnostics() {
|
|
35
|
+
const portFile = bridgePortFilePath();
|
|
36
|
+
const bridgePortFileExists = await exists(portFile);
|
|
37
|
+
let bridgeUrl;
|
|
38
|
+
if (bridgePortFileExists) {
|
|
39
|
+
const port = (await readFile(portFile, "utf-8")).trim();
|
|
40
|
+
if (port)
|
|
41
|
+
bridgeUrl = `http://127.0.0.1:${port}`;
|
|
42
|
+
}
|
|
43
|
+
const path = dbPath();
|
|
44
|
+
const connectionStore = await inspectConnectionStore({ path });
|
|
45
|
+
return {
|
|
46
|
+
appDataDir: appDataDir(),
|
|
47
|
+
dbPath: path,
|
|
48
|
+
dbPathExists: connectionStore.dbPathExists,
|
|
49
|
+
connectionsTableExists: connectionStore.connectionsTableExists,
|
|
50
|
+
connectionSecretsTableExists: connectionStore.connectionSecretsTableExists,
|
|
51
|
+
connectionRowCount: connectionStore.connectionRowCount,
|
|
52
|
+
loadConnectionsOk: connectionStore.loadConnectionsOk,
|
|
53
|
+
loadedConnectionCount: connectionStore.loadedConnectionCount,
|
|
54
|
+
loadConnectionsError: connectionStore.loadConnectionsError,
|
|
55
|
+
loadConnectionsHint: connectionStore.loadConnectionsError
|
|
56
|
+
? connectionStoreHint(connectionStore.loadConnectionsError)
|
|
57
|
+
: undefined,
|
|
58
|
+
bridgePortFile: portFile,
|
|
59
|
+
bridgePortFileExists,
|
|
60
|
+
bridgeUrl,
|
|
61
|
+
directQueryTypes: [...DIRECT_QUERY_TYPES],
|
|
62
|
+
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function connectionStoreHint(message) {
|
|
66
|
+
if (/NODE_MODULE_VERSION|compiled against a different Node\.js version/i.test(message)) {
|
|
67
|
+
return "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx.";
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
async function exists(path) {
|
|
72
|
+
try {
|
|
73
|
+
await access(path);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./backend.js";
|
|
2
|
+
export * from "./bridge.js";
|
|
3
|
+
export * from "./connections.js";
|
|
4
|
+
export * from "./database.js";
|
|
5
|
+
export * from "./diagnostics.js";
|
|
6
|
+
export * from "./paths.js";
|
|
7
|
+
export * from "./schema-context.js";
|
|
8
|
+
export * from "./sql-safety.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./backend.js";
|
|
2
|
+
export * from "./bridge.js";
|
|
3
|
+
export * from "./connections.js";
|
|
4
|
+
export * from "./database.js";
|
|
5
|
+
export * from "./diagnostics.js";
|
|
6
|
+
export * from "./paths.js";
|
|
7
|
+
export * from "./schema-context.js";
|
|
8
|
+
export * from "./sql-safety.js";
|
package/dist/paths.d.ts
ADDED
package/dist/paths.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function appDataDir() {
|
|
4
|
+
const home = homedir();
|
|
5
|
+
switch (platform()) {
|
|
6
|
+
case "darwin":
|
|
7
|
+
return join(home, "Library", "Application Support", "com.dbx.app");
|
|
8
|
+
case "win32":
|
|
9
|
+
return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "com.dbx.app");
|
|
10
|
+
default:
|
|
11
|
+
return join(home, ".config", "com.dbx.app");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function dbPath() {
|
|
15
|
+
return join(appDataDir(), "dbx.db");
|
|
16
|
+
}
|
|
17
|
+
export function bridgePortFilePath() {
|
|
18
|
+
return join(appDataDir(), "mcp-bridge-port");
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConnectionConfig } from "./connections.js";
|
|
2
|
+
import type { ColumnInfo, TableInfo } from "./database.js";
|
|
3
|
+
export interface SchemaContextBackend {
|
|
4
|
+
listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
|
|
5
|
+
describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
|
|
6
|
+
}
|
|
7
|
+
export interface SchemaContextOptions {
|
|
8
|
+
schema?: string;
|
|
9
|
+
tables?: string[];
|
|
10
|
+
maxTables?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface SchemaContextTable {
|
|
13
|
+
name: string;
|
|
14
|
+
type: string;
|
|
15
|
+
columns: ColumnInfo[];
|
|
16
|
+
}
|
|
17
|
+
export interface SchemaContext {
|
|
18
|
+
connection: string;
|
|
19
|
+
database: string;
|
|
20
|
+
schema: string;
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
tables: SchemaContextTable[];
|
|
23
|
+
}
|
|
24
|
+
export declare function buildSchemaContext(backend: SchemaContextBackend, config: ConnectionConfig, options?: SchemaContextOptions): Promise<SchemaContext>;
|
|
25
|
+
export declare function formatSchemaContext(context: SchemaContext): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const DEFAULT_MAX_TABLES = 8;
|
|
2
|
+
export async function buildSchemaContext(backend, config, options = {}) {
|
|
3
|
+
const maxTables = Math.max(1, Math.min(options.maxTables ?? DEFAULT_MAX_TABLES, 20));
|
|
4
|
+
const availableTables = await backend.listTables(config, options.schema);
|
|
5
|
+
const requested = new Set((options.tables ?? []).map((table) => table.toLowerCase()));
|
|
6
|
+
const selected = requested.size
|
|
7
|
+
? availableTables.filter((table) => requested.has(table.name.toLowerCase()))
|
|
8
|
+
: availableTables.slice(0, maxTables);
|
|
9
|
+
const limited = selected.slice(0, maxTables);
|
|
10
|
+
const tables = await Promise.all(limited.map(async (table) => ({
|
|
11
|
+
name: table.name,
|
|
12
|
+
type: table.type,
|
|
13
|
+
columns: await backend.describeTable(config, table.name, options.schema),
|
|
14
|
+
})));
|
|
15
|
+
return {
|
|
16
|
+
connection: config.name,
|
|
17
|
+
database: config.database || "",
|
|
18
|
+
schema: options.schema || "",
|
|
19
|
+
truncated: selected.length > limited.length || (!requested.size && availableTables.length > limited.length),
|
|
20
|
+
tables,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function formatSchemaContext(context) {
|
|
24
|
+
const header = [
|
|
25
|
+
`Connection: ${context.connection}`,
|
|
26
|
+
context.database ? `Database: ${context.database}` : "",
|
|
27
|
+
context.schema ? `Schema: ${context.schema}` : "",
|
|
28
|
+
].filter(Boolean);
|
|
29
|
+
const sections = context.tables.map((table) => {
|
|
30
|
+
const lines = table.columns.map((column) => {
|
|
31
|
+
const parts = [
|
|
32
|
+
column.name,
|
|
33
|
+
column.data_type,
|
|
34
|
+
column.is_nullable ? "NULL" : "NOT NULL",
|
|
35
|
+
column.is_primary_key ? "PK" : "",
|
|
36
|
+
].filter(Boolean);
|
|
37
|
+
return `- ${parts.join(" ")}${column.comment ? ` -- ${column.comment}` : ""}`;
|
|
38
|
+
});
|
|
39
|
+
return [`## ${table.name}`, `Type: ${table.type}`, ...lines].join("\n");
|
|
40
|
+
});
|
|
41
|
+
const suffix = context.truncated ? "\n\nNote: table list was truncated; request specific table names for more context." : "";
|
|
42
|
+
return `${header.join("\n")}\n\n${sections.join("\n\n")}${suffix}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SqlSafetyOptions {
|
|
2
|
+
allowWrites?: boolean;
|
|
3
|
+
allowDangerous?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface SqlSafetyDecision {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function evaluateSqlSafety(sql: string, options?: SqlSafetyOptions): SqlSafetyDecision;
|
|
10
|
+
export declare function sqlSafetyFromEnv(env?: NodeJS.ProcessEnv): SqlSafetyOptions;
|