sandal-db 1.0.5 → 1.0.7

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.
Files changed (3) hide show
  1. package/README.md +6 -4
  2. package/dist/cli.js +236 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SANDAL: Safe Agentic Natural-language Database Access Layer
2
2
 
3
- ![SANDAL](image.png)
3
+ ![SANDAL](https://raw.githubusercontent.com/umerkang66/sandal/refs/heads/main/image.png)
4
4
 
5
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.
6
6
 
@@ -138,7 +138,7 @@ npx sandal-db@latest md [options] [fileOrText]
138
138
  | `--no-strict` | `flag` | Disables strict safety mode. | `false` |
139
139
  | `--allow-full-wipe` | `flag` | Explicitly permits database-level drops after interactive phrase confirmation. | `false` |
140
140
  | `--threshold <number>` | `integer` | Row count threshold above which updates are classified as dangerous operations. | `50` |
141
- | `--markdown` | `boolean` | Formats and renders LLM responses as rich terminal markdown. | `true` |
141
+ | `--markdown` | `boolean` | Formats and renders LLM responses as rich terminal markdown. | `true` |
142
142
  | `--no-markdown` | `flag` | Disables terminal markdown rendering and outputs raw text. | `false` |
143
143
  | `-V, --version` | `flag` | Output the version number. | |
144
144
  | `-h, --help` | `flag` | Display command help and exit. | |
@@ -169,10 +169,10 @@ Dot commands provide direct utility functions without issuing requests to the LL
169
169
  | --------------------------- | ----------------------------------------------------------------------------------------------------- |
170
170
  | `.tables` or `.collections` | Lists all discovered database tables or MongoDB collections with current row counts. |
171
171
  | `.schema [name]` | Displays column names, data types, nullability constraints, and primary keys for the specified table. |
172
- | `.connect` or `.switch` | Shows saved database connections; select by number or enter a new URL (preserves chat memory). |
172
+ | `.connect` or `.switch` | Shows saved database connections; select by number or enter a new URL (preserves chat memory). |
173
173
  | `.model [name]` | Displays current model or switches to a new model on the fly. |
174
174
  | `.provider [name]` | Switches active LLM provider (`google`, `openai`, or `anthropic`). |
175
- | `.key [remove \| set]` | Displays active API key, removes it from config, or updates it live. |
175
+ | `.key [remove \| set]` | Displays active API key, removes it from config, or updates it live. |
176
176
  | `.chats` | Lists all saved chat sessions with message counts and last active times. |
177
177
  | `.chat <id \| number>` | Switches to a specific chat session and restores its conversation memory. |
178
178
  | `.new` | Starts a fresh chat session with clean memory. |
@@ -271,6 +271,7 @@ Select a connection [number, N, C]: 2
271
271
  When you switch databases, your **conversation history and chat memory are preserved**, allowing you to continue querying or comparing insights across databases.
272
272
 
273
273
  From the command line:
274
+
274
275
  ```bash
275
276
  # List all saved database connections
276
277
  npx sandal-db config --connections
@@ -290,6 +291,7 @@ Modify your AI configuration during an active session:
290
291
  - `.key set <new-key>` — Update your API key dynamically.
291
292
 
292
293
  From the command line:
294
+
293
295
  ```bash
294
296
  # Remove stored API key for a specific provider
295
297
  npx sandal-db config --remove-key google
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
- var { Pool } = pg;
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
- const res = await client.query("SELECT current_database() as db_name;");
567
- if (res.rows[0]?.db_name) {
568
- this.databaseName = res.rows[0].db_name;
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
- } finally {
571
- client.release();
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,
@@ -3214,7 +3396,9 @@ Execution failed: ${err.message}
3214
3396
  const spinner = ora(`Connecting to ${maskUrl(urlToConnect)}...`).start();
3215
3397
  try {
3216
3398
  const newAdapter = createDatabaseAdapter(urlToConnect);
3217
- await newAdapter.connect();
3399
+ await newAdapter.connect((status) => {
3400
+ spinner.text = status;
3401
+ });
3218
3402
  await newAdapter.inspectSchema(true);
3219
3403
  try {
3220
3404
  await this.adapter.disconnect();
@@ -3222,12 +3406,20 @@ Execution failed: ${err.message}
3222
3406
  }
3223
3407
  this.adapter = newAdapter;
3224
3408
  this.initAgent();
3225
- addSavedConnection(urlToConnect);
3226
- spinner.succeed(
3227
- chalk4.green(
3228
- `Switched to ${newAdapter.type.toUpperCase()} database: ${newAdapter.databaseName} (${newAdapter.getMaskedUrl()})`
3229
- )
3230
- );
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
+ }
3231
3423
  console.log(
3232
3424
  chalk4.gray(
3233
3425
  `Chat session [${this.sessionId}] continuing with conversation memory on the new database.
@@ -4158,13 +4350,29 @@ No API key found for ${provider.toUpperCase()} in environment or config.`)
4158
4350
  ).start();
4159
4351
  const adapter = createDatabaseAdapter(dbUrl);
4160
4352
  try {
4161
- await adapter.connect();
4162
- dbSpinner.succeed(
4163
- chalk5.green(
4164
- `Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`
4165
- )
4166
- );
4167
- addSavedConnection(dbUrl);
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);
4168
4376
  } catch (err) {
4169
4377
  dbSpinner.fail(chalk5.red(`Failed to connect to database: ${err.message}`));
4170
4378
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandal-db",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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": {