feat(cli): migrate CLI to native Rust binary

This commit is contained in:
t8y2
2026-07-18 22:29:29 +08:00
parent fa5579a3f1
commit 4bcf618162
47 changed files with 2369 additions and 1255 deletions
+169 -31
View File
@@ -77,6 +77,12 @@ jobs:
"packages/mongo-shell/package.json",
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/cli-darwin-arm64/package.json",
"packages/cli-darwin-x64/package.json",
"packages/cli-linux-arm64-gnu/package.json",
"packages/cli-linux-x64-gnu/package.json",
"packages/cli-win32-arm64/package.json",
"packages/cli-win32-x64/package.json",
"packages/mcp-server/package.json",
"packages/mcp-darwin-arm64/package.json",
"packages/mcp-darwin-x64/package.json",
@@ -99,6 +105,15 @@ jobs:
}
writeJson(mcpPackagePath, mcpPackage);
const cliPackagePath = "packages/cli/package.json";
const cliPackage = readJson(cliPackagePath);
for (const dependency of Object.keys(cliPackage.optionalDependencies ?? {})) {
if (dependency.startsWith("@dbx-app/cli-")) {
cliPackage.optionalDependencies[dependency] = version;
}
}
writeJson(cliPackagePath, cliPackage);
const lockPath = "pnpm-lock.yaml";
let lockfile = fs.readFileSync(lockPath, "utf8");
for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) {
@@ -106,6 +121,11 @@ jobs:
const specifierPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+`);
lockfile = lockfile.replace(specifierPattern, `$1${version}`);
}
for (const dependency of Object.keys(cliPackage.optionalDependencies ?? {})) {
const escapedDependency = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const specifierPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+`);
lockfile = lockfile.replace(specifierPattern, `$1${version}`);
}
fs.writeFileSync(lockPath, lockfile);
const serverPath = "packages/mcp-server/server.json";
@@ -122,12 +142,16 @@ jobs:
const cargo = fs.readFileSync(cargoPath, "utf8").replace(/^version = "[^"]+"/m, `version = "${version}"`);
fs.writeFileSync(cargoPath, cargo);
const cliCargoPath = "crates/dbx-cli/Cargo.toml";
const cliCargo = fs.readFileSync(cliCargoPath, "utf8").replace(/^version = "[^"]+"/m, `version = "${version}"`);
fs.writeFileSync(cliCargoPath, cliCargo);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=packages-v${version}\n`);
NODE
- name: Update Rust lockfile
run: cargo check -p dbx-mcp --no-default-features
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
- name: Run package tests
run: pnpm test:packages
@@ -143,7 +167,7 @@ jobs:
- name: Commit package release version
run: |
VERSION="${{ steps.version.outputs.version }}"
git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json
git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml crates/dbx-cli/Cargo.toml packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/cli-*/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json
if git diff --cached --quiet; then
echo "Package versions already committed for ${VERSION}."
else
@@ -275,10 +299,129 @@ jobs:
pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks
echo "published_or_existing=true" >> "$GITHUB_OUTPUT"
publish-cli-platforms:
name: Publish ${{ matrix.package-name }}
needs: prepare
runs-on: ${{ matrix.runner }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
package-dir: cli-darwin-arm64
package-name: "@dbx-app/cli-darwin-arm64"
binary: dbx
- runner: macos-15-intel
target: x86_64-apple-darwin
package-dir: cli-darwin-x64
package-name: "@dbx-app/cli-darwin-x64"
binary: dbx
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
package-dir: cli-linux-arm64-gnu
package-name: "@dbx-app/cli-linux-arm64-gnu"
binary: dbx
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
package-dir: cli-linux-x64-gnu
package-name: "@dbx-app/cli-linux-x64-gnu"
binary: dbx
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
package-dir: cli-win32-arm64
package-name: "@dbx-app/cli-win32-arm64"
binary: dbx.exe
- runner: windows-2025
target: x86_64-pc-windows-msvc
package-dir: cli-win32-x64
package-name: "@dbx-app/cli-win32-x64"
binary: dbx.exe
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: dbx-cli-${{ matrix.target }}
cache-targets: false
cache-on-failure: true
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libfontconfig1-dev
- name: Build Rust CLI binary
shell: bash
run: cargo build --release -p dbx-cli --no-default-features --target "${{ matrix.target }}"
- name: Stage platform package
shell: bash
run: |
mkdir -p "packages/${{ matrix.package-dir }}/bin"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
if [[ "${{ runner.os }}" != "Windows" ]]; then chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"; fi
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Publish platform package
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
exit 0
fi
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
publish-cli:
name: Publish @dbx-app/cli
runs-on: ubuntu-latest
needs: [prepare, publish-node-core]
needs: [prepare, publish-cli-platforms]
steps:
- uses: actions/checkout@v5
@@ -296,22 +439,35 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsecret-1-dev
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace dependencies
run: pnpm --filter @dbx-app/node-core build
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Publish package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
wait_for_package() {
package="$1"
for attempt in $(seq 1 18); do
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then return 0; fi
echo "Waiting for ${package}@${VERSION} to become visible on npm (${attempt}/18)."
sleep 10
done
return 1
}
for package in \
@dbx-app/cli-darwin-arm64 \
@dbx-app/cli-darwin-x64 \
@dbx-app/cli-linux-arm64-gnu \
@dbx-app/cli-linux-x64-gnu \
@dbx-app/cli-win32-arm64 \
@dbx-app/cli-win32-x64; do
wait_for_package "${package}" || {
echo "${package}@${VERSION} is not available on npm; refusing to publish @dbx-app/cli."
exit 1
}
done
if npm view "@dbx-app/cli@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/cli@${VERSION} already exists on npm; skipping."
exit 0
@@ -648,30 +804,12 @@ jobs:
sha256 "__CLI_SHA256__"
license "Apache-2.0"
depends_on "node"
on_linux do
depends_on "pkgconf" => :build
depends_on "libsecret"
end
depends_on "node"
def install
system "npm", "install", *std_npm_args
bin.install_symlink libexec.glob("bin/*")
# Rebuild better-sqlite3 and keytar native bindings for the current platform.
# prebuild-install is blocked by the Homebrew sandbox during npm install,
# so we must rebuild them explicitly via node-gyp.
node_modules = libexec/"lib/node_modules/@dbx-app/cli/node_modules"
cd node_modules/"better-sqlite3" do
system "npm", "run", "build-release"
end
cd node_modules/"keytar" do
rm_r "prebuilds" if File.directory?("prebuilds")
system "npm", "run", "build"
end
end
test do
Generated
+14 -1
View File
@@ -1858,6 +1858,19 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "dbx-cli"
version = "0.4.34"
dependencies = [
"async-trait",
"dbx-core",
"dbx-mcp",
"serde",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "dbx-core"
version = "0.1.0"
@@ -1877,6 +1890,7 @@ dependencies = [
"font-kit",
"futures",
"iana-time-zone",
"json5",
"jsonwebtoken",
"log",
"mongodb",
@@ -1915,7 +1929,6 @@ version = "0.4.34"
dependencies = [
"async-trait",
"dbx-core",
"json5",
"reqwest 0.12.28",
"rmcp",
"schemars 1.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp"]
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"]
[patch.crates-io]
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" }
+1
View File
@@ -446,6 +446,7 @@ export const mongoDropDatabase = forward("mongoDropDatabase");
export const mongoDropCollection = forward("mongoDropCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoParseShellCommand = forward("mongoParseShellCommand");
export const mongoFindOne = forward("mongoFindOne");
export const mongoCountDocuments = forward("mongoCountDocuments");
export const mongoServerVersion = forward("mongoServerVersion");
+6
View File
@@ -37,6 +37,7 @@ import type {
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -2071,6 +2072,11 @@ export async function mongoFindDocuments(connectionId: string, database: string,
return documentFindDocuments(connectionId, database, collection, skip, limit, filter, projection, sort, executionId);
}
export async function mongoParseShellCommand(source: string): Promise<MongoCommand> {
const raw = await post<Record<string, unknown>>("/api/mongo/parse-shell-command", { source });
return normalizeRustMongoCommand(raw);
}
export async function mongoFindOne(connectionId: string, database: string, collection: string, filter?: string, projection?: string, options?: string, executionId?: string): Promise<MongoDocumentResult> {
return post("/api/mongo/find-one", { connectionId, database, collection, filter, projection, options, executionId });
}
+6
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import type {
ConnectionConfig,
ConnectionTestResult,
@@ -1858,6 +1859,11 @@ export async function mongoFindOne(connectionId: string, database: string, colle
return invoke("mongo_find_one", { connectionId, database, collection, filter, projection, options, executionId });
}
export async function mongoParseShellCommand(source: string): Promise<MongoCommand> {
const raw = await invoke<Record<string, unknown>>("mongo_parse_shell_command", { source });
return normalizeRustMongoCommand(raw);
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, executionId?: string): Promise<MongoDocumentResult> {
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
}
@@ -90,6 +90,20 @@ export type MongoCommand =
export type MongoWriteCommand = Extract<MongoCommand, { kind: MongoWriteKind }>;
export function normalizeRustMongoCommand(raw: Record<string, unknown>): MongoCommand {
const command = Object.fromEntries(Object.entries(raw).filter(([, value]) => value !== null)) as Record<string, any>;
if (command.kind === "countDocuments") {
const { accurate, ...rest } = command;
return { ...rest, kind: "countDocuments", mode: accurate ? "accurate" : "legacy" } as MongoCommand;
}
if (command.kind === "dropIndexes") {
const { single, indexes, ...rest } = command;
if (single) return { ...rest, kind: "dropIndex", index: indexes } as MongoCommand;
return { ...rest, kind: "dropIndexes", ...(indexes ? { indexes } : {}) } as MongoCommand;
}
return command as MongoCommand;
}
export interface ParsedMongoCommand {
text: string;
command: MongoCommand;
+4 -1
View File
@@ -2932,7 +2932,7 @@ export const useQueryStore = defineStore("query", () => {
let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined;
for (const parsedCommand of mongoCommands) {
const mongoCommand = parsedCommand.command;
let mongoCommand = parsedCommand.command;
const sourceStatement = parsedCommand.text;
const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to };
const commandStartedAt = performance.now();
@@ -2944,6 +2944,9 @@ export const useQueryStore = defineStore("query", () => {
return annotated;
};
try {
// The frontend parser remains responsible for editor ranges, while
// dbx-core is authoritative for command semantics at execution time.
mongoCommand = await api.mongoParseShellCommand(sourceStatement);
switch (mongoCommand.kind) {
case "find": {
queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase });
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dbx-cli"
version = "0.4.34"
edition = "2021"
license = "Apache-2.0"
[[bin]]
name = "dbx"
path = "src/main.rs"
[dependencies]
dbx-core = { path = "../dbx-core", default-features = false }
dbx-mcp = { path = "../dbx-mcp" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
[dev-dependencies]
async-trait = "0.1"
tempfile = "3"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -40,6 +40,7 @@ sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
json5 = "0.4"
regex = "1"
rayon = "1"
percent-encoding = "2"
+1
View File
@@ -31,6 +31,7 @@ pub mod history;
pub mod jdbc;
pub mod models;
pub mod mongo_ops;
pub mod mongo_shell;
#[cfg(feature = "mq-admin")]
pub mod mq;
pub mod nacos;
@@ -1,20 +1,57 @@
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind")]
pub enum MongoCommand {
#[serde(rename = "version")]
Version,
#[serde(rename = "use")]
Use { database: String },
#[serde(rename = "find")]
Find { collection: String, filter: String, projection: Option<String>, sort: Option<String>, skip: u64, limit: i64 },
#[serde(rename = "findOne")]
FindOne { collection: String, filter: String, projection: Option<String>, options: Option<String> },
#[serde(rename = "countDocuments")]
Count { collection: String, filter: String, accurate: bool },
#[serde(rename = "aggregate")]
Aggregate { collection: String, pipeline: String, options: Option<String> },
#[serde(rename = "distinct")]
Distinct { collection: String, field: String, filter: Option<String> },
#[serde(rename = "getIndexes")]
GetIndexes { collection: String },
#[serde(rename = "collectionStats")]
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
Insert { collection: String, documents: String },
#[serde(rename = "insert")]
Insert {
collection: String,
#[serde(rename = "docsJson")]
documents: String,
},
#[serde(rename = "update")]
Update { collection: String, filter: String, update: String, options: Option<String>, many: bool },
#[serde(rename = "delete")]
Delete { collection: String, filter: String, many: bool },
#[serde(rename = "createIndex")]
CreateIndex { collection: String, keys: String, options: Option<String> },
#[serde(rename = "dropIndexes")]
DropIndexes { collection: String, indexes: Option<String>, single: bool },
#[serde(rename = "dropCollection")]
DropCollection { collection: String },
#[serde(rename = "findOneAndUpdate")]
FindOneAndUpdate { collection: String, filter: String, update: String, options: Option<String> },
#[serde(rename = "findOneAndReplace")]
FindOneAndReplace { collection: String, filter: String, replacement: String, options: Option<String> },
#[serde(rename = "findOneAndDelete")]
FindOneAndDelete { collection: String, filter: String, options: Option<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MongoSafetyError {
WritesDisabled,
EmptyFilter,
Dangerous,
ProductionWrite,
}
impl MongoCommand {
@@ -27,6 +64,9 @@ impl MongoCommand {
| Self::CreateIndex { .. }
| Self::DropIndexes { .. }
| Self::DropCollection { .. }
| Self::FindOneAndUpdate { .. }
| Self::FindOneAndReplace { .. }
| Self::FindOneAndDelete { .. }
) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
@@ -38,17 +78,45 @@ impl MongoCommand {
pub fn has_empty_filter(&self) -> bool {
match self {
Self::Update { filter, .. } | Self::Delete { filter, .. } => is_empty_object(filter),
Self::Update { filter, .. }
| Self::Delete { filter, .. }
| Self::FindOneAndUpdate { filter, .. }
| Self::FindOneAndReplace { filter, .. }
| Self::FindOneAndDelete { filter, .. } => is_empty_object(filter),
_ => false,
}
}
}
pub fn validate_safety(
command: &MongoCommand,
allow_writes: bool,
allow_dangerous: bool,
production_database: bool,
) -> Result<(), MongoSafetyError> {
if command.is_mutating() && !allow_writes {
return Err(MongoSafetyError::WritesDisabled);
}
if command.has_empty_filter() && !allow_dangerous {
return Err(MongoSafetyError::EmptyFilter);
}
if command.is_dangerous() && !allow_dangerous {
return Err(MongoSafetyError::Dangerous);
}
if command.is_mutating() && production_database {
return Err(MongoSafetyError::ProductionWrite);
}
Ok(())
}
pub fn parse(input: &str) -> Result<MongoCommand, String> {
let source = input.trim().trim_end_matches(';').trim();
if source.eq_ignore_ascii_case("db.version()") {
return Ok(MongoCommand::Version);
}
if let Some(database) = parse_use_database(source) {
return Ok(MongoCommand::Use { database });
}
let (collection, prefix_end) = parse_collection_prefix(source)?;
if let Some((args, tail)) = method_call(source, prefix_end, "find") {
@@ -75,6 +143,44 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
return Ok(MongoCommand::Find { collection, filter, projection, sort, skip, limit });
}
if let Some((args, tail)) = method_call(source, prefix_end, "findOne") {
if !tail.is_empty() || args.len() > 3 {
return Err("Invalid MongoDB findOne() command.".to_string());
}
return Ok(MongoCommand::FindOne {
collection,
filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?,
projection: optional_json_argument(args.get(1))?,
options: optional_json_argument(args.get(2))?,
});
}
for method in ["findOneAndUpdate", "findOneAndReplace"] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err(format!("Invalid MongoDB {method}() command."));
}
let filter = normalized_json(&args[0])?;
let value = normalized_json(&args[1])?;
let options = optional_json_argument(args.get(2))?;
return Ok(if method == "findOneAndUpdate" {
MongoCommand::FindOneAndUpdate { collection, filter, update: value, options }
} else {
MongoCommand::FindOneAndReplace { collection, filter, replacement: value, options }
});
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "findOneAndDelete") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB findOneAndDelete() command.".to_string());
}
return Ok(MongoCommand::FindOneAndDelete {
collection,
filter: normalized_json(&args[0])?,
options: optional_json_argument(args.get(1))?,
});
}
for (method, accurate) in [("countDocuments", true), ("count", false)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || args.len() > 1 {
@@ -152,6 +258,18 @@ pub fn parse(input: &str) -> Result<MongoCommand, String> {
}
return Ok(MongoCommand::Insert { collection, documents });
}
// MongoDB keeps insert() for legacy shell compatibility; preserve its
// single-document-or-array contract without silently ignoring options.
if let Some((args, tail)) = method_call(source, prefix_end, "insert") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insert() command.".to_string());
}
let documents = normalized_json(&args[0])?;
if !parse_json_value(&documents).is_some_and(|value| value.is_object() || value.is_array()) {
return Err("MongoDB insert() requires a document or document array.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents });
}
for (method, many) in [("updateOne", false), ("updateMany", true)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
@@ -338,6 +456,25 @@ fn parse_json_value(value: &str) -> Option<Value> {
serde_json::from_str(value).ok()
}
fn optional_json_argument(value: Option<&String>) -> Result<Option<String>, String> {
value.filter(|value| !value.trim().is_empty()).map(|value| normalized_json(value)).transpose()
}
fn parse_use_database(source: &str) -> Option<String> {
let mut parts = source.split_whitespace();
if !parts.next()?.eq_ignore_ascii_case("use") {
return None;
}
let database = parts.next()?;
if parts.next().is_some()
|| database.is_empty()
|| !database.chars().all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return None;
}
Some(database.to_string())
}
fn is_empty_object(value: &str) -> bool {
parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty()))
}
@@ -480,6 +617,61 @@ mod tests {
assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. }));
}
#[test]
fn accepts_legacy_insert_and_rejects_unsupported_options() {
assert_eq!(
parse(r#"db.getCollection("accounting_reconciliations").insert({accountId: 999, status: "done"})"#)
.unwrap(),
MongoCommand::Insert {
collection: "accounting_reconciliations".to_string(),
documents: r#"{"accountId":999,"status":"done"}"#.to_string(),
}
);
assert_eq!(
parse("db.products.insert([{name: 'first'}, {name: 'second'}])").unwrap(),
MongoCommand::Insert {
collection: "products".to_string(),
documents: r#"[{"name":"first"},{"name":"second"}]"#.to_string(),
}
);
assert!(parse("db.products.insert({name: 'demo'}, {writeConcern: {w: 1}})").is_err());
assert!(parse("db.products.insert()").is_err());
assert!(parse("db.products.insert('demo')").is_err());
}
#[test]
fn parses_desktop_find_one_find_and_modify_and_use_commands() {
assert_eq!(
parse("db.users.findOne({name: 'Ada'}, {_id: 0}, {maxTimeMS: 500})").unwrap(),
MongoCommand::FindOne {
collection: "users".to_string(),
filter: r#"{"name":"Ada"}"#.to_string(),
projection: Some(r#"{"_id":0}"#.to_string()),
options: Some(r#"{"maxTimeMS":500}"#.to_string()),
}
);
assert!(matches!(
parse("db.users.findOneAndUpdate({_id: 1}, {$set: {active: true}}, {returnDocument: 'after'})").unwrap(),
MongoCommand::FindOneAndUpdate { options: Some(_), .. }
));
assert!(matches!(
parse("db.users.findOneAndReplace({_id: 1}, {name: 'Grace'})").unwrap(),
MongoCommand::FindOneAndReplace { .. }
));
assert!(matches!(parse("db.users.findOneAndDelete({_id: 1})").unwrap(), MongoCommand::FindOneAndDelete { .. }));
assert_eq!(parse("use analytics-test").unwrap(), MongoCommand::Use { database: "analytics-test".to_string() });
}
#[test]
fn serializes_frontend_command_contract() {
let insert = serde_json::to_value(parse("db.items.insert({_id: 1})").unwrap()).unwrap();
assert_eq!(insert["kind"], "insert");
assert_eq!(insert["docsJson"], r#"{"_id":1}"#);
let count = serde_json::to_value(parse("db.items.count({})").unwrap()).unwrap();
assert_eq!(count["kind"], "countDocuments");
assert_eq!(count["accurate"], false);
}
#[test]
fn accepts_stats_and_rejects_negative_pagination() {
assert!(matches!(
-1
View File
@@ -13,7 +13,6 @@ async-trait = "0.1"
dbx-core = { path = "../dbx-core", default-features = false }
rmcp = { version = "2.2.0", features = ["client", "transport-io"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
json5 = "0.4"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+296 -72
View File
@@ -53,6 +53,17 @@ pub trait DbxBackend: Send + Sync {
arguments: Value,
permissions: AgentSqlPermissions,
) -> ToolResult;
async fn execute_query(
&self,
connection: &ConnectionConfig,
database: &str,
sql: &str,
max_rows: Option<usize>,
timeout_secs: Option<u64>,
) -> Result<dbx_core::db::QueryResult, String> {
let _ = (connection, database, sql, max_rows, timeout_secs);
Err("SQL queries are not supported by this backend.".to_string())
}
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String>;
async fn list_tables(
&self,
@@ -88,7 +99,7 @@ pub trait DbxBackend: Send + Sync {
connection: &ConnectionConfig,
database: &str,
command: &MongoCommand,
) -> Result<String, String> {
) -> Result<dbx_core::db::QueryResult, String> {
let _ = (connection, database, command);
Err("MongoDB shell commands are not supported by this backend.".to_string())
}
@@ -255,6 +266,26 @@ impl DbxBackend for LocalBackend {
agent_tools::execute_tool(&call, &self.state, &connection.id, database, &connection.db_type, permissions).await
}
async fn execute_query(
&self,
connection: &ConnectionConfig,
database: &str,
sql: &str,
max_rows: Option<usize>,
timeout_secs: Option<u64>,
) -> Result<dbx_core::db::QueryResult, String> {
dbx_core::query::execute_sql_statement_with_options(
&self.state,
&connection.id,
database,
sql,
None,
None,
dbx_core::query::QueryExecutionOptions { max_rows, timeout_secs, ..Default::default() },
)
.await
}
async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String> {
self.state.storage.save_connections(connections).await?;
*self.state.configs.write().await =
@@ -303,14 +334,15 @@ impl DbxBackend for LocalBackend {
connection: &ConnectionConfig,
database: &str,
command: &MongoCommand,
) -> Result<String, String> {
) -> Result<dbx_core::db::QueryResult, String> {
use dbx_core::mongo_ops;
let connection_id = &connection.id;
match command {
MongoCommand::Version => mongo_ops::mongo_server_version_core(&self.state, connection_id, database)
.await
.map(|version| format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version))),
.map(|version| scalar_query_result("version", Value::String(version))),
MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))),
MongoCommand::Find { collection, filter, projection, sort, skip, limit } => {
let result = mongo_ops::mongo_find_documents_core(
&self.state,
@@ -324,7 +356,20 @@ impl DbxBackend for LocalBackend {
sort.as_deref(),
)
.await?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOne { collection, filter, projection, options } => {
let result = mongo_ops::mongo_find_one_core(
&self.state,
connection_id,
database,
collection,
Some(filter),
projection.as_deref(),
options.as_deref(),
)
.await?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::Count { collection, filter, accurate } => {
let mode = if *accurate { "accurate" } else { "legacy" };
@@ -337,7 +382,7 @@ impl DbxBackend for LocalBackend {
Some(mode),
)
.await?;
Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)"))
Ok(scalar_query_result("count", Value::from(total)))
}
MongoCommand::Aggregate { collection, pipeline, options } => {
let result = mongo_ops::mongo_aggregate_documents_core(
@@ -350,7 +395,7 @@ impl DbxBackend for LocalBackend {
options.as_deref(),
)
.await?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::Distinct { collection, field, filter } => {
let result = mongo_ops::mongo_distinct_core(
@@ -362,7 +407,7 @@ impl DbxBackend for LocalBackend {
filter.as_deref(),
)
.await?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::GetIndexes { collection } => {
let result = mongo_ops::mongo_aggregate_documents_core(
@@ -375,7 +420,7 @@ impl DbxBackend for LocalBackend {
None,
)
.await?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::CollectionStats { collection, metric, scale } => {
let stats = mongo_ops::mongo_collection_stats_core(
@@ -388,7 +433,7 @@ impl DbxBackend for LocalBackend {
.await?;
let value = serde_json::to_value(stats).map_err(|error| error.to_string())?;
if metric == "stats" {
Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()))
Ok(mongo_documents_query_result(vec![value]))
} else {
let key = match metric.as_str() {
"dataSize" => "size",
@@ -397,14 +442,14 @@ impl DbxBackend for LocalBackend {
_ => metric,
};
let metric_value = value.get(key).cloned().unwrap_or(Value::Null);
Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value)))
Ok(scalar_query_result(metric, metric_value))
}
}
MongoCommand::Insert { collection, documents } => {
let affected =
mongo_ops::mongo_insert_documents_core(&self.state, connection_id, database, collection, documents)
.await?;
Ok(format!("Query executed. {affected} row(s) affected."))
Ok(affected_query_result(affected))
}
MongoCommand::Update { collection, filter, update, options, many } => {
let affected = mongo_ops::mongo_update_documents_core(
@@ -418,7 +463,7 @@ impl DbxBackend for LocalBackend {
options.as_deref(),
)
.await?;
Ok(format!("Query executed. {affected} row(s) affected."))
Ok(affected_query_result(affected))
}
MongoCommand::Delete { collection, filter, many } => {
let affected = mongo_ops::mongo_delete_documents_core(
@@ -430,7 +475,7 @@ impl DbxBackend for LocalBackend {
*many,
)
.await?;
Ok(format!("Query executed. {affected} row(s) affected."))
Ok(affected_query_result(affected))
}
MongoCommand::CreateIndex { collection, keys, options } => {
let name = mongo_ops::mongo_create_index_core(
@@ -442,7 +487,7 @@ impl DbxBackend for LocalBackend {
options.as_deref(),
)
.await?;
Ok(format!("| name |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&name)))
Ok(scalar_query_result("name", Value::String(name)))
}
MongoCommand::DropIndexes { collection, indexes, single } => {
let result = mongo_ops::mongo_drop_indexes_core(
@@ -454,11 +499,54 @@ impl DbxBackend for LocalBackend {
*single,
)
.await?;
Ok(format!("Query executed. {} row(s) affected.", result.affected_rows))
let rows = result.dropped_names.into_iter().map(|name| vec![Value::String(name)]).collect::<Vec<_>>();
Ok(query_result(
if rows.is_empty() { Vec::new() } else { vec!["name".to_string()] },
rows,
result.affected_rows,
))
}
MongoCommand::DropCollection { collection } => {
mongo_ops::mongo_drop_collection_core(&self.state, connection_id, database, collection).await?;
Ok("Query executed. 1 row(s) affected.".to_string())
Ok(affected_query_result(1))
}
MongoCommand::FindOneAndUpdate { collection, filter, update, options } => {
let result = mongo_ops::mongo_find_one_and_update_core(
&self.state,
connection_id,
database,
collection,
filter,
update,
options.as_deref(),
)
.await?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOneAndReplace { collection, filter, replacement, options } => {
let result = mongo_ops::mongo_find_one_and_replace_core(
&self.state,
connection_id,
database,
collection,
filter,
replacement,
options.as_deref(),
)
.await?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOneAndDelete { collection, filter, options } => {
let result = mongo_ops::mongo_find_one_and_delete_core(
&self.state,
connection_id,
database,
collection,
filter,
options.as_deref(),
)
.await?;
Ok(mongo_documents_query_result(result.documents))
}
}
}
@@ -532,6 +620,29 @@ impl DbxBackend for WebBackend {
}
}
async fn execute_query(
&self,
connection: &ConnectionConfig,
database: &str,
sql: &str,
_max_rows: Option<usize>,
_timeout_secs: Option<u64>,
) -> Result<dbx_core::db::QueryResult, String> {
if connection.db_type == DatabaseType::MongoDb {
return Err("MongoDB shell commands in DBX Web mode are not implemented by the Rust CLI yet.".to_string());
}
self.ensure_connected(connection).await?;
self.request(
reqwest::Method::POST,
"/api/query/execute",
Some(json!({ "connectionId": connection.id, "database": database, "sql": sql })),
)
.await?
.json()
.await
.map_err(|error| format!("Invalid query response: {error}"))
}
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
Err("Connection changes are unavailable in DBX Web mode.".to_string())
}
@@ -665,7 +776,7 @@ impl DbxBackend for WebBackend {
connection: &ConnectionConfig,
database: &str,
command: &MongoCommand,
) -> Result<String, String> {
) -> Result<dbx_core::db::QueryResult, String> {
self.ensure_connected(connection).await?;
let connection_id = &connection.id;
match command {
@@ -680,8 +791,9 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB version response: {error}"))?;
Ok(format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version)))
Ok(scalar_query_result("version", Value::String(version)))
}
MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))),
MongoCommand::Find { collection, filter, projection, sort, skip, limit } => {
let result = self
.request(
@@ -702,7 +814,27 @@ impl DbxBackend for WebBackend {
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB find response: {error}"))?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOne { collection, filter, projection, options } => {
let result = self
.request(
reqwest::Method::POST,
"/api/mongo/find-one",
Some(json!({
"connectionId": connection_id,
"database": database,
"collection": collection,
"filter": filter,
"projection": projection,
"options": options,
})),
)
.await?
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB findOne response: {error}"))?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::Count { collection, filter, accurate } => {
let total: u64 = self
@@ -721,7 +853,7 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB count response: {error}"))?;
Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)"))
Ok(scalar_query_result("count", Value::from(total)))
}
MongoCommand::Aggregate { collection, pipeline, options } => {
let result = self
@@ -741,7 +873,7 @@ impl DbxBackend for WebBackend {
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB aggregate response: {error}"))?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::Distinct { collection, field, filter } => {
let result = self
@@ -760,7 +892,7 @@ impl DbxBackend for WebBackend {
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB distinct response: {error}"))?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::GetIndexes { collection } => {
let result = self
@@ -779,7 +911,7 @@ impl DbxBackend for WebBackend {
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB indexes response: {error}"))?;
Ok(format_mongo_documents(&result.documents))
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::CollectionStats { collection, metric, scale } => {
let value: Value = self
@@ -798,7 +930,7 @@ impl DbxBackend for WebBackend {
.await
.map_err(|error| format!("Invalid MongoDB stats response: {error}"))?;
if metric == "stats" {
Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()))
Ok(mongo_documents_query_result(vec![value]))
} else {
let key = match metric.as_str() {
"dataSize" => "size",
@@ -807,7 +939,7 @@ impl DbxBackend for WebBackend {
_ => metric,
};
let metric_value = value.get(key).cloned().unwrap_or(Value::Null);
Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value)))
Ok(scalar_query_result(metric, metric_value))
}
}
MongoCommand::Insert { collection, documents } => {
@@ -826,7 +958,7 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB insert response: {error}"))?;
Ok(format_affected_rows(&value))
Ok(affected_query_result(affected_rows_from_value(&value)))
}
MongoCommand::Update { collection, filter, update, options, many } => {
let value: Value = self
@@ -847,7 +979,7 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB update response: {error}"))?;
Ok(format_affected_rows(&value))
Ok(affected_query_result(affected_rows_from_value(&value)))
}
MongoCommand::Delete { collection, filter, many } => {
let value: Value = self
@@ -866,7 +998,7 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB delete response: {error}"))?;
Ok(format_affected_rows(&value))
Ok(affected_query_result(affected_rows_from_value(&value)))
}
MongoCommand::CreateIndex { collection, keys, options } => {
let value: Value = self
@@ -885,9 +1017,9 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB create index response: {error}"))?;
Ok(format!(
"| name |\n| --- |\n| {} |\n\n1 row(s)",
escape_markdown_cell(value.get("name").and_then(Value::as_str).unwrap_or(""))
Ok(scalar_query_result(
"name",
Value::String(value.get("name").and_then(Value::as_str).unwrap_or("").to_string()),
))
}
MongoCommand::DropIndexes { collection, indexes, single } => {
@@ -907,7 +1039,20 @@ impl DbxBackend for WebBackend {
.json()
.await
.map_err(|error| format!("Invalid MongoDB drop indexes response: {error}"))?;
Ok(format_affected_rows(&value))
let dropped_names = value
.get("dropped_names")
.or_else(|| value.get("droppedNames"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(|name| vec![Value::String(name.to_string())])
.collect::<Vec<_>>();
Ok(query_result(
if dropped_names.is_empty() { Vec::new() } else { vec!["name".to_string()] },
dropped_names,
affected_rows_from_value(&value),
))
}
MongoCommand::DropCollection { collection } => {
self.request(
@@ -916,7 +1061,66 @@ impl DbxBackend for WebBackend {
Some(json!({ "connectionId": connection_id, "database": database, "collection": collection })),
)
.await?;
Ok("Query executed. 1 row(s) affected.".to_string())
Ok(affected_query_result(1))
}
MongoCommand::FindOneAndUpdate { collection, filter, update, options } => {
let result = self
.request(
reqwest::Method::POST,
"/api/mongo/find-one-and-update",
Some(json!({
"connectionId": connection_id,
"database": database,
"collection": collection,
"filterJson": filter,
"updateJson": update,
"optionsJson": options,
})),
)
.await?
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB findOneAndUpdate response: {error}"))?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOneAndReplace { collection, filter, replacement, options } => {
let result = self
.request(
reqwest::Method::POST,
"/api/mongo/find-one-and-replace",
Some(json!({
"connectionId": connection_id,
"database": database,
"collection": collection,
"filterJson": filter,
"replacementJson": replacement,
"optionsJson": options,
})),
)
.await?
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB findOneAndReplace response: {error}"))?;
Ok(mongo_documents_query_result(result.documents))
}
MongoCommand::FindOneAndDelete { collection, filter, options } => {
let result = self
.request(
reqwest::Method::POST,
"/api/mongo/find-one-and-delete",
Some(json!({
"connectionId": connection_id,
"database": database,
"collection": collection,
"filterJson": filter,
"optionsJson": options,
})),
)
.await?
.json::<WebMongoDocuments>()
.await
.map_err(|error| format!("Invalid MongoDB findOneAndDelete response: {error}"))?;
Ok(mongo_documents_query_result(result.documents))
}
}
}
@@ -938,7 +1142,7 @@ fn url_encode(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String {
pub(crate) fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String {
if result.columns.is_empty() {
return format!("Query executed. {} row(s) affected.", result.affected_rows);
}
@@ -953,6 +1157,64 @@ fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> S
output
}
fn query_result(columns: Vec<String>, rows: Vec<Vec<Value>>, affected_rows: u64) -> dbx_core::db::QueryResult {
dbx_core::db::QueryResult {
columns,
column_types: Vec::new(),
column_sortables: Vec::new(),
rows,
affected_rows,
execution_time_ms: 0,
truncated: false,
session_id: None,
has_more: false,
}
}
fn scalar_query_result(column: impl Into<String>, value: Value) -> dbx_core::db::QueryResult {
query_result(vec![column.into()], vec![vec![value]], 0)
}
fn affected_query_result(affected_rows: u64) -> dbx_core::db::QueryResult {
query_result(Vec::new(), Vec::new(), affected_rows)
}
fn mongo_documents_query_result(documents: Vec<Value>) -> dbx_core::db::QueryResult {
if documents.is_empty() {
return query_result(Vec::new(), Vec::new(), 0);
}
let mut columns = std::collections::BTreeSet::new();
for document in &documents {
if let Some(object) = document.as_object() {
columns.extend(object.keys().cloned());
} else {
columns.insert("value".to_string());
}
}
let columns = columns.into_iter().collect::<Vec<_>>();
let rows = documents
.into_iter()
.map(|document| {
columns
.iter()
.map(|column| {
document
.as_object()
.and_then(|object| object.get(column))
.cloned()
.or_else(|| (column == "value").then(|| document.clone()))
.unwrap_or(Value::Null)
})
.collect()
})
.collect();
query_result(columns, rows, 0)
}
fn affected_rows_from_value(value: &Value) -> u64 {
value.get("affected_rows").or_else(|| value.get("affectedRows")).and_then(Value::as_u64).unwrap_or(0)
}
fn format_query_cell(value: &Value) -> String {
match value {
Value::Null => "NULL".to_string(),
@@ -962,44 +1224,6 @@ fn format_query_cell(value: &Value) -> String {
}
}
fn format_mongo_documents(documents: &[Value]) -> String {
if documents.is_empty() {
return "Query returned 0 rows.".to_string();
}
let mut headers = std::collections::BTreeSet::new();
for document in documents {
if let Some(object) = document.as_object() {
headers.extend(object.keys().cloned());
} else {
headers.insert("value".to_string());
}
}
let headers = headers.into_iter().collect::<Vec<_>>();
let rows = documents
.iter()
.map(|document| {
headers
.iter()
.map(|header| {
document
.as_object()
.and_then(|object| object.get(header))
.or_else(|| (header == "value").then_some(document))
.map(format_query_cell)
.unwrap_or_else(|| "NULL".to_string())
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
format!("{}\n\n{} row(s)", markdown_table(&headers, &rows), documents.len())
}
fn format_affected_rows(value: &Value) -> String {
let affected =
value.get("affected_rows").or_else(|| value.get("affectedRows")).and_then(Value::as_u64).unwrap_or(0);
format!("Query executed. {affected} row(s) affected.")
}
fn markdown_table(headers: &[String], rows: &[Vec<String>]) -> String {
let mut output = format!(
"| {} |\n| {} |",
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod backend;
pub mod mongo;
pub mod paths;
pub mod server;
pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, WebBackend};
pub use dbx_core::mongo_shell as mongo;
pub use server::{DbxMcpServer, McpScope};
+26 -23
View File
@@ -9,8 +9,8 @@ use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::backend::{new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
use crate::mongo::{self, MongoCommand};
use crate::backend::{format_query_result, new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
use crate::mongo::{self, MongoCommand, MongoSafetyError};
use dbx_core::{
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
models::connection::DatabaseType,
@@ -262,7 +262,7 @@ impl DbxMcpServer {
Err(error) => return error,
};
return match self.backend.execute_mongo_command(&connection, &database, &command).await {
Ok(result) => text(result),
Ok(result) => text(format_query_result(&result, 100)),
Err(error) => tool_error("QUERY_ERROR", error),
};
}
@@ -644,26 +644,29 @@ fn validate_mongo_command(
)
})?;
let permissions = default_permissions();
if command.is_mutating() && !permissions.allow_writes {
return Err(tool_error(
"SQL_BLOCKED",
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
));
}
if command.has_empty_filter() && !permissions.allow_dangerous {
return Err(tool_error(
"SQL_BLOCKED",
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
));
}
if command.is_dangerous() && !permissions.allow_dangerous {
return Err(tool_error(
"SQL_BLOCKED",
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
));
}
if command.is_mutating() && is_production_database(connection, database) {
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
if let Err(error) = mongo::validate_safety(
&command,
permissions.allow_writes,
permissions.allow_dangerous,
is_production_database(connection, database),
) {
return Err(match error {
MongoSafetyError::WritesDisabled => tool_error(
"SQL_BLOCKED",
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
),
MongoSafetyError::EmptyFilter => tool_error(
"SQL_BLOCKED",
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
),
MongoSafetyError::Dangerous => tool_error(
"SQL_BLOCKED",
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
),
MongoSafetyError::ProductionWrite => {
tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database.")
}
});
}
Ok(command)
}
+1 -1
View File
@@ -78,7 +78,7 @@ async fn executes_mongo_shell_commands_without_desktop_process() {
std::env::set_var("DBX_MCP_ALLOW_WRITES", "1");
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
call_query(&client, "db.items.insertOne({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
call_query(&client, "db.items.insert({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
let result = call_query(&client, "db.items.find({_id: 'rust-mcp-e2e'}).limit(1)").await;
assert!(result.contains("Ada"), "unexpected MongoDB result: {result}");
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
+1
View File
@@ -508,6 +508,7 @@ async fn main() {
.route("/document-store/update-document", post(routes::document_store::update_document))
.route("/document-store/delete-document", post(routes::document_store::delete_document))
.route("/mongo/find-documents", post(routes::mongo::find_documents))
.route("/mongo/parse-shell-command", post(routes::mongo::parse_shell_command))
.route("/mongo/find-one", post(routes::mongo::find_one))
.route("/mongo/count-documents", post(routes::mongo::count_documents))
.route("/mongo/server-version", post(routes::mongo::server_version))
+12
View File
@@ -8,6 +8,18 @@ use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MongoParseShellCommandRequest {
source: String,
}
pub async fn parse_shell_command(
Json(req): Json<MongoParseShellCommandRequest>,
) -> Result<Json<dbx_core::mongo_shell::MongoCommand>, AppError> {
dbx_core::mongo_shell::parse(&req.source).map(Json).map_err(AppError)
}
async fn run_cancellable<T, F>(state: &Arc<WebState>, execution_id: Option<String>, future: F) -> Result<T, AppError>
where
F: Future<Output = Result<T, String>>,
+3 -9
View File
@@ -18,7 +18,7 @@ brew tap t8y2/dbx
brew install dbx-cli
```
需要 Node.js 22.13.0 或更高版本。
通过 npm 安装时需要 Node.js 18.18.0 或更高版本。CLI 本身不依赖 `better-sqlite3`,也不受 Node.js native module ABI 影响。Homebrew 用户不需要单独管理 Node.js。
查看版本:
@@ -52,13 +52,7 @@ dbx doctor
dbx doctor --json
```
如果切换 Node.js 版本后,`dbx doctor` 报 `NODE_MODULE_VERSION` 不匹配,请用运行 `dbx` 的同一个 Node.js 版本重建 native 依赖:
```bash
pnpm rebuild better-sqlite3 keytar --pending
```
如果是全局 npm 安装,使用同一个 Node.js 版本重新安装 CLI:
如果 npm 没有安装当前平台包,请不要使用 `--no-optional`,重新安装:
```bash
npm uninstall -g @dbx-app/cli
@@ -72,7 +66,7 @@ dbx capabilities
dbx capabilities --json
```
当前直接执行支持 PostgreSQL/Redshift、MySQL 兼容数据库(MySQL、Doris、StarRocks)和 SQLite。其它数据库类型在对应驱动加入 `@dbx-app/node-core` 前,会使用 DBX 桌面端 bridge。
当前直接执行支持 PostgreSQL/Redshift、MySQL 兼容数据库(MySQL、Doris、StarRocks)和 SQLite。其它数据库类型会使用 DBX 桌面端 bridge 或 DBX Agent/JDBC 基础设施。
## 默认连接
+3 -9
View File
@@ -20,7 +20,7 @@ brew tap t8y2/dbx
brew install dbx-cli
```
Requires Node.js 22.13.0 or newer.
Node.js 18.18.0 or newer is required for the npm launcher. The CLI itself does not depend on `better-sqlite3` or a Node.js native-module ABI. Homebrew users do not need to manage Node.js separately.
Check the installed version:
@@ -54,13 +54,7 @@ dbx doctor
dbx doctor --json
```
If `dbx doctor` reports a `NODE_MODULE_VERSION` mismatch after switching Node.js versions, rebuild the native dependencies with the Node.js version you use to run `dbx`:
```bash
pnpm rebuild better-sqlite3 keytar --pending
```
For global npm installs, reinstall the CLI with the same Node.js version:
If the optional platform package was not installed, reinstall without `--no-optional`:
```bash
npm uninstall -g @dbx-app/cli
@@ -74,7 +68,7 @@ dbx capabilities
dbx capabilities --json
```
Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge until their drivers are added to `@dbx-app/node-core`.
Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge or DBX Agent/JDBC infrastructure.
## Default Connection
+2
View File
@@ -14,6 +14,8 @@
"test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test",
"pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/mongo-shell pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check",
"verify:package-install": "node scripts/verify-package-install.mjs /tmp/dbx-pack-check",
"test:cli:migration": "node scripts/verify-cli-migration.mjs",
"benchmark:cli": "node scripts/benchmark-cli.mjs",
"publish:dry-run": "pnpm build:packages && pnpm pack:packages && pnpm verify:package-install",
"release": "node scripts/release.mjs",
"release:agents": "node scripts/release.mjs agents",
+16
View File
@@ -10,6 +10,7 @@ import {
mongoDistinctToQueryResult,
mongoDocumentsToQueryResult,
mongoIndexesToQueryResult,
normalizeRustMongoCommand,
parseMongoAggregateCommand,
parseMongoCollectionStatsCommand,
parseMongoCommand,
@@ -39,6 +40,21 @@ test("parseMongoFindCommand parses db collection find with an empty JSON filter"
});
});
test("normalizeRustMongoCommand preserves the desktop command contract", () => {
assert.deepEqual(
normalizeRustMongoCommand({ kind: "countDocuments", collection: "users", filter: "{}", accurate: false }),
{ kind: "countDocuments", collection: "users", filter: "{}", mode: "legacy" },
);
assert.deepEqual(
normalizeRustMongoCommand({ kind: "dropIndexes", collection: "users", indexes: '"email_1"', single: true }),
{ kind: "dropIndex", collection: "users", index: '"email_1"' },
);
assert.deepEqual(
normalizeRustMongoCommand({ kind: "findOne", collection: "users", filter: "{}", projection: null, options: null }),
{ kind: "findOne", collection: "users", filter: "{}" },
);
});
test("parseMongoFindCommand parses getCollection find with chained sort skip and limit", () => {
assert.deepEqual(parseMongoFindCommand('db.getCollection("audit.logs").find({"level":"warn"}).sort({"createdAt":-1}).skip(20).limit(10)'), {
collection: "audit.logs",
+20 -4
View File
@@ -5,6 +5,7 @@ import { isReactive } from "vue";
import { decodeQueryResultArchive } from "../../apps/desktop/src/lib/query/queryResultArchive.ts";
import { analyzeEditableQueryEditability } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts";
import { resultSqlForGrid } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts";
import { parseMongoCommand } from "../../apps/desktop/src/lib/mongo/mongoShellCommand.ts";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts";
import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
@@ -91,6 +92,21 @@ function withConnectionHealthMock(handler: typeof fetch): typeof fetch {
if (String(input) === "/api/connection/check-health") {
return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } });
}
if (String(input) === "/api/mongo/parse-shell-command") {
const source = JSON.parse(String(init?.body ?? "{}")).source as string;
const parsed = parseMongoCommand(source)?.command;
if (!parsed) return new Response("invalid MongoDB command", { status: 400 });
let command: Record<string, unknown> = parsed as unknown as Record<string, unknown>;
if (parsed.kind === "countDocuments") {
const { mode, ...rest } = parsed;
command = { ...rest, kind: "countDocuments", accurate: mode === "accurate" };
} else if (parsed.kind === "dropIndex") {
command = { kind: "dropIndexes", collection: parsed.collection, indexes: parsed.index, single: true };
} else if (parsed.kind === "dropIndexes") {
command = { ...parsed, single: false };
}
return new Response(JSON.stringify(command), { status: 200, headers: { "Content-Type": "application/json" } });
}
return handler(input, init);
};
}
@@ -3495,7 +3511,7 @@ test("mongo multi-command execution reconnects before running commands", async (
});
connectionStore.connectedIds.delete("mongo-1");
globalThis.fetch = async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/connection/connect") {
requests.push(url);
@@ -3514,7 +3530,7 @@ test("mongo multi-command execution reconnects before running commands", async (
});
}
return new Response("unexpected request", { status: 500 });
};
});
try {
const tabId = store.createTab("mongo-1", "accounting", "Query", "query", "");
@@ -3592,10 +3608,10 @@ test("mongo use-only execution updates the tab without reconnecting", async () =
});
connectionStore.connectedIds.delete("mongo-1");
globalThis.fetch = async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
requests.push(String(input));
return new Response("unexpected request", { status: 500 });
};
});
try {
const tabId = store.createTab("mongo-1", "accounting", "Query", "query", "");
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@dbx-app/cli-darwin-arm64",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for darwin arm64",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-darwin-arm64" },
"os": ["darwin"],
"cpu": ["arm64"],
"bin": { "dbx-native": "bin/dbx" },
"files": ["bin"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@dbx-app/cli-darwin-x64",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for darwin x64",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-darwin-x64" },
"os": ["darwin"],
"cpu": ["x64"],
"bin": { "dbx-native": "bin/dbx" },
"files": ["bin"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@dbx-app/cli-linux-arm64-gnu",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for Linux arm64 glibc",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-linux-arm64-gnu" },
"os": ["linux"],
"cpu": ["arm64"],
"libc": ["glibc"],
"bin": { "dbx-native": "bin/dbx" },
"files": ["bin"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@dbx-app/cli-linux-x64-gnu",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for Linux x64 glibc",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-linux-x64-gnu" },
"os": ["linux"],
"cpu": ["x64"],
"libc": ["glibc"],
"bin": { "dbx-native": "bin/dbx" },
"files": ["bin"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@dbx-app/cli-win32-arm64",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for Windows arm64",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-win32-arm64" },
"os": ["win32"],
"cpu": ["arm64"],
"bin": { "dbx-native": "bin/dbx.exe" },
"files": ["bin"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@dbx-app/cli-win32-x64",
"version": "0.4.34",
"description": "Precompiled DBX CLI binary for Windows x64",
"license": "Apache-2.0",
"repository": { "type": "git", "url": "https://github.com/t8y2/dbx", "directory": "packages/cli-win32-x64" },
"os": ["win32"],
"cpu": ["x64"],
"bin": { "dbx-native": "bin/dbx.exe" },
"files": ["bin"]
}
+5 -9
View File
@@ -17,7 +17,7 @@ brew tap t8y2/dbx
brew install dbx-cli
```
Requires Node.js 22.13.0 or newer.
The npm package installs the native CLI for the current platform automatically. Node.js 18.18.0 or newer is only needed for the npm launcher; direct native distributions do not require Node.js.
## Usage
@@ -90,23 +90,19 @@ Some CLI commands can run without DBX Desktop:
- `query`
- `context`
Direct execution currently supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge until their drivers are added to `@dbx-app/node-core`.
Direct execution supports PostgreSQL/Redshift, MySQL-compatible databases (MySQL, Doris, StarRocks), and SQLite. Other database types use the DBX Desktop bridge or DBX Agent/JDBC infrastructure.
Use `dbx doctor` to check whether the DBX connection database, connection table, native SQLite loader, and desktop bridge are available. Use `dbx capabilities` to list direct-query and bridge-required database types.
If `dbx doctor` reports a `NODE_MODULE_VERSION` mismatch after switching Node.js versions, rebuild the native dependencies with the Node.js version you use to run `dbx`:
```bash
pnpm rebuild better-sqlite3 keytar --pending
```
For global npm installs, reinstall the CLI with the same Node.js version:
If the optional platform package was not installed, reinstall without `--no-optional`:
```bash
npm uninstall -g @dbx-app/cli
npm install -g @dbx-app/cli
```
The native CLI does not require `better-sqlite3` and is not coupled to the Node.js ABI.
## Error Codes
CLI JSON errors use stable codes:
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const platformPackages = {
"darwin-arm64": "@dbx-app/cli-darwin-arm64",
"darwin-x64": "@dbx-app/cli-darwin-x64",
"linux-arm64": "@dbx-app/cli-linux-arm64-gnu",
"linux-x64": "@dbx-app/cli-linux-x64-gnu",
"win32-arm64": "@dbx-app/cli-win32-arm64",
"win32-x64": "@dbx-app/cli-win32-x64",
};
const platformKey = `${process.platform}-${process.arch}`;
const packageName = platformPackages[platformKey];
if (!packageName) {
console.error(`Unsupported platform: ${platformKey}`);
process.exit(1);
}
let binary;
try {
const packageJson = require.resolve(`${packageName}/package.json`);
binary = join(dirname(packageJson), "bin", process.platform === "win32" ? "dbx.exe" : "dbx");
} catch {
console.error(`The optional package ${packageName} was not installed. Reinstall @dbx-app/cli without --no-optional.`);
process.exit(1);
}
if (!existsSync(binary)) {
console.error(`DBX CLI binary was not found at ${binary}`);
process.exit(1);
}
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit", env: process.env });
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
process.exit(result.status ?? 1);
+14 -14
View File
@@ -21,27 +21,27 @@
"directory": "packages/cli"
},
"bin": {
"dbx": "dist/cli.js"
"dbx": "bin/dbx.js"
},
"files": [
"dist"
"bin"
],
"type": "module",
"scripts": {
"start": "tsx src/cli.ts",
"test": "pnpm --filter @dbx-app/node-core build && vitest run --config vitest.config.ts",
"build": "pnpm --filter @dbx-app/node-core build && tsc",
"prepublishOnly": "tsc"
"start": "cargo run -p dbx-cli --no-default-features --",
"test": "cargo test -p dbx-cli --no-default-features",
"build": "cargo build -p dbx-cli --release --no-default-features",
"prepublishOnly": "node --check bin/dbx.js"
},
"dependencies": {
"@dbx-app/node-core": "workspace:^"
},
"devDependencies": {
"@types/node": "^22.15.21",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"optionalDependencies": {
"@dbx-app/cli-darwin-arm64": "workspace:*",
"@dbx-app/cli-darwin-x64": "workspace:*",
"@dbx-app/cli-linux-arm64-gnu": "workspace:*",
"@dbx-app/cli-linux-x64-gnu": "workspace:*",
"@dbx-app/cli-win32-arm64": "workspace:*",
"@dbx-app/cli-win32-x64": "workspace:*"
},
"engines": {
"node": ">=22.13.0"
"node": ">=18.18.0"
}
}
-62
View File
@@ -1,62 +0,0 @@
import { formatCell, mdTable, type ConnectionConfig } from "@dbx-app/node-core";
export { formatCell, mdTable };
export interface ConnectionSummary {
name: string;
type: string;
host: string;
port: number;
database?: string;
}
export function connectionSummary(connection: ConnectionConfig): ConnectionSummary {
return {
name: connection.name,
type: connection.db_type,
host: connection.host,
port: connection.port,
database: connection.database || undefined,
};
}
export interface ErrorPayload {
error: {
code: string;
message: string;
hint?: string;
};
}
export function errorPayload(code: string, message: string): ErrorPayload {
const hint = errorHint(code, message);
return { error: hint ? { code, message, hint } : { code, message } };
}
export function formatErrorMessage(code: string, message: string): string {
const hint = errorHint(code, message);
return hint ? `${message}\n\nHint: ${hint}` : message;
}
function errorHint(code: string, message: string): string | undefined {
if (code === "CONNECTION_STORE_ERROR" && /NODE_MODULE_VERSION|compiled against a different Node\.js version/i.test(message)) {
return "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx.";
}
return undefined;
}
export function csvTable<T extends object>(headers: string[], rows: T[]): string {
const lines = [headers.map(csvCell).join(",")];
for (const row of rows) {
const values = row as Record<string, unknown>;
lines.push(headers.map((header) => csvCell(values[header])).join(","));
}
return `${lines.join("\n")}\n`;
}
function csvCell(value: unknown): string {
if (value === null || value === undefined) return "";
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
-410
View File
@@ -1,410 +0,0 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { buildSchemaContext, createBackend, DIRECT_QUERY_TYPES, BRIDGE_REQUIRED_TYPES, evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, isMainModule, postBridge, supportsHashLineComments, type Backend, type DbxDiagnostics, type SqlSafetyOptions } from "@dbx-app/node-core";
import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "./cli-format.js";
export interface CliResult {
exitCode: number;
stdout: string;
stderr: string;
}
interface RunOptions {
backend?: Backend;
backendFactory?: (env?: NodeJS.ProcessEnv) => Promise<Backend>;
env?: NodeJS.ProcessEnv;
diagnostics?: () => Promise<DbxDiagnostics>;
}
interface ParsedFlags {
args: string[];
json: boolean;
format: "table" | "json" | "csv";
schema?: string;
database?: string;
tables?: string[];
maxTables?: number;
maxRows?: number;
timeoutMs?: number;
file?: string;
allowWrites: boolean;
allowDangerous: boolean;
help: boolean;
version: boolean;
}
class CliError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
}
}
export async function runCli(argv: string[], options: RunOptions = {}): Promise<CliResult> {
const env = options.env ?? process.env;
let ownedBackend: Backend | undefined;
try {
const flags = parseFlags(argv);
const args = flags.args;
if (flags.version) {
return ok(`${await packageVersion()}\n`);
}
if (args.length === 0 || flags.help || args[0] === "help") {
return ok(`${usage()}\n`);
}
const backendFactory = options.backendFactory ?? createBackend;
const backend = options.backend ?? (ownedBackend = await backendFactory(env));
if (args[0] === "doctor") {
ensureArgCount(args, 1, "dbx doctor");
const diagnostics = await (options.diagnostics ?? getDbxDiagnostics)();
if (flags.format === "json") return okJson(diagnostics);
if (flags.format === "csv") {
return ok(
csvTable(
["check", "value"],
[
{ check: "appDataDir", value: diagnostics.appDataDir },
{ check: "dbPath", value: diagnostics.dbPath },
{ check: "dbPathExists", value: diagnostics.dbPathExists },
{ check: "connectionsTableExists", value: diagnostics.connectionsTableExists },
{ check: "connectionRowCount", value: diagnostics.connectionRowCount },
{ check: "loadConnectionsOk", value: diagnostics.loadConnectionsOk },
{ check: "loadedConnectionCount", value: diagnostics.loadedConnectionCount },
{ check: "loadConnectionsError", value: diagnostics.loadConnectionsError ?? "" },
{ check: "loadConnectionsHint", value: diagnostics.loadConnectionsHint ?? "" },
{ check: "bridgePortFile", value: diagnostics.bridgePortFile },
{ check: "bridgePortFileExists", value: diagnostics.bridgePortFileExists },
{ check: "bridgeUrl", value: diagnostics.bridgeUrl ?? "" },
],
),
);
}
return ok(formatDoctor(diagnostics));
}
if (args[0] === "capabilities") {
ensureArgCount(args, 1, "dbx capabilities");
const payload = {
directQueryTypes: [...DIRECT_QUERY_TYPES],
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
};
if (flags.format === "json") return okJson(payload);
if (flags.format === "csv") {
return ok(csvTable(["mode", "type"], [...payload.directQueryTypes.map((type) => ({ mode: "direct", type })), ...payload.bridgeRequiredTypes.map((type) => ({ mode: "bridge", type }))]));
}
return ok(
`${mdTable(
["Mode", "Types"],
[
["Direct", payload.directQueryTypes.join(", ")],
["Requires DBX Desktop", payload.bridgeRequiredTypes.join(", ")],
],
)}\n`,
);
}
if (args[0] === "connections" && args[1] === "list") {
ensureArgCount(args, 2, "dbx connections list");
const connections = (await backend.loadConnections()).map(connectionSummary);
if (flags.format === "json") return okJson({ connections });
if (flags.format === "csv") return ok(csvTable(["name", "type", "host", "port", "database"], connections));
return ok(
`${mdTable(
["Name", "Type", "Host", "Port", "Database"],
connections.map((c) => [c.name, c.type, c.host, String(c.port), c.database ?? ""]),
)}\n`,
);
}
if (args[0] === "schema" && args[1] === "list") {
ensureArgCount(args, 3, "dbx schema list");
const connectionName = required(args[2], "Connection name is required.");
const config = await findConnectionOrThrow(backend, connectionName);
const tables = await backend.listTables(config, flags.schema);
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, tables });
if (flags.format === "csv") return ok(csvTable(["name", "type"], tables));
return ok(
`${mdTable(
["Table", "Type"],
tables.map((t) => [t.name, t.type]),
)}\n`,
);
}
if (args[0] === "schema" && args[1] === "describe") {
ensureArgCount(args, 4, "dbx schema describe");
const connectionName = required(args[2], "Connection name is required.");
const table = required(args[3], "Table name is required.");
const config = await findConnectionOrThrow(backend, connectionName);
const columns = await backend.describeTable(config, table, flags.schema);
if (flags.format === "json") return okJson({ connection: connectionName, schema: flags.schema, table, columns });
if (flags.format === "csv") {
return ok(csvTable(["name", "data_type", "is_nullable", "is_primary_key", "column_default", "comment"], columns));
}
return ok(
`${mdTable(
["Column", "Type", "Nullable", "Default", "Comment"],
columns.map((c) => [c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? ""]),
)}\n`,
);
}
if (args[0] === "query") {
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === (flags.file ? 1 : 2);
ensureArgCount(args, usesDefaultConnection ? (flags.file ? 1 : 2) : flags.file ? 2 : 3, "dbx query");
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
if (flags.file && args[2]) {
throw new CliError("INVALID_ARGUMENT", "Provide SQL either inline or with --file, not both.");
}
const sqlArg = usesDefaultConnection ? args[1] : args[2];
const sql = flags.file ? await readFile(flags.file, "utf-8") : required(sqlArg, "SQL string or --file is required.");
const config = await findConnectionOrThrow(backend, connectionName);
const envSafety = sqlSafetyFromCliEnv(env);
if (flags.allowDangerous && !flags.allowWrites && !envSafety.allowWrites) {
throw new CliError("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes.");
}
const safetyOptions: SqlSafetyOptions = {
allowWrites: flags.allowWrites || envSafety.allowWrites,
allowDangerous: flags.allowDangerous || envSafety.allowDangerous,
hashLineComments: supportsHashLineComments(config.db_type),
};
const safety = evaluateSqlSafety(sql, safetyOptions);
if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json);
const result = await backend.executeQuery(config, sql, { maxRows: flags.maxRows, timeoutMs: flags.timeoutMs });
if (flags.format === "json") {
return okJson({ connection: connectionName, columns: result.columns, rows: result.rows, row_count: result.row_count });
}
if (flags.format === "csv") return ok(csvTable(result.columns, result.rows));
if (result.columns.length === 0) return ok(`Query executed. ${result.row_count} row(s) affected.\n`);
return ok(
`${mdTable(
result.columns,
result.rows.map((row) => result.columns.map((column) => formatCell(row[column]))),
)}\n\n${result.row_count} row(s)\n`,
);
}
if (args[0] === "context") {
const usesDefaultConnection = !!env.DBX_CONNECTION && args.length === 1;
ensureArgCount(args, usesDefaultConnection ? 1 : 2, "dbx context");
const connectionName = usesDefaultConnection ? env.DBX_CONNECTION! : required(args[1], "Connection name is required.");
const config = await findConnectionOrThrow(backend, connectionName);
const context = await buildSchemaContext(backend, config, {
schema: flags.schema,
tables: flags.tables,
maxTables: flags.maxTables,
});
if (flags.format === "json") return okJson(context);
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx context.");
return ok(`${formatSchemaContext(context)}\n`);
}
if (args[0] === "open") {
ensureArgCount(args, 3, "dbx open");
const connectionName = required(args[1], "Connection name is required.");
const table = required(args[2], "Table name is required.");
const response = await postBridge("/open-table", {
connection_name: connectionName,
table,
schema: flags.schema,
database: flags.database,
});
if (!response.ok) {
return fail("DBX_NOT_RUNNING", response.text || "DBX is not running. Please start DBX first.", flags.json);
}
if (flags.format === "json") return okJson({ opened: true, connection: connectionName, table, schema: flags.schema, database: flags.database });
if (flags.format === "csv") throw new CliError("INVALID_OPTION", "CSV format is not supported for dbx open.");
return ok(`Opened ${table} in DBX\n`);
}
return fail("USAGE", usage(), flags.json);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof CliError ? error.code : typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : "ERROR";
const wantsJson = argv.includes("--json");
return fail(code, message, wantsJson);
} finally {
await ownedBackend?.close?.().catch(() => {});
}
}
function parseFlags(argv: string[]): ParsedFlags {
const args: string[] = [];
const flags: ParsedFlags = {
args,
json: false,
format: "table",
allowWrites: false,
allowDangerous: false,
help: false,
version: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--") {
args.push(...argv.slice(i + 1));
break;
}
if (arg === "--json") {
flags.json = true;
flags.format = "json";
} else if (arg === "--format") flags.format = parseFormat(readOptionValue(argv, ++i, "--format"));
else if (arg === "--help" || arg === "-h") flags.help = true;
else if (arg === "--version" || arg === "-V") flags.version = true;
else if (arg === "--schema") flags.schema = readOptionValue(argv, ++i, "--schema");
else if (arg === "--database") flags.database = readOptionValue(argv, ++i, "--database");
else if (arg === "--tables") flags.tables = splitCsv(readOptionValue(argv, ++i, "--tables"));
else if (arg === "--max-tables") flags.maxTables = parsePositiveInt(readOptionValue(argv, ++i, "--max-tables"), "--max-tables");
else if (arg === "--limit") flags.maxRows = parsePositiveInt(readOptionValue(argv, ++i, "--limit"), "--limit");
else if (arg === "--timeout") flags.timeoutMs = parseDurationMs(readOptionValue(argv, ++i, "--timeout"), "--timeout");
else if (arg === "--file") flags.file = readOptionValue(argv, ++i, "--file");
else if (arg === "--allow-writes") flags.allowWrites = true;
else if (arg === "--allow-dangerous-sql") flags.allowDangerous = true;
else if (arg.startsWith("-")) throw new CliError("UNKNOWN_OPTION", `Unknown option: ${arg}`);
else args.push(arg);
}
return flags;
}
function parseFormat(value: string): "table" | "json" | "csv" {
if (value === "table" || value === "json" || value === "csv") return value;
throw new CliError("INVALID_OPTION", "--format must be one of: table, json, csv.");
}
function ensureArgCount(args: string[], count: number, command: string): void {
if (args.length !== count) {
throw new CliError("INVALID_ARGUMENT", `${command} expects ${count - 1} argument(s); received ${args.length - 1}.`);
}
}
function readOptionValue(argv: string[], index: number, option: string): string {
const value = argv[index];
if (!value || value.startsWith("-")) {
throw new CliError("INVALID_OPTION", `${option} requires a value.`);
}
return value;
}
function parsePositiveInt(value: string, option: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new CliError("INVALID_OPTION", `${option} must be a positive integer.`);
}
return parsed;
}
function parseDurationMs(value: string, option: string): number {
const match = value.match(/^(\d+)(ms|s|m)?$/);
if (!match) {
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
}
const amount = Number(match[1]);
if (!Number.isInteger(amount) || amount < 1) {
throw new CliError("INVALID_OPTION", `${option} must be a positive duration such as 500ms, 10s, or 1m.`);
}
const unit = match[2] ?? "ms";
if (unit === "ms") return amount;
if (unit === "s") return amount * 1000;
return amount * 60_000;
}
function parseBooleanEnv(value: string | undefined): boolean {
if (value === undefined) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true";
}
function sqlSafetyFromCliEnv(env: NodeJS.ProcessEnv): Required<Pick<SqlSafetyOptions, "allowWrites" | "allowDangerous">> {
return {
allowWrites: parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES),
allowDangerous: parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL),
};
}
function splitCsv(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
async function findConnectionOrThrow(backend: Backend, name: string) {
const config = await backend.findConnection(name);
if (!config) throw new CliError("CONNECTION_NOT_FOUND", `Connection "${name}" not found.`);
return config;
}
function required(value: string | undefined, message: string): string {
if (!value) throw new Error(message);
return value;
}
function ok(stdout: string): CliResult {
return { exitCode: 0, stdout, stderr: "" };
}
function okJson(payload: unknown): CliResult {
return ok(`${JSON.stringify(payload, null, 2)}\n`);
}
function fail(code: string, message: string, json: boolean): CliResult {
const text = json ? `${JSON.stringify(errorPayload(code, message), null, 2)}\n` : `${formatErrorMessage(code, message)}\n`;
return { exitCode: 1, stdout: "", stderr: text };
}
function usage(): string {
return [
"Usage:",
" dbx doctor [--json]",
" dbx capabilities [--json]",
" dbx connections list [--json]",
" dbx schema list <connection> [--schema name] [--json]",
" dbx schema describe <connection> <table> [--schema name] [--json]",
" dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]",
" dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]",
" dbx open <connection> <table> [--schema name] [--database name] [--json]",
].join("\n");
}
function formatDoctor(diagnostics: DbxDiagnostics): string {
const rows = [
["App data directory", diagnostics.appDataDir],
["DBX database", diagnostics.dbPathExists ? `found (${diagnostics.dbPath})` : `missing (${diagnostics.dbPath})`],
["Connections table", diagnostics.connectionsTableExists ? `${diagnostics.connectionRowCount} row(s)` : "missing"],
["Connection loading", diagnostics.loadConnectionsOk ? `ok (${diagnostics.loadedConnectionCount} loaded)` : `failed (${diagnostics.loadConnectionsError ?? "unknown error"})`],
...(diagnostics.loadConnectionsHint ? [["Connection fix", diagnostics.loadConnectionsHint]] : []),
["Desktop bridge", diagnostics.bridgePortFileExists ? `available (${diagnostics.bridgeUrl ?? diagnostics.bridgePortFile})` : "not running"],
["Direct query types", diagnostics.directQueryTypes.join(", ")],
["Bridge-required types", diagnostics.bridgeRequiredTypes.join(", ")],
];
return `${mdTable(["Check", "Value"], rows)}\n`;
}
async function packageVersion(): Promise<string> {
const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf-8");
const parsed = JSON.parse(packageJson) as { version?: string };
return parsed.version ?? "0.0.0";
}
async function main() {
const result = await runCli(process.argv.slice(2));
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exitCode = result.exitCode;
}
if (isMainModule(import.meta.url, process.argv[1])) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
-53
View File
@@ -1,53 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { test } from "vitest";
const packageDir = fileURLToPath(new URL("..", import.meta.url));
const cliSource = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
test("prints version when invoked through an npm-style symlink", async () => {
const bin = await symlinkedCli();
try {
const result = runDbx(bin, ["--version"]);
assert.equal(result.status, 0);
assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/);
assert.equal(result.stderr, "");
} finally {
await rm(bin.dir, { recursive: true, force: true });
}
});
test("prints capabilities when invoked through an npm-style symlink", async () => {
const bin = await symlinkedCli();
try {
const result = runDbx(bin, ["capabilities", "--json"]);
assert.equal(result.status, 0);
assert.equal(result.stderr, "");
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
assert.ok(payload.directQueryTypes.includes("postgres"));
assert.ok(payload.directQueryTypes.includes("rqlite"));
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
} finally {
await rm(bin.dir, { recursive: true, force: true });
}
});
async function symlinkedCli() {
const dir = await mkdtemp(join(tmpdir(), "dbx-cli-bin-"));
const path = join(dir, "dbx");
await symlink(cliSource, path);
return { dir, path };
}
function runDbx(bin: { path: string }, args: string[]) {
return spawnSync(process.execPath, ["--import", "tsx", bin.path, ...args], {
cwd: packageDir,
encoding: "utf-8",
});
}
-66
View File
@@ -1,66 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import type { ConnectionConfig } from "@dbx-app/node-core";
import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "../src/cli-format.js";
const connection: ConnectionConfig = {
id: "1",
name: "local",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "app",
password: "secret",
database: "demo",
ssh_enabled: false,
proxy_password: "proxy-secret",
ssl: false,
};
test("redacts secrets from connection summaries", () => {
const summary = connectionSummary(connection);
assert.deepEqual(summary, {
name: "local",
type: "postgres",
host: "127.0.0.1",
port: 5432,
database: "demo",
});
assert.equal(JSON.stringify(summary).includes("secret"), false);
});
test("formats markdown tables", () => {
const table = mdTable(["Name", "Type"], [["local", "postgres"]]);
assert.match(table, /Name/);
assert.match(table, /postgres/);
});
test("formats database cell values with shared formatter", () => {
assert.equal(formatCell(null), "NULL");
assert.equal(formatCell({ ok: true }), '{"ok":true}');
});
test("builds stable error payloads", () => {
assert.deepEqual(errorPayload("SQL_BLOCKED", "read-only"), {
error: { code: "SQL_BLOCKED", message: "read-only" },
});
});
test("adds remediation hints for native SQLite ABI errors", () => {
assert.deepEqual(errorPayload("CONNECTION_STORE_ERROR", "NODE_MODULE_VERSION 127 mismatch"), {
error: {
code: "CONNECTION_STORE_ERROR",
message: "NODE_MODULE_VERSION 127 mismatch",
hint: "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx.",
},
});
assert.match(formatErrorMessage("CONNECTION_STORE_ERROR", "compiled against a different Node.js version"), /Hint: Rebuild/);
});
test("formats csv tables with escaping", () => {
const csv = csvTable(["name", "note"], [{ name: "Ada", note: 'hello, "dbx"' }]);
assert.equal(csv, 'name,note\nAda,"hello, ""dbx"""\n');
});
-429
View File
@@ -1,429 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import type { Backend, ConnectionConfig, DbxDiagnostics } from "@dbx-app/node-core";
import { runCli } from "../src/cli.js";
const connection: ConnectionConfig = {
id: "1",
name: "local",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "app",
password: "secret",
database: "demo",
ssh_enabled: false,
ssl: false,
};
function fakeBackend(overrides: Partial<Backend> = {}): Backend {
return {
loadConnections: async () => [connection],
findConnection: async (name) => (name === "local" ? connection : undefined),
addConnection: async () => connection,
removeConnection: async () => true,
listTables: async () => [{ name: "users", type: "BASE TABLE" }],
describeTable: async () => [
{
name: "id",
data_type: "integer",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
],
executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }),
...overrides,
};
}
const diagnostics: DbxDiagnostics = {
appDataDir: "/tmp/dbx",
dbPath: "/tmp/dbx/dbx.db",
dbPathExists: true,
connectionsTableExists: true,
connectionRowCount: 2,
loadConnectionsOk: true,
loadedConnectionCount: 2,
bridgePortFile: "/tmp/dbx/mcp-bridge-port",
bridgePortFileExists: false,
directQueryTypes: ["postgres", "mysql", "sqlite", "rqlite"],
bridgeRequiredTypes: ["oracle", "mongodb"],
};
test("lists connections as json", async () => {
const result = await runCli(["connections", "list", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.deepEqual(JSON.parse(result.stdout), {
connections: [{ name: "local", type: "postgres", host: "127.0.0.1", port: 5432, database: "demo" }],
});
});
test("blocks write query by default", async () => {
const result = await runCli(["query", "local", "update users set name = 'x' where id = 1", "--json"], {
backend: fakeBackend(),
});
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "SQL_BLOCKED");
});
test("runs read query as json", async () => {
const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], {
backend: fakeBackend(),
});
assert.equal(result.exitCode, 0);
assert.deepEqual(JSON.parse(result.stdout), {
connection: "local",
columns: ["total"],
rows: [{ total: 1 }],
row_count: 1,
});
});
test("closes backend resources created by the CLI", async () => {
let closed = false;
const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], {
backendFactory: async () =>
fakeBackend({
close: async () => {
closed = true;
},
}),
});
assert.equal(result.exitCode, 0);
assert.equal(closed, true);
});
test("closes backend resources created by the CLI after failures", async () => {
let closed = false;
const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], {
backendFactory: async () =>
fakeBackend({
executeQuery: async () => {
throw new Error("boom");
},
close: async () => {
closed = true;
},
}),
});
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.message, "boom");
assert.equal(closed, true);
});
test("does not close caller-provided backend resources", async () => {
let closed = false;
const result = await runCli(["query", "local", "select count(*) as total from users", "--json"], {
backend: fakeBackend({
close: async () => {
closed = true;
},
}),
});
assert.equal(result.exitCode, 0);
assert.equal(closed, false);
});
test("runs query as csv", async () => {
const result = await runCli(["query", "local", "select count(*) as total from users", "--format", "csv"], {
backend: fakeBackend(),
});
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, "total\n1\n");
});
test("uses json format alias", async () => {
const result = await runCli(["connections", "list", "--format", "json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.deepEqual(JSON.parse(result.stdout), {
connections: [{ name: "local", type: "postgres", host: "127.0.0.1", port: 5432, database: "demo" }],
});
});
test("prints diagnostics as json", async () => {
const result = await runCli(["doctor", "--json"], {
backend: fakeBackend(),
diagnostics: async () => diagnostics,
});
assert.equal(result.exitCode, 0);
assert.deepEqual(JSON.parse(result.stdout), diagnostics);
});
test("prints connection-store remediation in doctor output", async () => {
const result = await runCli(["doctor"], {
backend: fakeBackend(),
diagnostics: async () => ({
...diagnostics,
loadConnectionsOk: false,
loadConnectionsError: "compiled against a different Node.js version",
loadConnectionsHint: "Rebuild DBX CLI native dependencies with your active Node.js.",
}),
});
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /Connection fix/);
assert.match(result.stdout, /Rebuild DBX CLI native dependencies/);
});
test("surfaces connection store failures instead of returning an empty list", async () => {
const result = await runCli(["connections", "list", "--json"], {
backend: fakeBackend({
loadConnections: async () => {
throw Object.assign(new Error("Failed to load DBX connections from /tmp/dbx/dbx.db: NODE_MODULE_VERSION 127 mismatch"), {
code: "CONNECTION_STORE_ERROR",
});
},
}),
});
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "CONNECTION_STORE_ERROR");
assert.match(JSON.parse(result.stderr).error.hint, /Rebuild DBX CLI native dependencies/);
});
test("prints capabilities as json", async () => {
const result = await runCli(["capabilities", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
assert.ok(payload.directQueryTypes.includes("postgres"));
assert.ok(payload.directQueryTypes.includes("sqlite"));
assert.ok(payload.directQueryTypes.includes("rqlite"));
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
});
test("rejects invalid formats", async () => {
const result = await runCli(["query", "local", "select 1", "--json", "--format", "xml"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("uses DBX_CONNECTION when query connection is omitted", async () => {
const result = await runCli(["query", "select count(*) as total from users", "--json"], {
backend: fakeBackend(),
env: { DBX_CONNECTION: "local" },
});
assert.equal(result.exitCode, 0);
assert.equal(JSON.parse(result.stdout).connection, "local");
});
test("uses DBX_CONNECTION when context connection is omitted", async () => {
const result = await runCli(["context", "--tables", "users"], {
backend: fakeBackend(),
env: { DBX_CONNECTION: "local" },
});
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /Connection: local/);
});
test("passes query limit and timeout to the backend", async () => {
let received: unknown;
const result = await runCli(["query", "local", "select * from users", "--limit", "5", "--timeout", "2s", "--json"], {
backend: fakeBackend({
executeQuery: async (_config, _sql, options) => {
received = options;
return { columns: ["id"], rows: [{ id: 1 }], row_count: 1 };
},
}),
});
assert.equal(result.exitCode, 0);
assert.deepEqual(received, { maxRows: 5, timeoutMs: 2000 });
});
test("rejects invalid query limits", async () => {
const result = await runCli(["query", "local", "select 1", "--limit", "0", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("rejects invalid query timeouts", async () => {
const result = await runCli(["query", "local", "select 1", "--timeout", "forever", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("requires a connection when DBX_CONNECTION is not set", async () => {
const result = await runCli(["query", "select 1", "--json"], { backend: fakeBackend(), env: {} });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT");
});
test("describes schema as json", async () => {
const result = await runCli(["schema", "describe", "local", "users", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.equal(JSON.parse(result.stdout).columns[0].name, "id");
});
test("lists schema tables as json", async () => {
const result = await runCli(["schema", "list", "local", "--schema", "public", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.deepEqual(JSON.parse(result.stdout), {
connection: "local",
schema: "public",
tables: [{ name: "users", type: "BASE TABLE" }],
});
});
test("builds schema context as prompt-ready text", async () => {
const result = await runCli(["context", "local", "--tables", "users"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /Connection: local/);
assert.match(result.stdout, /## users/);
assert.match(result.stdout, /id integer NOT NULL PK/);
});
test("runs SQL from file", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-cli-"));
const file = join(dir, "query.sql");
let executedSql = "";
try {
await writeFile(file, "select count(*) as total from users", "utf-8");
const result = await runCli(["query", "local", "--file", file, "--json"], {
backend: fakeBackend({
executeQuery: async (_config, sql) => {
executedSql = sql;
return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 };
},
}),
});
assert.equal(result.exitCode, 0);
assert.equal(executedSql, "select count(*) as total from users");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("allows non-dangerous writes when explicitly enabled", async () => {
let executed = false;
const result = await runCli(["query", "local", "update users set name = 'x' where id = 1", "--allow-writes", "--json"], {
backend: fakeBackend({
executeQuery: async () => {
executed = true;
return { columns: [], rows: [], row_count: 1 };
},
}),
});
assert.equal(result.exitCode, 0);
assert.equal(executed, true);
assert.equal(JSON.parse(result.stdout).row_count, 1);
});
test("keeps dangerous SQL blocked without allow-dangerous-sql", async () => {
const result = await runCli(["query", "local", "drop table users", "--allow-writes", "--json"], {
backend: fakeBackend(),
});
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "SQL_BLOCKED");
});
test("rejects unknown options", async () => {
const result = await runCli(["connections", "list", "--wat", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "UNKNOWN_OPTION");
});
test("rejects options with missing values", async () => {
const result = await runCli(["schema", "list", "local", "--schema", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("rejects query with both inline SQL and file SQL", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-cli-"));
const file = join(dir, "query.sql");
try {
await writeFile(file, "select 1", "utf-8");
const result = await runCli(["query", "local", "select 2", "--file", file, "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("requires allow-writes before allow-dangerous-sql", async () => {
const result = await runCli(["query", "local", "drop table users", "--allow-dangerous-sql", "--json"], {
backend: fakeBackend(),
env: {},
});
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("rejects invalid max-tables values", async () => {
const result = await runCli(["context", "local", "--max-tables", "nope", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_OPTION");
});
test("prints the package version", async () => {
const result = await runCli(["--version"], { backend: fakeBackend() });
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/);
});
test("rejects unexpected positional arguments", async () => {
const result = await runCli(["connections", "list", "extra", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "INVALID_ARGUMENT");
});
test("uses a specific connection-not-found error code", async () => {
const result = await runCli(["schema", "list", "missing", "--json"], { backend: fakeBackend() });
assert.equal(result.exitCode, 1);
assert.equal(JSON.parse(result.stderr).error.code, "CONNECTION_NOT_FOUND");
});
test("supports -- to pass SQL that starts with a dash", async () => {
let executedSql = "";
const result = await runCli(["query", "local", "--json", "--", "-- comment\nselect 1"], {
backend: fakeBackend({
executeQuery: async (_config, sql) => {
executedSql = sql;
return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 };
},
}),
});
assert.equal(result.exitCode, 0);
assert.equal(executedSql, "-- comment\nselect 1");
});
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/*.test.ts"],
},
});
+104 -33
View File
@@ -233,20 +233,37 @@ importers:
version: 3.3.3(typescript@6.0.3)
packages/cli:
dependencies:
'@dbx-app/node-core':
specifier: workspace:^
version: link:../node-core
devDependencies:
'@types/node':
specifier: ^22.15.21
version: 22.19.19
tsx:
specifier: ^4.19.4
version: 4.21.0
typescript:
specifier: ^5.8.3
version: 5.9.3
optionalDependencies:
'@dbx-app/cli-darwin-arm64':
specifier: workspace:*
version: link:../cli-darwin-arm64
'@dbx-app/cli-darwin-x64':
specifier: workspace:*
version: link:../cli-darwin-x64
'@dbx-app/cli-linux-arm64-gnu':
specifier: workspace:*
version: link:../cli-linux-arm64-gnu
'@dbx-app/cli-linux-x64-gnu':
specifier: workspace:*
version: link:../cli-linux-x64-gnu
'@dbx-app/cli-win32-arm64':
specifier: workspace:*
version: link:../cli-win32-arm64
'@dbx-app/cli-win32-x64':
specifier: workspace:*
version: link:../cli-win32-x64
packages/cli-darwin-arm64: {}
packages/cli-darwin-x64: {}
packages/cli-linux-arm64-gnu: {}
packages/cli-linux-x64-gnu: {}
packages/cli-win32-arm64: {}
packages/cli-win32-x64: {}
packages/mcp-darwin-arm64: {}
@@ -257,25 +274,6 @@ importers:
packages/mcp-linux-x64-gnu: {}
packages/mcp-server:
optionalDependencies:
'@dbx-app/mcp-darwin-arm64':
specifier: 0.4.34
version: link:../mcp-darwin-arm64
'@dbx-app/mcp-darwin-x64':
specifier: 0.4.34
version: link:../mcp-darwin-x64
'@dbx-app/mcp-linux-arm64-gnu':
specifier: 0.4.34
version: link:../mcp-linux-arm64-gnu
'@dbx-app/mcp-linux-x64-gnu':
specifier: 0.4.34
version: link:../mcp-linux-x64-gnu
'@dbx-app/mcp-win32-arm64':
specifier: 0.4.34
version: link:../mcp-win32-arm64
'@dbx-app/mcp-win32-x64':
specifier: 0.4.34
version: link:../mcp-win32-x64
devDependencies:
'@dbx-app/node-core':
specifier: workspace:^
@@ -298,6 +296,25 @@ importers:
zod:
specifier: ^3.25.20
version: 3.25.76
optionalDependencies:
'@dbx-app/mcp-darwin-arm64':
specifier: 0.4.34
version: 0.4.34
'@dbx-app/mcp-darwin-x64':
specifier: 0.4.34
version: 0.4.34
'@dbx-app/mcp-linux-arm64-gnu':
specifier: 0.4.34
version: 0.4.34
'@dbx-app/mcp-linux-x64-gnu':
specifier: 0.4.34
version: 0.4.34
'@dbx-app/mcp-win32-arm64':
specifier: 0.4.34
version: 0.4.34
'@dbx-app/mcp-win32-x64':
specifier: 0.4.34
version: 0.4.34
packages/mcp-win32-arm64: {}
@@ -534,6 +551,42 @@ packages:
'@codemirror/view@6.43.0':
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
'@dbx-app/mcp-darwin-arm64@0.4.34':
resolution: {integrity: sha512-RxxXq5GnBvTxETF3vtT1KQEvVSf2XcTEcSozJJ0wNBt5pfyJPeI3m3xRHrzgKlElmGfFFL191D+Jf0eaM0s0bQ==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@dbx-app/mcp-darwin-x64@0.4.34':
resolution: {integrity: sha512-2DIIh3DsEDijpWyKdKoCG+pa1XCnGTSnUyrPZlDoZ78L0I0trkb0NFCaFmnKiLR5LWJ2ZvPnYvwEmBMbnDnviw==}
cpu: [x64]
os: [darwin]
hasBin: true
'@dbx-app/mcp-linux-arm64-gnu@0.4.34':
resolution: {integrity: sha512-ogI2DnQNAO5cr86GlKfvDIw7/cF27o+PRyn5a7/CXKmBugLtPtN4C8UV0wVkObu1avxuMza/7QpAoDB+v+tCZA==}
cpu: [arm64]
os: [linux]
hasBin: true
'@dbx-app/mcp-linux-x64-gnu@0.4.34':
resolution: {integrity: sha512-CH+2juzBjlJcsSR8A8KUjE1CPi432v8YQ6nmJ3fDxmqt7THa0NT8vvTmczzy9KBhR9dNkEIZ94qZASuh5okN+g==}
cpu: [x64]
os: [linux]
hasBin: true
'@dbx-app/mcp-win32-arm64@0.4.34':
resolution: {integrity: sha512-sRqH+0yX66t0cqapr110470U7B9JB6M9pVVlfAyIJdeIJZHlPiDi0Kz/+n+7sO1Pbf6W43E7lueE0i4N0u8Sow==}
cpu: [arm64]
os: [win32]
hasBin: true
'@dbx-app/mcp-win32-x64@0.4.34':
resolution: {integrity: sha512-lE3hyvI2oKTxgWaeW0OgLbX9gjIwM2t4OE+icg57QvX+WKHcrwu1p7ZWUoW3DmRO4ngKCG8hA9u9ynSGxWhq+A==}
cpu: [x64]
os: [win32]
hasBin: true
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@@ -4488,6 +4541,24 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dbx-app/mcp-darwin-arm64@0.4.34':
optional: true
'@dbx-app/mcp-darwin-x64@0.4.34':
optional: true
'@dbx-app/mcp-linux-arm64-gnu@0.4.34':
optional: true
'@dbx-app/mcp-linux-x64-gnu@0.4.34':
optional: true
'@dbx-app/mcp-win32-arm64@0.4.34':
optional: true
'@dbx-app/mcp-win32-x64@0.4.34':
optional: true
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0
+79
View File
@@ -0,0 +1,79 @@
import { spawnSync } from "node:child_process";
import { performance } from "node:perf_hooks";
const rustBinary = process.env.DBX_CLI_RUST_BIN;
const legacyBinary = process.env.DBX_CLI_LEGACY_BIN;
const connection = process.env.DBX_CLI_TEST_CONNECTION;
const table = process.env.DBX_CLI_TEST_TABLE ?? "orders";
const iterations = Number.parseInt(process.env.DBX_CLI_BENCH_ITERATIONS ?? "12", 10);
if (!rustBinary || !legacyBinary || !connection) {
throw new Error("Set DBX_CLI_RUST_BIN, DBX_CLI_LEGACY_BIN, and DBX_CLI_TEST_CONNECTION.");
}
if (!Number.isInteger(iterations) || iterations < 3) {
throw new Error("DBX_CLI_BENCH_ITERATIONS must be an integer of at least 3.");
}
const cases = [
{ name: "version", args: ["--version"], iterations: Math.max(iterations, 30) },
{ name: "capabilities", args: ["capabilities", "--json"], iterations: Math.max(iterations, 30) },
{ name: "connections", args: ["connections", "list", "--json"], iterations: Math.max(iterations, 20) },
{ name: "schema-list", args: ["schema", "list", connection, "--json"], iterations },
{ name: "schema-describe", args: ["schema", "describe", connection, table, "--json"], iterations },
{ name: "context", args: ["context", connection, "--tables", table, "--json"], iterations },
{ name: "query", args: ["query", connection, "select 1 as benchmark_value", "--json"], iterations },
];
function run(binary, args) {
const started = performance.now();
const result = spawnSync(binary, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
const elapsed = performance.now() - started;
if (result.status !== 0) throw new Error(`${binary} ${args.join(" ")} failed: ${result.stderr}`);
return elapsed;
}
function percentile(values, ratio) {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
}
function summarize(values) {
return {
median: percentile(values, 0.5),
p90: percentile(values, 0.9),
p95: percentile(values, 0.95),
mean: values.reduce((sum, value) => sum + value, 0) / values.length,
};
}
function peakRss(binary, args) {
const values = [];
for (let index = 0; index < 5; index += 1) {
const result = spawnSync("/usr/bin/time", ["-l", binary, ...args], { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
if (result.status !== 0) throw new Error(`RSS measurement failed: ${result.stderr}`);
const match = result.stderr.match(/(\d+)\s+maximum resident set size/);
if (!match) throw new Error(`Unable to parse maximum resident set size: ${result.stderr}`);
values.push(Number(match[1]) / 1024 / 1024);
}
return percentile(values, 0.5);
}
console.log(`iterations=${iterations} connection=${connection} table=${table}`);
console.log("case\truntime\tmedian_ms\tp90_ms\tp95_ms\tmean_ms\tpeak_rss_mib");
for (const benchmark of cases) {
run(rustBinary, benchmark.args);
run(legacyBinary, benchmark.args);
const samples = { rust: [], legacy: [] };
for (let index = 0; index < benchmark.iterations; index += 1) {
const order = index % 2 === 0 ? [["rust", rustBinary], ["legacy", legacyBinary]] : [["legacy", legacyBinary], ["rust", rustBinary]];
for (const [runtime, binary] of order) samples[runtime].push(run(binary, benchmark.args));
}
for (const [runtime, binary] of [["rust", rustBinary], ["legacy", legacyBinary]]) {
const stats = summarize(samples[runtime]);
const rss = peakRss(binary, benchmark.args);
console.log(
`${benchmark.name}\t${runtime}\t${stats.median.toFixed(1)}\t${stats.p90.toFixed(1)}\t${stats.p95.toFixed(1)}\t${stats.mean.toFixed(1)}\t${rss.toFixed(1)}`,
);
}
}
+9 -1
View File
@@ -20,7 +20,13 @@ const PACKAGE_RELEASE_PATHS = [
"packages/cli/src/",
"packages/cli/README.md",
"packages/cli/package.json",
"packages/cli/tsconfig.json",
"packages/cli/bin/",
"packages/cli-darwin-arm64/",
"packages/cli-darwin-x64/",
"packages/cli-linux-arm64-gnu/",
"packages/cli-linux-x64-gnu/",
"packages/cli-win32-arm64/",
"packages/cli-win32-x64/",
"packages/mcp-server/src/",
"packages/mcp-server/bin/",
"packages/mcp-server/README.md",
@@ -34,6 +40,8 @@ const PACKAGE_RELEASE_PATHS = [
"packages/mcp-win32-arm64/",
"packages/mcp-win32-x64/",
"crates/dbx-mcp/",
"crates/dbx-cli/",
"crates/dbx-core/src/mongo_shell.rs",
"Cargo.toml",
"Cargo.lock",
".github/workflows/mcp-release.yml",
+184
View File
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const rustBinary = process.env.DBX_CLI_RUST_BIN;
const legacyBinary = process.env.DBX_CLI_LEGACY_BIN;
const connection = process.env.DBX_CLI_TEST_CONNECTION;
if (!rustBinary || !existsSync(rustBinary)) {
throw new Error("Set DBX_CLI_RUST_BIN to a release dbx binary before running this test.");
}
if (!legacyBinary || !existsSync(legacyBinary)) {
throw new Error("Set DBX_CLI_LEGACY_BIN to the legacy TypeScript dbx binary before running this test.");
}
if (!connection) {
throw new Error("Set DBX_CLI_TEST_CONNECTION to a safe test connection name before running this test.");
}
function run(binary, args, extraEnv = {}) {
const env = { ...process.env };
for (const [key, value] of Object.entries(extraEnv)) {
if (value == null) delete env[key];
else env[key] = value;
}
const result = spawnSync(binary, args, { encoding: "utf8", env });
return { status: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim() };
}
function jsonOutput(result) {
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}
function compareJsonCommand(name, args, compare = (legacy, rust) => assert.deepEqual(rust, legacy)) {
const legacy = jsonOutput(run(legacyBinary, args));
const rust = jsonOutput(run(rustBinary, args));
compare(legacy, rust);
console.log(`PASS ${name}`);
}
function normalizeRows(rows) {
return rows.map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value == null ? value : String(value)])));
}
const version = run(rustBinary, ["--version"]);
assert.equal(version.status, 0);
assert.match(version.stdout, /^\d+\.\d+\.\d+$/);
assert.equal(run(legacyBinary, ["--version"]).status, 0);
console.log("PASS version");
assert.equal(run(rustBinary, ["--help"]).status, 0);
assert.match(run(rustBinary, ["--help"]).stdout, /dbx query/);
assert.match(run(legacyBinary, ["--help"]).stdout, /dbx query/);
console.log("PASS help");
compareJsonCommand("capabilities", ["capabilities", "--json"], (legacy, rust) => {
assert.deepEqual(Object.keys(rust).sort(), Object.keys(legacy).sort());
assert.ok(rust.directQueryTypes.includes("postgres"));
assert.ok(rust.bridgeRequiredTypes.includes("oracle"));
});
compareJsonCommand("connections", ["connections", "list", "--json"], (legacy, rust) => {
assert.deepEqual(rust.connections, legacy.connections);
});
compareJsonCommand("schema list", ["schema", "list", connection, "--json"], (legacy, rust) => {
assert.equal(rust.connection, legacy.connection);
assert.ok(rust.tables.length >= legacy.tables.length);
assert.deepEqual(rust.tables.slice(0, legacy.tables.length), legacy.tables);
});
const schema = jsonOutput(run(rustBinary, ["schema", "list", connection, "--json"]));
const table = schema.tables[0]?.name;
if (!table) throw new Error(`No table found in ${connection}`);
compareJsonCommand("schema describe", ["schema", "describe", connection, table, "--json"], (legacy, rust) => {
assert.equal(rust.connection, legacy.connection);
assert.equal(rust.table, legacy.table);
assert.deepEqual(
rust.columns.map(({ name, is_nullable, column_default, is_primary_key, enum_values }) => ({ name, is_nullable, column_default, is_primary_key, enum_values: enum_values ?? null })),
legacy.columns.map(({ name, is_nullable, column_default, is_primary_key, enum_values }) => ({ name, is_nullable, column_default, is_primary_key, enum_values: enum_values ?? null })),
);
});
compareJsonCommand("query", ["query", connection, "select 1 as dbx_cli_migration_check", "--json"], (legacy, rust) => {
assert.deepEqual(rust.columns, legacy.columns);
assert.deepEqual(normalizeRows(rust.rows), normalizeRows(legacy.rows));
assert.equal(rust.row_count, legacy.row_count);
});
const temporaryDirectory = mkdtempSync(join(tmpdir(), "dbx-cli-migration-"));
const sqlFile = join(temporaryDirectory, "query.sql");
writeFileSync(sqlFile, "select 1 as dbx_cli_file_check", "utf8");
try {
compareJsonCommand("file query", ["query", connection, "--file", sqlFile, "--json"], (legacy, rust) => {
assert.deepEqual(rust.columns, legacy.columns);
assert.deepEqual(normalizeRows(rust.rows), normalizeRows(legacy.rows));
assert.equal(rust.row_count, legacy.row_count);
});
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
const context = run(rustBinary, ["context", connection, "--max-tables", "1"]);
assert.equal(context.status, 0, context.stderr);
assert.match(context.stdout, /Connection:/);
assert.match(context.stdout, /## /);
console.log("PASS context");
const contextJson = jsonOutput(run(rustBinary, ["context", "--tables", table, "--json"], { DBX_CONNECTION: connection }));
assert.equal(contextJson.connection, connection);
assert.equal(contextJson.tables.length, 1);
assert.equal(contextJson.tables[0].name.toLowerCase(), table.toLowerCase());
assert.equal(typeof contextJson.truncated, "boolean");
console.log("PASS DBX_CONNECTION context");
const defaultConnectionQuery = jsonOutput(run(rustBinary, ["query", "select 1 as default_connection", "--json"], { DBX_CONNECTION: connection }));
assert.equal(defaultConnectionQuery.connection, connection);
assert.equal(String(defaultConnectionQuery.rows[0].default_connection), "1");
console.log("PASS DBX_CONNECTION query");
const formatAlias = jsonOutput(run(rustBinary, ["query", connection, "select 1 as format_alias", "--format", "json"]));
assert.equal(String(formatAlias.rows[0].format_alias), "1");
console.log("PASS format json alias");
const csv = run(rustBinary, ["query", connection, "select 'a,b' as csv_value", "--format", "csv"]);
assert.equal(csv.status, 0, csv.stderr);
assert.match(csv.stdout, /^csv_value\n"a,b"$/);
console.log("PASS csv escaping");
const limited = jsonOutput(
run(rustBinary, ["query", connection, "select 1 as n union all select 2 union all select 3", "--limit", "2", "--json"]),
);
assert.equal(limited.rows.length, 2);
assert.equal(limited.row_count, 2);
console.log("PASS query limit");
const doubleDash = jsonOutput(run(rustBinary, ["query", connection, "--json", "--", "-- comment\nselect 1 as double_dash"]));
assert.equal(String(doubleDash.rows[0].double_dash), "1");
console.log("PASS double dash SQL");
const doctor = jsonOutput(run(rustBinary, ["doctor", "--json"]));
for (const key of ["appDataDir", "dbPath", "loadConnectionsOk", "directQueryTypes", "bridgeRequiredTypes"]) assert.ok(key in doctor);
console.log("PASS doctor");
for (const [name, args, code] of [
["unknown option", ["connections", "list", "--wat", "--json"], "UNKNOWN_OPTION"],
["missing option value", ["schema", "list", connection, "--schema", "--json"], "INVALID_OPTION"],
["missing connection", ["schema", "list", "missing-db", "--json"], "CONNECTION_NOT_FOUND"],
["blocked write", ["query", connection, "update users set value = 1", "--json"], "SQL_BLOCKED"],
["dangerous requires writes", ["query", connection, "drop table users", "--allow-dangerous-sql", "--json"], "INVALID_OPTION"],
["dangerous blocked", ["query", connection, "drop table users", "--allow-writes", "--json"], "SQL_BLOCKED"],
["invalid format", ["connections", "list", "--format", "yaml", "--json"], "INVALID_OPTION"],
["invalid limit", ["query", connection, "select 1", "--limit", "0", "--json"], "INVALID_OPTION"],
["invalid timeout", ["query", connection, "select 1", "--timeout", "later", "--json"], "INVALID_OPTION"],
["invalid max tables", ["context", connection, "--max-tables", "nope", "--json"], "INVALID_OPTION"],
["unexpected positional", ["connections", "list", "extra", "--json"], "INVALID_ARGUMENT"],
]) {
const result = run(rustBinary, args);
assert.equal(result.status, 1, `${name}: ${result.stdout}`);
assert.equal(JSON.parse(result.stderr).error.code, code);
console.log(`PASS ${name}`);
}
const missingDefault = run(rustBinary, ["query", "select 1", "--json"], { DBX_CONNECTION: null });
assert.equal(missingDefault.status, 1);
assert.equal(JSON.parse(missingDefault.stderr).error.code, "INVALID_ARGUMENT");
console.log("PASS missing default connection");
const temporaryConflictDirectory = mkdtempSync(join(tmpdir(), "dbx-cli-migration-conflict-"));
const conflictFile = join(temporaryConflictDirectory, "query.sql");
writeFileSync(conflictFile, "select 1", "utf8");
try {
const conflict = run(rustBinary, ["query", connection, "select 2", "--file", conflictFile, "--json"]);
assert.equal(conflict.status, 1);
assert.equal(JSON.parse(conflict.stderr).error.code, "INVALID_ARGUMENT");
console.log("PASS inline and file conflict");
} finally {
rmSync(temporaryConflictDirectory, { recursive: true, force: true });
}
console.log("CLI migration matrix passed.");
+5
View File
@@ -5,6 +5,11 @@ use tauri::State;
use crate::commands::connection::{ensure_connection_writable, AppState};
use dbx_core::db::mongo_driver::MongoDocumentResult;
#[tauri::command]
pub fn mongo_parse_shell_command(source: String) -> Result<dbx_core::mongo_shell::MongoCommand, String> {
dbx_core::mongo_shell::parse(&source)
}
async fn run_cancellable<T, F>(state: &Arc<AppState>, execution_id: Option<String>, future: F) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
+1
View File
@@ -1299,6 +1299,7 @@ pub fn run() {
commands::document_cmd::document_upload_gridfs_file,
commands::document_cmd::document_delete_gridfs_file,
commands::mongo_cmd::mongo_find_documents,
commands::mongo_cmd::mongo_parse_shell_command,
commands::mongo_cmd::mongo_find_one,
commands::mongo_cmd::mongo_count_documents,
commands::mongo_cmd::mongo_server_version,