sandal-db 1.0.0
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 +21 -0
- package/README.md +318 -0
- package/dist/cli.js +2458 -0
- package/package.json +67 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk3 from "chalk";
|
|
6
|
+
import ora2 from "ora";
|
|
7
|
+
import { input as input2, select, confirm as confirm2 } from "@inquirer/prompts";
|
|
8
|
+
|
|
9
|
+
// src/config/index.ts
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import dotenv from "dotenv";
|
|
14
|
+
dotenv.config();
|
|
15
|
+
var CONFIG_DIR_NAME = ".sandal";
|
|
16
|
+
var LEGACY_CONFIG_DIR_NAME = ".db-agent";
|
|
17
|
+
var CONFIG_FILE_NAME = "config.json";
|
|
18
|
+
function getConfigDirPath() {
|
|
19
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME);
|
|
20
|
+
}
|
|
21
|
+
function getConfigFilePath() {
|
|
22
|
+
return path.join(getConfigDirPath(), CONFIG_FILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
function getLegacyConfigFilePath() {
|
|
25
|
+
return path.join(os.homedir(), LEGACY_CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
function readConfig() {
|
|
28
|
+
const filePath = getConfigFilePath();
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(filePath)) {
|
|
31
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
32
|
+
return JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
const legacyPath = getLegacyConfigFilePath();
|
|
35
|
+
if (fs.existsSync(legacyPath)) {
|
|
36
|
+
const content = fs.readFileSync(legacyPath, "utf-8");
|
|
37
|
+
return JSON.parse(content);
|
|
38
|
+
}
|
|
39
|
+
return {};
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function writeConfig(newConfig) {
|
|
45
|
+
const dirPath = getConfigDirPath();
|
|
46
|
+
const filePath = getConfigFilePath();
|
|
47
|
+
if (!fs.existsSync(dirPath)) {
|
|
48
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: 448 });
|
|
49
|
+
}
|
|
50
|
+
const existing = readConfig();
|
|
51
|
+
const merged = {
|
|
52
|
+
...existing,
|
|
53
|
+
...newConfig
|
|
54
|
+
};
|
|
55
|
+
fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
mode: 384
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
fs.chmodSync(filePath, 384);
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function maskUrl(rawUrl) {
|
|
65
|
+
if (!rawUrl) return "";
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(rawUrl);
|
|
68
|
+
if (url.password) {
|
|
69
|
+
url.password = "***";
|
|
70
|
+
}
|
|
71
|
+
if (url.username && url.username.length > 2) {
|
|
72
|
+
url.username = url.username[0] + "***";
|
|
73
|
+
}
|
|
74
|
+
return url.toString();
|
|
75
|
+
} catch {
|
|
76
|
+
return rawUrl.replace(/(:\/\/)([^:@\s]+):([^@\s]+)@/g, "$1$2:***@");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function maskApiKey(rawKey) {
|
|
80
|
+
if (!rawKey) return "";
|
|
81
|
+
const trimmed = rawKey.trim();
|
|
82
|
+
if (trimmed.length <= 8) {
|
|
83
|
+
return "***";
|
|
84
|
+
}
|
|
85
|
+
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
|
|
86
|
+
}
|
|
87
|
+
function getDefaultModelForProvider(provider) {
|
|
88
|
+
switch (provider) {
|
|
89
|
+
case "google":
|
|
90
|
+
return "gemini-2.5-flash";
|
|
91
|
+
case "openai":
|
|
92
|
+
return "gpt-4o";
|
|
93
|
+
case "anthropic":
|
|
94
|
+
return "claude-3-5-sonnet-latest";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function resolveCredentials(options) {
|
|
98
|
+
const config = readConfig();
|
|
99
|
+
let dbUrl = options.cliDbUrl;
|
|
100
|
+
let dbSource = "cli";
|
|
101
|
+
if (!dbUrl) {
|
|
102
|
+
const envUrl = process.env.DATABASE_URL || process.env.DB_URL;
|
|
103
|
+
if (envUrl) {
|
|
104
|
+
dbUrl = envUrl;
|
|
105
|
+
dbSource = "env";
|
|
106
|
+
} else if (config.dbUrl) {
|
|
107
|
+
dbUrl = config.dbUrl;
|
|
108
|
+
dbSource = "config";
|
|
109
|
+
} else {
|
|
110
|
+
dbSource = "none";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let provider = "google";
|
|
114
|
+
if (options.cliProvider) {
|
|
115
|
+
const p = options.cliProvider.toLowerCase();
|
|
116
|
+
if (p === "openai" || p === "anthropic" || p === "google") {
|
|
117
|
+
provider = p;
|
|
118
|
+
}
|
|
119
|
+
} else if (config.defaultProvider) {
|
|
120
|
+
provider = config.defaultProvider;
|
|
121
|
+
} else if (process.env.GEMINI_API_KEY) {
|
|
122
|
+
provider = "google";
|
|
123
|
+
} else if (process.env.OPENAI_API_KEY) {
|
|
124
|
+
provider = "openai";
|
|
125
|
+
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
126
|
+
provider = "anthropic";
|
|
127
|
+
}
|
|
128
|
+
let apiKey = options.cliApiKey;
|
|
129
|
+
let keySource = "cli";
|
|
130
|
+
if (!apiKey) {
|
|
131
|
+
if (provider === "google") {
|
|
132
|
+
if (process.env.GEMINI_API_KEY) {
|
|
133
|
+
apiKey = process.env.GEMINI_API_KEY;
|
|
134
|
+
keySource = "env";
|
|
135
|
+
} else if (config.geminiApiKey) {
|
|
136
|
+
apiKey = config.geminiApiKey;
|
|
137
|
+
keySource = "config";
|
|
138
|
+
}
|
|
139
|
+
} else if (provider === "openai") {
|
|
140
|
+
if (process.env.OPENAI_API_KEY) {
|
|
141
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
142
|
+
keySource = "env";
|
|
143
|
+
} else if (config.openaiApiKey) {
|
|
144
|
+
apiKey = config.openaiApiKey;
|
|
145
|
+
keySource = "config";
|
|
146
|
+
}
|
|
147
|
+
} else if (provider === "anthropic") {
|
|
148
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
149
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
150
|
+
keySource = "env";
|
|
151
|
+
} else if (config.anthropicApiKey) {
|
|
152
|
+
apiKey = config.anthropicApiKey;
|
|
153
|
+
keySource = "config";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!apiKey) {
|
|
157
|
+
if (process.env.GEMINI_API_KEY || config.geminiApiKey) {
|
|
158
|
+
provider = "google";
|
|
159
|
+
apiKey = process.env.GEMINI_API_KEY || config.geminiApiKey;
|
|
160
|
+
keySource = process.env.GEMINI_API_KEY ? "env" : "config";
|
|
161
|
+
} else if (process.env.OPENAI_API_KEY || config.openaiApiKey) {
|
|
162
|
+
provider = "openai";
|
|
163
|
+
apiKey = process.env.OPENAI_API_KEY || config.openaiApiKey;
|
|
164
|
+
keySource = process.env.OPENAI_API_KEY ? "env" : "config";
|
|
165
|
+
} else if (process.env.ANTHROPIC_API_KEY || config.anthropicApiKey) {
|
|
166
|
+
provider = "anthropic";
|
|
167
|
+
apiKey = process.env.ANTHROPIC_API_KEY || config.anthropicApiKey;
|
|
168
|
+
keySource = process.env.ANTHROPIC_API_KEY ? "env" : "config";
|
|
169
|
+
} else {
|
|
170
|
+
keySource = "none";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const model = options.cliModel || config.defaultModel || getDefaultModelForProvider(provider);
|
|
175
|
+
return {
|
|
176
|
+
dbUrl,
|
|
177
|
+
apiKey,
|
|
178
|
+
provider,
|
|
179
|
+
model,
|
|
180
|
+
source: {
|
|
181
|
+
dbUrl: dbSource,
|
|
182
|
+
apiKey: keySource
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/db/postgres.ts
|
|
188
|
+
import pg from "pg";
|
|
189
|
+
var { Pool } = pg;
|
|
190
|
+
var PostgresAdapter = class {
|
|
191
|
+
type = "postgres";
|
|
192
|
+
pool = null;
|
|
193
|
+
connectionUrl;
|
|
194
|
+
cachedSchema = null;
|
|
195
|
+
databaseName = "postgres";
|
|
196
|
+
constructor(connectionUrl) {
|
|
197
|
+
this.connectionUrl = connectionUrl;
|
|
198
|
+
try {
|
|
199
|
+
const parsed = new URL(connectionUrl);
|
|
200
|
+
this.databaseName = parsed.pathname.replace(/^\//, "") || "postgres";
|
|
201
|
+
} catch {
|
|
202
|
+
this.databaseName = "postgres";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
getMaskedUrl() {
|
|
206
|
+
return maskUrl(this.connectionUrl);
|
|
207
|
+
}
|
|
208
|
+
async connect() {
|
|
209
|
+
if (this.pool) return;
|
|
210
|
+
this.pool = new Pool({
|
|
211
|
+
connectionString: this.connectionUrl,
|
|
212
|
+
connectionTimeoutMillis: 1e4,
|
|
213
|
+
idleTimeoutMillis: 3e4
|
|
214
|
+
});
|
|
215
|
+
const client = await this.pool.connect();
|
|
216
|
+
try {
|
|
217
|
+
const res = await client.query("SELECT current_database() as db_name;");
|
|
218
|
+
if (res.rows[0]?.db_name) {
|
|
219
|
+
this.databaseName = res.rows[0].db_name;
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
client.release();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async disconnect() {
|
|
226
|
+
if (this.pool) {
|
|
227
|
+
await this.pool.end();
|
|
228
|
+
this.pool = null;
|
|
229
|
+
}
|
|
230
|
+
this.cachedSchema = null;
|
|
231
|
+
}
|
|
232
|
+
isConnected() {
|
|
233
|
+
return this.pool !== null;
|
|
234
|
+
}
|
|
235
|
+
async inspectSchema(forceRefresh = false) {
|
|
236
|
+
if (this.cachedSchema && !forceRefresh) {
|
|
237
|
+
return this.cachedSchema;
|
|
238
|
+
}
|
|
239
|
+
if (!this.pool) {
|
|
240
|
+
await this.connect();
|
|
241
|
+
}
|
|
242
|
+
const client = await this.pool.connect();
|
|
243
|
+
try {
|
|
244
|
+
const tablesResult = await client.query(`
|
|
245
|
+
SELECT table_schema, table_name
|
|
246
|
+
FROM information_schema.tables
|
|
247
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
248
|
+
AND table_type = 'BASE TABLE'
|
|
249
|
+
ORDER BY table_schema, table_name;
|
|
250
|
+
`);
|
|
251
|
+
const columnsResult = await client.query(`
|
|
252
|
+
SELECT
|
|
253
|
+
c.table_schema,
|
|
254
|
+
c.table_name,
|
|
255
|
+
c.column_name,
|
|
256
|
+
c.data_type,
|
|
257
|
+
c.is_nullable,
|
|
258
|
+
c.column_default,
|
|
259
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key
|
|
260
|
+
FROM information_schema.columns c
|
|
261
|
+
LEFT JOIN (
|
|
262
|
+
SELECT ku.table_schema, ku.table_name, ku.column_name
|
|
263
|
+
FROM information_schema.table_constraints tc
|
|
264
|
+
JOIN information_schema.key_column_usage ku
|
|
265
|
+
ON tc.constraint_name = ku.constraint_name
|
|
266
|
+
AND tc.table_schema = ku.table_schema
|
|
267
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
268
|
+
) pk ON c.table_schema = pk.table_schema
|
|
269
|
+
AND c.table_name = pk.table_name
|
|
270
|
+
AND c.column_name = pk.column_name
|
|
271
|
+
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
272
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
273
|
+
`);
|
|
274
|
+
const indexesResult = await client.query(`
|
|
275
|
+
SELECT
|
|
276
|
+
schemaname as table_schema,
|
|
277
|
+
tablename as table_name,
|
|
278
|
+
indexname,
|
|
279
|
+
indexdef
|
|
280
|
+
FROM pg_indexes
|
|
281
|
+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
282
|
+
ORDER BY schemaname, tablename, indexname;
|
|
283
|
+
`);
|
|
284
|
+
const foreignKeysResult = await client.query(`
|
|
285
|
+
SELECT
|
|
286
|
+
tc.constraint_name,
|
|
287
|
+
tc.table_schema,
|
|
288
|
+
tc.table_name,
|
|
289
|
+
kcu.column_name,
|
|
290
|
+
ccu.table_name AS foreign_table_name,
|
|
291
|
+
ccu.column_name AS foreign_column_name
|
|
292
|
+
FROM information_schema.table_constraints AS tc
|
|
293
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
294
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
295
|
+
AND tc.table_schema = kcu.table_schema
|
|
296
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
297
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
298
|
+
AND ccu.table_schema = tc.table_schema
|
|
299
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
300
|
+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema');
|
|
301
|
+
`);
|
|
302
|
+
const rowCountsResult = await client.query(`
|
|
303
|
+
SELECT
|
|
304
|
+
nspname AS table_schema,
|
|
305
|
+
relname AS table_name,
|
|
306
|
+
reltuples::bigint AS approx_row_count
|
|
307
|
+
FROM pg_class C
|
|
308
|
+
LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
|
|
309
|
+
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
|
|
310
|
+
AND relkind = 'r';
|
|
311
|
+
`);
|
|
312
|
+
const rowCountsMap = /* @__PURE__ */ new Map();
|
|
313
|
+
for (const row of rowCountsResult.rows) {
|
|
314
|
+
rowCountsMap.set(`${row.table_schema}.${row.table_name}`, Math.max(0, Number(row.approx_row_count)));
|
|
315
|
+
}
|
|
316
|
+
const columnsMap = /* @__PURE__ */ new Map();
|
|
317
|
+
for (const row of columnsResult.rows) {
|
|
318
|
+
const key = `${row.table_schema}.${row.table_name}`;
|
|
319
|
+
const list = columnsMap.get(key) || [];
|
|
320
|
+
list.push({
|
|
321
|
+
name: row.column_name,
|
|
322
|
+
dataType: row.data_type,
|
|
323
|
+
isNullable: row.is_nullable === "YES",
|
|
324
|
+
defaultValue: row.column_default,
|
|
325
|
+
isPrimaryKey: Boolean(row.is_primary_key)
|
|
326
|
+
});
|
|
327
|
+
columnsMap.set(key, list);
|
|
328
|
+
}
|
|
329
|
+
const indexesMap = /* @__PURE__ */ new Map();
|
|
330
|
+
for (const row of indexesResult.rows) {
|
|
331
|
+
const key = `${row.table_schema}.${row.table_name}`;
|
|
332
|
+
const list = indexesMap.get(key) || [];
|
|
333
|
+
const isUnique = /create\s+unique\s+index/i.test(row.indexdef);
|
|
334
|
+
const isPrimary = /_pkey$/i.test(row.indexname) || /primary key/i.test(row.indexdef);
|
|
335
|
+
list.push({
|
|
336
|
+
name: row.indexname,
|
|
337
|
+
tableName: row.table_name,
|
|
338
|
+
columnNames: [],
|
|
339
|
+
// extracted from indexdef
|
|
340
|
+
isUnique,
|
|
341
|
+
isPrimary,
|
|
342
|
+
definition: row.indexdef
|
|
343
|
+
});
|
|
344
|
+
indexesMap.set(key, list);
|
|
345
|
+
}
|
|
346
|
+
const foreignKeysMap = /* @__PURE__ */ new Map();
|
|
347
|
+
for (const row of foreignKeysResult.rows) {
|
|
348
|
+
const key = `${row.table_schema}.${row.table_name}`;
|
|
349
|
+
const list = foreignKeysMap.get(key) || [];
|
|
350
|
+
list.push({
|
|
351
|
+
constraintName: row.constraint_name,
|
|
352
|
+
columnName: row.column_name,
|
|
353
|
+
foreignTableName: row.foreign_table_name,
|
|
354
|
+
foreignColumnName: row.foreign_column_name
|
|
355
|
+
});
|
|
356
|
+
foreignKeysMap.set(key, list);
|
|
357
|
+
}
|
|
358
|
+
const tables = tablesResult.rows.map((r) => {
|
|
359
|
+
const key = `${r.table_schema}.${r.table_name}`;
|
|
360
|
+
return {
|
|
361
|
+
name: r.table_name,
|
|
362
|
+
schema: r.table_schema,
|
|
363
|
+
columns: columnsMap.get(key) || [],
|
|
364
|
+
indexes: indexesMap.get(key) || [],
|
|
365
|
+
foreignKeys: foreignKeysMap.get(key) || [],
|
|
366
|
+
approximateRowCount: rowCountsMap.get(key) ?? 0
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
this.cachedSchema = {
|
|
370
|
+
type: "postgres",
|
|
371
|
+
databaseName: this.databaseName,
|
|
372
|
+
tables,
|
|
373
|
+
inspectedAt: /* @__PURE__ */ new Date()
|
|
374
|
+
};
|
|
375
|
+
return this.cachedSchema;
|
|
376
|
+
} finally {
|
|
377
|
+
client.release();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async executeQuery(query) {
|
|
381
|
+
if (!this.pool) {
|
|
382
|
+
await this.connect();
|
|
383
|
+
}
|
|
384
|
+
const sql = query.sql || query.rawDisplay;
|
|
385
|
+
const startTime = Date.now();
|
|
386
|
+
const client = await this.pool.connect();
|
|
387
|
+
try {
|
|
388
|
+
const res = await client.query(sql, query.params || []);
|
|
389
|
+
const durationMs = Date.now() - startTime;
|
|
390
|
+
let rows = [];
|
|
391
|
+
let fields = [];
|
|
392
|
+
let rowCount = 0;
|
|
393
|
+
if (Array.isArray(res)) {
|
|
394
|
+
const lastResult = res[res.length - 1];
|
|
395
|
+
rows = lastResult?.rows || [];
|
|
396
|
+
fields = (lastResult?.fields || []).map((f) => f.name);
|
|
397
|
+
rowCount = lastResult?.rowCount ?? rows.length;
|
|
398
|
+
} else if (res) {
|
|
399
|
+
rows = res.rows || [];
|
|
400
|
+
fields = (res.fields || []).map((f) => f.name);
|
|
401
|
+
rowCount = res.rowCount ?? rows.length;
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
success: true,
|
|
405
|
+
rows,
|
|
406
|
+
fields,
|
|
407
|
+
rowCount,
|
|
408
|
+
affectedRows: rowCount,
|
|
409
|
+
durationMs,
|
|
410
|
+
command: Array.isArray(res) ? res[res.length - 1]?.command : res?.command
|
|
411
|
+
};
|
|
412
|
+
} catch (err) {
|
|
413
|
+
const durationMs = Date.now() - startTime;
|
|
414
|
+
return {
|
|
415
|
+
success: false,
|
|
416
|
+
durationMs,
|
|
417
|
+
error: err.message || String(err)
|
|
418
|
+
};
|
|
419
|
+
} finally {
|
|
420
|
+
client.release();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async dryRunCount(query) {
|
|
424
|
+
if (!this.pool) {
|
|
425
|
+
await this.connect();
|
|
426
|
+
}
|
|
427
|
+
const sql = (query.sql || query.rawDisplay).trim();
|
|
428
|
+
const deleteMatch = sql.match(/^DELETE\s+FROM\s+([^\s;()]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;)?$/i);
|
|
429
|
+
if (deleteMatch) {
|
|
430
|
+
const table = deleteMatch[1];
|
|
431
|
+
const whereClause = deleteMatch[2];
|
|
432
|
+
const countSql = whereClause ? `SELECT COUNT(*) as count FROM ${table} WHERE ${whereClause}` : `SELECT COUNT(*) as count FROM ${table}`;
|
|
433
|
+
try {
|
|
434
|
+
const client = await this.pool.connect();
|
|
435
|
+
try {
|
|
436
|
+
const res = await client.query(countSql, query.params || []);
|
|
437
|
+
return Number(res.rows[0]?.count ?? 0);
|
|
438
|
+
} finally {
|
|
439
|
+
client.release();
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const updateMatch = sql.match(/^UPDATE\s+([^\s;()]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;)?$/i);
|
|
446
|
+
if (updateMatch) {
|
|
447
|
+
const table = updateMatch[1];
|
|
448
|
+
const whereClause = updateMatch[2];
|
|
449
|
+
const countSql = whereClause ? `SELECT COUNT(*) as count FROM ${table} WHERE ${whereClause}` : `SELECT COUNT(*) as count FROM ${table}`;
|
|
450
|
+
try {
|
|
451
|
+
const client = await this.pool.connect();
|
|
452
|
+
try {
|
|
453
|
+
const res = await client.query(countSql);
|
|
454
|
+
return Number(res.rows[0]?.count ?? 0);
|
|
455
|
+
} finally {
|
|
456
|
+
client.release();
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const truncateOrDropMatch = sql.match(/^(?:TRUNCATE(?:\s+TABLE)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?)\s+([^\s;()]+)/i);
|
|
463
|
+
if (truncateOrDropMatch) {
|
|
464
|
+
const table = truncateOrDropMatch[1];
|
|
465
|
+
try {
|
|
466
|
+
const client = await this.pool.connect();
|
|
467
|
+
try {
|
|
468
|
+
const res = await client.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
469
|
+
return Number(res.rows[0]?.count ?? 0);
|
|
470
|
+
} finally {
|
|
471
|
+
client.release();
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
async explainQuery(query) {
|
|
480
|
+
if (!this.pool) {
|
|
481
|
+
await this.connect();
|
|
482
|
+
}
|
|
483
|
+
const sql = (query.sql || query.rawDisplay).trim();
|
|
484
|
+
if (!/^(SELECT|UPDATE|DELETE|INSERT)/i.test(sql)) {
|
|
485
|
+
return "";
|
|
486
|
+
}
|
|
487
|
+
const client = await this.pool.connect();
|
|
488
|
+
try {
|
|
489
|
+
const res = await client.query(`EXPLAIN ${sql}`, query.params || []);
|
|
490
|
+
return res.rows.map((r) => Object.values(r)[0]).join("\n");
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return `Explain failed: ${err.message}`;
|
|
493
|
+
} finally {
|
|
494
|
+
client.release();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// src/db/mongo.ts
|
|
500
|
+
import { MongoClient } from "mongodb";
|
|
501
|
+
var MongoAdapter = class {
|
|
502
|
+
type = "mongodb";
|
|
503
|
+
client = null;
|
|
504
|
+
db = null;
|
|
505
|
+
connectionUrl;
|
|
506
|
+
cachedSchema = null;
|
|
507
|
+
databaseName = "admin";
|
|
508
|
+
constructor(connectionUrl) {
|
|
509
|
+
this.connectionUrl = connectionUrl;
|
|
510
|
+
try {
|
|
511
|
+
const parsed = new URL(connectionUrl);
|
|
512
|
+
const dbName = parsed.pathname.replace(/^\//, "");
|
|
513
|
+
if (dbName) {
|
|
514
|
+
this.databaseName = dbName;
|
|
515
|
+
}
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
getMaskedUrl() {
|
|
520
|
+
return maskUrl(this.connectionUrl);
|
|
521
|
+
}
|
|
522
|
+
async connect() {
|
|
523
|
+
if (this.client) return;
|
|
524
|
+
this.client = new MongoClient(this.connectionUrl, {
|
|
525
|
+
serverSelectionTimeoutMS: 1e4,
|
|
526
|
+
connectTimeoutMS: 1e4
|
|
527
|
+
});
|
|
528
|
+
await this.client.connect();
|
|
529
|
+
this.db = this.client.db(this.databaseName === "admin" ? void 0 : this.databaseName);
|
|
530
|
+
this.databaseName = this.db.databaseName;
|
|
531
|
+
}
|
|
532
|
+
async disconnect() {
|
|
533
|
+
if (this.client) {
|
|
534
|
+
await this.client.close();
|
|
535
|
+
this.client = null;
|
|
536
|
+
this.db = null;
|
|
537
|
+
}
|
|
538
|
+
this.cachedSchema = null;
|
|
539
|
+
}
|
|
540
|
+
isConnected() {
|
|
541
|
+
return this.client !== null && this.db !== null;
|
|
542
|
+
}
|
|
543
|
+
async inspectSchema(forceRefresh = false) {
|
|
544
|
+
if (this.cachedSchema && !forceRefresh) {
|
|
545
|
+
return this.cachedSchema;
|
|
546
|
+
}
|
|
547
|
+
if (!this.isConnected()) {
|
|
548
|
+
await this.connect();
|
|
549
|
+
}
|
|
550
|
+
const db = this.db;
|
|
551
|
+
const collectionsList = await db.listCollections().toArray();
|
|
552
|
+
const collectionSchemas = [];
|
|
553
|
+
for (const colInfo of collectionsList) {
|
|
554
|
+
const colName = colInfo.name;
|
|
555
|
+
if (colName.startsWith("system.")) continue;
|
|
556
|
+
const collection = db.collection(colName);
|
|
557
|
+
let documentCount = 0;
|
|
558
|
+
try {
|
|
559
|
+
documentCount = await collection.estimatedDocumentCount();
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
const sampleDocs = await collection.find({}).limit(20).toArray();
|
|
563
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
564
|
+
let sampleDoc = sampleDocs[0];
|
|
565
|
+
for (const doc of sampleDocs) {
|
|
566
|
+
this.extractFields(doc, "", fieldMap);
|
|
567
|
+
}
|
|
568
|
+
const fields = Array.from(fieldMap.entries()).map(([name, types]) => ({
|
|
569
|
+
name,
|
|
570
|
+
types: Array.from(types)
|
|
571
|
+
}));
|
|
572
|
+
let indexes = [];
|
|
573
|
+
try {
|
|
574
|
+
const rawIndexes = await collection.indexes();
|
|
575
|
+
indexes = rawIndexes.map((idx) => ({
|
|
576
|
+
name: idx.name || "unknown",
|
|
577
|
+
tableName: colName,
|
|
578
|
+
columnNames: Object.keys(idx.key || {}),
|
|
579
|
+
isUnique: Boolean(idx.unique),
|
|
580
|
+
isPrimary: idx.name === "_id_",
|
|
581
|
+
definition: JSON.stringify(idx.key)
|
|
582
|
+
}));
|
|
583
|
+
} catch {
|
|
584
|
+
}
|
|
585
|
+
collectionSchemas.push({
|
|
586
|
+
name: colName,
|
|
587
|
+
fields,
|
|
588
|
+
indexes,
|
|
589
|
+
documentCount,
|
|
590
|
+
sampleDocument: sampleDoc
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
this.cachedSchema = {
|
|
594
|
+
type: "mongodb",
|
|
595
|
+
databaseName: this.databaseName,
|
|
596
|
+
collections: collectionSchemas,
|
|
597
|
+
inspectedAt: /* @__PURE__ */ new Date()
|
|
598
|
+
};
|
|
599
|
+
return this.cachedSchema;
|
|
600
|
+
}
|
|
601
|
+
extractFields(obj, prefix, fieldMap) {
|
|
602
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj) || obj instanceof Date) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
606
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
607
|
+
let type = typeof value;
|
|
608
|
+
if (value === null) type = "null";
|
|
609
|
+
else if (Array.isArray(value)) type = "array";
|
|
610
|
+
else if (value instanceof Date) type = "date";
|
|
611
|
+
else if (value && typeof value === "object" && value._bsontype) type = value._bsontype;
|
|
612
|
+
if (!fieldMap.has(fullKey)) {
|
|
613
|
+
fieldMap.set(fullKey, /* @__PURE__ */ new Set());
|
|
614
|
+
}
|
|
615
|
+
fieldMap.get(fullKey).add(type);
|
|
616
|
+
if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !value._bsontype) {
|
|
617
|
+
this.extractFields(value, fullKey, fieldMap);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async executeQuery(query) {
|
|
622
|
+
if (!this.isConnected()) {
|
|
623
|
+
await this.connect();
|
|
624
|
+
}
|
|
625
|
+
const startTime = Date.now();
|
|
626
|
+
const db = this.db;
|
|
627
|
+
try {
|
|
628
|
+
if (query.operation === "command" || query.rawCommand) {
|
|
629
|
+
const cmd = query.rawCommand || (typeof query.rawDisplay === "string" ? JSON.parse(query.rawDisplay) : query.rawDisplay);
|
|
630
|
+
const result = await db.command(cmd);
|
|
631
|
+
const durationMs2 = Date.now() - startTime;
|
|
632
|
+
return {
|
|
633
|
+
success: true,
|
|
634
|
+
durationMs: durationMs2,
|
|
635
|
+
rawOutput: result,
|
|
636
|
+
rowCount: 1,
|
|
637
|
+
rows: [result]
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
let collectionName = query.collection;
|
|
641
|
+
let operation = query.operation;
|
|
642
|
+
let filter = query.filter || {};
|
|
643
|
+
let update = query.update || {};
|
|
644
|
+
let pipeline = query.pipeline || [];
|
|
645
|
+
let document = query.document;
|
|
646
|
+
let documents = query.documents;
|
|
647
|
+
let options = query.options || {};
|
|
648
|
+
if (!collectionName || !operation) {
|
|
649
|
+
try {
|
|
650
|
+
const parsed = JSON.parse(query.rawDisplay);
|
|
651
|
+
collectionName = parsed.collection || collectionName;
|
|
652
|
+
operation = parsed.operation || operation;
|
|
653
|
+
filter = parsed.filter || filter;
|
|
654
|
+
update = parsed.update || update;
|
|
655
|
+
pipeline = parsed.pipeline || pipeline;
|
|
656
|
+
document = parsed.document || document;
|
|
657
|
+
documents = parsed.documents || documents;
|
|
658
|
+
options = parsed.options || options;
|
|
659
|
+
} catch {
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (!collectionName) {
|
|
663
|
+
throw new Error("Mongo operation requires a target collection.");
|
|
664
|
+
}
|
|
665
|
+
const collection = db.collection(collectionName);
|
|
666
|
+
let rows = [];
|
|
667
|
+
let rowCount = 0;
|
|
668
|
+
let affectedRows = 0;
|
|
669
|
+
switch (operation) {
|
|
670
|
+
case "find": {
|
|
671
|
+
const limit = options.limit || 100;
|
|
672
|
+
const cursor = collection.find(filter, options).limit(limit);
|
|
673
|
+
rows = await cursor.toArray();
|
|
674
|
+
rowCount = rows.length;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "aggregate": {
|
|
678
|
+
const cursor = collection.aggregate(pipeline, options);
|
|
679
|
+
rows = await cursor.toArray();
|
|
680
|
+
rowCount = rows.length;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
case "countDocuments": {
|
|
684
|
+
const count = await collection.countDocuments(filter, options);
|
|
685
|
+
rows = [{ count }];
|
|
686
|
+
rowCount = 1;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case "insertOne": {
|
|
690
|
+
const res = await collection.insertOne(document || filter, options);
|
|
691
|
+
rows = [{ insertedId: res.insertedId, acknowledged: res.acknowledged }];
|
|
692
|
+
rowCount = 1;
|
|
693
|
+
affectedRows = 1;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case "insertMany": {
|
|
697
|
+
const docs = documents || (Array.isArray(document) ? document : [document]);
|
|
698
|
+
const res = await collection.insertMany(docs, options);
|
|
699
|
+
rows = [{ insertedCount: res.insertedCount, acknowledged: res.acknowledged }];
|
|
700
|
+
rowCount = res.insertedCount;
|
|
701
|
+
affectedRows = res.insertedCount;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
case "updateOne": {
|
|
705
|
+
const res = await collection.updateOne(filter, update, options);
|
|
706
|
+
rows = [{ matchedCount: res.matchedCount, modifiedCount: res.modifiedCount, upsertedId: res.upsertedId }];
|
|
707
|
+
rowCount = res.modifiedCount;
|
|
708
|
+
affectedRows = res.modifiedCount;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case "updateMany": {
|
|
712
|
+
const res = await collection.updateMany(filter, update, options);
|
|
713
|
+
rows = [{ matchedCount: res.matchedCount, modifiedCount: res.modifiedCount, upsertedId: res.upsertedId }];
|
|
714
|
+
rowCount = res.modifiedCount;
|
|
715
|
+
affectedRows = res.modifiedCount;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case "deleteOne": {
|
|
719
|
+
const res = await collection.deleteOne(filter, options);
|
|
720
|
+
rows = [{ deletedCount: res.deletedCount }];
|
|
721
|
+
rowCount = res.deletedCount;
|
|
722
|
+
affectedRows = res.deletedCount;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
case "deleteMany": {
|
|
726
|
+
const res = await collection.deleteMany(filter, options);
|
|
727
|
+
rows = [{ deletedCount: res.deletedCount }];
|
|
728
|
+
rowCount = res.deletedCount;
|
|
729
|
+
affectedRows = res.deletedCount;
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case "drop": {
|
|
733
|
+
const res = await collection.drop();
|
|
734
|
+
rows = [{ dropped: res }];
|
|
735
|
+
rowCount = res ? 1 : 0;
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
case "createIndex": {
|
|
739
|
+
const keys = options.keys || filter;
|
|
740
|
+
const indexOptions = options.indexOptions || {};
|
|
741
|
+
const indexName = await collection.createIndex(keys, indexOptions);
|
|
742
|
+
rows = [{ createdIndex: indexName }];
|
|
743
|
+
rowCount = 1;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
case "createCollection": {
|
|
747
|
+
await db.createCollection(collectionName, options);
|
|
748
|
+
rows = [{ createdCollection: collectionName }];
|
|
749
|
+
rowCount = 1;
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
default:
|
|
753
|
+
throw new Error(`Unsupported MongoDB operation: ${operation}`);
|
|
754
|
+
}
|
|
755
|
+
const durationMs = Date.now() - startTime;
|
|
756
|
+
const fields = rows.length > 0 && typeof rows[0] === "object" ? Object.keys(rows[0]) : [];
|
|
757
|
+
return {
|
|
758
|
+
success: true,
|
|
759
|
+
rows,
|
|
760
|
+
rowCount,
|
|
761
|
+
affectedRows,
|
|
762
|
+
fields,
|
|
763
|
+
durationMs,
|
|
764
|
+
command: operation
|
|
765
|
+
};
|
|
766
|
+
} catch (err) {
|
|
767
|
+
const durationMs = Date.now() - startTime;
|
|
768
|
+
return {
|
|
769
|
+
success: false,
|
|
770
|
+
durationMs,
|
|
771
|
+
error: err.message || String(err)
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async dryRunCount(query) {
|
|
776
|
+
if (!this.isConnected()) {
|
|
777
|
+
await this.connect();
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
let collectionName = query.collection;
|
|
781
|
+
let operation = query.operation;
|
|
782
|
+
let filter = query.filter || {};
|
|
783
|
+
if (!collectionName || !operation) {
|
|
784
|
+
try {
|
|
785
|
+
const parsed = JSON.parse(query.rawDisplay);
|
|
786
|
+
collectionName = parsed.collection || collectionName;
|
|
787
|
+
operation = parsed.operation || operation;
|
|
788
|
+
filter = parsed.filter || filter;
|
|
789
|
+
} catch {
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (!collectionName) return null;
|
|
793
|
+
const collection = this.db.collection(collectionName);
|
|
794
|
+
if (operation === "deleteOne") {
|
|
795
|
+
const count = await collection.countDocuments(filter);
|
|
796
|
+
return Math.min(1, count);
|
|
797
|
+
}
|
|
798
|
+
if (operation === "deleteMany" || operation === "updateOne" || operation === "updateMany") {
|
|
799
|
+
return await collection.countDocuments(filter);
|
|
800
|
+
}
|
|
801
|
+
if (operation === "drop") {
|
|
802
|
+
return await collection.estimatedDocumentCount();
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async explainQuery(query) {
|
|
810
|
+
if (!this.isConnected()) {
|
|
811
|
+
await this.connect();
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
let collectionName = query.collection;
|
|
815
|
+
let operation = query.operation || "find";
|
|
816
|
+
let filter = query.filter || {};
|
|
817
|
+
let pipeline = query.pipeline || [];
|
|
818
|
+
if (!collectionName) {
|
|
819
|
+
try {
|
|
820
|
+
const parsed = JSON.parse(query.rawDisplay);
|
|
821
|
+
collectionName = parsed.collection || collectionName;
|
|
822
|
+
operation = parsed.operation || operation;
|
|
823
|
+
filter = parsed.filter || filter;
|
|
824
|
+
pipeline = parsed.pipeline || pipeline;
|
|
825
|
+
} catch {
|
|
826
|
+
return "";
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (!collectionName) return "";
|
|
830
|
+
const collection = this.db.collection(collectionName);
|
|
831
|
+
if (operation === "find") {
|
|
832
|
+
const explanation = await collection.find(filter).explain("executionStats");
|
|
833
|
+
return JSON.stringify(explanation, null, 2);
|
|
834
|
+
}
|
|
835
|
+
if (operation === "aggregate") {
|
|
836
|
+
const explanation = await collection.aggregate(pipeline).explain("executionStats");
|
|
837
|
+
return JSON.stringify(explanation, null, 2);
|
|
838
|
+
}
|
|
839
|
+
return "";
|
|
840
|
+
} catch (err) {
|
|
841
|
+
return `Explain failed: ${err.message}`;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// src/db/connection.ts
|
|
847
|
+
function detectDatabaseType(url) {
|
|
848
|
+
const trimmed = url.trim().toLowerCase();
|
|
849
|
+
if (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://")) {
|
|
850
|
+
return "postgres";
|
|
851
|
+
}
|
|
852
|
+
if (trimmed.startsWith("mongodb://") || trimmed.startsWith("mongodb+srv://")) {
|
|
853
|
+
return "mongodb";
|
|
854
|
+
}
|
|
855
|
+
throw new Error(
|
|
856
|
+
`Unsupported database URL scheme. Expected postgres://, postgresql://, mongodb://, or mongodb+srv://. Received: ${url.slice(0, 15)}...`
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
function createDatabaseAdapter(url) {
|
|
860
|
+
const type = detectDatabaseType(url);
|
|
861
|
+
if (type === "postgres") {
|
|
862
|
+
return new PostgresAdapter(url);
|
|
863
|
+
}
|
|
864
|
+
if (type === "mongodb") {
|
|
865
|
+
return new MongoAdapter(url);
|
|
866
|
+
}
|
|
867
|
+
throw new Error(`Unknown database type: ${type}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/agent/llm.ts
|
|
871
|
+
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
|
872
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
873
|
+
import { ChatAnthropic } from "@langchain/anthropic";
|
|
874
|
+
function createChatModel(options) {
|
|
875
|
+
const { provider, model, apiKey, temperature = 0 } = options;
|
|
876
|
+
switch (provider) {
|
|
877
|
+
case "google":
|
|
878
|
+
return new ChatGoogleGenerativeAI({
|
|
879
|
+
model,
|
|
880
|
+
apiKey,
|
|
881
|
+
temperature,
|
|
882
|
+
maxRetries: 2
|
|
883
|
+
});
|
|
884
|
+
case "openai":
|
|
885
|
+
return new ChatOpenAI({
|
|
886
|
+
modelName: model,
|
|
887
|
+
apiKey,
|
|
888
|
+
temperature,
|
|
889
|
+
maxRetries: 2
|
|
890
|
+
});
|
|
891
|
+
case "anthropic":
|
|
892
|
+
return new ChatAnthropic({
|
|
893
|
+
modelName: model,
|
|
894
|
+
anthropicApiKey: apiKey,
|
|
895
|
+
temperature,
|
|
896
|
+
maxRetries: 2
|
|
897
|
+
});
|
|
898
|
+
default:
|
|
899
|
+
throw new Error(`Unsupported LLM provider: ${provider}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/repl.ts
|
|
904
|
+
import readline from "node:readline";
|
|
905
|
+
import chalk2 from "chalk";
|
|
906
|
+
import boxen2 from "boxen";
|
|
907
|
+
import ora from "ora";
|
|
908
|
+
import Table from "cli-table3";
|
|
909
|
+
|
|
910
|
+
// src/agent/graph.ts
|
|
911
|
+
import { Annotation, StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
|
|
912
|
+
import { HumanMessage as HumanMessage2, SystemMessage, AIMessage } from "@langchain/core/messages";
|
|
913
|
+
|
|
914
|
+
// src/safety/classifier.ts
|
|
915
|
+
var DEFAULT_DANGEROUS_UPDATE_THRESHOLD = 50;
|
|
916
|
+
function classifyQuery(query, dbType, options = {}) {
|
|
917
|
+
const rowThreshold = options.rowThresholdForDangerousUpdate ?? DEFAULT_DANGEROUS_UPDATE_THRESHOLD;
|
|
918
|
+
const rawText = (query.sql || query.rawDisplay || "").trim();
|
|
919
|
+
if (dbType === "postgres") {
|
|
920
|
+
return classifyPostgresQuery(rawText, query, rowThreshold, options);
|
|
921
|
+
} else {
|
|
922
|
+
return classifyMongoQuery(rawText, query, rowThreshold, options);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function classifyPostgresQuery(sql, query, rowThreshold, options) {
|
|
926
|
+
const cleanSql = sql.replace(/\/\*[\s\S]*?\*\/|--.*$/gm, "").trim();
|
|
927
|
+
const upper = cleanSql.toUpperCase();
|
|
928
|
+
const warnings = [];
|
|
929
|
+
const isDropDb = /DROP\s+DATABASE/i.test(cleanSql);
|
|
930
|
+
const isDropSchemaCascade = /DROP\s+SCHEMA\s+[\s\S]*?CASCADE/i.test(cleanSql);
|
|
931
|
+
if (isDropDb || isDropSchemaCascade) {
|
|
932
|
+
return {
|
|
933
|
+
category: "full_wipe",
|
|
934
|
+
isDestructive: true,
|
|
935
|
+
isStructural: false,
|
|
936
|
+
isFullWipe: true,
|
|
937
|
+
requiresLiteralWord: true,
|
|
938
|
+
literalWord: "DROP DATABASE",
|
|
939
|
+
hasWhereClause: false,
|
|
940
|
+
warnings: ["This operation permanently deletes an entire database or schema hierarchy."],
|
|
941
|
+
explanation: "Permanently deletes database or schema cascade."
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
const isCreateIndex = /CREATE\s+(UNIQUE\s+)?INDEX/i.test(cleanSql);
|
|
945
|
+
const isCreateTable = /CREATE\s+TABLE/i.test(cleanSql);
|
|
946
|
+
const isCreateSchema = /CREATE\s+SCHEMA/i.test(cleanSql);
|
|
947
|
+
const isAlterAdd = /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+(COLUMN|CONSTRAINT)/i.test(cleanSql);
|
|
948
|
+
const isAlterOtherSafe = /ALTER\s+TABLE\s+([^\s;]+)\s+(ALTER\s+COLUMN|RENAME)/i.test(cleanSql);
|
|
949
|
+
if (isCreateIndex) {
|
|
950
|
+
const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
|
|
951
|
+
let suggestion;
|
|
952
|
+
if (!isConcurrently) {
|
|
953
|
+
warnings.push(
|
|
954
|
+
"Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
|
|
955
|
+
);
|
|
956
|
+
suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
|
|
957
|
+
}
|
|
958
|
+
const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
|
|
959
|
+
const tableName = tableMatch ? tableMatch[1] : void 0;
|
|
960
|
+
return {
|
|
961
|
+
category: "structural",
|
|
962
|
+
isDestructive: false,
|
|
963
|
+
isStructural: true,
|
|
964
|
+
isFullWipe: false,
|
|
965
|
+
requiresLiteralWord: false,
|
|
966
|
+
hasWhereClause: false,
|
|
967
|
+
tableOrCollection: tableName,
|
|
968
|
+
explanation: `Creates an index on ${tableName || "table"}${isConcurrently ? " concurrently (non-blocking)" : " (may lock table during build)"}.`,
|
|
969
|
+
warnings,
|
|
970
|
+
suggestedAlternative: suggestion
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (isCreateTable) {
|
|
974
|
+
const match = cleanSql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
|
|
975
|
+
const tableName = match ? match[1] : void 0;
|
|
976
|
+
return {
|
|
977
|
+
category: "structural",
|
|
978
|
+
isDestructive: false,
|
|
979
|
+
isStructural: true,
|
|
980
|
+
isFullWipe: false,
|
|
981
|
+
requiresLiteralWord: false,
|
|
982
|
+
hasWhereClause: false,
|
|
983
|
+
tableOrCollection: tableName,
|
|
984
|
+
explanation: `Creates a new table "${tableName || "unknown"}".`,
|
|
985
|
+
warnings
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
if (isCreateSchema) {
|
|
989
|
+
const match = cleanSql.match(/CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
|
|
990
|
+
const schemaName = match ? match[1] : void 0;
|
|
991
|
+
return {
|
|
992
|
+
category: "structural",
|
|
993
|
+
isDestructive: false,
|
|
994
|
+
isStructural: true,
|
|
995
|
+
isFullWipe: false,
|
|
996
|
+
requiresLiteralWord: false,
|
|
997
|
+
hasWhereClause: false,
|
|
998
|
+
tableOrCollection: schemaName,
|
|
999
|
+
explanation: `Creates a new schema "${schemaName || "unknown"}".`,
|
|
1000
|
+
warnings
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
if (isAlterAdd || isAlterOtherSafe) {
|
|
1004
|
+
const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
|
|
1005
|
+
const tableName = match ? match[1] : void 0;
|
|
1006
|
+
return {
|
|
1007
|
+
category: "structural",
|
|
1008
|
+
isDestructive: false,
|
|
1009
|
+
isStructural: true,
|
|
1010
|
+
isFullWipe: false,
|
|
1011
|
+
requiresLiteralWord: false,
|
|
1012
|
+
hasWhereClause: false,
|
|
1013
|
+
tableOrCollection: tableName,
|
|
1014
|
+
explanation: `Alters table "${tableName || "unknown"}" to add or modify structural attributes.`,
|
|
1015
|
+
warnings
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const isAlterDrop = /ALTER\s+TABLE\s+([^\s;]+)\s+DROP\s+COLUMN/i.test(cleanSql);
|
|
1019
|
+
if (isAlterDrop) {
|
|
1020
|
+
const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
|
|
1021
|
+
const tableName = match ? match[1] : void 0;
|
|
1022
|
+
return {
|
|
1023
|
+
category: "dangerous",
|
|
1024
|
+
isDestructive: true,
|
|
1025
|
+
isStructural: false,
|
|
1026
|
+
isFullWipe: false,
|
|
1027
|
+
requiresLiteralWord: false,
|
|
1028
|
+
hasWhereClause: false,
|
|
1029
|
+
tableOrCollection: tableName,
|
|
1030
|
+
explanation: `Removes a column from table "${tableName || "unknown"}", permanently discarding column data.`,
|
|
1031
|
+
warnings: ["Removing a column is permanent and irreversible."]
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const isDropTable = /DROP\s+TABLE/i.test(cleanSql);
|
|
1035
|
+
const isTruncate = /TRUNCATE(\s+TABLE)?/i.test(cleanSql);
|
|
1036
|
+
if (isDropTable || isTruncate) {
|
|
1037
|
+
const match = cleanSql.match(/(?:DROP\s+TABLE|TRUNCATE(?:\s+TABLE)?)\s+(?:IF\s+EXISTS\s+)?([^\s;(]+)/i);
|
|
1038
|
+
const tableName = match ? match[1] : void 0;
|
|
1039
|
+
return {
|
|
1040
|
+
category: "dangerous",
|
|
1041
|
+
isDestructive: true,
|
|
1042
|
+
isStructural: false,
|
|
1043
|
+
isFullWipe: false,
|
|
1044
|
+
requiresLiteralWord: true,
|
|
1045
|
+
literalWord: isDropTable ? "DROP TABLE" : "TRUNCATE",
|
|
1046
|
+
hasWhereClause: false,
|
|
1047
|
+
tableOrCollection: tableName,
|
|
1048
|
+
explanation: `Permanently removes all data from table "${tableName || "unknown"}".`,
|
|
1049
|
+
warnings: ["All rows will be permanently deleted."]
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const isDelete = /^DELETE\s+FROM/i.test(cleanSql);
|
|
1053
|
+
if (isDelete) {
|
|
1054
|
+
const match = cleanSql.match(/^DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+))?/i);
|
|
1055
|
+
const tableName = match ? match[1] : void 0;
|
|
1056
|
+
const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
|
|
1057
|
+
if (!hasWhere) {
|
|
1058
|
+
return {
|
|
1059
|
+
category: "dangerous",
|
|
1060
|
+
isDestructive: true,
|
|
1061
|
+
isStructural: false,
|
|
1062
|
+
isFullWipe: false,
|
|
1063
|
+
requiresLiteralWord: true,
|
|
1064
|
+
literalWord: "DELETE ALL",
|
|
1065
|
+
hasWhereClause: false,
|
|
1066
|
+
tableOrCollection: tableName,
|
|
1067
|
+
explanation: `Deletes ALL rows from table "${tableName || "unknown"}" (no WHERE clause present).`,
|
|
1068
|
+
warnings: ["No WHERE clause specified: EVERY row in the table will be deleted!"]
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
category: "dangerous",
|
|
1073
|
+
isDestructive: true,
|
|
1074
|
+
isStructural: false,
|
|
1075
|
+
isFullWipe: false,
|
|
1076
|
+
requiresLiteralWord: false,
|
|
1077
|
+
hasWhereClause: true,
|
|
1078
|
+
tableOrCollection: tableName,
|
|
1079
|
+
explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
|
|
1080
|
+
warnings: ["Mutates existing database records."]
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const isUpdate = /^UPDATE\s+/i.test(cleanSql);
|
|
1084
|
+
if (isUpdate) {
|
|
1085
|
+
const match = cleanSql.match(/^UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+))?$/i);
|
|
1086
|
+
const tableName = match ? match[1] : void 0;
|
|
1087
|
+
const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
|
|
1088
|
+
if (!hasWhere) {
|
|
1089
|
+
return {
|
|
1090
|
+
category: "dangerous",
|
|
1091
|
+
isDestructive: true,
|
|
1092
|
+
isStructural: false,
|
|
1093
|
+
isFullWipe: false,
|
|
1094
|
+
requiresLiteralWord: true,
|
|
1095
|
+
literalWord: "UPDATE ALL",
|
|
1096
|
+
hasWhereClause: false,
|
|
1097
|
+
tableOrCollection: tableName,
|
|
1098
|
+
explanation: `Updates ALL rows in table "${tableName || "unknown"}" (no WHERE clause present).`,
|
|
1099
|
+
warnings: ["No WHERE clause specified: EVERY row in the table will be updated!"]
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
return {
|
|
1103
|
+
category: "write",
|
|
1104
|
+
isDestructive: false,
|
|
1105
|
+
isStructural: false,
|
|
1106
|
+
isFullWipe: false,
|
|
1107
|
+
requiresLiteralWord: false,
|
|
1108
|
+
hasWhereClause: true,
|
|
1109
|
+
tableOrCollection: tableName,
|
|
1110
|
+
explanation: `Updates filtered rows in table "${tableName || "unknown"}".`,
|
|
1111
|
+
warnings: ["Mutates existing database records."]
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const isInsert = /^INSERT\s+INTO/i.test(cleanSql);
|
|
1115
|
+
if (isInsert) {
|
|
1116
|
+
const match = cleanSql.match(/^INSERT\s+INTO\s+([^\s;(]+)/i);
|
|
1117
|
+
const tableName = match ? match[1] : void 0;
|
|
1118
|
+
return {
|
|
1119
|
+
category: "write",
|
|
1120
|
+
isDestructive: false,
|
|
1121
|
+
isStructural: false,
|
|
1122
|
+
isFullWipe: false,
|
|
1123
|
+
requiresLiteralWord: false,
|
|
1124
|
+
hasWhereClause: false,
|
|
1125
|
+
tableOrCollection: tableName,
|
|
1126
|
+
explanation: `Inserts new records into table "${tableName || "unknown"}".`,
|
|
1127
|
+
warnings: []
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (/^(SELECT|EXPLAIN|SHOW|WITH)\b/i.test(cleanSql)) {
|
|
1131
|
+
return {
|
|
1132
|
+
category: "read",
|
|
1133
|
+
isDestructive: false,
|
|
1134
|
+
isStructural: false,
|
|
1135
|
+
isFullWipe: false,
|
|
1136
|
+
requiresLiteralWord: false,
|
|
1137
|
+
hasWhereClause: /WHERE/i.test(cleanSql),
|
|
1138
|
+
explanation: "Read-only query.",
|
|
1139
|
+
warnings: []
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
return {
|
|
1143
|
+
category: "write",
|
|
1144
|
+
isDestructive: false,
|
|
1145
|
+
isStructural: false,
|
|
1146
|
+
isFullWipe: false,
|
|
1147
|
+
requiresLiteralWord: false,
|
|
1148
|
+
hasWhereClause: false,
|
|
1149
|
+
explanation: "Database operation.",
|
|
1150
|
+
warnings: ["Non-standard query statement."]
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
function classifyMongoQuery(rawText, query, rowThreshold, options) {
|
|
1154
|
+
let collectionName = query.collection;
|
|
1155
|
+
let operation = query.operation;
|
|
1156
|
+
let filter = query.filter;
|
|
1157
|
+
if (!collectionName || !operation) {
|
|
1158
|
+
try {
|
|
1159
|
+
const parsed = JSON.parse(rawText);
|
|
1160
|
+
collectionName = parsed.collection || collectionName;
|
|
1161
|
+
operation = parsed.operation || operation;
|
|
1162
|
+
filter = parsed.filter || filter;
|
|
1163
|
+
} catch {
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const warnings = [];
|
|
1167
|
+
if (operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
|
|
1168
|
+
return {
|
|
1169
|
+
category: "full_wipe",
|
|
1170
|
+
isDestructive: true,
|
|
1171
|
+
isStructural: false,
|
|
1172
|
+
isFullWipe: true,
|
|
1173
|
+
requiresLiteralWord: true,
|
|
1174
|
+
literalWord: "DROP DATABASE",
|
|
1175
|
+
hasWhereClause: false,
|
|
1176
|
+
warnings: ["This operation permanently drops the entire MongoDB database."],
|
|
1177
|
+
explanation: "Drops the entire database."
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (operation === "createIndex") {
|
|
1181
|
+
const isBackground = query.options?.background === true;
|
|
1182
|
+
let suggestion;
|
|
1183
|
+
if (!isBackground) {
|
|
1184
|
+
warnings.push("Index is not specified with background: true. It may block other operations during build.");
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
category: "structural",
|
|
1188
|
+
isDestructive: false,
|
|
1189
|
+
isStructural: true,
|
|
1190
|
+
isFullWipe: false,
|
|
1191
|
+
requiresLiteralWord: false,
|
|
1192
|
+
hasWhereClause: false,
|
|
1193
|
+
tableOrCollection: collectionName,
|
|
1194
|
+
explanation: `Creates an index on collection "${collectionName || "collection"}".`,
|
|
1195
|
+
warnings,
|
|
1196
|
+
suggestedAlternative: suggestion
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
if (operation === "createCollection") {
|
|
1200
|
+
return {
|
|
1201
|
+
category: "structural",
|
|
1202
|
+
isDestructive: false,
|
|
1203
|
+
isStructural: true,
|
|
1204
|
+
isFullWipe: false,
|
|
1205
|
+
requiresLiteralWord: false,
|
|
1206
|
+
hasWhereClause: false,
|
|
1207
|
+
tableOrCollection: collectionName,
|
|
1208
|
+
explanation: `Creates collection "${collectionName}".`,
|
|
1209
|
+
warnings
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
if (operation === "drop") {
|
|
1213
|
+
return {
|
|
1214
|
+
category: "dangerous",
|
|
1215
|
+
isDestructive: true,
|
|
1216
|
+
isStructural: false,
|
|
1217
|
+
isFullWipe: false,
|
|
1218
|
+
requiresLiteralWord: true,
|
|
1219
|
+
literalWord: "DROP COLLECTION",
|
|
1220
|
+
hasWhereClause: false,
|
|
1221
|
+
tableOrCollection: collectionName,
|
|
1222
|
+
explanation: `Drops collection "${collectionName}", permanently deleting all documents and indexes.`,
|
|
1223
|
+
warnings: ["All documents in this collection will be removed."]
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
const hasFilter = filter && Object.keys(filter).length > 0;
|
|
1227
|
+
if (operation === "deleteMany" || operation === "deleteOne") {
|
|
1228
|
+
if (!hasFilter) {
|
|
1229
|
+
return {
|
|
1230
|
+
category: "dangerous",
|
|
1231
|
+
isDestructive: true,
|
|
1232
|
+
isStructural: false,
|
|
1233
|
+
isFullWipe: false,
|
|
1234
|
+
requiresLiteralWord: true,
|
|
1235
|
+
literalWord: "DELETE ALL",
|
|
1236
|
+
hasWhereClause: false,
|
|
1237
|
+
tableOrCollection: collectionName,
|
|
1238
|
+
explanation: `Deletes documents from "${collectionName}" with an empty filter (deletes all documents).`,
|
|
1239
|
+
warnings: ["No filter specified: all documents matching the empty filter will be deleted!"]
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
category: "dangerous",
|
|
1244
|
+
isDestructive: true,
|
|
1245
|
+
isStructural: false,
|
|
1246
|
+
isFullWipe: false,
|
|
1247
|
+
requiresLiteralWord: false,
|
|
1248
|
+
hasWhereClause: true,
|
|
1249
|
+
tableOrCollection: collectionName,
|
|
1250
|
+
explanation: `Deletes filtered documents from collection "${collectionName}".`,
|
|
1251
|
+
warnings: ["Mutates existing database documents."]
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (operation === "updateMany" || operation === "updateOne") {
|
|
1255
|
+
if (!hasFilter) {
|
|
1256
|
+
return {
|
|
1257
|
+
category: "dangerous",
|
|
1258
|
+
isDestructive: true,
|
|
1259
|
+
isStructural: false,
|
|
1260
|
+
isFullWipe: false,
|
|
1261
|
+
requiresLiteralWord: true,
|
|
1262
|
+
literalWord: "UPDATE ALL",
|
|
1263
|
+
hasWhereClause: false,
|
|
1264
|
+
tableOrCollection: collectionName,
|
|
1265
|
+
explanation: `Updates documents in "${collectionName}" with an empty filter (updates all documents).`,
|
|
1266
|
+
warnings: ["No filter specified: every document in the collection will be updated!"]
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
category: "write",
|
|
1271
|
+
isDestructive: false,
|
|
1272
|
+
isStructural: false,
|
|
1273
|
+
isFullWipe: false,
|
|
1274
|
+
requiresLiteralWord: false,
|
|
1275
|
+
hasWhereClause: true,
|
|
1276
|
+
tableOrCollection: collectionName,
|
|
1277
|
+
explanation: `Updates documents in collection "${collectionName}".`,
|
|
1278
|
+
warnings: ["Mutates existing database documents."]
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (operation === "insertOne" || operation === "insertMany") {
|
|
1282
|
+
return {
|
|
1283
|
+
category: "write",
|
|
1284
|
+
isDestructive: false,
|
|
1285
|
+
isStructural: false,
|
|
1286
|
+
isFullWipe: false,
|
|
1287
|
+
requiresLiteralWord: false,
|
|
1288
|
+
hasWhereClause: false,
|
|
1289
|
+
tableOrCollection: collectionName,
|
|
1290
|
+
explanation: `Inserts document(s) into collection "${collectionName}".`,
|
|
1291
|
+
warnings: []
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
if (operation === "find" || operation === "aggregate" || operation === "countDocuments") {
|
|
1295
|
+
return {
|
|
1296
|
+
category: "read",
|
|
1297
|
+
isDestructive: false,
|
|
1298
|
+
isStructural: false,
|
|
1299
|
+
isFullWipe: false,
|
|
1300
|
+
requiresLiteralWord: false,
|
|
1301
|
+
hasWhereClause: Boolean(hasFilter),
|
|
1302
|
+
tableOrCollection: collectionName,
|
|
1303
|
+
explanation: "Read-only operation.",
|
|
1304
|
+
warnings: []
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
category: "read",
|
|
1309
|
+
isDestructive: false,
|
|
1310
|
+
isStructural: false,
|
|
1311
|
+
isFullWipe: false,
|
|
1312
|
+
requiresLiteralWord: false,
|
|
1313
|
+
hasWhereClause: false,
|
|
1314
|
+
explanation: "Database operation.",
|
|
1315
|
+
warnings: []
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/safety/confirm.ts
|
|
1320
|
+
import chalk from "chalk";
|
|
1321
|
+
import boxen from "boxen";
|
|
1322
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
1323
|
+
async function requestUserConfirmation(query, safety, affectedRows) {
|
|
1324
|
+
const queryDisplay = (query.sql || query.rawDisplay || "").trim();
|
|
1325
|
+
if (safety.isFullWipe) {
|
|
1326
|
+
const boxContent = [
|
|
1327
|
+
chalk.bold.red("\u{1F6A8} CRITICAL: FULL DATABASE / ALL TABLES WIPE ATTEMPTED \u{1F6A8}"),
|
|
1328
|
+
"",
|
|
1329
|
+
chalk.yellow(queryDisplay),
|
|
1330
|
+
"",
|
|
1331
|
+
...safety.warnings.length ? [chalk.red(safety.warnings.join("\n")), ""] : [],
|
|
1332
|
+
chalk.bold.white(`Type ${chalk.red.underline(safety.literalWord || "DROP DATABASE")} to proceed:`)
|
|
1333
|
+
].join("\n");
|
|
1334
|
+
console.log(
|
|
1335
|
+
boxen(boxContent, {
|
|
1336
|
+
padding: 1,
|
|
1337
|
+
borderColor: "red",
|
|
1338
|
+
borderStyle: "double",
|
|
1339
|
+
margin: { top: 1, bottom: 1 }
|
|
1340
|
+
})
|
|
1341
|
+
);
|
|
1342
|
+
const typed = await input({
|
|
1343
|
+
message: chalk.red.bold(`Type "${safety.literalWord || "DROP DATABASE"}" to confirm full wipe:`)
|
|
1344
|
+
});
|
|
1345
|
+
if (typed.trim() === safety.literalWord) {
|
|
1346
|
+
return { confirmed: true };
|
|
1347
|
+
}
|
|
1348
|
+
return {
|
|
1349
|
+
confirmed: false,
|
|
1350
|
+
reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation aborted.`
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
if (safety.category === "dangerous") {
|
|
1354
|
+
const lines2 = [
|
|
1355
|
+
chalk.bold.hex("#FFA500")("\u26A0\uFE0F DANGEROUS OPERATION"),
|
|
1356
|
+
"",
|
|
1357
|
+
chalk.white(queryDisplay),
|
|
1358
|
+
""
|
|
1359
|
+
];
|
|
1360
|
+
if (affectedRows !== null) {
|
|
1361
|
+
lines2.push(
|
|
1362
|
+
chalk.bold.yellow(`Affected rows: ${affectedRows.toLocaleString()}`),
|
|
1363
|
+
""
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
if (safety.warnings.length > 0) {
|
|
1367
|
+
for (const w of safety.warnings) {
|
|
1368
|
+
lines2.push(chalk.red(`\u2022 ${w}`));
|
|
1369
|
+
}
|
|
1370
|
+
lines2.push("");
|
|
1371
|
+
}
|
|
1372
|
+
console.log(
|
|
1373
|
+
boxen(lines2.join("\n"), {
|
|
1374
|
+
padding: 1,
|
|
1375
|
+
borderColor: "yellow",
|
|
1376
|
+
borderStyle: "round",
|
|
1377
|
+
margin: { top: 1, bottom: 1 }
|
|
1378
|
+
})
|
|
1379
|
+
);
|
|
1380
|
+
if (safety.requiresLiteralWord && safety.literalWord) {
|
|
1381
|
+
const typed = await input({
|
|
1382
|
+
message: chalk.hex("#FFA500").bold(
|
|
1383
|
+
`Destructive operation with no filter. Type "${safety.literalWord}" to confirm:`
|
|
1384
|
+
)
|
|
1385
|
+
});
|
|
1386
|
+
if (typed.trim() === safety.literalWord) {
|
|
1387
|
+
return { confirmed: true };
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
confirmed: false,
|
|
1391
|
+
reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation cancelled.`
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
const answer2 = await confirm({
|
|
1395
|
+
message: chalk.hex("#FFA500").bold("Proceed with dangerous operation?"),
|
|
1396
|
+
default: false
|
|
1397
|
+
});
|
|
1398
|
+
return { confirmed: answer2 };
|
|
1399
|
+
}
|
|
1400
|
+
if (safety.isStructural) {
|
|
1401
|
+
const lines2 = [
|
|
1402
|
+
chalk.bold.cyan("\u{1F6E0}\uFE0F STRUCTURAL DDL OPERATION"),
|
|
1403
|
+
"",
|
|
1404
|
+
chalk.white(queryDisplay),
|
|
1405
|
+
"",
|
|
1406
|
+
chalk.cyan(`Effect: ${safety.explanation || "Modifies database structure."}`)
|
|
1407
|
+
];
|
|
1408
|
+
if (safety.suggestedAlternative) {
|
|
1409
|
+
lines2.push(
|
|
1410
|
+
"",
|
|
1411
|
+
chalk.yellow("\u{1F4A1} Recommendation:"),
|
|
1412
|
+
chalk.gray(safety.suggestedAlternative)
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
if (safety.warnings.length > 0) {
|
|
1416
|
+
lines2.push("");
|
|
1417
|
+
for (const w of safety.warnings) {
|
|
1418
|
+
lines2.push(chalk.yellow(`\u2022 ${w}`));
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
console.log(
|
|
1422
|
+
boxen(lines2.join("\n"), {
|
|
1423
|
+
padding: 1,
|
|
1424
|
+
borderColor: "cyan",
|
|
1425
|
+
borderStyle: "round",
|
|
1426
|
+
margin: { top: 1, bottom: 1 }
|
|
1427
|
+
})
|
|
1428
|
+
);
|
|
1429
|
+
const answer2 = await confirm({
|
|
1430
|
+
message: chalk.cyan.bold("Apply this structural change?"),
|
|
1431
|
+
default: true
|
|
1432
|
+
});
|
|
1433
|
+
return { confirmed: answer2 };
|
|
1434
|
+
}
|
|
1435
|
+
if (safety.category === "write") {
|
|
1436
|
+
const lines2 = [
|
|
1437
|
+
chalk.bold.magenta("\u270F\uFE0F DATABASE WRITE OPERATION"),
|
|
1438
|
+
"",
|
|
1439
|
+
chalk.white(queryDisplay)
|
|
1440
|
+
];
|
|
1441
|
+
if (affectedRows !== null) {
|
|
1442
|
+
lines2.push("", chalk.magenta(`Estimated affected rows: ${affectedRows.toLocaleString()}`));
|
|
1443
|
+
}
|
|
1444
|
+
console.log(
|
|
1445
|
+
boxen(lines2.join("\n"), {
|
|
1446
|
+
padding: 1,
|
|
1447
|
+
borderColor: "magenta",
|
|
1448
|
+
borderStyle: "round",
|
|
1449
|
+
margin: { top: 1, bottom: 1 }
|
|
1450
|
+
})
|
|
1451
|
+
);
|
|
1452
|
+
const answer2 = await confirm({
|
|
1453
|
+
message: chalk.magenta.bold("Execute write query?"),
|
|
1454
|
+
default: false
|
|
1455
|
+
});
|
|
1456
|
+
return { confirmed: answer2 };
|
|
1457
|
+
}
|
|
1458
|
+
const lines = [
|
|
1459
|
+
chalk.bold.green("\u{1F50D} READ QUERY"),
|
|
1460
|
+
"",
|
|
1461
|
+
chalk.white(queryDisplay)
|
|
1462
|
+
];
|
|
1463
|
+
console.log(
|
|
1464
|
+
boxen(lines.join("\n"), {
|
|
1465
|
+
padding: 1,
|
|
1466
|
+
borderColor: "green",
|
|
1467
|
+
borderStyle: "round",
|
|
1468
|
+
margin: { top: 1, bottom: 1 }
|
|
1469
|
+
})
|
|
1470
|
+
);
|
|
1471
|
+
const answer = await confirm({
|
|
1472
|
+
message: chalk.green.bold("Execute read query?"),
|
|
1473
|
+
default: true
|
|
1474
|
+
});
|
|
1475
|
+
return { confirmed: answer };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/agent/guardrails.ts
|
|
1479
|
+
import { HumanMessage } from "@langchain/core/messages";
|
|
1480
|
+
|
|
1481
|
+
// src/agent/prompts.ts
|
|
1482
|
+
var REFUSAL_MESSAGE = "I'm scoped to database operations on your connected DB only (schema inspection, queries, CRUD, analysis). I can't help with that here.";
|
|
1483
|
+
function getSystemPrompt(dbType, schemaSummary) {
|
|
1484
|
+
return `You are "SANDAL" (Safe Agentic Natural-language Database Access Layer), an expert, production-grade agentic database assistant.
|
|
1485
|
+
You are connected directly to a live ${dbType === "postgres" ? "PostgreSQL" : "MongoDB"} database.
|
|
1486
|
+
|
|
1487
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1488
|
+
STRICT SCOPE & SAFETY RULES
|
|
1489
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1490
|
+
1. You are strictly scoped to database operations on the connected database:
|
|
1491
|
+
- Schema inspection and navigation
|
|
1492
|
+
- Data analysis, querying, filtering, sorting, aggregations, and statistics
|
|
1493
|
+
- CRUD operations (SELECT/find, INSERT/insertOne, UPDATE/updateOne, DELETE/deleteOne)
|
|
1494
|
+
- Structural DDL operations (CREATE TABLE, CREATE INDEX, ALTER TABLE ADD COLUMN, createCollection, createIndex)
|
|
1495
|
+
2. REFUSE ANY OUT-OF-SCOPE REQUEST:
|
|
1496
|
+
- If the user asks for general programming (e.g. "write a Python script", "build a web scraper"), general Q&A, creative writing, math tutoring, or any non-database task, politely refuse immediately with:
|
|
1497
|
+
"${REFUSAL_MESSAGE}"
|
|
1498
|
+
3. NEVER generate or suggest shell/bash commands or OS actions.
|
|
1499
|
+
4. NEVER expose raw credentials, passwords, or API keys in your responses.
|
|
1500
|
+
5. NEVER hallucinate data. Ground your answers ONLY in the actual query results returned from the database.
|
|
1501
|
+
|
|
1502
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1503
|
+
QUERY GENERATION RULES
|
|
1504
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1505
|
+
${dbType === "postgres" ? `\u2022 Target Dialect: PostgreSQL.
|
|
1506
|
+
\u2022 Produce valid SQL. For queries with variables, prefer parameterized queries or clean literals when executing directly.
|
|
1507
|
+
\u2022 For UPDATE or DELETE queries, ALWAYS include a specific, narrow WHERE clause unless the user explicitly requested modifying all rows.
|
|
1508
|
+
\u2022 For index creation, prefer "CREATE INDEX CONCURRENTLY" to avoid write-locking tables.
|
|
1509
|
+
\u2022 Return your query formatted in a json object:
|
|
1510
|
+
{
|
|
1511
|
+
"type": "postgres",
|
|
1512
|
+
"sql": "<the SQL statement>",
|
|
1513
|
+
"params": [],
|
|
1514
|
+
"targetTables": ["table1"],
|
|
1515
|
+
"explanation": "<brief summary of what the query does>"
|
|
1516
|
+
}` : `\u2022 Target Dialect: MongoDB.
|
|
1517
|
+
\u2022 Produce valid MongoDB operations in structured JSON format:
|
|
1518
|
+
{
|
|
1519
|
+
"type": "mongodb",
|
|
1520
|
+
"collection": "<collection_name>",
|
|
1521
|
+
"operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection",
|
|
1522
|
+
"filter": {},
|
|
1523
|
+
"update": {},
|
|
1524
|
+
"pipeline": [],
|
|
1525
|
+
"document": {},
|
|
1526
|
+
"options": {},
|
|
1527
|
+
"explanation": "<brief summary of what the operation does>"
|
|
1528
|
+
}
|
|
1529
|
+
\u2022 For UPDATE or DELETE operations, ALWAYS include a specific filter unless the user explicitly requested modifying all documents.
|
|
1530
|
+
\u2022 For createIndex, prefer options like {"background": true}.`}
|
|
1531
|
+
|
|
1532
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1533
|
+
CURRENT DATABASE SCHEMA CONTEXT
|
|
1534
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1535
|
+
${schemaSummary}
|
|
1536
|
+
`;
|
|
1537
|
+
}
|
|
1538
|
+
function formatSchemaForPrompt(schema) {
|
|
1539
|
+
if (schema.type === "postgres") {
|
|
1540
|
+
if (!schema.tables || schema.tables.length === 0) {
|
|
1541
|
+
return "No tables found in database.";
|
|
1542
|
+
}
|
|
1543
|
+
const lines = [`Database: ${schema.databaseName}`, "Tables:"];
|
|
1544
|
+
for (const t of schema.tables) {
|
|
1545
|
+
const colDefs = t.columns.map((c) => `${c.name} (${c.dataType}${c.isPrimaryKey ? ", PK" : ""}${c.isNullable ? "" : ", NOT NULL"})`).join(", ");
|
|
1546
|
+
const idxDefs = t.indexes.map((i) => i.name).join(", ");
|
|
1547
|
+
const fkDefs = t.foreignKeys.map((f) => `${f.columnName}->${f.foreignTableName}.${f.foreignColumnName}`).join(", ");
|
|
1548
|
+
lines.push(`- Table: ${t.schema}.${t.name} (~${t.approximateRowCount ?? 0} rows)`);
|
|
1549
|
+
lines.push(` Columns: ${colDefs}`);
|
|
1550
|
+
if (idxDefs) lines.push(` Indexes: ${idxDefs}`);
|
|
1551
|
+
if (fkDefs) lines.push(` Foreign Keys: ${fkDefs}`);
|
|
1552
|
+
}
|
|
1553
|
+
return lines.join("\n");
|
|
1554
|
+
} else {
|
|
1555
|
+
if (!schema.collections || schema.collections.length === 0) {
|
|
1556
|
+
return "No collections found in database.";
|
|
1557
|
+
}
|
|
1558
|
+
const lines = [`Database: ${schema.databaseName}`, "Collections:"];
|
|
1559
|
+
for (const c of schema.collections) {
|
|
1560
|
+
const fieldList = c.fields.map((f) => `${f.name} [${f.types.join("|")}]`).join(", ");
|
|
1561
|
+
lines.push(`- Collection: ${c.name} (~${c.documentCount ?? 0} documents)`);
|
|
1562
|
+
if (fieldList) lines.push(` Fields: ${fieldList}`);
|
|
1563
|
+
if (c.indexes.length) {
|
|
1564
|
+
lines.push(` Indexes: ${c.indexes.map((i) => i.name).join(", ")}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return lines.join("\n");
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
var INTENT_CLASSIFICATION_PROMPT = `You are a strict security and intent classifier for a dedicated DATABASE OPERATIONS assistant.
|
|
1571
|
+
Determine whether the following user message is a "database_task" or "out_of_scope".
|
|
1572
|
+
|
|
1573
|
+
Definitions:
|
|
1574
|
+
- "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, filtering, grouping, aggregating, or analyzing database records, or conversational follow-ups directly relating to the database session.
|
|
1575
|
+
- "out_of_scope": The user asks for general coding help (e.g., "write a python script to scrape a website", "create a React app", "write a bash script"), general programming questions, creative writing, math tutoring, conversational chat unrelated to the DB, weather, philosophy, history, or anything not acting on the connected database.
|
|
1576
|
+
|
|
1577
|
+
Respond with EXACTLY ONE JSON OBJECT and nothing else:
|
|
1578
|
+
{"classification": "database_task"}
|
|
1579
|
+
OR
|
|
1580
|
+
{"classification": "out_of_scope", "reason": "<brief explanation>"}
|
|
1581
|
+
|
|
1582
|
+
User message:
|
|
1583
|
+
`;
|
|
1584
|
+
|
|
1585
|
+
// src/agent/guardrails.ts
|
|
1586
|
+
var OBVIOUS_OUT_OF_SCOPE_PATTERNS = [
|
|
1587
|
+
/write\s+(?:me\s+)?(?:a\s+)?python\s+script/i,
|
|
1588
|
+
/scrape\s+(?:a\s+)?website/i,
|
|
1589
|
+
/write\s+(?:me\s+)?(?:a\s+)?(?:bash|shell|sh|powershell)\s+script/i,
|
|
1590
|
+
/create\s+(?:a\s+)?(?:react|vue|angular|flask|django|express)\s+app/i,
|
|
1591
|
+
/write\s+(?:me\s+)?(?:a\s+)?(?:poem|essay|story|song|joke)/i,
|
|
1592
|
+
/who\s+(?:was|is)\s+/i,
|
|
1593
|
+
/what\s+is\s+the\s+capital\s+of/i,
|
|
1594
|
+
/how\s+to\s+cook/i,
|
|
1595
|
+
/solve\s+(?:this\s+)?(?:math|equation|integral)/i,
|
|
1596
|
+
/help\s+me\s+with\s+(?:my\s+)?homework/i,
|
|
1597
|
+
/acting\s+as\s+a\s+general\s+chatbot/i
|
|
1598
|
+
];
|
|
1599
|
+
var OBVIOUS_DATABASE_PATTERNS = [
|
|
1600
|
+
/^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH)\b/i,
|
|
1601
|
+
/\b(?:table|tables|collection|collections|schema|column|columns|index|indexes|foreign key|primary key)\b/i,
|
|
1602
|
+
/\b(?:find|aggregate|count|where|group by|order by|having|limit|join|left join|inner join)\b/i,
|
|
1603
|
+
/\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
|
|
1604
|
+
/\b(?:database|postgres|mongodb|mongo|pg_)\b/i
|
|
1605
|
+
];
|
|
1606
|
+
async function classifyIntent(input3, model) {
|
|
1607
|
+
const trimmed = input3.trim();
|
|
1608
|
+
for (const pattern of OBVIOUS_OUT_OF_SCOPE_PATTERNS) {
|
|
1609
|
+
if (pattern.test(trimmed)) {
|
|
1610
|
+
return {
|
|
1611
|
+
isDatabaseTask: false,
|
|
1612
|
+
reason: "Matched out-of-scope pattern"
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
for (const pattern of OBVIOUS_DATABASE_PATTERNS) {
|
|
1617
|
+
if (pattern.test(trimmed)) {
|
|
1618
|
+
return {
|
|
1619
|
+
isDatabaseTask: true
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if (/^(?:tables|collections|schema|indexes|views|stats|count|info)$/i.test(trimmed)) {
|
|
1624
|
+
return { isDatabaseTask: true };
|
|
1625
|
+
}
|
|
1626
|
+
if (!model) {
|
|
1627
|
+
return { isDatabaseTask: true };
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
const response = await model.invoke([
|
|
1631
|
+
new HumanMessage(INTENT_CLASSIFICATION_PROMPT + "\n" + trimmed)
|
|
1632
|
+
]);
|
|
1633
|
+
const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
|
|
1634
|
+
const jsonMatch = content.match(/\{[\s\S]*?\}/);
|
|
1635
|
+
if (jsonMatch) {
|
|
1636
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1637
|
+
if (parsed.classification === "out_of_scope") {
|
|
1638
|
+
return {
|
|
1639
|
+
isDatabaseTask: false,
|
|
1640
|
+
reason: parsed.reason || "Classified as out of scope by intent guardrail"
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
return { isDatabaseTask: true };
|
|
1644
|
+
}
|
|
1645
|
+
return { isDatabaseTask: true };
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
return { isDatabaseTask: true };
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
function checkStrictWipe(classification, options = {}) {
|
|
1651
|
+
const strictMode = options.strictMode !== false;
|
|
1652
|
+
const allowFullWipe = options.allowFullWipe === true;
|
|
1653
|
+
if (classification.isFullWipe && strictMode && !allowFullWipe) {
|
|
1654
|
+
return {
|
|
1655
|
+
blocked: true,
|
|
1656
|
+
message: "Operation blocked by --strict mode: full database or all-table drop is prohibited. To allow this, start sandal-db with --allow-full-wipe."
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
return { blocked: false };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/agent/graph.ts
|
|
1663
|
+
var AgentStateAnnotation = Annotation.Root({
|
|
1664
|
+
userInput: Annotation({
|
|
1665
|
+
reducer: (_, update) => update,
|
|
1666
|
+
default: () => ""
|
|
1667
|
+
}),
|
|
1668
|
+
schema: Annotation({
|
|
1669
|
+
reducer: (curr, update) => update ?? curr,
|
|
1670
|
+
default: () => void 0
|
|
1671
|
+
}),
|
|
1672
|
+
schemaSummary: Annotation({
|
|
1673
|
+
reducer: (curr, update) => update ?? curr ?? "",
|
|
1674
|
+
default: () => ""
|
|
1675
|
+
}),
|
|
1676
|
+
targetEntities: Annotation({
|
|
1677
|
+
reducer: (_, update) => update ?? [],
|
|
1678
|
+
default: () => []
|
|
1679
|
+
}),
|
|
1680
|
+
generatedQuery: Annotation({
|
|
1681
|
+
reducer: (_, update) => update,
|
|
1682
|
+
default: () => void 0
|
|
1683
|
+
}),
|
|
1684
|
+
safety: Annotation({
|
|
1685
|
+
reducer: (_, update) => update,
|
|
1686
|
+
default: () => void 0
|
|
1687
|
+
}),
|
|
1688
|
+
affectedRowCount: Annotation({
|
|
1689
|
+
reducer: (_, update) => update,
|
|
1690
|
+
default: () => null
|
|
1691
|
+
}),
|
|
1692
|
+
confirmed: Annotation({
|
|
1693
|
+
reducer: (_, update) => update ?? false,
|
|
1694
|
+
default: () => false
|
|
1695
|
+
}),
|
|
1696
|
+
cancelReason: Annotation({
|
|
1697
|
+
reducer: (_, update) => update,
|
|
1698
|
+
default: () => void 0
|
|
1699
|
+
}),
|
|
1700
|
+
queryResult: Annotation({
|
|
1701
|
+
reducer: (_, update) => update,
|
|
1702
|
+
default: () => void 0
|
|
1703
|
+
}),
|
|
1704
|
+
analysis: Annotation({
|
|
1705
|
+
reducer: (_, update) => update,
|
|
1706
|
+
default: () => void 0
|
|
1707
|
+
}),
|
|
1708
|
+
stats: Annotation({
|
|
1709
|
+
reducer: (_, update) => update,
|
|
1710
|
+
default: () => void 0
|
|
1711
|
+
}),
|
|
1712
|
+
conclusion: Annotation({
|
|
1713
|
+
reducer: (_, update) => update,
|
|
1714
|
+
default: () => void 0
|
|
1715
|
+
}),
|
|
1716
|
+
requiresSchemaRefresh: Annotation({
|
|
1717
|
+
reducer: (_, update) => update ?? false,
|
|
1718
|
+
default: () => false
|
|
1719
|
+
}),
|
|
1720
|
+
messages: Annotation({
|
|
1721
|
+
reducer: (curr, update) => curr.concat(update ?? []),
|
|
1722
|
+
default: () => []
|
|
1723
|
+
})
|
|
1724
|
+
});
|
|
1725
|
+
function createDatabaseAgent(config) {
|
|
1726
|
+
const { adapter, model, classifierOptions = {} } = config;
|
|
1727
|
+
async function inspectSchemaNode(state) {
|
|
1728
|
+
let schema = state.schema;
|
|
1729
|
+
const forceRefresh = state.requiresSchemaRefresh || !schema;
|
|
1730
|
+
if (forceRefresh) {
|
|
1731
|
+
schema = await adapter.inspectSchema(true);
|
|
1732
|
+
}
|
|
1733
|
+
const summary = schema ? formatSchemaForPrompt(schema) : "";
|
|
1734
|
+
return {
|
|
1735
|
+
schema,
|
|
1736
|
+
schemaSummary: summary,
|
|
1737
|
+
requiresSchemaRefresh: false
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
async function identifyTargetsNode(state) {
|
|
1741
|
+
const prompt = `Based on this user request: "${state.userInput}"
|
|
1742
|
+
And the available database schema:
|
|
1743
|
+
${state.schemaSummary}
|
|
1744
|
+
|
|
1745
|
+
List the relevant table names (PostgreSQL) or collection names (MongoDB) needed to fulfill the request.
|
|
1746
|
+
Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not include markdown codeblocks or extra text.`;
|
|
1747
|
+
try {
|
|
1748
|
+
const response = await model.invoke([new HumanMessage2(prompt)]);
|
|
1749
|
+
const content = typeof response.content === "string" ? response.content.trim() : "";
|
|
1750
|
+
const match = content.match(/\[[\s\S]*?\]/);
|
|
1751
|
+
const targets = match ? JSON.parse(match[0]) : [];
|
|
1752
|
+
return { targetEntities: Array.isArray(targets) ? targets : [] };
|
|
1753
|
+
} catch {
|
|
1754
|
+
return { targetEntities: [] };
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
async function generateQueryNode(state) {
|
|
1758
|
+
const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary);
|
|
1759
|
+
const historyMessages = state.messages.slice(-6);
|
|
1760
|
+
const userPrompt = `User request: "${state.userInput}"
|
|
1761
|
+
Target entities: ${JSON.stringify(state.targetEntities)}
|
|
1762
|
+
|
|
1763
|
+
Generate the appropriate database query to execute.
|
|
1764
|
+
Ensure you return valid JSON formatted as specified in the system prompt.
|
|
1765
|
+
Do not wrap with markdown or code fences.`;
|
|
1766
|
+
const response = await model.invoke([
|
|
1767
|
+
new SystemMessage(systemPrompt),
|
|
1768
|
+
...historyMessages,
|
|
1769
|
+
new HumanMessage2(userPrompt)
|
|
1770
|
+
]);
|
|
1771
|
+
const content = typeof response.content === "string" ? response.content.trim() : "";
|
|
1772
|
+
let parsed = null;
|
|
1773
|
+
try {
|
|
1774
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
1775
|
+
if (jsonMatch) {
|
|
1776
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
1777
|
+
}
|
|
1778
|
+
} catch {
|
|
1779
|
+
}
|
|
1780
|
+
let query;
|
|
1781
|
+
if (adapter.type === "postgres") {
|
|
1782
|
+
const sql = parsed?.sql || content.replace(/```(?:sql|json)?/g, "").trim();
|
|
1783
|
+
query = {
|
|
1784
|
+
sql,
|
|
1785
|
+
params: parsed?.params || [],
|
|
1786
|
+
rawDisplay: sql
|
|
1787
|
+
};
|
|
1788
|
+
} else {
|
|
1789
|
+
if (parsed && parsed.collection && parsed.operation) {
|
|
1790
|
+
query = {
|
|
1791
|
+
collection: parsed.collection,
|
|
1792
|
+
operation: parsed.operation,
|
|
1793
|
+
filter: parsed.filter,
|
|
1794
|
+
update: parsed.update,
|
|
1795
|
+
pipeline: parsed.pipeline,
|
|
1796
|
+
document: parsed.document,
|
|
1797
|
+
documents: parsed.documents,
|
|
1798
|
+
options: parsed.options,
|
|
1799
|
+
rawDisplay: JSON.stringify(parsed, null, 2)
|
|
1800
|
+
};
|
|
1801
|
+
} else {
|
|
1802
|
+
query = {
|
|
1803
|
+
rawDisplay: content
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return { generatedQuery: query };
|
|
1808
|
+
}
|
|
1809
|
+
async function classifySafetyNode(state) {
|
|
1810
|
+
if (!state.generatedQuery) {
|
|
1811
|
+
return {
|
|
1812
|
+
confirmed: false,
|
|
1813
|
+
cancelReason: "No query was generated."
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
const safety = classifyQuery(state.generatedQuery, adapter.type, classifierOptions);
|
|
1817
|
+
const strictCheck = checkStrictWipe(safety, classifierOptions);
|
|
1818
|
+
if (strictCheck.blocked) {
|
|
1819
|
+
return {
|
|
1820
|
+
safety,
|
|
1821
|
+
confirmed: false,
|
|
1822
|
+
cancelReason: strictCheck.message
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
let affectedRows = null;
|
|
1826
|
+
if (safety.isDestructive || safety.category === "write" || safety.category === "dangerous") {
|
|
1827
|
+
try {
|
|
1828
|
+
affectedRows = await adapter.dryRunCount(state.generatedQuery);
|
|
1829
|
+
} catch {
|
|
1830
|
+
affectedRows = null;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return {
|
|
1834
|
+
safety,
|
|
1835
|
+
affectedRowCount: affectedRows
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
async function requestConfirmationNode(state) {
|
|
1839
|
+
if (state.cancelReason) {
|
|
1840
|
+
return { confirmed: false };
|
|
1841
|
+
}
|
|
1842
|
+
if (!state.generatedQuery || !state.safety) {
|
|
1843
|
+
return { confirmed: false, cancelReason: "Missing query or safety classification." };
|
|
1844
|
+
}
|
|
1845
|
+
const result = await requestUserConfirmation(
|
|
1846
|
+
state.generatedQuery,
|
|
1847
|
+
state.safety,
|
|
1848
|
+
state.affectedRowCount ?? null
|
|
1849
|
+
);
|
|
1850
|
+
return {
|
|
1851
|
+
confirmed: result.confirmed,
|
|
1852
|
+
cancelReason: result.reason || (result.confirmed ? void 0 : "Execution cancelled by user.")
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
async function executeQueryNode(state) {
|
|
1856
|
+
if (!state.confirmed || !state.generatedQuery) {
|
|
1857
|
+
return {};
|
|
1858
|
+
}
|
|
1859
|
+
const result = await adapter.executeQuery(state.generatedQuery);
|
|
1860
|
+
const isStructuralSuccess = Boolean(state.safety?.isStructural && result.success);
|
|
1861
|
+
return {
|
|
1862
|
+
queryResult: result,
|
|
1863
|
+
requiresSchemaRefresh: isStructuralSuccess
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
async function analyzeResultsNode(state) {
|
|
1867
|
+
const res = state.queryResult;
|
|
1868
|
+
if (!res || !res.success || !res.rows || res.rows.length === 0) {
|
|
1869
|
+
return { stats: null };
|
|
1870
|
+
}
|
|
1871
|
+
const firstRow = res.rows[0];
|
|
1872
|
+
if (typeof firstRow !== "object" || firstRow === null) {
|
|
1873
|
+
return { stats: null };
|
|
1874
|
+
}
|
|
1875
|
+
const numericFields = Object.keys(firstRow).filter((k) => {
|
|
1876
|
+
const val = firstRow[k];
|
|
1877
|
+
return typeof val === "number" || !isNaN(Number(val)) && typeof val === "string" && val.trim() !== "";
|
|
1878
|
+
});
|
|
1879
|
+
const stats = {
|
|
1880
|
+
totalRows: res.rowCount ?? res.rows.length,
|
|
1881
|
+
columns: {}
|
|
1882
|
+
};
|
|
1883
|
+
for (const field of numericFields) {
|
|
1884
|
+
const nums = res.rows.map((r) => Number(r[field])).filter((n) => !isNaN(n));
|
|
1885
|
+
if (nums.length > 0) {
|
|
1886
|
+
const sum = nums.reduce((a, b) => a + b, 0);
|
|
1887
|
+
const avg = sum / nums.length;
|
|
1888
|
+
const min = Math.min(...nums);
|
|
1889
|
+
const max = Math.max(...nums);
|
|
1890
|
+
stats.columns[field] = {
|
|
1891
|
+
count: nums.length,
|
|
1892
|
+
sum,
|
|
1893
|
+
average: Math.round(avg * 100) / 100,
|
|
1894
|
+
min,
|
|
1895
|
+
max
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return { stats };
|
|
1900
|
+
}
|
|
1901
|
+
async function produceConclusionNode(state) {
|
|
1902
|
+
if (!state.confirmed || state.cancelReason) {
|
|
1903
|
+
const conclusion = state.cancelReason || "Operation was not executed.";
|
|
1904
|
+
return {
|
|
1905
|
+
conclusion,
|
|
1906
|
+
messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
const res = state.queryResult;
|
|
1910
|
+
if (!res) {
|
|
1911
|
+
const conclusion = "No query was executed.";
|
|
1912
|
+
return {
|
|
1913
|
+
conclusion,
|
|
1914
|
+
messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
if (!res.success) {
|
|
1918
|
+
const conclusion = `Query failed with error: ${res.error} (took ${res.durationMs}ms)`;
|
|
1919
|
+
return {
|
|
1920
|
+
conclusion,
|
|
1921
|
+
messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
const prompt = `User request: "${state.userInput}"
|
|
1925
|
+
Executed Query:
|
|
1926
|
+
${state.generatedQuery?.sql || state.generatedQuery?.rawDisplay}
|
|
1927
|
+
|
|
1928
|
+
Query Results Summary:
|
|
1929
|
+
- Success: ${res.success}
|
|
1930
|
+
- Rows returned: ${res.rowCount ?? 0}
|
|
1931
|
+
- Affected rows: ${res.affectedRows ?? 0}
|
|
1932
|
+
- Execution time: ${res.durationMs}ms
|
|
1933
|
+
- Sample data: ${JSON.stringify((res.rows || []).slice(0, 10), null, 2)}
|
|
1934
|
+
- Computed statistics: ${state.stats ? JSON.stringify(state.stats, null, 2) : "None"}
|
|
1935
|
+
|
|
1936
|
+
Provide a clear, concise, and direct natural-language response answering the user's request.
|
|
1937
|
+
Ground your response strictly in the query results above. Never hallucinate rows or numbers.`;
|
|
1938
|
+
try {
|
|
1939
|
+
const response = await model.invoke([new HumanMessage2(prompt)]);
|
|
1940
|
+
const conclusion = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
|
|
1941
|
+
return {
|
|
1942
|
+
conclusion,
|
|
1943
|
+
messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
|
|
1944
|
+
};
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
const conclusion = `Query executed successfully (${res.rowCount ?? 0} rows, ${res.durationMs}ms).`;
|
|
1947
|
+
return {
|
|
1948
|
+
conclusion,
|
|
1949
|
+
messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const workflow = new StateGraph(AgentStateAnnotation).addNode("inspect_schema", inspectSchemaNode).addNode("identify_targets", identifyTargetsNode).addNode("generate_query", generateQueryNode).addNode("classify_safety", classifySafetyNode).addNode("request_confirmation", requestConfirmationNode).addNode("execute_query", executeQueryNode).addNode("analyze_results", analyzeResultsNode).addNode("produce_conclusion", produceConclusionNode).addEdge(START, "inspect_schema").addEdge("inspect_schema", "identify_targets").addEdge("identify_targets", "generate_query").addEdge("generate_query", "classify_safety").addEdge("classify_safety", "request_confirmation").addConditionalEdges("request_confirmation", (state) => {
|
|
1954
|
+
if (state.confirmed && !state.cancelReason) {
|
|
1955
|
+
return "execute_query";
|
|
1956
|
+
}
|
|
1957
|
+
return "produce_conclusion";
|
|
1958
|
+
}).addEdge("execute_query", "analyze_results").addEdge("analyze_results", "produce_conclusion").addEdge("produce_conclusion", END);
|
|
1959
|
+
const checkpointer = new MemorySaver();
|
|
1960
|
+
const app = workflow.compile({ checkpointer });
|
|
1961
|
+
return app;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// src/repl.ts
|
|
1965
|
+
var ReplSession = class {
|
|
1966
|
+
adapter;
|
|
1967
|
+
model;
|
|
1968
|
+
provider;
|
|
1969
|
+
modelName;
|
|
1970
|
+
strictMode;
|
|
1971
|
+
allowFullWipe;
|
|
1972
|
+
rowThreshold;
|
|
1973
|
+
isRunning = true;
|
|
1974
|
+
sessionId = `session-${Date.now()}`;
|
|
1975
|
+
agent;
|
|
1976
|
+
constructor(options) {
|
|
1977
|
+
this.adapter = options.adapter;
|
|
1978
|
+
this.model = options.model;
|
|
1979
|
+
this.provider = options.provider;
|
|
1980
|
+
this.modelName = options.modelName;
|
|
1981
|
+
this.strictMode = options.strictMode;
|
|
1982
|
+
this.allowFullWipe = options.allowFullWipe;
|
|
1983
|
+
this.rowThreshold = options.rowThreshold ?? 50;
|
|
1984
|
+
const classifierOptions = {
|
|
1985
|
+
strictMode: this.strictMode,
|
|
1986
|
+
allowFullWipe: this.allowFullWipe,
|
|
1987
|
+
rowThresholdForDangerousUpdate: this.rowThreshold
|
|
1988
|
+
};
|
|
1989
|
+
this.agent = createDatabaseAgent({
|
|
1990
|
+
adapter: this.adapter,
|
|
1991
|
+
model: this.model,
|
|
1992
|
+
classifierOptions
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
async start() {
|
|
1996
|
+
this.setupSignalHandlers();
|
|
1997
|
+
this.printWelcomeBanner();
|
|
1998
|
+
const rl = readline.createInterface({
|
|
1999
|
+
input: process.stdin,
|
|
2000
|
+
output: process.stdout,
|
|
2001
|
+
terminal: true
|
|
2002
|
+
});
|
|
2003
|
+
const promptText = chalk2.cyan(`sandal [${this.adapter.type}]> `);
|
|
2004
|
+
const askQuestion = (query) => {
|
|
2005
|
+
return new Promise((resolve) => rl.question(query, resolve));
|
|
2006
|
+
};
|
|
2007
|
+
while (this.isRunning) {
|
|
2008
|
+
const input3 = await askQuestion(promptText);
|
|
2009
|
+
const trimmed = input3.trim();
|
|
2010
|
+
if (!trimmed) continue;
|
|
2011
|
+
if (["exit", "quit", ".exit", ".quit"].includes(trimmed.toLowerCase())) {
|
|
2012
|
+
break;
|
|
2013
|
+
}
|
|
2014
|
+
if (trimmed === ".clear") {
|
|
2015
|
+
console.clear();
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (trimmed === ".help") {
|
|
2019
|
+
this.printHelp();
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
if (trimmed === ".tables" || trimmed === ".collections") {
|
|
2023
|
+
await this.handleListEntities();
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
if (trimmed.startsWith(".schema")) {
|
|
2027
|
+
const entityName = trimmed.replace(/^\.schema\s*/, "").trim();
|
|
2028
|
+
await this.handleShowSchema(entityName);
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
if (trimmed === ".refresh") {
|
|
2032
|
+
const spinner = ora(chalk2.blue("Refreshing schema cache...")).start();
|
|
2033
|
+
try {
|
|
2034
|
+
await this.adapter.inspectSchema(true);
|
|
2035
|
+
spinner.succeed(chalk2.green("Schema refreshed successfully."));
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
spinner.fail(chalk2.red(`Failed to refresh schema: ${err.message}`));
|
|
2038
|
+
}
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
const intentSpinner = ora(chalk2.blue("Analyzing intent...")).start();
|
|
2042
|
+
const intent = await classifyIntent(trimmed, this.model);
|
|
2043
|
+
intentSpinner.stop();
|
|
2044
|
+
if (!intent.isDatabaseTask) {
|
|
2045
|
+
console.log(
|
|
2046
|
+
boxen2(
|
|
2047
|
+
`${chalk2.bold.red("\u{1F6E1}\uFE0F GUARDRAIL ENFORCEMENT")}
|
|
2048
|
+
|
|
2049
|
+
${chalk2.yellow(REFUSAL_MESSAGE)}
|
|
2050
|
+
|
|
2051
|
+
${chalk2.gray(
|
|
2052
|
+
`Reason: ${intent.reason || "Non-database request rejected"}`
|
|
2053
|
+
)}`,
|
|
2054
|
+
{
|
|
2055
|
+
padding: 1,
|
|
2056
|
+
borderColor: "red",
|
|
2057
|
+
borderStyle: "round",
|
|
2058
|
+
margin: { top: 1, bottom: 1 }
|
|
2059
|
+
}
|
|
2060
|
+
)
|
|
2061
|
+
);
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
const agentSpinner = ora(chalk2.cyan("Agent planning query...")).start();
|
|
2065
|
+
try {
|
|
2066
|
+
const result = await this.agent.invoke(
|
|
2067
|
+
{
|
|
2068
|
+
userInput: trimmed
|
|
2069
|
+
},
|
|
2070
|
+
{
|
|
2071
|
+
configurable: {
|
|
2072
|
+
thread_id: this.sessionId
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
);
|
|
2076
|
+
agentSpinner.stop();
|
|
2077
|
+
if (result.queryResult && result.queryResult.success && result.queryResult.rows) {
|
|
2078
|
+
this.renderRowsTable(result.queryResult);
|
|
2079
|
+
}
|
|
2080
|
+
if (result.conclusion) {
|
|
2081
|
+
console.log("\n" + chalk2.green("\u{1F916} Answer:"));
|
|
2082
|
+
console.log(chalk2.white(result.conclusion) + "\n");
|
|
2083
|
+
}
|
|
2084
|
+
} catch (err) {
|
|
2085
|
+
agentSpinner.fail(chalk2.red(`Execution failed: ${err.message}`));
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
rl.close();
|
|
2089
|
+
await this.shutdown();
|
|
2090
|
+
}
|
|
2091
|
+
renderRowsTable(queryResult) {
|
|
2092
|
+
const rows = queryResult.rows;
|
|
2093
|
+
if (!rows || rows.length === 0) {
|
|
2094
|
+
console.log(chalk2.gray("(0 rows returned)"));
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
const fields = queryResult.fields && queryResult.fields.length > 0 ? queryResult.fields : Object.keys(rows[0] || {});
|
|
2098
|
+
if (fields.length === 0) return;
|
|
2099
|
+
const displayRows = rows.slice(0, 25);
|
|
2100
|
+
const table = new Table({
|
|
2101
|
+
head: fields.map((f) => chalk2.cyan.bold(f)),
|
|
2102
|
+
style: { head: [], border: ["gray"] }
|
|
2103
|
+
});
|
|
2104
|
+
for (const r of displayRows) {
|
|
2105
|
+
table.push(
|
|
2106
|
+
fields.map((f) => {
|
|
2107
|
+
const val = r[f];
|
|
2108
|
+
if (val === null || val === void 0) return chalk2.gray("NULL");
|
|
2109
|
+
if (typeof val === "object") {
|
|
2110
|
+
try {
|
|
2111
|
+
return JSON.stringify(val);
|
|
2112
|
+
} catch {
|
|
2113
|
+
return String(val);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
return String(val);
|
|
2117
|
+
})
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
console.log(table.toString());
|
|
2121
|
+
if (rows.length > 25) {
|
|
2122
|
+
console.log(chalk2.yellow(`Showing first 25 of ${rows.length.toLocaleString()} rows.`));
|
|
2123
|
+
}
|
|
2124
|
+
console.log(
|
|
2125
|
+
chalk2.gray(
|
|
2126
|
+
`Total rows: ${queryResult.rowCount ?? rows.length} | Duration: ${queryResult.durationMs ?? 0}ms`
|
|
2127
|
+
)
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
async handleListEntities() {
|
|
2131
|
+
const spinner = ora(chalk2.blue("Loading schema...")).start();
|
|
2132
|
+
try {
|
|
2133
|
+
const schema = await this.adapter.inspectSchema();
|
|
2134
|
+
spinner.stop();
|
|
2135
|
+
if (schema.type === "postgres") {
|
|
2136
|
+
const tables = schema.tables || [];
|
|
2137
|
+
if (tables.length === 0) {
|
|
2138
|
+
console.log(chalk2.yellow("No tables found in public schema."));
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
console.log(chalk2.bold.cyan(`
|
|
2142
|
+
Tables in "${schema.databaseName}":`));
|
|
2143
|
+
for (const t of tables) {
|
|
2144
|
+
console.log(
|
|
2145
|
+
` \u2022 ${chalk2.white.bold(t.schema + "." + t.name)} (~${(t.approximateRowCount ?? 0).toLocaleString()} rows, ${t.columns.length} columns)`
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
console.log("");
|
|
2149
|
+
} else {
|
|
2150
|
+
const collections = schema.collections || [];
|
|
2151
|
+
if (collections.length === 0) {
|
|
2152
|
+
console.log(chalk2.yellow("No collections found."));
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
console.log(chalk2.bold.cyan(`
|
|
2156
|
+
Collections in "${schema.databaseName}":`));
|
|
2157
|
+
for (const c of collections) {
|
|
2158
|
+
console.log(
|
|
2159
|
+
` \u2022 ${chalk2.white.bold(c.name)} (~${(c.documentCount ?? 0).toLocaleString()} documents, ${c.fields.length} inferred fields)`
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
console.log("");
|
|
2163
|
+
}
|
|
2164
|
+
} catch (err) {
|
|
2165
|
+
spinner.fail(chalk2.red(`Failed to fetch entities: ${err.message}`));
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async handleShowSchema(entityName) {
|
|
2169
|
+
const spinner = ora(chalk2.blue("Loading schema details...")).start();
|
|
2170
|
+
try {
|
|
2171
|
+
const schema = await this.adapter.inspectSchema();
|
|
2172
|
+
spinner.stop();
|
|
2173
|
+
if (!entityName) {
|
|
2174
|
+
console.log("\n" + formatSchemaForPrompt(schema) + "\n");
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
if (schema.type === "postgres") {
|
|
2178
|
+
const table = schema.tables?.find(
|
|
2179
|
+
(t) => t.name.toLowerCase() === entityName.toLowerCase() || `${t.schema}.${t.name}`.toLowerCase() === entityName.toLowerCase()
|
|
2180
|
+
);
|
|
2181
|
+
if (!table) {
|
|
2182
|
+
console.log(chalk2.red(`Table "${entityName}" not found.`));
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
console.log(chalk2.bold.cyan(`
|
|
2186
|
+
Schema for table: ${table.schema}.${table.name}`));
|
|
2187
|
+
const tableDetails = new Table({
|
|
2188
|
+
head: ["Column", "Type", "Nullable", "Default", "Primary Key"].map((h) => chalk2.cyan.bold(h))
|
|
2189
|
+
});
|
|
2190
|
+
for (const col of table.columns) {
|
|
2191
|
+
tableDetails.push([
|
|
2192
|
+
col.name,
|
|
2193
|
+
col.dataType,
|
|
2194
|
+
col.isNullable ? "YES" : "NO",
|
|
2195
|
+
col.defaultValue || "-",
|
|
2196
|
+
col.isPrimaryKey ? "PK" : ""
|
|
2197
|
+
]);
|
|
2198
|
+
}
|
|
2199
|
+
console.log(tableDetails.toString());
|
|
2200
|
+
} else {
|
|
2201
|
+
const collection = schema.collections?.find((c) => c.name.toLowerCase() === entityName.toLowerCase());
|
|
2202
|
+
if (!collection) {
|
|
2203
|
+
console.log(chalk2.red(`Collection "${entityName}" not found.`));
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
console.log(chalk2.bold.cyan(`
|
|
2207
|
+
Schema for collection: ${collection.name}`));
|
|
2208
|
+
const colDetails = new Table({
|
|
2209
|
+
head: ["Field", "Inferred Types"].map((h) => chalk2.cyan.bold(h))
|
|
2210
|
+
});
|
|
2211
|
+
for (const f of collection.fields) {
|
|
2212
|
+
colDetails.push([f.name, f.types.join(", ")]);
|
|
2213
|
+
}
|
|
2214
|
+
console.log(colDetails.toString());
|
|
2215
|
+
}
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
spinner.fail(chalk2.red(`Failed to inspect schema: ${err.message}`));
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
printWelcomeBanner() {
|
|
2221
|
+
const banner = [
|
|
2222
|
+
chalk2.bold.cyan("SANDAL: Safe Agentic Natural-language Database Access Layer"),
|
|
2223
|
+
chalk2.gray("LangGraph + Multi-Provider AI (Gemini, OpenAI, Anthropic)"),
|
|
2224
|
+
"",
|
|
2225
|
+
`${chalk2.bold("Database:")} ${chalk2.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
|
|
2226
|
+
`${chalk2.bold("LLM Provider:")} ${chalk2.blue(this.provider)} [${chalk2.white(this.modelName)}]`,
|
|
2227
|
+
`${chalk2.bold("Strict Mode:")} ${this.strictMode ? chalk2.green("ON (Full wipes blocked)") : chalk2.yellow("OFF")}`,
|
|
2228
|
+
`${chalk2.bold("Allow Full Wipe:")} ${this.allowFullWipe ? chalk2.red("ENABLED") : chalk2.gray("NO")}`,
|
|
2229
|
+
"",
|
|
2230
|
+
chalk2.gray("Type your natural language request or SQL/Mongo query."),
|
|
2231
|
+
chalk2.gray("Commands: .tables, .schema [name], .refresh, .help, exit")
|
|
2232
|
+
].join("\n");
|
|
2233
|
+
console.log(
|
|
2234
|
+
boxen2(banner, {
|
|
2235
|
+
padding: 1,
|
|
2236
|
+
borderColor: "cyan",
|
|
2237
|
+
borderStyle: "round",
|
|
2238
|
+
margin: { top: 1, bottom: 1 }
|
|
2239
|
+
})
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
printHelp() {
|
|
2243
|
+
console.log(
|
|
2244
|
+
boxen2(
|
|
2245
|
+
[
|
|
2246
|
+
chalk2.bold.cyan("SANDAL REPL Commands:"),
|
|
2247
|
+
"",
|
|
2248
|
+
`${chalk2.bold(".tables / .collections")} List all tables or collections with row counts`,
|
|
2249
|
+
`${chalk2.bold(".schema [name]")} Show columns, types, and indexes for a table`,
|
|
2250
|
+
`${chalk2.bold(".refresh")} Force refresh cached schema introspection`,
|
|
2251
|
+
`${chalk2.bold(".clear")} Clear terminal screen`,
|
|
2252
|
+
`${chalk2.bold(".help")} Display this command reference`,
|
|
2253
|
+
`${chalk2.bold("exit / quit")} Gracefully disconnect and exit`,
|
|
2254
|
+
"",
|
|
2255
|
+
chalk2.bold.yellow("Example Natural Language Requests:"),
|
|
2256
|
+
' "Show top 10 users signed up in the last 30 days"',
|
|
2257
|
+
' "Calculate total revenue grouped by product category"',
|
|
2258
|
+
' "Add a concurrent index on orders(created_at)"',
|
|
2259
|
+
' "Delete users where status is inactive"'
|
|
2260
|
+
].join("\n"),
|
|
2261
|
+
{
|
|
2262
|
+
padding: 1,
|
|
2263
|
+
borderColor: "gray",
|
|
2264
|
+
borderStyle: "single",
|
|
2265
|
+
margin: { top: 0, bottom: 1 }
|
|
2266
|
+
}
|
|
2267
|
+
)
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
setupSignalHandlers() {
|
|
2271
|
+
const handleExit = async () => {
|
|
2272
|
+
this.isRunning = false;
|
|
2273
|
+
await this.shutdown();
|
|
2274
|
+
process.exit(0);
|
|
2275
|
+
};
|
|
2276
|
+
process.on("SIGINT", handleExit);
|
|
2277
|
+
process.on("SIGTERM", handleExit);
|
|
2278
|
+
}
|
|
2279
|
+
async shutdown() {
|
|
2280
|
+
if (this.adapter.isConnected()) {
|
|
2281
|
+
try {
|
|
2282
|
+
await this.adapter.disconnect();
|
|
2283
|
+
} catch {
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
console.log(chalk2.cyan("\nDatabase connection closed. Goodbye! \u{1F44B}\n"));
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
|
|
2290
|
+
// src/cli.ts
|
|
2291
|
+
var program = new Command();
|
|
2292
|
+
program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Database Access Layer").version("1.0.0").argument("[dbUrl]", "Database connection URL (PostgreSQL or MongoDB)").option("-p, --provider <provider>", "LLM provider: google | openai | anthropic").option("-m, --model <model>", "LLM model name (e.g. gemini-2.5-flash, gpt-4o, claude-3-5-sonnet-latest)").option("-k, --key <key>", "API key for the chosen LLM provider").option("--strict", "Strict safety mode: hard-blocks full database wipes (default: true)", true).option("--no-strict", "Disable strict safety mode").option("--allow-full-wipe", "Explicitly allow full database / all-table wipe queries with confirmation", false).option("--threshold <number>", "Row count threshold for classifying updates as dangerous", "50").action(async (cliDbUrl, options) => {
|
|
2293
|
+
try {
|
|
2294
|
+
await runCli({
|
|
2295
|
+
cliDbUrl,
|
|
2296
|
+
cliProvider: options.provider,
|
|
2297
|
+
cliModel: options.model,
|
|
2298
|
+
cliApiKey: options.key,
|
|
2299
|
+
strictMode: options.strict !== false,
|
|
2300
|
+
allowFullWipe: Boolean(options.allowFullWipe),
|
|
2301
|
+
rowThreshold: parseInt(options.threshold, 10) || 50
|
|
2302
|
+
});
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
console.error(chalk3.red(`
|
|
2305
|
+
Error: ${err.message}`));
|
|
2306
|
+
process.exit(1);
|
|
2307
|
+
}
|
|
2308
|
+
});
|
|
2309
|
+
var configCmd = program.command("config").description("Manage stored database credentials and API keys in ~/.sandal/config.json").option("--set-db <url>", "Set default database connection URL").option("--set-key <key>", "Set API key for the default provider").option("--set-gemini <key>", "Set Google Gemini API key").option("--set-openai <key>", "Set OpenAI API key").option("--set-anthropic <key>", "Set Anthropic API key").option("--set-provider <provider>", "Set default LLM provider (google | openai | anthropic)").option("--set-model <model>", "Set default model name").option("--show", "Display current stored configuration (with masked secrets)").option("--path", "Print the path to the configuration file").action((opts) => {
|
|
2310
|
+
if (opts.path) {
|
|
2311
|
+
console.log(getConfigFilePath());
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
if (opts.show) {
|
|
2315
|
+
const cfg = readConfig();
|
|
2316
|
+
console.log(chalk3.bold.cyan("\nSaved Configuration (~/.sandal/config.json):"));
|
|
2317
|
+
console.log(` Database URL: ${cfg.dbUrl ? chalk3.green(maskUrl(cfg.dbUrl)) : chalk3.gray("Not set")}`);
|
|
2318
|
+
console.log(` Default Provider: ${cfg.defaultProvider ? chalk3.blue(cfg.defaultProvider) : chalk3.gray("Not set (defaults to google)")}`);
|
|
2319
|
+
console.log(` Default Model: ${cfg.defaultModel ? chalk3.white(cfg.defaultModel) : chalk3.gray("Default for provider")}`);
|
|
2320
|
+
console.log(` Gemini Key: ${cfg.geminiApiKey ? chalk3.yellow(maskApiKey(cfg.geminiApiKey)) : chalk3.gray("Not set")}`);
|
|
2321
|
+
console.log(` OpenAI Key: ${cfg.openaiApiKey ? chalk3.yellow(maskApiKey(cfg.openaiApiKey)) : chalk3.gray("Not set")}`);
|
|
2322
|
+
console.log(` Anthropic Key: ${cfg.anthropicApiKey ? chalk3.yellow(maskApiKey(cfg.anthropicApiKey)) : chalk3.gray("Not set")}
|
|
2323
|
+
`);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
const updates = {};
|
|
2327
|
+
if (opts.setDb) {
|
|
2328
|
+
detectDatabaseType(opts.setDb);
|
|
2329
|
+
updates.dbUrl = opts.setDb;
|
|
2330
|
+
console.log(chalk3.green(`Updated default database URL: ${maskUrl(opts.setDb)}`));
|
|
2331
|
+
}
|
|
2332
|
+
if (opts.setProvider) {
|
|
2333
|
+
const p = opts.setProvider.toLowerCase();
|
|
2334
|
+
if (!["google", "openai", "anthropic"].includes(p)) {
|
|
2335
|
+
console.error(chalk3.red("Invalid provider. Expected google, openai, or anthropic."));
|
|
2336
|
+
process.exit(1);
|
|
2337
|
+
}
|
|
2338
|
+
updates.defaultProvider = p;
|
|
2339
|
+
console.log(chalk3.green(`Updated default provider: ${p}`));
|
|
2340
|
+
}
|
|
2341
|
+
if (opts.setModel) {
|
|
2342
|
+
updates.defaultModel = opts.setModel;
|
|
2343
|
+
console.log(chalk3.green(`Updated default model: ${opts.setModel}`));
|
|
2344
|
+
}
|
|
2345
|
+
if (opts.setGemini) {
|
|
2346
|
+
updates.geminiApiKey = opts.setGemini;
|
|
2347
|
+
console.log(chalk3.green("Updated Gemini API key."));
|
|
2348
|
+
}
|
|
2349
|
+
if (opts.setOpenai) {
|
|
2350
|
+
updates.openaiApiKey = opts.setOpenai;
|
|
2351
|
+
console.log(chalk3.green("Updated OpenAI API key."));
|
|
2352
|
+
}
|
|
2353
|
+
if (opts.setAnthropic) {
|
|
2354
|
+
updates.anthropicApiKey = opts.setAnthropic;
|
|
2355
|
+
console.log(chalk3.green("Updated Anthropic API key."));
|
|
2356
|
+
}
|
|
2357
|
+
if (opts.setKey) {
|
|
2358
|
+
const current = readConfig();
|
|
2359
|
+
const prov = updates.defaultProvider || current.defaultProvider || "google";
|
|
2360
|
+
if (prov === "google") updates.geminiApiKey = opts.setKey;
|
|
2361
|
+
else if (prov === "openai") updates.openaiApiKey = opts.setKey;
|
|
2362
|
+
else if (prov === "anthropic") updates.anthropicApiKey = opts.setKey;
|
|
2363
|
+
console.log(chalk3.green(`Updated API key for provider "${prov}".`));
|
|
2364
|
+
}
|
|
2365
|
+
if (Object.keys(updates).length > 0) {
|
|
2366
|
+
writeConfig(updates);
|
|
2367
|
+
console.log(chalk3.gray(`Saved to ${getConfigFilePath()}`));
|
|
2368
|
+
} else {
|
|
2369
|
+
configCmd.help();
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
async function runCli(opts) {
|
|
2373
|
+
let resolved = resolveCredentials({
|
|
2374
|
+
cliDbUrl: opts.cliDbUrl,
|
|
2375
|
+
cliProvider: opts.cliProvider,
|
|
2376
|
+
cliModel: opts.cliModel,
|
|
2377
|
+
cliApiKey: opts.cliApiKey
|
|
2378
|
+
});
|
|
2379
|
+
let dbUrl = resolved.dbUrl;
|
|
2380
|
+
if (!dbUrl) {
|
|
2381
|
+
console.log(chalk3.yellow("\nNo database connection URL found in CLI flags, environment, or config."));
|
|
2382
|
+
dbUrl = await input2({
|
|
2383
|
+
message: "Enter your database URL (postgres://... or mongodb://...):",
|
|
2384
|
+
validate: (val) => {
|
|
2385
|
+
try {
|
|
2386
|
+
detectDatabaseType(val);
|
|
2387
|
+
return true;
|
|
2388
|
+
} catch (e) {
|
|
2389
|
+
return e.message;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
});
|
|
2393
|
+
const saveDb = await confirm2({
|
|
2394
|
+
message: "Save this database URL to ~/.sandal/config.json for future runs?",
|
|
2395
|
+
default: true
|
|
2396
|
+
});
|
|
2397
|
+
if (saveDb) {
|
|
2398
|
+
writeConfig({ dbUrl });
|
|
2399
|
+
}
|
|
2400
|
+
} else if (opts.cliDbUrl && resolved.source.dbUrl === "cli") {
|
|
2401
|
+
const currentCfg = readConfig();
|
|
2402
|
+
if (currentCfg.dbUrl !== opts.cliDbUrl) {
|
|
2403
|
+
writeConfig({ dbUrl: opts.cliDbUrl });
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
let provider = resolved.provider;
|
|
2407
|
+
let apiKey = resolved.apiKey;
|
|
2408
|
+
if (!apiKey) {
|
|
2409
|
+
console.log(chalk3.yellow("\nNo API key found in environment or config."));
|
|
2410
|
+
provider = await select({
|
|
2411
|
+
message: "Select your LLM provider:",
|
|
2412
|
+
choices: [
|
|
2413
|
+
{ name: "Google Gemini (GEMINI_API_KEY)", value: "google" },
|
|
2414
|
+
{ name: "OpenAI (OPENAI_API_KEY)", value: "openai" },
|
|
2415
|
+
{ name: "Anthropic (ANTHROPIC_API_KEY)", value: "anthropic" }
|
|
2416
|
+
]
|
|
2417
|
+
});
|
|
2418
|
+
apiKey = await input2({
|
|
2419
|
+
message: `Enter your ${provider.toUpperCase()} API key:`,
|
|
2420
|
+
validate: (val) => val.trim().length > 0 ? true : "API key cannot be empty."
|
|
2421
|
+
});
|
|
2422
|
+
const saveKey = await confirm2({
|
|
2423
|
+
message: "Save this API key to ~/.sandal/config.json?",
|
|
2424
|
+
default: true
|
|
2425
|
+
});
|
|
2426
|
+
if (saveKey) {
|
|
2427
|
+
if (provider === "google") writeConfig({ geminiApiKey: apiKey, defaultProvider: "google" });
|
|
2428
|
+
else if (provider === "openai") writeConfig({ openaiApiKey: apiKey, defaultProvider: "openai" });
|
|
2429
|
+
else if (provider === "anthropic") writeConfig({ anthropicApiKey: apiKey, defaultProvider: "anthropic" });
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
const modelName = opts.cliModel || resolved.model || getDefaultModelForProvider(provider);
|
|
2433
|
+
const dbSpinner = ora2(`Connecting to database (${maskUrl(dbUrl)})...`).start();
|
|
2434
|
+
const adapter = createDatabaseAdapter(dbUrl);
|
|
2435
|
+
try {
|
|
2436
|
+
await adapter.connect();
|
|
2437
|
+
dbSpinner.succeed(chalk3.green(`Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`));
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
dbSpinner.fail(chalk3.red(`Failed to connect to database: ${err.message}`));
|
|
2440
|
+
process.exit(1);
|
|
2441
|
+
}
|
|
2442
|
+
const model = createChatModel({
|
|
2443
|
+
provider,
|
|
2444
|
+
model: modelName,
|
|
2445
|
+
apiKey
|
|
2446
|
+
});
|
|
2447
|
+
const repl = new ReplSession({
|
|
2448
|
+
adapter,
|
|
2449
|
+
model,
|
|
2450
|
+
provider,
|
|
2451
|
+
modelName,
|
|
2452
|
+
strictMode: opts.strictMode,
|
|
2453
|
+
allowFullWipe: opts.allowFullWipe,
|
|
2454
|
+
rowThreshold: opts.rowThreshold
|
|
2455
|
+
});
|
|
2456
|
+
await repl.start();
|
|
2457
|
+
}
|
|
2458
|
+
program.parse(process.argv);
|