sandal-db 1.0.4 → 1.0.6
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/README.md +2 -0
- package/dist/cli.js +306 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SANDAL: Safe Agentic Natural-language Database Access Layer
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
SANDAL is an agentic command-line interface that enables software engineers, data analysts, and site reliability engineers to query, inspect, and manage databases using natural language. Powered by LangGraph and multi-provider language models, SANDAL translates natural language prompts into parameterized database queries, displays execution previews and impact estimations, and enforces multi-layered guardrails before executing any mutating statements.
|
|
4
6
|
|
|
5
7
|
---
|
package/dist/cli.js
CHANGED
|
@@ -534,12 +534,155 @@ function removeSavedConnection(target) {
|
|
|
534
534
|
}
|
|
535
535
|
|
|
536
536
|
// src/db/postgres.ts
|
|
537
|
+
import pg2 from "pg";
|
|
538
|
+
|
|
539
|
+
// src/db/supabase.ts
|
|
537
540
|
import pg from "pg";
|
|
538
|
-
|
|
541
|
+
function isSupabaseDirectUrl(url) {
|
|
542
|
+
if (!url || typeof url !== "string") return false;
|
|
543
|
+
return /db\.([a-z0-9_-]+)\.supabase\.(co|in|net)/i.test(url);
|
|
544
|
+
}
|
|
545
|
+
function parseSupabaseDirectUrl(rawUrl) {
|
|
546
|
+
try {
|
|
547
|
+
const url = new URL(rawUrl);
|
|
548
|
+
const hostMatch = url.hostname.match(/^db\.([a-z0-9_-]+)\.supabase\.(co|in|net)$/i);
|
|
549
|
+
if (!hostMatch) return null;
|
|
550
|
+
const projectRef = hostMatch[1];
|
|
551
|
+
const username = url.username || "postgres";
|
|
552
|
+
const password = url.password ? decodeURIComponent(url.password) : void 0;
|
|
553
|
+
const database = url.pathname.replace(/^\//, "") || "postgres";
|
|
554
|
+
const port = url.port || "5432";
|
|
555
|
+
const search = url.search || "";
|
|
556
|
+
return {
|
|
557
|
+
projectRef,
|
|
558
|
+
username,
|
|
559
|
+
password,
|
|
560
|
+
database,
|
|
561
|
+
port,
|
|
562
|
+
search
|
|
563
|
+
};
|
|
564
|
+
} catch {
|
|
565
|
+
const match = rawUrl.match(
|
|
566
|
+
/^(postgresql|postgres):\/\/([^:]+)(?::([^@]+))?@db\.([a-z0-9_-]+)\.supabase\.(?:co|in|net)(?::(\d+))?(?:\/([^?]*))?(\?.*)?$/i
|
|
567
|
+
);
|
|
568
|
+
if (!match) return null;
|
|
569
|
+
return {
|
|
570
|
+
username: match[2] || "postgres",
|
|
571
|
+
password: match[3] ? decodeURIComponent(match[3]) : void 0,
|
|
572
|
+
projectRef: match[4],
|
|
573
|
+
port: match[5] || "5432",
|
|
574
|
+
database: match[6] || "postgres",
|
|
575
|
+
search: match[7] || ""
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function buildSupabasePoolerUrl(params) {
|
|
580
|
+
const user = params.username || "postgres";
|
|
581
|
+
const poolerUser = user.includes(".") ? user : `${user}.${params.projectRef}`;
|
|
582
|
+
const encodedUser = encodeURIComponent(poolerUser);
|
|
583
|
+
const encodedPass = params.password !== void 0 ? `:${encodeURIComponent(params.password)}` : "";
|
|
584
|
+
const port = params.port || 5432;
|
|
585
|
+
const db = params.database || "postgres";
|
|
586
|
+
const search = params.search || "";
|
|
587
|
+
return `postgresql://${encodedUser}${encodedPass}@aws-0-${params.region}.pooler.supabase.com:${port}/${db}${search}`;
|
|
588
|
+
}
|
|
589
|
+
var SUPABASE_REGIONS = [
|
|
590
|
+
"ap-northeast-1",
|
|
591
|
+
"ap-northeast-2",
|
|
592
|
+
"ap-south-1",
|
|
593
|
+
"ap-southeast-1",
|
|
594
|
+
"ap-southeast-2",
|
|
595
|
+
"us-east-1",
|
|
596
|
+
"us-east-2",
|
|
597
|
+
"us-west-1",
|
|
598
|
+
"us-west-2",
|
|
599
|
+
"eu-central-1",
|
|
600
|
+
"eu-central-2",
|
|
601
|
+
"eu-west-1",
|
|
602
|
+
"eu-west-2",
|
|
603
|
+
"eu-west-3",
|
|
604
|
+
"eu-north-1",
|
|
605
|
+
"ca-central-1",
|
|
606
|
+
"sa-east-1",
|
|
607
|
+
"me-central-1",
|
|
608
|
+
"af-south-1"
|
|
609
|
+
];
|
|
610
|
+
async function probeSupabaseRegion(params) {
|
|
611
|
+
const timeoutMs = params.timeoutMs || 3500;
|
|
612
|
+
const probeOne = async (region) => {
|
|
613
|
+
const poolerUrl = buildSupabasePoolerUrl({
|
|
614
|
+
region,
|
|
615
|
+
projectRef: params.projectRef,
|
|
616
|
+
username: params.username,
|
|
617
|
+
password: params.password,
|
|
618
|
+
database: params.database,
|
|
619
|
+
search: params.search
|
|
620
|
+
});
|
|
621
|
+
const client = new pg.Client({
|
|
622
|
+
connectionString: poolerUrl,
|
|
623
|
+
connectionTimeoutMillis: timeoutMs
|
|
624
|
+
});
|
|
625
|
+
client.on("error", () => {
|
|
626
|
+
});
|
|
627
|
+
try {
|
|
628
|
+
await client.connect();
|
|
629
|
+
await client.end().catch(() => {
|
|
630
|
+
});
|
|
631
|
+
return region;
|
|
632
|
+
} catch (err) {
|
|
633
|
+
await client.end().catch(() => {
|
|
634
|
+
});
|
|
635
|
+
const msg = (err?.message || "").toLowerCase();
|
|
636
|
+
if (err?.code === "28P01" || msg.includes("password authentication failed") || msg.includes("pg_hba.conf")) {
|
|
637
|
+
return region;
|
|
638
|
+
}
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
try {
|
|
643
|
+
return await Promise.any(SUPABASE_REGIONS.map((r) => probeOne(r)));
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function resolveSupabaseUrl(rawUrl, onProgress) {
|
|
649
|
+
if (!isSupabaseDirectUrl(rawUrl)) return null;
|
|
650
|
+
const parsed = parseSupabaseDirectUrl(rawUrl);
|
|
651
|
+
if (!parsed) return null;
|
|
652
|
+
onProgress?.(`Probing Supabase pooler regions for project ${parsed.projectRef}...`);
|
|
653
|
+
const region = await probeSupabaseRegion({
|
|
654
|
+
projectRef: parsed.projectRef,
|
|
655
|
+
username: parsed.username,
|
|
656
|
+
password: parsed.password,
|
|
657
|
+
database: parsed.database,
|
|
658
|
+
search: parsed.search
|
|
659
|
+
});
|
|
660
|
+
if (!region) return null;
|
|
661
|
+
const poolerUrl = buildSupabasePoolerUrl({
|
|
662
|
+
region,
|
|
663
|
+
projectRef: parsed.projectRef,
|
|
664
|
+
username: parsed.username,
|
|
665
|
+
password: parsed.password,
|
|
666
|
+
database: parsed.database,
|
|
667
|
+
port: 5432,
|
|
668
|
+
search: parsed.search
|
|
669
|
+
});
|
|
670
|
+
return {
|
|
671
|
+
poolerUrl,
|
|
672
|
+
region,
|
|
673
|
+
projectRef: parsed.projectRef
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/db/postgres.ts
|
|
678
|
+
var { Pool } = pg2;
|
|
539
679
|
var PostgresAdapter = class {
|
|
540
680
|
type = "postgres";
|
|
541
681
|
pool = null;
|
|
542
682
|
connectionUrl;
|
|
683
|
+
isSupabaseAutoRouted = false;
|
|
684
|
+
supabaseRegion;
|
|
685
|
+
originalUrl;
|
|
543
686
|
cachedSchema = null;
|
|
544
687
|
databaseName = "postgres";
|
|
545
688
|
constructor(connectionUrl) {
|
|
@@ -554,21 +697,60 @@ var PostgresAdapter = class {
|
|
|
554
697
|
getMaskedUrl() {
|
|
555
698
|
return maskUrl(this.connectionUrl);
|
|
556
699
|
}
|
|
557
|
-
async connect() {
|
|
700
|
+
async connect(onProgress) {
|
|
558
701
|
if (this.pool) return;
|
|
559
|
-
this.pool = new Pool({
|
|
560
|
-
connectionString: this.connectionUrl,
|
|
561
|
-
connectionTimeoutMillis: 1e4,
|
|
562
|
-
idleTimeoutMillis: 3e4
|
|
563
|
-
});
|
|
564
|
-
const client = await this.pool.connect();
|
|
565
702
|
try {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
703
|
+
this.pool = new Pool({
|
|
704
|
+
connectionString: this.connectionUrl,
|
|
705
|
+
connectionTimeoutMillis: 1e4,
|
|
706
|
+
idleTimeoutMillis: 3e4
|
|
707
|
+
});
|
|
708
|
+
const client = await this.pool.connect();
|
|
709
|
+
try {
|
|
710
|
+
const res = await client.query("SELECT current_database() as db_name;");
|
|
711
|
+
if (res.rows[0]?.db_name) {
|
|
712
|
+
this.databaseName = res.rows[0].db_name;
|
|
713
|
+
}
|
|
714
|
+
} finally {
|
|
715
|
+
client.release();
|
|
569
716
|
}
|
|
570
|
-
}
|
|
571
|
-
|
|
717
|
+
} catch (err) {
|
|
718
|
+
if (this.pool) {
|
|
719
|
+
await this.pool.end().catch(() => {
|
|
720
|
+
});
|
|
721
|
+
this.pool = null;
|
|
722
|
+
}
|
|
723
|
+
if (isSupabaseDirectUrl(this.connectionUrl)) {
|
|
724
|
+
onProgress?.("Direct Supabase connection failed (IPv6-only). Auto-detecting IPv4 pooler region...");
|
|
725
|
+
const autoResolved = await resolveSupabaseUrl(this.connectionUrl, onProgress);
|
|
726
|
+
if (autoResolved) {
|
|
727
|
+
this.originalUrl = this.connectionUrl;
|
|
728
|
+
this.connectionUrl = autoResolved.poolerUrl;
|
|
729
|
+
this.isSupabaseAutoRouted = true;
|
|
730
|
+
this.supabaseRegion = autoResolved.region;
|
|
731
|
+
onProgress?.(`Found region [${autoResolved.region}]. Connecting via IPv4 pooler...`);
|
|
732
|
+
this.pool = new Pool({
|
|
733
|
+
connectionString: this.connectionUrl,
|
|
734
|
+
connectionTimeoutMillis: 1e4,
|
|
735
|
+
idleTimeoutMillis: 3e4
|
|
736
|
+
});
|
|
737
|
+
const client = await this.pool.connect();
|
|
738
|
+
try {
|
|
739
|
+
const res = await client.query("SELECT current_database() as db_name;");
|
|
740
|
+
if (res.rows[0]?.db_name) {
|
|
741
|
+
this.databaseName = res.rows[0].db_name;
|
|
742
|
+
}
|
|
743
|
+
} finally {
|
|
744
|
+
client.release();
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
747
|
+
} else {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`Direct Supabase connection failed (${err.message}) because direct connections are IPv6-only, and auto-detecting the Supabase IPv4 pooler failed. Please check your project ref/password or copy your Connection Pooler URL directly from Supabase Dashboard.`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
throw err;
|
|
572
754
|
}
|
|
573
755
|
}
|
|
574
756
|
async disconnect() {
|
|
@@ -868,7 +1050,7 @@ var MongoAdapter = class {
|
|
|
868
1050
|
getMaskedUrl() {
|
|
869
1051
|
return maskUrl(this.connectionUrl);
|
|
870
1052
|
}
|
|
871
|
-
async connect() {
|
|
1053
|
+
async connect(_onProgress) {
|
|
872
1054
|
if (this.client) return;
|
|
873
1055
|
this.client = new MongoClient(this.connectionUrl, {
|
|
874
1056
|
serverSelectionTimeoutMS: 1e4,
|
|
@@ -1220,33 +1402,90 @@ function createDatabaseAdapter(url) {
|
|
|
1220
1402
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
|
1221
1403
|
import { ChatOpenAI } from "@langchain/openai";
|
|
1222
1404
|
import { ChatAnthropic } from "@langchain/anthropic";
|
|
1223
|
-
function
|
|
1224
|
-
const
|
|
1405
|
+
function isReasoningOrFixedTempModel(provider, model) {
|
|
1406
|
+
const normalized = model.toLowerCase().replace(/^models\//, "").trim();
|
|
1225
1407
|
switch (provider) {
|
|
1226
1408
|
case "google":
|
|
1227
|
-
return
|
|
1409
|
+
return normalized.includes("thinking") || normalized.includes("reasoning") || /^gemini-([3-9]|\d{2,}|2\.[5-9])/i.test(normalized);
|
|
1410
|
+
case "openai":
|
|
1411
|
+
return /^o[1-9]/i.test(normalized) || normalized.includes("reasoning") || normalized.includes("thinking");
|
|
1412
|
+
case "anthropic":
|
|
1413
|
+
return normalized.includes("thinking") || normalized.includes("reasoning");
|
|
1414
|
+
default:
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
function resolveTemperature(provider, model, requestedTemperature) {
|
|
1419
|
+
if (isReasoningOrFixedTempModel(provider, model)) {
|
|
1420
|
+
return void 0;
|
|
1421
|
+
}
|
|
1422
|
+
return requestedTemperature ?? 0;
|
|
1423
|
+
}
|
|
1424
|
+
function attachTemperatureFallback(model) {
|
|
1425
|
+
const originalInvoke = model.invoke.bind(model);
|
|
1426
|
+
model.invoke = (async (input3, options) => {
|
|
1427
|
+
try {
|
|
1428
|
+
return await originalInvoke(input3, options);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
const errMsg = err?.message || String(err);
|
|
1431
|
+
const isTempError = errMsg.toLowerCase().includes("temperature") && (errMsg.toLowerCase().includes("unsupported") || errMsg.toLowerCase().includes("does not support") || errMsg.toLowerCase().includes("not support"));
|
|
1432
|
+
if (isTempError) {
|
|
1433
|
+
model.temperature = void 0;
|
|
1434
|
+
if (model.client?.generationConfig) {
|
|
1435
|
+
delete model.client.generationConfig.temperature;
|
|
1436
|
+
}
|
|
1437
|
+
return await originalInvoke(input3, options);
|
|
1438
|
+
}
|
|
1439
|
+
throw err;
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
return model;
|
|
1443
|
+
}
|
|
1444
|
+
function createChatModel(options) {
|
|
1445
|
+
const { provider, model, apiKey, temperature: requestedTemp } = options;
|
|
1446
|
+
const temperature = resolveTemperature(provider, model, requestedTemp);
|
|
1447
|
+
let chatModel;
|
|
1448
|
+
switch (provider) {
|
|
1449
|
+
case "google": {
|
|
1450
|
+
const config = {
|
|
1228
1451
|
model,
|
|
1229
1452
|
apiKey,
|
|
1230
|
-
temperature,
|
|
1231
1453
|
maxRetries: 2
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1454
|
+
};
|
|
1455
|
+
if (temperature !== void 0) {
|
|
1456
|
+
config.temperature = temperature;
|
|
1457
|
+
}
|
|
1458
|
+
chatModel = new ChatGoogleGenerativeAI(config);
|
|
1459
|
+
break;
|
|
1460
|
+
}
|
|
1461
|
+
case "openai": {
|
|
1462
|
+
const config = {
|
|
1235
1463
|
modelName: model,
|
|
1236
1464
|
apiKey,
|
|
1237
|
-
temperature,
|
|
1238
1465
|
maxRetries: 2
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1466
|
+
};
|
|
1467
|
+
if (temperature !== void 0) {
|
|
1468
|
+
config.temperature = temperature;
|
|
1469
|
+
}
|
|
1470
|
+
chatModel = new ChatOpenAI(config);
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
case "anthropic": {
|
|
1474
|
+
const config = {
|
|
1242
1475
|
modelName: model,
|
|
1243
1476
|
anthropicApiKey: apiKey,
|
|
1244
|
-
temperature,
|
|
1245
1477
|
maxRetries: 2
|
|
1246
|
-
}
|
|
1478
|
+
};
|
|
1479
|
+
if (temperature !== void 0) {
|
|
1480
|
+
config.temperature = temperature;
|
|
1481
|
+
}
|
|
1482
|
+
chatModel = new ChatAnthropic(config);
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1247
1485
|
default:
|
|
1248
1486
|
throw new Error(`Unsupported LLM provider: ${provider}`);
|
|
1249
1487
|
}
|
|
1488
|
+
return attachTemperatureFallback(chatModel);
|
|
1250
1489
|
}
|
|
1251
1490
|
|
|
1252
1491
|
// src/repl.ts
|
|
@@ -3157,7 +3396,9 @@ Execution failed: ${err.message}
|
|
|
3157
3396
|
const spinner = ora(`Connecting to ${maskUrl(urlToConnect)}...`).start();
|
|
3158
3397
|
try {
|
|
3159
3398
|
const newAdapter = createDatabaseAdapter(urlToConnect);
|
|
3160
|
-
await newAdapter.connect()
|
|
3399
|
+
await newAdapter.connect((status) => {
|
|
3400
|
+
spinner.text = status;
|
|
3401
|
+
});
|
|
3161
3402
|
await newAdapter.inspectSchema(true);
|
|
3162
3403
|
try {
|
|
3163
3404
|
await this.adapter.disconnect();
|
|
@@ -3165,12 +3406,20 @@ Execution failed: ${err.message}
|
|
|
3165
3406
|
}
|
|
3166
3407
|
this.adapter = newAdapter;
|
|
3167
3408
|
this.initAgent();
|
|
3168
|
-
addSavedConnection(
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3409
|
+
addSavedConnection(newAdapter.connectionUrl);
|
|
3410
|
+
if (newAdapter instanceof PostgresAdapter && newAdapter.isSupabaseAutoRouted) {
|
|
3411
|
+
spinner.succeed(
|
|
3412
|
+
chalk4.green(
|
|
3413
|
+
`Switched to Supabase PostgreSQL database via IPv4 pooler [${newAdapter.supabaseRegion}]: ${newAdapter.databaseName} (${newAdapter.getMaskedUrl()})`
|
|
3414
|
+
)
|
|
3415
|
+
);
|
|
3416
|
+
} else {
|
|
3417
|
+
spinner.succeed(
|
|
3418
|
+
chalk4.green(
|
|
3419
|
+
`Switched to ${newAdapter.type.toUpperCase()} database: ${newAdapter.databaseName} (${newAdapter.getMaskedUrl()})`
|
|
3420
|
+
)
|
|
3421
|
+
);
|
|
3422
|
+
}
|
|
3174
3423
|
console.log(
|
|
3175
3424
|
chalk4.gray(
|
|
3176
3425
|
`Chat session [${this.sessionId}] continuing with conversation memory on the new database.
|
|
@@ -4101,13 +4350,29 @@ No API key found for ${provider.toUpperCase()} in environment or config.`)
|
|
|
4101
4350
|
).start();
|
|
4102
4351
|
const adapter = createDatabaseAdapter(dbUrl);
|
|
4103
4352
|
try {
|
|
4104
|
-
await adapter.connect()
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4353
|
+
await adapter.connect((status) => {
|
|
4354
|
+
dbSpinner.text = status;
|
|
4355
|
+
});
|
|
4356
|
+
if (adapter instanceof PostgresAdapter && adapter.isSupabaseAutoRouted) {
|
|
4357
|
+
dbSpinner.succeed(
|
|
4358
|
+
chalk5.green(
|
|
4359
|
+
`Connected to Supabase PostgreSQL database via IPv4 pooler [${adapter.supabaseRegion}]: ${adapter.databaseName}`
|
|
4360
|
+
)
|
|
4361
|
+
);
|
|
4362
|
+
console.log(
|
|
4363
|
+
chalk5.gray(
|
|
4364
|
+
` Auto-resolved direct Supabase URL to connection pooler: ${adapter.getMaskedUrl()}`
|
|
4365
|
+
)
|
|
4366
|
+
);
|
|
4367
|
+
writeConfig({ dbUrl: adapter.connectionUrl });
|
|
4368
|
+
} else {
|
|
4369
|
+
dbSpinner.succeed(
|
|
4370
|
+
chalk5.green(
|
|
4371
|
+
`Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`
|
|
4372
|
+
)
|
|
4373
|
+
);
|
|
4374
|
+
}
|
|
4375
|
+
addSavedConnection(adapter.connectionUrl);
|
|
4111
4376
|
} catch (err) {
|
|
4112
4377
|
dbSpinner.fail(chalk5.red(`Failed to connect to database: ${err.message}`));
|
|
4113
4378
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sandal-db",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "SANDAL - Safe Agentic Natural-language Database Access Layer. Production-grade agentic database assistant CLI using LangGraph and LLMs (Google Gemini, OpenAI, Anthropic)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|