From 77c1203e6f4234055d2d87924dbb0ef5c8c0dea7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 19 Jul 2026 00:47:56 +0800 Subject: [PATCH] feat(mcp): remove legacy TypeScript runtime --- .github/workflows/mcp-release.yml | 103 +- CONTRIBUTING.md | 2 +- CONTRIBUTING.zh-CN.md | 2 +- package.json | 6 +- packages/mcp-server/package.json | 12 +- packages/mcp-server/src/index.ts | 541 ----- .../mcp-server/tests/driver-manifest.test.ts | 57 - packages/mcp-server/tests/server.test.ts | 919 -------- packages/mcp-server/tsconfig.json | 15 - packages/mongo-shell/package.json | 7 +- packages/node-core/README.md | 27 - packages/node-core/package.json | 50 - packages/node-core/src/backend.ts | 37 - packages/node-core/src/bridge.ts | 25 - packages/node-core/src/connections.ts | 376 ---- packages/node-core/src/database.ts | 1839 ----------------- packages/node-core/src/diagnostics.ts | 132 -- packages/node-core/src/entrypoint.ts | 21 - packages/node-core/src/format.ts | 13 - packages/node-core/src/index.ts | 14 - packages/node-core/src/paths.ts | 35 - packages/node-core/src/production-safety.ts | 315 --- packages/node-core/src/redis-command.ts | 213 -- packages/node-core/src/schema-context.ts | 66 - packages/node-core/src/sql-diagnostics.ts | 130 -- packages/node-core/src/sql-risk.ts | 254 --- packages/node-core/src/sql-safety.ts | 189 -- packages/node-core/src/web-backend.ts | 452 ---- packages/node-core/tests/collections.test.ts | 23 - .../node-core/tests/connection-store.test.ts | 52 - .../node-core/tests/describe-table.test.ts | 295 --- .../node-core/tests/driver-manifest.test.ts | 98 - packages/node-core/tests/entrypoint.test.ts | 53 - packages/node-core/tests/format.test.ts | 14 - .../tests/mongo-count-routing.test.ts | 71 - packages/node-core/tests/mongo-query.test.ts | 476 ----- packages/node-core/tests/mysql-url.test.ts | 34 - packages/node-core/tests/paths.test.ts | 11 - packages/node-core/tests/postgres-ssl.test.ts | 140 -- packages/node-core/tests/postgres-url.test.ts | 79 - .../node-core/tests/production-safety.test.ts | 108 - .../node-core/tests/redis-command.test.ts | 37 - packages/node-core/tests/redis-direct.test.ts | 145 -- .../node-core/tests/rqlite-direct.test.ts | 131 -- .../node-core/tests/schema-context.test.ts | 87 - .../node-core/tests/sql-diagnostics.test.ts | 107 - packages/node-core/tests/sql-safety.test.ts | 187 -- .../node-core/tests/sqlite-direct.test.ts | 131 -- .../node-core/tests/starrocks-url.test.ts | 62 - packages/node-core/tests/web-backend.test.ts | 183 -- packages/node-core/tsconfig.json | 14 - packages/node-core/vitest.config.ts | 7 - pnpm-lock.yaml | 923 +-------- scripts/release.mjs | 11 - scripts/verify-package-install.mjs | 2 - vitest.config.ts | 2 +- 56 files changed, 68 insertions(+), 9267 deletions(-) delete mode 100644 packages/mcp-server/src/index.ts delete mode 100644 packages/mcp-server/tests/driver-manifest.test.ts delete mode 100644 packages/mcp-server/tests/server.test.ts delete mode 100644 packages/mcp-server/tsconfig.json delete mode 100644 packages/node-core/README.md delete mode 100644 packages/node-core/package.json delete mode 100644 packages/node-core/src/backend.ts delete mode 100644 packages/node-core/src/bridge.ts delete mode 100644 packages/node-core/src/connections.ts delete mode 100644 packages/node-core/src/database.ts delete mode 100644 packages/node-core/src/diagnostics.ts delete mode 100644 packages/node-core/src/entrypoint.ts delete mode 100644 packages/node-core/src/format.ts delete mode 100644 packages/node-core/src/index.ts delete mode 100644 packages/node-core/src/paths.ts delete mode 100644 packages/node-core/src/production-safety.ts delete mode 100644 packages/node-core/src/redis-command.ts delete mode 100644 packages/node-core/src/schema-context.ts delete mode 100644 packages/node-core/src/sql-diagnostics.ts delete mode 100644 packages/node-core/src/sql-risk.ts delete mode 100644 packages/node-core/src/sql-safety.ts delete mode 100644 packages/node-core/src/web-backend.ts delete mode 100644 packages/node-core/tests/collections.test.ts delete mode 100644 packages/node-core/tests/connection-store.test.ts delete mode 100644 packages/node-core/tests/describe-table.test.ts delete mode 100644 packages/node-core/tests/driver-manifest.test.ts delete mode 100644 packages/node-core/tests/entrypoint.test.ts delete mode 100644 packages/node-core/tests/format.test.ts delete mode 100644 packages/node-core/tests/mongo-count-routing.test.ts delete mode 100644 packages/node-core/tests/mongo-query.test.ts delete mode 100644 packages/node-core/tests/mysql-url.test.ts delete mode 100644 packages/node-core/tests/paths.test.ts delete mode 100644 packages/node-core/tests/postgres-ssl.test.ts delete mode 100644 packages/node-core/tests/postgres-url.test.ts delete mode 100644 packages/node-core/tests/production-safety.test.ts delete mode 100644 packages/node-core/tests/redis-command.test.ts delete mode 100644 packages/node-core/tests/redis-direct.test.ts delete mode 100644 packages/node-core/tests/rqlite-direct.test.ts delete mode 100644 packages/node-core/tests/schema-context.test.ts delete mode 100644 packages/node-core/tests/sql-diagnostics.test.ts delete mode 100644 packages/node-core/tests/sql-safety.test.ts delete mode 100644 packages/node-core/tests/sqlite-direct.test.ts delete mode 100644 packages/node-core/tests/starrocks-url.test.ts delete mode 100644 packages/node-core/tests/web-backend.test.ts delete mode 100644 packages/node-core/tsconfig.json delete mode 100644 packages/node-core/vitest.config.ts diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 477eb05d3..40e6e240f 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -74,8 +74,6 @@ jobs: const writeJson = (path, data) => fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); for (const path of [ - "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", @@ -175,7 +173,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 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 + git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml crates/dbx-cli/Cargo.toml 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 @@ -208,105 +206,6 @@ jobs: git push origin "refs/tags/${TAG}:refs/tags/${TAG}" fi - publish-mongo-shell: - name: Publish mongo-shell - runs-on: ubuntu-latest - needs: prepare - - steps: - - uses: actions/checkout@v5 - with: - ref: ${{ needs.prepare.outputs.tag }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.13.0 - registry-url: https://registry.npmjs.org - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Publish Mongo shell parser - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - PACKAGE_PATH="packages/mongo-shell/package.json" - set_package_version() { - PACKAGE_PATH="${PACKAGE_PATH}" PACKAGE_VERSION="$1" node <<'NODE' - const fs = require("fs"); - const path = process.env.PACKAGE_PATH; - const pkg = JSON.parse(fs.readFileSync(path, "utf8")); - pkg.version = process.env.PACKAGE_VERSION; - fs.writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`); - NODE - } - - # node-core@0.4.31 already references ^0.1.0, so bootstrap that version once to repair existing installs. - if ! npm view "@dbx-app/mongo-shell@0.1.0" version >/dev/null 2>&1; then - set_package_version "0.1.0" - pnpm --filter @dbx-app/mongo-shell publish --access public --provenance --no-git-checks - fi - - set_package_version "${VERSION}" - if npm view "@dbx-app/mongo-shell@${VERSION}" version >/dev/null 2>&1; then - echo "@dbx-app/mongo-shell@${VERSION} already exists on npm; skipping." - exit 0 - fi - pnpm --filter @dbx-app/mongo-shell publish --access public --provenance --no-git-checks - - publish-node-core: - name: Publish node-core - runs-on: ubuntu-latest - # node-core keeps mongo-shell as a runtime dependency, so npm must receive it first. - needs: [prepare, publish-mongo-shell] - outputs: - published-or-existing: ${{ steps.publish.outputs.published_or_existing }} - - steps: - - uses: actions/checkout@v5 - with: - ref: ${{ needs.prepare.outputs.tag }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.13.0 - registry-url: https://registry.npmjs.org - 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: Publish Node core - id: publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - if npm view "@dbx-app/node-core@${VERSION}" version >/dev/null 2>&1; then - echo "@dbx-app/node-core@${VERSION} already exists on npm; skipping." - echo "published_or_existing=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a94a6169d..be1a4574b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl | `crates/dbx-web/` | Docker / Web HTTP backend | | `packages/cli/` | `@dbx-app/cli` | | `packages/mcp-server/` | `@dbx-app/mcp-server` | -| `packages/node-core/` | Shared Node.js bridge and direct-query logic | +| `packages/mongo-shell/` | Private MongoDB editor parsing helpers | | `docs/` | Official documentation site | | `examples/` | Sample configs and automation scripts | | `agents/` | JDBC agent driver projects | diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 40433e1aa..18deeb781 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -64,7 +64,7 @@ cd agents | `crates/dbx-web/` | Docker / Web HTTP 后端 | | `packages/cli/` | `@dbx-app/cli` | | `packages/mcp-server/` | `@dbx-app/mcp-server` | -| `packages/node-core/` | Node.js bridge 与直连查询逻辑 | +| `packages/mongo-shell/` | 桌面端内部 MongoDB 编辑器解析工具 | | `docs/` | 官方文档站 | | `examples/` | 配置与自动化示例 | | `agents/` | JDBC Agent 驱动工程 | diff --git a/package.json b/package.json index cfb8630e7..4740e769c 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web", "dev:backend": "node scripts/dev-backend.mjs", "dev:full:win": ".\\scripts\\dev-full.bat", - "build:packages": "pnpm --filter @dbx-app/mongo-shell build && pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build", - "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", + "build:packages": "pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build", + "test:packages": "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/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", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index a5a76c0a8..5206e86be 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -29,9 +29,8 @@ "type": "module", "scripts": { "start": "cargo run -p dbx-mcp --", - "test": "cargo build -p dbx-mcp && pnpm build:legacy && vitest run --config vitest.config.ts", - "build": "pnpm build:legacy", - "build:legacy": "pnpm --filter @dbx-app/node-core build && tsc", + "test": "cargo build -p dbx-mcp && vitest run --config vitest.config.ts", + "build": "node --check bin/dbx-mcp-server.js", "prepublishOnly": "node bin/dbx-mcp-server.js --verify-platform" }, "optionalDependencies": { @@ -43,13 +42,8 @@ "@dbx-app/mcp-win32-x64": "0.4.36" }, "devDependencies": { - "@dbx-app/node-core": "workspace:^", - "@modelcontextprotocol/sdk": "^1.12.1", "@types/node": "^22.15.21", - "tsx": "^4.19.4", - "typescript": "^5.8.3", - "vitest": "^4.1.8", - "zod": "^3.25.20" + "vitest": "^4.1.8" }, "engines": { "node": ">=18.18.0" diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index 1b9619215..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,541 +0,0 @@ -#!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createRequire } from "node:module"; -import { z } from "zod"; -import { - buildSchemaContext, - createBackend, - evaluateRedisCommandSafety, - evaluateMongoAggregateSafety, - evaluateSqlSafety, - formatCell, - formatSchemaContext, - isMainModule, - mdTable, - notifyReload, - parseMongoAggregateCommand, - assessProductionSql, - isLikelyMongoMutation, - isProductionDatabase, - postBridge, - logSqlDiagnostic, - sqlSafetyFromEnv, - splitSqlStatements, - supportsHashLineComments, - type Backend, - type ConnectionConfig, - type QueryResult, - type RedisCommandResult, -} from "@dbx-app/node-core"; - -const require = createRequire(import.meta.url); -const packageJson = require("../package.json") as { version?: string }; -export const DBX_MCP_PACKAGE_VERSION = packageJson.version ?? "0.0.0"; - -function text(s: string) { - return { content: [{ type: "text" as const, text: s }] }; -} - -function toolError(code: string, message: string) { - return { ...text(`${code}: ${message}`), isError: true }; -} - -function withDatabase(config: ConnectionConfig, database?: string): ConnectionConfig { - return database === undefined ? config : { ...config, database }; -} - -function metadataScope(config: ConnectionConfig, database?: string, schema?: string): { config: ConnectionConfig; schema?: string } { - if (config.db_type !== "dameng") { - return { config: withDatabase(config, database), schema }; - } - - // Dameng exposes tables under user-owned schemas rather than separate - // databases. Accept the legacy database argument as a schema, and default to - // the login user when neither argument is provided. - const resolvedSchema = schema?.trim() || database?.trim() || config.username?.trim() || undefined; - return { config, schema: resolvedSchema }; -} - -function connectionIdentity(config: ConnectionConfig): string { - return `${config.name} (${config.id}) [${config.db_type} @ ${config.host}:${config.port}]`; -} - -function labeledText(config: ConnectionConfig, body: string): ReturnType { - return text(`[${connectionIdentity(config)}]\n${body}`); -} - -function formatQueryToolResult(result: QueryResult, title?: string) { - const prefix = title ? `${title}\n` : ""; - if (result.columns.length === 0) return text(`${prefix}Query executed. ${result.row_count} row(s) affected.`); - const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c]))); - return text(`${prefix}${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`); -} - -function redisDbFromValue(value?: string): number | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - const db = Number(trimmed); - return Number.isInteger(db) && db >= 0 ? db : undefined; -} - -function defaultRedisDb(config: ConnectionConfig, scope: McpScope, db?: number): number { - return db ?? redisDbFromValue(scope.database) ?? redisDbFromValue(config.database) ?? 0; -} - -function formatRedisCommandValue(value: unknown): string { - if (typeof value === "string") return value; - return JSON.stringify(value, null, 2) ?? String(value); -} - -function formatRedisCommandToolResult(result: RedisCommandResult) { - return text(`Command: ${result.command}\nSafety: ${result.safety}\n\n${formatRedisCommandValue(result.value)}`); -} - -export const DBX_CONNECTION_TYPE_DESCRIPTION = - "Database type: postgres, mysql, sqlite, rqlite, cloudflare-d1, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, milvus, qdrant, weaviate, chromadb, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, prestosql, hive, spark, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, oscar, tdengine, iotdb, xugu, zookeeper, jdbc, access, mq"; -const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]); - -interface McpScope { - connectionId?: string; - connectionName?: string; - database?: string; -} - -function scopedValue(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function mcpScopeFromEnv(): McpScope { - return { - connectionId: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_ID), - connectionName: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_NAME), - database: scopedValue(process.env.DBX_MCP_SCOPE_DATABASE), - }; -} - -function scopeEnabled(scope: McpScope): boolean { - return !!(scope.connectionId || scope.connectionName); -} - -function connectionMatchesScope(config: ConnectionConfig, scope: McpScope): boolean { - return (!!scope.connectionId && config.id === scope.connectionId) || (!!scope.connectionName && config.name === scope.connectionName); -} - -async function loadScopedConnections(backend: Backend, scope: McpScope): Promise { - const connections = await backend.loadConnections(); - if (!scopeEnabled(scope)) return connections; - return connections.filter((config) => connectionMatchesScope(config, scope)); -} - -async function resolveConnection(backend: Backend, scope: McpScope, requestedId?: string, requestedName?: string): Promise<{ config?: ConnectionConfig; error?: ReturnType }> { - // connection_id takes priority over connection_name when both are provided. - if (requestedId?.trim()) { - const connections = await backend.loadConnections(); - const config = connections.find((c) => c.id === requestedId.trim()); - if (!config) return { error: toolError("CONNECTION_NOT_FOUND", `Connection with id "${requestedId}" not found.`) }; - // In scoped mode, verify the resolved connection is within the scope. - if (scopeEnabled(scope) && !connectionMatchesScope(config, scope)) { - return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedId}" is outside this DBX AI session scope.`) }; - } - return { config }; - } - - if (!scopeEnabled(scope)) { - if (!requestedName?.trim()) return { error: toolError("CONNECTION_NOT_FOUND", "Connection name is required.") }; - const connections = await backend.loadConnections(); - const matching = connections.filter((c) => c.name.toLowerCase() === requestedName.trim().toLowerCase()); - if (matching.length === 0) return { error: toolError("CONNECTION_NOT_FOUND", `Connection "${requestedName}" not found.`) }; - if (matching.length > 1) { - const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`); - return { - error: toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${requestedName}". Please specify connection_id:\n${lines.join("\n")}`), - }; - } - return { config: matching[0] }; - } - - const [scopedConfig] = await loadScopedConnections(backend, scope); - if (!scopedConfig) return { error: toolError("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found.") }; - if (requestedName?.trim() && requestedName !== scopedConfig.name && requestedName !== scopedConfig.id) { - return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedName}" is outside this DBX AI session scope.`) }; - } - return { config: scopedConfig }; -} - -export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer { - const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL; - const scope = mcpScopeFromEnv(); - const scoped = scopeEnabled(scope); - const server = new McpServer({ - name: "dbx", - version: DBX_MCP_PACKAGE_VERSION, - }); - - server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => { - const connections = await loadScopedConnections(backend, scope); - if (connections.length === 0) return text("No connections configured in DBX."); - const rows = connections.map((c) => [c.id, c.name, c.db_type, c.host, String(c.port), c.database || ""]); - return text(mdTable(["ID", "Name", "Type", "Host", "Port", "Database"], rows)); - }); - - server.tool( - "dbx_list_tables", - "List tables and views for a database connection", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - database: z.string().optional().describe("Database name; for Dameng this is also accepted as a schema alias"), - schema: z.string().optional().describe("Schema name (default: public for PostgreSQL, login user for Dameng)"), - }, - async ({ connection_id, connection_name, database, schema }) => { - const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name); - if (error) return error; - const resolvedConfig = config!; - const scopeValue = metadataScope(resolvedConfig, database ?? scope.database, schema); - const tables = await backend.listTables(scopeValue.config, scopeValue.schema); - if (tables.length === 0) return text("No tables found."); - const rows = tables.map((t) => [t.name, t.type]); - return labeledText(resolvedConfig, mdTable(["Table", "Type"], rows)); - }, - ); - - server.tool( - "dbx_describe_table", - "Get column definitions for a table", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - table: z.string().describe("Table name"), - database: z.string().optional().describe("Database name; for Dameng this is also accepted as a schema alias"), - schema: z.string().optional().describe("Schema name (default: public for PostgreSQL, login user for Dameng)"), - }, - async ({ connection_id, connection_name, table, database, schema }) => { - const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name); - if (error) return error; - const resolvedConfig = config!; - const scopeValue = metadataScope(resolvedConfig, database ?? scope.database, schema); - const columns = await backend.describeTable(scopeValue.config, table, scopeValue.schema); - if (columns.length === 0) return text("No columns found."); - const rows = 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 ?? ""]); - return labeledText(resolvedConfig, mdTable(["Column", "Type", "Nullable", "Default", "Comment"], rows)); - }, - ); - - server.tool( - "dbx_execute_query", - "Execute a SQL query on a database connection (max 100 rows returned)", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - database: z.string().optional().describe("Database name"), - sql: z.string().describe("SQL query to execute"), - }, - async ({ connection_id, connection_name, database, sql }) => { - logSqlDiagnostic("dbx_execute_query", sql, { connection_id, connection_name, database }); - const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name); - if (error) return error; - const scopedConfig = config!; - if (scopedConfig.db_type === "redis") { - return toolError("REDIS_COMMAND_REQUIRED", "Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command with a Redis command such as GET key or INFO."); - } - if (scopedConfig.db_type !== "mongodb") { - const hashLineComments = supportsHashLineComments(scopedConfig.db_type); - const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true, hashLineComments }); - if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked."); - const production = assessProductionSql(sql, scopedConfig, database ?? scope.database ?? scopedConfig.database); - if (production.active && production.isMutation) { - return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the SQL for a user to review and run in DBX."); - } - } else if (isProductionDatabase(scopedConfig, database ?? scope.database ?? scopedConfig.database) && isLikelyMongoMutation(sql)) { - return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the command for a user to review and run in DBX."); - } - // MongoDB shell commands don't fit the SQL safety evaluator; the backend - // (node-core executeQuery) applies command-aware read/write gating. - try { - const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql, { hashLineComments: supportsHashLineComments(scopedConfig.db_type) }); - const results = []; - for (const statement of statements) { - results.push(await backend.executeQuery(withDatabase(scopedConfig, database ?? scope.database), statement)); - } - if (results.length === 1) return labeledText(scopedConfig, formatQueryToolResult(results[0]).content[0].text); - return labeledText(scopedConfig, results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n")); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - return toolError("QUERY_ERROR", msg); - } - }, - ); - - server.tool( - "dbx_execute_redis_command", - "Execute a Redis command on a Redis connection", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX Redis connection"), - db: z.number().int().min(0).optional().describe("Redis logical database number (default: scoped/default database or 0)"), - command: z.string().describe("Redis command to execute, for example: GET mykey, INFO, or DBSIZE"), - }, - async ({ connection_id, connection_name, db, command }) => { - const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name); - if (error) return error; - const scopedConfig = config!; - if (scopedConfig.db_type !== "redis") { - return toolError("INVALID_CONNECTION_TYPE", `Connection "${scopedConfig.name}" is ${scopedConfig.db_type}, not Redis.`); - } - if (!backend.executeRedisCommand) { - return toolError("UNSUPPORTED_BACKEND", "This DBX backend does not support Redis command execution."); - } - const safety = evaluateRedisCommandSafety(command, sqlSafetyFromEnv()); - if (!safety.allowed) return toolError("REDIS_COMMAND_BLOCKED", safety.reason ?? "Redis command blocked."); - if (isProductionDatabase(scopedConfig, String(defaultRedisDb(scopedConfig, scope, db))) && safety.safety !== "allowed") { - return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute write or dangerous Redis commands against a production database."); - } - try { - const result = await backend.executeRedisCommand(scopedConfig, defaultRedisDb(scopedConfig, scope, db), command, { - skipSafetyCheck: safety.skipSafetyCheck, - }); - return labeledText(scopedConfig, formatRedisCommandToolResult(result).content[0].text); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - return toolError("REDIS_COMMAND_ERROR", msg); - } - }, - ); - - server.tool( - "dbx_get_schema_context", - "Get compact table and column context for writing SQL", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - database: z.string().optional().describe("Database name"), - schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"), - tables: z.array(z.string()).optional().describe("Specific table names to include"), - max_tables: z.number().int().min(1).max(20).default(8).describe("Maximum number of tables to include"), - }, - async ({ connection_id, connection_name, database, schema, tables, max_tables }) => { - const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name); - if (error) return error; - const resolvedConfig = config!; - const context = await buildSchemaContext(backend, withDatabase(resolvedConfig, database ?? scope.database), { - schema, - tables, - maxTables: max_tables, - }); - if (context.tables.length === 0) return text("No matching tables found."); - return labeledText(resolvedConfig, formatSchemaContext(context)); - }, - ); - - if (!scoped) { - server.tool( - "dbx_add_connection", - "Add a new database connection to DBX", - { - name: z.string().describe("Connection name"), - db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION), - host: z.string().describe("Database host; for cloudflare-d1, use the Cloudflare Account ID"), - port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"), - username: z.string().default("").describe("Username"), - password: z.string().default("").describe("Password; for cloudflare-d1, use the API Token"), - database: z.string().optional().describe("Default database name; for cloudflare-d1, use the D1 Database ID"), - ssl: z.boolean().default(false).describe("Enable SSL"), - driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"), - }, - async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => { - const existing = await backend.findConnection(name); - if (existing) return text(`Connection "${name}" already exists.`); - const DEFAULT_PORTS: Record = { - kwdb: 26257, - rqlite: 4001, - "cloudflare-d1": 443, - tdengine: 6041, - oscar: 2003, - iotdb: 6667, - xugu: 5138, - }; - const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined); - if (resolvedPort === undefined) return text("Port is required for this database type."); - const config = await backend.addConnection({ - name, - db_type, - host, - port: resolvedPort, - username, - password, - database, - ssl, - driver_profile, - ssh_enabled: false, - } as Omit); - await notifyReload(); - return text(`Connection "${config.name}" added (id: ${config.id}).`); - }, - ); - - server.tool( - "dbx_remove_connection", - "Remove a database connection from DBX", - { - connection_name: z.string().describe("Name of the connection to remove"), - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to remove by id instead of name)"), - }, - async ({ connection_name, connection_id }) => { - if (connection_id?.trim()) { - if (backend.removeConnectionById) { - const removed = await backend.removeConnectionById(connection_id.trim()); - if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`); - await notifyReload(); - return text(`Connection with id "${connection_id}" removed.`); - } - // Fallback: resolve by id then remove by name - const connections = await backend.loadConnections(); - const config = connections.find((c) => c.id === connection_id.trim()); - if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`); - const removed = await backend.removeConnection(config.name); - if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${config.name}" could not be removed.`); - await notifyReload(); - return text(`Connection "${config.name}" (id: ${config.id}) removed.`); - } - const allConnections = await backend.loadConnections(); - const matching = allConnections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase()); - if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`); - if (matching.length > 1) { - const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`); - return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`); - } - const removed = await backend.removeConnection(connection_name); - if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`); - await notifyReload(); - return text(`Connection "${connection_name}" removed.`); - }, - ); - } - - // Desktop-only tools: open table and execute-and-show require the Tauri bridge - if (!isWebMode && !scoped) { - server.tool( - "dbx_open_table", - "Open a table in DBX desktop app UI. Requires DBX to be running.", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - table: z.string().describe("Table name to open"), - database: z.string().optional().describe("Database name"), - schema: z.string().optional().describe("Schema name"), - }, - async ({ connection_id, connection_name, table, database, schema }) => { - let config: ConnectionConfig | undefined; - if (connection_id?.trim()) { - const connections = await backend.loadConnections(); - config = connections.find((c) => c.id === connection_id.trim()); - if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`); - } else if (connection_name?.trim()) { - const connections = await backend.loadConnections(); - const matching = connections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase()); - if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`); - if (matching.length > 1) { - const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`); - return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`); - } - config = matching[0]; - } else { - return toolError("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."); - } - return bridgeRequest("/open-table", { connection_id: config.id, connection_name: config.name, table, database, schema }, `Opened ${table} in DBX`); - }, - ); - - server.tool( - "dbx_execute_and_show", - "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running.", - { - connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"), - connection_name: z.string().optional().describe("Name of the DBX connection"), - sql: z.string().describe("SQL query to execute"), - database: z.string().optional().describe("Database name"), - }, - async ({ connection_id, connection_name, sql, database }) => { - let config: ConnectionConfig | undefined; - if (connection_id?.trim()) { - const connections = await backend.loadConnections(); - config = connections.find((c) => c.id === connection_id.trim()); - if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`); - } else if (connection_name?.trim()) { - const connections = await backend.loadConnections(); - const matching = connections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase()); - if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`); - if (matching.length > 1) { - const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`); - return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`); - } - config = matching[0]; - } else { - return toolError("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."); - } - const safetyOptions = sqlSafetyFromEnv(); - if (config?.db_type === "mongodb") { - const aggregate = parseMongoAggregateCommand(sql); - if (aggregate) { - const safety = evaluateMongoAggregateSafety(aggregate, safetyOptions); - if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked."); - } - } else { - const hashLineComments = supportsHashLineComments(config?.db_type); - const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true, hashLineComments }); - if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked."); - } - if (config?.db_type === "mongodb") { - if (isProductionDatabase(config, database ?? scope.database ?? config.database) && isLikelyMongoMutation(sql)) { - return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX."); - } - } else { - const production = assessProductionSql(sql, config, database ?? scope.database ?? config.database); - if (production.active && production.isMutation) { - return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX."); - } - } - // MongoDB shell commands bypass the SQL safety evaluator; pass MCP - // safety flags to the desktop executor for command-aware gating. - logSqlDiagnostic("dbx_execute_in_app", sql, { connection_id: config!.id, connection_name: config!.name, database }); - return bridgeRequest( - "/execute-query", - { - connection_id: config!.id, - connection_name: config!.name, - sql, - database, - allow_writes: safetyOptions.allowWrites, - allow_dangerous: safetyOptions.allowDangerous, - }, - "Query sent to DBX", - ); - }, - ); - } - - return server; -} - -async function bridgeRequest(path: string, body: Record, successMsg: string) { - const res = await postBridge(path, body); - if (res.ok) return text(successMsg); - const message = res.text.startsWith("DBX is not running") ? res.text : `Failed: ${res.text}`; - return toolError("DBX_NOT_RUNNING", message); -} - -async function main() { - const backend = await createBackend(); - const server = createDbxMcpServer(backend); - const transport = new StdioServerTransport(); - await server.connect(transport); -} - -if (isMainModule(import.meta.url, process.argv[1])) { - main().catch((e) => { - console.error("MCP Server failed to start:", e); - process.exit(1); - }); -} diff --git a/packages/mcp-server/tests/driver-manifest.test.ts b/packages/mcp-server/tests/driver-manifest.test.ts deleted file mode 100644 index 6538050f8..000000000 --- a/packages/mcp-server/tests/driver-manifest.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { test } from "vitest"; -import { DBX_CONNECTION_TYPE_DESCRIPTION } from "../src/index.js"; - -interface DriverManifest { - drivers: Array<{ - dbType: string; - supportLevel: "connect" | "browse" | "understand" | "operate"; - capabilities: Record; - }>; -} - -const PRODUCT_CAPABILITY_KEYS = [ - "queryExecution", - "metadataBrowse", - "objectBrowser", - "objectSource", - "schemaSearch", - "diagram", - "tableDataEdit", - "tableStructureEdit", - "tableImport", - "dataTransfer", - "sqlFileExecution", - "databaseCreate", - "fieldLineage", - "sqlExplain", - "userAdmin", - "driverManagement", -] as const; -type DatabaseProductCapability = (typeof PRODUCT_CAPABILITY_KEYS)[number]; - -function loadManifest(): DriverManifest { - const path = fileURLToPath(new URL("../../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url)); - return JSON.parse(readFileSync(path, "utf8")) as DriverManifest; -} - -test("add connection database type description includes every manifest database type", () => { - const manifest = loadManifest(); - - for (const driver of manifest.drivers) { - assert.match(DBX_CONNECTION_TYPE_DESCRIPTION, new RegExp(`\\b${driver.dbType}\\b`)); - } -}); - -test("driver manifest includes product capability metadata for every database type", () => { - const manifest = loadManifest(); - - for (const driver of manifest.drivers) { - assert.match(driver.supportLevel, /^(connect|browse|understand|operate)$/); - for (const key of PRODUCT_CAPABILITY_KEYS) { - assert.equal(typeof driver.capabilities[key], "boolean", `${driver.dbType}.${key} should be a boolean`); - } - } -}); diff --git a/packages/mcp-server/tests/server.test.ts b/packages/mcp-server/tests/server.test.ts deleted file mode 100644 index 2a9b12480..000000000 --- a/packages/mcp-server/tests/server.test.ts +++ /dev/null @@ -1,919 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "vitest"; -import type { Backend, ConnectionConfig } from "@dbx-app/node-core"; -import { createDbxMcpServer, DBX_MCP_PACKAGE_VERSION } from "../src/index.js"; - -const connection: ConnectionConfig = { - id: "1", - name: "local", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "app", - password: "", - database: "demo", - ssh_enabled: false, - ssl: false, -}; - -const backend: Backend = { - 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 }), -}; - -async function withScopedEnv(env: Record, fn: () => T | Promise): Promise { - const oldValues = new Map(); - for (const key of Object.keys(env)) { - oldValues.set(key, process.env[key]); - process.env[key] = env[key]; - } - try { - return await fn(); - } finally { - for (const [key, value] of oldValues) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } -} - -test("creates an MCP server without starting stdio transport", () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - - assert.equal(typeof server.connect, "function"); -}); - -test("MCP server metadata version matches package metadata", () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - - assert.equal((server as any).server._serverInfo.version, DBX_MCP_PACKAGE_VERSION); -}); - -test("README runtime requirements match package engines", async () => { - const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8")) as { - engines: { node: string }; - }; - const readme = await readFile(new URL("../README.md", import.meta.url), "utf-8"); - const minimumNodeVersion = packageJson.engines.node.replace(">=", ""); - - assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} or newer`)); - assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} 或更高版本`)); -}); - -test("execute query scopes the connection to the requested database", async () => { - let usedDatabase = ""; - const scopedBackend: Backend = { - ...backend, - executeQuery: async (config) => { - usedDatabase = config.database || ""; - return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - database: "stores_demo", - sql: "SELECT FIRST 1 tabname FROM systables", - }); - - assert.equal(usedDatabase, "stores_demo"); -}); - -test("execute query runs safe multi-statement SQL one statement at a time", async () => { - const executed: string[] = []; - const scopedBackend: Backend = { - ...backend, - executeQuery: async (_config, sql) => { - executed.push(sql); - return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "select 1; select 2;", - }); - - assert.deepEqual(executed, ["select 1", "select 2"]); - assert.match(result.content[0].text, /Statement 1/); - assert.match(result.content[0].text, /Statement 2/); -}); - -test("execute query preserves string literals and PostgreSQL dollar quotes", async () => { - const executed: string[] = []; - const scopedBackend: Backend = { - ...backend, - executeQuery: async (_config, sql) => { - executed.push(sql); - return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "SELECT 'a;b' AS first; SELECT $tag$c;d$tag$ AS second;", - }); - - assert.deepEqual(executed, ["SELECT 'a;b' AS first", "SELECT $tag$c;d$tag$ AS second"]); -}); - -test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "select 1; delete from users;", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /SQL_BLOCKED:/); - assert.match(result.content[0].text, /Statement 2/); - assert.match(result.content[0].text, /WHERE/); -}); - -test("scoped MCP lists only the active connection", async () => { - const other: ConnectionConfig = { ...connection, id: "2", name: "other", database: "other_db" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connection, other], - }; - - const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => { - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_list_connections.handler({}); - }); - - assert.match(result.content[0].text, /local/); - assert.doesNotMatch(result.content[0].text, /other/); -}); - -test("scoped MCP rejects out-of-scope connection tool calls", async () => { - const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "other" }); - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /CONNECTION_OUT_OF_SCOPE:/); -}); - -test("scoped MCP defaults connection-taking tools to the active connection and database", async () => { - let usedDatabase = ""; - const scopedBackend: Backend = { - ...backend, - listTables: async (config) => { - usedDatabase = config.database || ""; - return [{ name: "users", type: "BASE TABLE" }]; - }, - }; - - const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_SCOPE_DATABASE: "scoped_db" }, () => { - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_list_tables.handler({}); - }); - - assert.match(result.content[0].text, /users/); - assert.equal(usedDatabase, "scoped_db"); -}); - -test("scoped MCP does not register mutation or desktop bridge tools", async () => { - await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => { - const server = createDbxMcpServer(backend, { isWebMode: false }); - const tools = (server as any)._registeredTools; - - assert.equal(tools.dbx_add_connection, undefined); - assert.equal(tools.dbx_remove_connection, undefined); - assert.equal(tools.dbx_open_table, undefined); - assert.equal(tools.dbx_execute_and_show, undefined); - }); -}); - -test("scoped MCP with writes disabled blocks write SQL", async () => { - const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_ALLOW_WRITES: "0" }, () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_execute_query.handler({ - sql: "update users set name = 'x' where id = 1", - }); - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /SQL_BLOCKED:/); -}); - -test("redis execute query points callers to the redis command tool", async () => { - const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "0" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [redisConnection], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "GET session:1", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /REDIS_COMMAND_REQUIRED:/); - assert.match(result.content[0].text, /dbx_execute_redis_command/); -}); - -test("redis command tool executes redis commands on the selected database", async () => { - const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "2" }; - let usedDb = -1; - let usedCommand = ""; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [redisConnection], - executeRedisCommand: async (_config, db, command) => { - usedDb = db; - usedCommand = command; - return { command: "GET", safety: "allowed", value: "value-1" }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_redis_command.handler({ - connection_name: "local", - command: "GET session:1", - }); - - assert.equal(result.isError, undefined); - assert.equal(usedDb, 2); - assert.equal(usedCommand, "GET session:1"); - assert.match(result.content[0].text, /Command: GET/); - assert.match(result.content[0].text, /value-1/); -}); - -test("dbx_execute_query does not log SQL when debug diagnostics are disabled", async () => { - const original = console.error; - const originalDebug = process.env.DBX_SQL_DEBUG; - const originalMcpDebug = process.env.DBX_MCP_DEBUG_SQL; - const messages: unknown[][] = []; - delete process.env.DBX_SQL_DEBUG; - delete process.env.DBX_MCP_DEBUG_SQL; - console.error = (...args: unknown[]) => messages.push(args); - try { - const server = createDbxMcpServer(backend, { isWebMode: true }); - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "select 'secret-123' as token", - }); - assert.equal(result.isError, undefined); - } finally { - console.error = original; - if (originalDebug === undefined) delete process.env.DBX_SQL_DEBUG; - else process.env.DBX_SQL_DEBUG = originalDebug; - if (originalMcpDebug === undefined) delete process.env.DBX_MCP_DEBUG_SQL; - else process.env.DBX_MCP_DEBUG_SQL = originalMcpDebug; - } - - assert.equal(messages.length, 0); -}); - -test("dbx_execute_query omits raw SQL from user-facing query errors", async () => { - const sensitiveSql = "select 'secret-123' as token"; - const scopedBackend: Backend = { - ...backend, - executeQuery: async () => { - throw new Error("driver rejected statement"); - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: sensitiveSql, - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /QUERY_ERROR: driver rejected statement/); - assert.doesNotMatch(result.content[0].text, /secret-123|SQL:/); -}); - -test("redis command tool blocks write commands in read-only MCP sessions", async () => { - let executed = false; - const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [redisConnection], - executeRedisCommand: async () => { - executed = true; - return { command: "SET", safety: "write", value: "OK" }; - }, - }; - - const result = await withScopedEnv({ DBX_MCP_ALLOW_WRITES: "0" }, () => { - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_execute_redis_command.handler({ - connection_name: "local", - command: "SET session:1 value", - }); - }); - - assert.equal(executed, false); - assert.equal(result.isError, true); - assert.match(result.content[0].text, /REDIS_COMMAND_BLOCKED:/); -}); - -test("redis command tool allows dangerous redis commands only when explicitly enabled", async () => { - const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" }; - let skipSafetyCheck = false; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [redisConnection], - executeRedisCommand: async (_config, _db, _command, options) => { - skipSafetyCheck = options?.skipSafetyCheck ?? false; - return { command: "KEYS", safety: "blocked", value: ["session:1"] }; - }, - }; - - const blocked = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "0" }, () => { - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_execute_redis_command.handler({ - connection_name: "local", - command: "KEYS *", - }); - }); - const allowed = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "1" }, () => { - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - return (server as any)._registeredTools.dbx_execute_redis_command.handler({ - connection_name: "local", - command: "KEYS *", - }); - }); - - assert.equal(blocked.isError, true); - assert.match(blocked.content[0].text, /REDIS_COMMAND_BLOCKED:/); - assert.equal(allowed.isError, undefined); - assert.equal(skipSafetyCheck, true); - assert.match(allowed.content[0].text, /session:1/); -}); - -test("mongodb list tables returns collections from the selected database", async () => { - let usedDatabase = ""; - const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [mongoConnection], - listTables: async (config) => { - usedDatabase = config.database || ""; - return [{ name: "projects", type: "COLLECTION" }]; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_name: "local", - database: "pystrument", - }); - - assert.equal(usedDatabase, "pystrument"); - assert.match(result.content[0].text, /projects/); - assert.match(result.content[0].text, /COLLECTION/); -}); - -test("mongodb describe table returns inferred document fields", async () => { - const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [mongoConnection], - describeTable: async () => [ - { - name: "_id", - data_type: "object", - is_nullable: false, - column_default: null, - is_primary_key: true, - comment: null, - }, - { - name: "name", - data_type: "string", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: null, - }, - ], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_describe_table.handler({ - connection_name: "local", - database: "pystrument", - table: "projects", - }); - - assert.match(result.content[0].text, /_id \(PK\)/); - assert.match(result.content[0].text, /name/); -}); - -test("dameng metadata tools default to the login user schema", async () => { - const damengConnection: ConnectionConfig = { - ...connection, - db_type: "dameng", - username: "SYSDBA", - database: "DAMENG", - }; - const usedScopes: Array<{ database?: string; schema?: string }> = []; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [damengConnection], - listTables: async (config, schema) => { - usedScopes.push({ database: config.database, schema }); - return [{ name: "ORDERS", type: "TABLE" }]; - }, - describeTable: async (config, _table, schema) => { - usedScopes.push({ database: config.database, schema }); - return [{ name: "ID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, comment: null }]; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local" }); - await (server as any)._registeredTools.dbx_describe_table.handler({ connection_name: "local", table: "ORDERS" }); - - assert.deepEqual(usedScopes, [ - { database: "DAMENG", schema: "SYSDBA" }, - { database: "DAMENG", schema: "SYSDBA" }, - ]); -}); - -test("dameng metadata tools treat database as a schema alias while preferring explicit schema", async () => { - const damengConnection: ConnectionConfig = { ...connection, db_type: "dameng", username: "SYSDBA", database: "DAMENG" }; - let usedSchema = ""; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [damengConnection], - listTables: async (_config, schema) => { - usedSchema = schema || ""; - return [{ name: "ORDERS", type: "TABLE" }]; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC" }); - - assert.equal(usedSchema, "XC"); - - await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC", schema: "REPORTING" }); - - assert.equal(usedSchema, "REPORTING"); -}); - -test("mongodb execute query formats shell-style find results", async () => { - const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [mongoConnection], - executeQuery: async () => ({ - columns: ["_id", "meta", "missing"], - rows: [{ _id: "1", meta: { name: "demo" }, missing: null }], - row_count: 1, - }), - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - database: "pystrument", - sql: "db.projects.find({}).limit(1)", - }); - - assert.match(result.content[0].text, /"name":"demo"/); - assert.match(result.content[0].text, /NULL/); - assert.match(result.content[0].text, /1 row\(s\)/); -}); - -test("connection lookup failures include a stable MCP error code", async () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_name: "missing", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /CONNECTION_NOT_FOUND:/); - assert.match(result.content[0].text, /missing/); -}); - -test("add connection accepts H2 file paths without a port", async () => { - let added: Omit | undefined; - const scopedBackend: Backend = { - ...backend, - addConnection: async (config) => { - added = config; - return { id: "h2-file", ...config }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_add_connection.handler({ - name: "h2-local", - db_type: "h2", - host: "/data/app.mv.db", - username: "sa", - password: "", - }); - - assert.equal(result.isError, undefined); - assert.equal(added?.db_type, "h2"); - assert.equal(added?.host, "/data/app.mv.db"); - assert.equal(added?.port, 0); -}); - -test("SQL safety failures include a stable MCP error code", async () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "drop table users", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /SQL_BLOCKED:/); - assert.match(result.content[0].text, /Dangerous SQL/); -}); - -test("query exceptions include a stable MCP error code", async () => { - const scopedBackend: Backend = { - ...backend, - executeQuery: async () => { - throw new Error("database timeout"); - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "select 1", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /QUERY_ERROR: database timeout/); -}); - -test("desktop bridge failures include a stable MCP error code", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-mcp-home-")); - - try { - // Use DBX_DATA_DIR (honoured cross-platform) to point bridgePortFilePath() - // at an empty temp directory so no real bridge is reachable. - await withScopedEnv({ DBX_DATA_DIR: dir }, async () => { - const server = createDbxMcpServer(backend, { isWebMode: false }); - const result = await (server as any)._registeredTools.dbx_open_table.handler({ - connection_name: "local", - table: "users", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /DBX_NOT_RUNNING:/); - assert.match(result.content[0].text, /DBX is not running/); - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("mongodb execute-and-show blocks aggregate write stages before desktop bridge", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - delete process.env.DBX_MCP_ALLOW_WRITES; - delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [mongoConnection], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: false }); - - try { - const result = await (server as any)._registeredTools.dbx_execute_and_show.handler({ - connection_name: "local", - database: "pystrument", - sql: 'db.projects.aggregate([{"$out":"projects_dump"}])', - }); - - assert.match(result.content[0].text, /SQL_BLOCKED:/); - assert.match(result.content[0].text, /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - } finally { - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; - if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous; - } -}); - -test("connection_id parameter resolves correctly", async () => { - const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" }; - const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // Resolve by connection_id should return the correct connection - const result = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_id: "d4e5f6", - }); - - assert.match(result.content[0].text, /users/); -}); - -test("duplicate connection names return AMBIGUOUS_CONNECTION error", async () => { - const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres", host: "pg.local", port: 5432 }; - const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // Using connection_name with duplicates should return AMBIGUOUS_CONNECTION - const result = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_name: "shared-name", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/); - assert.match(result.content[0].text, /a1b2c3:/); - assert.match(result.content[0].text, /d4e5f6:/); - assert.match(result.content[0].text, /postgres @ pg.local:5432/); - assert.match(result.content[0].text, /redis @ redis.local:6379/); -}); - -test("connection_id takes priority over connection_name", async () => { - const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" }; - const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "mysql", host: "mysql.local", port: 3306 }; - let usedConfig: ConnectionConfig | undefined; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - listTables: async (config) => { - usedConfig = config; - return [{ name: "users", type: "BASE TABLE" }]; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // Provide both connection_id and connection_name; connection_id should win - await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_id: "d4e5f6", - connection_name: "shared-name", - }); - - assert.equal(usedConfig?.id, "d4e5f6"); -}); - -test("dbx_list_connections includes ID column", async () => { - const server = createDbxMcpServer(backend, { isWebMode: true }); - const result = await (server as any)._registeredTools.dbx_list_connections.handler({}); - - // The table header should include the ID column - assert.match(result.content[0].text, /ID.*Name.*Type.*Host.*Port.*Database/); - // The connection's ID value "1" should appear in the table - assert.match(result.content[0].text, /1\s+\|\s+local/); -}); - -test("same name and db_type with different host/port returns AMBIGUOUS_CONNECTION", async () => { - const connA: ConnectionConfig = { - ...connection, - id: "pg-prod-us", - name: "my-db", - db_type: "postgres", - host: "10.0.1.100", - port: 5432, - database: "app", - }; - const connB: ConnectionConfig = { - ...connection, - id: "pg-prod-eu", - name: "my-db", - db_type: "postgres", - host: "10.0.2.200", - port: 5432, - database: "app", - }; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // Using connection_name with duplicates (same db_type) should still return AMBIGUOUS_CONNECTION - const result = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_name: "my-db", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/); - assert.match(result.content[0].text, /pg-prod-us: postgres @ 10\.0\.1\.100:5432/); - assert.match(result.content[0].text, /pg-prod-eu: postgres @ 10\.0\.2\.200:5432/); -}); - -test("connection_id routes to correct host among same-name same-type connections", async () => { - const connA: ConnectionConfig = { - ...connection, - id: "pg-prod-us", - name: "my-db", - db_type: "postgres", - host: "10.0.1.100", - port: 5432, - database: "app", - }; - const connB: ConnectionConfig = { - ...connection, - id: "pg-prod-eu", - name: "my-db", - db_type: "postgres", - host: "10.0.2.200", - port: 5432, - database: "app", - }; - const usedConfigs: ConnectionConfig[] = []; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - listTables: async (config) => { - usedConfigs.push(config); - return [{ name: "orders", type: "BASE TABLE" }]; - }, - executeQuery: async (config, _sql) => { - usedConfigs.push(config); - return { columns: ["cnt"], rows: [{ cnt: 42 }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // Route to US instance via connection_id - const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({ - connection_id: "pg-prod-us", - }); - assert.match(listResult.content[0].text, /orders/); - assert.equal(usedConfigs[0].id, "pg-prod-us"); - assert.equal(usedConfigs[0].host, "10.0.1.100"); - - // Route to EU instance via connection_id - const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_id: "pg-prod-eu", - database: "app", - sql: "select count(*) as cnt from orders", - }); - assert.match(queryResult.content[0].text, /42/); - assert.equal(usedConfigs[1].id, "pg-prod-eu"); - assert.equal(usedConfigs[1].host, "10.0.2.200"); -}); - -test("tool responses are prefixed with connection identity label", async () => { - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [{ ...connection, id: "conn-xyz", name: "orders-db", db_type: "postgres", host: "10.5.5.5", port: 5432 }], - listTables: async () => [{ name: "orders", type: "BASE TABLE" }], - executeQuery: async () => ({ columns: ["cnt"], rows: [{ cnt: 7 }], row_count: 1 }), - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({ connection_id: "conn-xyz" }); - assert.match(listResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/); - - const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({ connection_id: "conn-xyz", sql: "select count(*) as cnt from orders" }); - assert.match(queryResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/); -}); - -test("dbx_remove_connection with duplicate names returns AMBIGUOUS_CONNECTION", async () => { - const connA: ConnectionConfig = { ...connection, id: "db-a", name: "staging", db_type: "postgres", host: "pg-a.local" }; - const connB: ConnectionConfig = { ...connection, id: "db-b", name: "staging", db_type: "mysql", host: "mysql-b.local", port: 3306 }; - let removedName: string | undefined; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connA, connB], - removeConnection: async (name) => { - removedName = name; - return true; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_remove_connection.handler({ - connection_name: "staging", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/); - assert.match(result.content[0].text, /db-a: postgres @ pg-a\.local/); - assert.match(result.content[0].text, /db-b: mysql @ mysql-b\.local/); - // removeConnection must NOT have been called — no silent deletion - assert.equal(removedName, undefined); -}); - -test("dbx_execute_query with connection_id routes correctly on bridge-backed (SSH) connections", async () => { - const connDirect: ConnectionConfig = { ...connection, id: "pg-direct", name: "shared", db_type: "postgres", host: "direct.local", ssh_enabled: false }; - const connSsh: ConnectionConfig = { ...connection, id: "pg-ssh", name: "shared", db_type: "postgres", host: "private.local", ssh_enabled: true }; - const usedConfigs: ConnectionConfig[] = []; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [connDirect, connSsh], - executeQuery: async (config, _sql) => { - usedConfigs.push(config); - return { columns: ["result"], rows: [{ result: "ok" }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // connection_name with two same-name connections (one SSH-backed) → AMBIGUOUS_CONNECTION - const ambigResult = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "shared", - sql: "select 1", - }); - assert.equal(ambigResult.isError, true); - assert.match(ambigResult.content[0].text, /AMBIGUOUS_CONNECTION:/); - assert.match(ambigResult.content[0].text, /pg-direct/); - assert.match(ambigResult.content[0].text, /pg-ssh/); - - // connection_id routes to the SSH-backed instance and passes its config through - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_id: "pg-ssh", - sql: "select 1", - }); - assert.equal(result.isError, undefined); - assert.equal(usedConfigs.length, 1); - assert.equal(usedConfigs[0].id, "pg-ssh"); - assert.equal(usedConfigs[0].host, "private.local"); - assert.equal(usedConfigs[0].ssh_enabled, true); -}); - -// --- Dialect-aware `#` comment handling --- - -test("dbx_execute_query splits PG `#` operator statements correctly", async () => { - const executed: string[] = []; - const scopedBackend: Backend = { - ...backend, - executeQuery: async (_config, sql) => { - executed.push(sql); - return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // On a postgres connection, `#` is an operator, not a comment. - // `SELECT 1 # 2; SELECT 3` should produce TWO executeQuery calls. - await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "SELECT 1 # 2; SELECT 3", - }); - - assert.deepEqual(executed, ["SELECT 1 # 2", "SELECT 3"]); -}); - -test("dbx_execute_query treats `#` as line comment on MySQL connections", async () => { - const mysqlConn: ConnectionConfig = { ...connection, id: "mysql-1", name: "mysql-local", db_type: "mysql" }; - const executed: string[] = []; - const scopedBackend: Backend = { - ...backend, - loadConnections: async () => [mysqlConn], - findConnection: async (name) => (name === "mysql-local" ? mysqlConn : undefined), - executeQuery: async (_config, sql) => { - executed.push(sql); - return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 }; - }, - }; - const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); - - // On a mysql connection, `#` IS a line comment. - // The `;` in `SELECT 1;` splits the first statement. The `# comment\nSELECT 2` - // is a single statement — the `#` makes everything on that line a comment, - // and after the newline `SELECT 2` continues (no `;` to split). - await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "mysql-local", - sql: "SELECT 1; # comment\nSELECT 2", - }); - - assert.deepEqual(executed, ["SELECT 1", "# comment\nSELECT 2"]); -}); - -test("dbx_execute_query blocks PG injection through `#` as comment in classification", async () => { - // `SELECT 1 # 2; DELETE FROM t` on a postgres connection: the `#` is an operator, - // so classification must see the DELETE and block it as a write in read-only mode. - const server = createDbxMcpServer(backend, { isWebMode: true }); - - const result = await (server as any)._registeredTools.dbx_execute_query.handler({ - connection_name: "local", - sql: "SELECT 1 # 2; DELETE FROM t", - }); - - assert.equal(result.isError, true); - assert.match(result.content[0].text, /SQL_BLOCKED:/); -}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index 27e58708f..000000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "nodenext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "types": ["node"], - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/packages/mongo-shell/package.json b/packages/mongo-shell/package.json index 20eba645e..f9e205e7a 100644 --- a/packages/mongo-shell/package.json +++ b/packages/mongo-shell/package.json @@ -1,7 +1,8 @@ { "name": "@dbx-app/mongo-shell", "version": "0.4.36", - "description": "Pure MongoDB shell JSON/argument parsing shared by desktop and node-core", + "private": true, + "description": "MongoDB shell parsing helpers used by the DBX desktop editor", "license": "Apache-2.0", "type": "module", "exports": { @@ -11,10 +12,6 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "src" - ], "scripts": { "build": "tsc", "prepare": "tsc" diff --git a/packages/node-core/README.md b/packages/node-core/README.md deleted file mode 100644 index 69f055aa4..000000000 --- a/packages/node-core/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# DBX Node Core - -Shared Node.js runtime utilities for DBX CLI and DBX MCP Server. - -This package reads DBX Desktop connection storage, redacts connection summaries, builds schema context, applies SQL safety rules, and executes supported direct database queries. - -## Supported Runtime - -Requires Node.js 22.13.0 or newer. - -## Direct Query Support - -Direct execution currently supports: - -- PostgreSQL and Redshift -- MySQL-compatible databases, including MySQL, Doris, and StarRocks -- SQLite - -Other DBX connection types can be routed through DBX Desktop bridge integrations used by the CLI and MCP server. - -## Public Modules - -```ts -import { createBackend, loadConnections, getDbxDiagnostics, evaluateSqlSafety, buildSchemaContext } from "@dbx-app/node-core"; -``` - -The package is intended as a shared implementation layer for official DBX Node packages. Applications should prefer `@dbx-app/cli` for terminal workflows and `@dbx-app/mcp-server` for MCP clients. diff --git a/packages/node-core/package.json b/packages/node-core/package.json deleted file mode 100644 index 78c3b9226..000000000 --- a/packages/node-core/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@dbx-app/node-core", - "version": "0.4.36", - "description": "Shared Node.js database and DBX connection utilities for DBX CLI and MCP server", - "license": "Apache-2.0", - "files": [ - "dist" - ], - "type": "module", - "exports": { - ".": "./dist/index.js", - "./backend": "./dist/backend.js", - "./bridge": "./dist/bridge.js", - "./connections": "./dist/connections.js", - "./database": "./dist/database.js", - "./diagnostics": "./dist/diagnostics.js", - "./entrypoint": "./dist/entrypoint.js", - "./format": "./dist/format.js", - "./paths": "./dist/paths.js", - "./production-safety": "./dist/production-safety.js", - "./redis-command": "./dist/redis-command.js", - "./schema-context": "./dist/schema-context.js", - "./sql-diagnostics": "./dist/sql-diagnostics.js", - "./sql-risk": "./dist/sql-risk.js", - "./sql-safety": "./dist/sql-safety.js" - }, - "scripts": { - "test": "vitest run --config vitest.config.ts", - "build": "pnpm --filter @dbx-app/mongo-shell build && tsc", - "prepublishOnly": "pnpm run build" - }, - "dependencies": { - "@dbx-app/mongo-shell": "workspace:^", - "better-sqlite3": "^12.9.0", - "ioredis": "^5.11.1", - "keytar": "^7.9.0", - "mysql2": "^3.14.1", - "pg": "^8.16.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22.15.21", - "@types/pg": "^8.15.4", - "tsx": "^4.19.4", - "typescript": "^5.8.3" - }, - "engines": { - "node": ">=22.13.0" - } -} diff --git a/packages/node-core/src/backend.ts b/packages/node-core/src/backend.ts deleted file mode 100644 index 8f0be02e2..000000000 --- a/packages/node-core/src/backend.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { addConnection as desktopAddConnection, findConnection as desktopFindConnection, loadConnections as desktopLoadConnections, removeConnection as desktopRemoveConnection, removeConnectionById as desktopRemoveConnectionById } from "./connections.js"; -import { closeDatabaseResources as desktopCloseDatabaseResources, describeTable as desktopDescribeTable, executeQuery as desktopExecuteQuery, executeRedisCommand as desktopExecuteRedisCommand, listTables as desktopListTables } from "./database.js"; -import type { ConnectionConfig } from "./connections.js"; -import type { ColumnInfo, QueryOptions, QueryResult, TableInfo } from "./database.js"; -import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js"; - -export interface Backend { - loadConnections(): Promise; - findConnection(name: string): Promise; - addConnection(config: Omit): Promise; - removeConnection(name: string): Promise; - removeConnectionById?(id: string): Promise; - listTables(config: ConnectionConfig, schema?: string): Promise; - describeTable(config: ConnectionConfig, table: string, schema?: string): Promise; - executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise; - executeRedisCommand?(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise; - close?(): Promise; -} - -export async function createBackend(env: NodeJS.ProcessEnv = process.env): Promise { - if (env.DBX_WEB_URL) { - return await import("./web-backend.js"); - } - - return { - loadConnections: desktopLoadConnections, - findConnection: desktopFindConnection, - addConnection: desktopAddConnection, - removeConnection: desktopRemoveConnection, - removeConnectionById: desktopRemoveConnectionById, - listTables: desktopListTables, - describeTable: desktopDescribeTable, - executeQuery: desktopExecuteQuery, - executeRedisCommand: desktopExecuteRedisCommand, - close: desktopCloseDatabaseResources, - }; -} diff --git a/packages/node-core/src/bridge.ts b/packages/node-core/src/bridge.ts deleted file mode 100644 index 475f4818d..000000000 --- a/packages/node-core/src/bridge.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { bridgePortFilePath } from "./paths.js"; - -export async function getBridgeUrl(): Promise { - const port = (await readFile(bridgePortFilePath(), "utf-8")).trim(); - return `http://127.0.0.1:${port}`; -} - -export async function postBridge(path: string, body: Record): Promise<{ ok: true; text: string } | { ok: false; text: string }> { - try { - const bridgeUrl = await getBridgeUrl(); - const res = await fetch(`${bridgeUrl}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return { ok: res.ok, text: res.ok ? "" : await res.text() }; - } catch { - return { ok: false, text: "DBX is not running. Please start DBX first." }; - } -} - -export async function notifyReload(): Promise { - await postBridge("/reload-connections", {}); -} diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts deleted file mode 100644 index 270879bd6..000000000 --- a/packages/node-core/src/connections.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { join } from "node:path"; -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import Database from "better-sqlite3"; -import { dbPath as defaultDbPath } from "./paths.js"; - -export interface ConnectionConfig { - id: string; - name: string; - db_type: string; - driver_profile?: string; - host: string; - port: number; - username: string; - password: string; - database?: string; - url_params?: string; - transport_layers?: TransportLayerConfig[]; - keepalive_interval_secs?: number; - ssl: boolean; - ca_cert_path?: string; - client_cert_path?: string; - client_key_path?: string; - oracle_connection_type?: "service_name" | "sid"; - redis_connection_mode?: "standalone" | "sentinel" | "cluster"; - redis_sentinel_master?: string; - redis_sentinel_nodes?: string; - redis_sentinel_username?: string; - redis_sentinel_password?: string; - redis_sentinel_tls?: boolean; - redis_cluster_nodes?: string; - redis_key_separator?: string; - read_only?: boolean; - is_production?: boolean; - production_databases?: string[]; -} - -export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig); - -export interface SshTunnelConfig { - id: string; - name?: string; - enabled?: boolean; - host: string; - port: number; - user: string; - password?: string; - key_path?: string; - key_passphrase?: string; - connect_timeout_secs?: number; - expose_lan?: boolean; - use_ssh_agent?: boolean; - ssh_agent_sock_path?: string; -} - -export interface ProxyTunnelConfig { - id: string; - name?: string; - enabled?: boolean; - proxy_type?: "socks5" | "http"; - host: string; - port: number; - username?: string; - password?: string; -} - -export interface ConnectionStoreOptions { - path?: string; -} - -export interface ConnectionStoreDiagnostics { - dbPath: string; - dbPathExists: boolean; - connectionsTableExists: boolean; - connectionSecretsTableExists: boolean; - connectionRowCount: number; - loadConnectionsOk: boolean; - loadedConnectionCount: number; - loadConnectionsError?: string; -} - -export class ConnectionStoreError extends Error { - readonly code = "CONNECTION_STORE_ERROR"; - - constructor(path: string, cause: unknown) { - const message = cause instanceof Error ? cause.message : String(cause); - super(`Failed to load DBX connections from ${path}: ${message}`); - this.name = "ConnectionStoreError"; - } -} - -export function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig { - if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") { - return { - ...config, - db_type: "tdengine", - driver_profile: "tdengine", - port: config.port === 0 || config.port === 6030 ? 6041 : config.port, - }; - } - if (config.db_type === "tdengine") { - return { - ...config, - driver_profile: "tdengine", - port: config.port || 6041, - }; - } - return config; -} - -function openDb(readonly = false, path = defaultDbPath()): Database.Database { - return new Database(path, { readonly }); -} - -function getSecret(db: Database.Database, connectionId: string, key: string): string { - const row = db.prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?").get(connectionId, key) as { secret: string } | undefined; - return row?.secret ?? ""; -} - -function transportLayerSecretSegment(index: number, layer: TransportLayerConfig): string { - return layer.id?.trim() || String(index); -} - -function transportLayerSshPasswordKey(index: number, layer: TransportLayerConfig): string { - return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_password`; -} - -function transportLayerSshKeyPassphraseKey(index: number, layer: TransportLayerConfig): string { - return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_key_passphrase`; -} - -function transportLayerProxyPasswordKey(index: number, layer: TransportLayerConfig): string { - return `transport_layers.${transportLayerSecretSegment(index, layer)}.proxy_password`; -} - -type LegacyConnectionConfig = ConnectionConfig & { - ssh_enabled?: boolean; - ssh_host?: string; - ssh_port?: number; - ssh_user?: string; - ssh_password?: string; - ssh_key_path?: string; - ssh_key_passphrase?: string; - ssh_expose_lan?: boolean; - ssh_connect_timeout_secs?: number; - ssh_tunnels?: SshTunnelConfig[]; - proxy_enabled?: boolean; - proxy_type?: "socks5" | "http"; - proxy_host?: string; - proxy_port?: number; - proxy_username?: string; - proxy_password?: string; -}; - -function normalizeTransportLayers(config: LegacyConnectionConfig): TransportLayerConfig[] { - if (Array.isArray(config.transport_layers) && config.transport_layers.length > 0) return config.transport_layers; - const layers: TransportLayerConfig[] = []; - if (config.ssh_enabled && Array.isArray(config.ssh_tunnels) && config.ssh_tunnels.length > 0) { - layers.push(...config.ssh_tunnels.map((hop) => ({ type: "ssh" as const, ...hop }))); - } else if (config.ssh_enabled && config.ssh_host) { - layers.push({ - type: "ssh", - id: "legacy", - enabled: true, - host: config.ssh_host, - port: config.ssh_port || 22, - user: config.ssh_user || "", - password: config.ssh_password || "", - key_path: config.ssh_key_path || "", - key_passphrase: config.ssh_key_passphrase || "", - connect_timeout_secs: config.ssh_connect_timeout_secs || 5, - expose_lan: !!config.ssh_expose_lan, - use_ssh_agent: false, - }); - } - if (config.proxy_enabled && config.proxy_host) { - layers.push({ - type: "proxy", - id: "legacy-proxy", - enabled: true, - proxy_type: config.proxy_type || "socks5", - host: config.proxy_host, - port: config.proxy_port || 1080, - username: config.proxy_username || "", - password: config.proxy_password || "", - }); - } - return layers; -} - -function hydrateTransportLayerSecrets(db: Database.Database, config: ConnectionConfig, connectionId: string) { - config.transport_layers = normalizeTransportLayers(config as LegacyConnectionConfig); - config.transport_layers.forEach((layer, index) => { - if (layer.type === "ssh") { - layer.password ||= getSecret(db, connectionId, transportLayerSshPasswordKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_password") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.password`)); - layer.key_passphrase ||= getSecret(db, connectionId, transportLayerSshKeyPassphraseKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_key_passphrase") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.key_passphrase`)); - } else { - layer.password ||= getSecret(db, connectionId, transportLayerProxyPasswordKey(index, layer)) || (layer.id === "legacy-proxy" ? getSecret(db, connectionId, "proxy_password") : ""); - } - }); -} - -export async function loadConnections(options: ConnectionStoreOptions = {}): Promise { - const path = options.path ?? defaultDbPath(); - if (!existsSync(path)) return []; - - let db: Database.Database | undefined; - try { - db = openDb(true, path); - const rows = db.prepare("SELECT id, config_json FROM connections").all() as { id: string; config_json: string }[]; - const configs: ConnectionConfig[] = []; - - for (const row of rows) { - const config: ConnectionConfig = canonicalizeConnection(JSON.parse(row.config_json)); - config.id = row.id; - if (!config.password) config.password = getSecret(db, row.id, "password"); - hydrateTransportLayerSecrets(db, config, row.id); - if (!config.redis_sentinel_password) { - config.redis_sentinel_password = getSecret(db, row.id, "redis_sentinel_password"); - } - configs.push(config); - } - - return configs; - } catch (error) { - throw new ConnectionStoreError(path, error); - } finally { - db?.close(); - } -} - -export async function inspectConnectionStore(options: ConnectionStoreOptions = {}): Promise { - const path = options.path ?? defaultDbPath(); - const diagnostics: ConnectionStoreDiagnostics = { - dbPath: path, - dbPathExists: existsSync(path), - connectionsTableExists: false, - connectionSecretsTableExists: false, - connectionRowCount: 0, - loadConnectionsOk: true, - loadedConnectionCount: 0, - }; - - if (!diagnostics.dbPathExists) return diagnostics; - - let db: Database.Database | undefined; - try { - db = openDb(true, path); - diagnostics.connectionsTableExists = tableExists(db, "connections"); - diagnostics.connectionSecretsTableExists = tableExists(db, "connection_secrets"); - if (diagnostics.connectionsTableExists) { - const row = db.prepare("SELECT COUNT(*) AS count FROM connections").get() as { count: number }; - diagnostics.connectionRowCount = row.count; - } - } catch (error) { - diagnostics.loadConnectionsOk = false; - diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error); - return diagnostics; - } finally { - db?.close(); - } - - try { - const connections = await loadConnections({ path }); - diagnostics.loadedConnectionCount = connections.length; - } catch (error) { - diagnostics.loadConnectionsOk = false; - diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error); - } - - return diagnostics; -} - -function tableExists(db: Database.Database, name: string): boolean { - const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { "1": number } | undefined; - return !!row; -} - -export async function findConnection(name: string): Promise { - const connections = await loadConnections(); - return connections.find((c) => c.name.toLowerCase() === name.toLowerCase()); -} - -export async function findConnectionById(id: string): Promise { - const connections = await loadConnections(); - return connections.find((c) => c.id === id); -} - -export async function addConnection(config: Omit): Promise { - const id = randomUUID(); - const db = openDb(); - const normalized = canonicalizeConnection({ ...config, id } as ConnectionConfig); - - const full = { - id, - name: normalized.name, - db_type: normalized.db_type, - driver_profile: normalized.driver_profile ?? normalized.db_type, - driver_label: null, - url_params: normalized.url_params ?? "", - host: normalized.host, - port: normalized.port, - username: normalized.username, - password: "", - database: normalized.database ?? null, - color: null, - transport_layers: normalizeTransportLayers(normalized as LegacyConnectionConfig).map((layer) => { - if (layer.type === "ssh") return { ...layer, password: "", key_passphrase: "" }; - return { ...layer, password: "" }; - }), - ssl: normalized.ssl ?? false, - sysdba: false, - oracle_connection_type: normalized.oracle_connection_type ?? null, - connection_string: null, - redis_connection_mode: normalized.redis_connection_mode ?? "standalone", - redis_sentinel_master: normalized.redis_sentinel_master ?? "", - redis_sentinel_nodes: normalized.redis_sentinel_nodes ?? "", - redis_sentinel_username: normalized.redis_sentinel_username ?? "", - redis_sentinel_password: "", - redis_sentinel_tls: normalized.redis_sentinel_tls ?? false, - }; - const configJson = JSON.stringify(full); - - const insert = db.transaction(() => { - db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson); - if (normalized.password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", normalized.password); - } - normalizeTransportLayers(normalized as LegacyConnectionConfig).forEach((layer, index) => { - if (layer.type === "ssh") { - if (layer.password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshPasswordKey(index, layer), layer.password); - } - if (layer.key_passphrase) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshKeyPassphraseKey(index, layer), layer.key_passphrase); - } - } else if (layer.password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerProxyPasswordKey(index, layer), layer.password); - } - }); - if (normalized.redis_sentinel_password) { - db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "redis_sentinel_password", normalized.redis_sentinel_password); - } - }); - insert(); - db.close(); - - return normalized; -} - -export async function removeConnection(name: string): Promise { - const connection = await findConnection(name); - if (!connection) return false; - - const db = openDb(); - const remove = db.transaction(() => { - db.prepare("DELETE FROM connections WHERE id = ?").run(connection.id); - db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(connection.id); - }); - remove(); - db.close(); - - return true; -} - -export async function removeConnectionById(id: string): Promise { - const db = openDb(); - const remove = db.transaction(() => { - const result = db.prepare("DELETE FROM connections WHERE id = ?").run(id); - db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(id); - return result.changes > 0; - }); - const deleted = remove(); - db.close(); - return deleted; -} diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts deleted file mode 100644 index c5f4446e9..000000000 --- a/packages/node-core/src/database.ts +++ /dev/null @@ -1,1839 +0,0 @@ -import type { ConnectionConfig, ProxyTunnelConfig } from "./connections.js"; -import type { SslOptions } from "mysql2"; -import { createServer, connect as netConnect, type Server, type Socket } from "node:net"; -import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import Database from "better-sqlite3"; -import { sqlSafetyFromEnv } from "./sql-safety.js"; -import { isDirectQueryType } from "./diagnostics.js"; -import { bridgePortFilePath } from "./paths.js"; -import { parseRedisCommandArgv, classifyRedisCommand, type RedisCommandOptions, type RedisCommandResult, type RedisCommandSafety } from "./redis-command.js"; -import { - chainedMethodCallPattern, - describeMongoCommandParseFailure, - findChainedMethodCallIndex, - findMatchingParen, - normalizeJsonArgument, - parseCollectionMethodTarget, - parseMongoAggregateCommand, - splitTopLevel, - type MongoAggregateCommand, -} from "@dbx-app/mongo-shell"; - -export { - describeMongoCommandParseFailure, - parseMongoAggregateCommand, - MONGO_SHELL_COMMAND_HINT, -} from "@dbx-app/mongo-shell"; -export type { MongoAggregateCommand } from "@dbx-app/mongo-shell"; - -export interface TableInfo { - name: string; - type: string; -} - -export interface CollectionInfo { - name: string; - id?: string; - dimension?: number | null; -} - -type CollectionListEntry = string | CollectionInfo; - -export interface ColumnInfo { - name: string; - data_type: string; - is_nullable: boolean; - column_default: string | null; - is_primary_key: boolean; - comment: string | null; - numeric_precision?: number | null; - numeric_scale?: number | null; - character_maximum_length?: number | null; - enum_values?: string[] | null; - character_set?: string | null; - collation?: string | null; -} - -export interface QueryResult { - columns: string[]; - rows: Record[]; - row_count: number; -} - -export interface QueryOptions { - maxRows?: number; - timeoutMs?: number; -} - -const MAX_ROWS = 100; -const IDLE_TIMEOUT_MS = 5 * 60 * 1000; -const QUERY_TIMEOUT_MS = 30_000; - -interface PoolEntry { - type: "pg" | "mysql"; - pool: unknown; - timer: ReturnType; -} - -type PostgresSslMode = "disable" | "prefer" | "require" | "verify-ca" | "verify-full"; - -interface RqliteResult { - columns?: string[]; - values?: unknown[][]; - rows_affected?: number; - error?: string; -} - -interface RqliteResponse { - results?: RqliteResult[]; -} - -const pools = new Map(); -const proxyTunnels = new Map }>(); - -function poolKey(config: ConnectionConfig): string { - return `${config.id}:${config.database || ""}`; -} - -function evictPool(key: string, entry: PoolEntry) { - pools.delete(key); - clearTimeout(entry.timer); - if (entry.type === "pg") { - (entry.pool as import("pg").Pool).end().catch(() => {}); - } else { - (entry.pool as import("mysql2/promise").Pool).end().catch(() => {}); - } -} - -function resetIdleTimer(key: string, entry: PoolEntry) { - clearTimeout(entry.timer); - entry.timer = setTimeout(() => evictPool(key, entry), IDLE_TIMEOUT_MS); -} - -export async function closeDatabaseResources(): Promise { - const poolEntries = [...pools.entries()]; - pools.clear(); - await Promise.all( - poolEntries.map(async ([, entry]) => { - clearTimeout(entry.timer); - if (entry.type === "pg") { - await (entry.pool as import("pg").Pool).end().catch(() => {}); - } else { - await (entry.pool as import("mysql2/promise").Pool).end().catch(() => {}); - } - }), - ); - - const tunnels = [...proxyTunnels.values()]; - proxyTunnels.clear(); - await Promise.all( - tunnels.map( - ({ server, sockets }) => - new Promise((resolve) => { - for (const socket of sockets) socket.destroy(); - server.close(() => resolve()); - }), - ), - ); -} - -async function getPgPool(config: ConnectionConfig, sslModeOverride?: PostgresSslMode): Promise { - const key = poolKey(config); - const existing = pools.get(key); - if (existing?.type === "pg") { - resetIdleTimer(key, existing); - return existing.pool as import("pg").Pool; - } - - const pg = await import("pg"); - const endpoint = await connectionEndpoint(config); - const sslMode = sslModeOverride ?? postgresSslMode(config); - const pool = new pg.default.Pool({ - // pg-connection-string lets URL SSL parameters override the explicit ssl - // object. Keep all TLS policy in one place so DBX modes cannot conflict. - connectionString: withoutPostgresSslUrlParams(buildConnectionUrl(config, endpoint)), - ssl: await postgresSslOptions(config, sslMode), - max: 3, - idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 10_000, - }); - pool.on("error", () => {}); - const entry: PoolEntry = { type: "pg", pool, timer: setTimeout(() => {}, 0) }; - pools.set(key, entry); - resetIdleTimer(key, entry); - return pool; -} - -function postgresSslMode(config: ConnectionConfig): PostgresSslMode { - const normalized = normalizePostgresUrlParams(config.url_params || "", config.ssl); - for (const part of normalized.split("&")) { - if (!urlParamKeyIs(part, "sslmode")) continue; - const [, rawValue] = splitUrlParam(part); - const value = decodeUrlParamPart(rawValue).toLowerCase(); - if (value === "disable" || value === "prefer" || value === "require" || value === "verify-ca" || value === "verify-full") { - return value; - } - } - // TLS is opt-in in the DBX connection form; only an explicit prefer mode may downgrade. - return config.ssl ? "require" : "disable"; -} - -function postgresSslFilePaths(config: ConnectionConfig): { ca?: string; cert?: string; key?: string } { - const paths: { ca?: string; cert?: string; key?: string } = { - ca: config.ca_cert_path?.trim() || undefined, - cert: config.client_cert_path?.trim() || undefined, - key: config.client_key_path?.trim() || undefined, - }; - const normalized = normalizePostgresUrlParams(config.url_params || "", config.ssl); - for (const part of normalized.split("&")) { - const [rawKey, rawValue] = splitUrlParam(part); - const key = decodeUrlParamPart(rawKey).toLowerCase(); - const value = decodeUrlParamPart(rawValue).trim(); - if (!value) continue; - if (key === "sslrootcert") paths.ca = value; - else if (key === "sslcert") paths.cert = value; - else if (key === "sslkey") paths.key = value; - } - return paths; -} - -async function postgresSslOptions(config: ConnectionConfig, mode: PostgresSslMode): Promise { - if (mode === "disable") return false; - - const paths = postgresSslFilePaths(config); - const ssl: Exclude = {}; - if (paths.ca) ssl.ca = await readFile(paths.ca); - if (paths.cert) ssl.cert = await readFile(paths.cert); - if (paths.key) ssl.key = await readFile(paths.key); - - if (mode === "prefer" || mode === "require") { - ssl.rejectUnauthorized = false; - } else if (mode === "verify-ca") { - ssl.checkServerIdentity = () => undefined; - } - return ssl; -} - -function withoutPostgresSslUrlParams(connectionString: string): string { - const url = new URL(connectionString); - const sslKeys = new Set(["ssl", "sslmode", "ssl-mode", "sslcert", "sslkey", "sslrootcert", "uselibpqcompat"]); - for (const key of [...url.searchParams.keys()]) { - if (sslKeys.has(key.toLowerCase())) url.searchParams.delete(key); - } - return url.toString(); -} - -function postgresServerRejectedSsl(error: unknown): boolean { - return error instanceof Error && error.message === "The server does not support SSL connections"; -} - -async function getMysqlPool(config: ConnectionConfig): Promise { - const key = poolKey(config); - const existing = pools.get(key); - if (existing?.type === "mysql") { - resetIdleTimer(key, existing); - return existing.pool as import("mysql2/promise").Pool; - } - - const mysql = await import("mysql2/promise"); - const endpoint = await connectionEndpoint(config); - const poolOptions: import("mysql2/promise").PoolOptions = { - uri: buildConnectionUrl(config, endpoint), - connectionLimit: 3, - idleTimeout: 30_000, - connectTimeout: 10_000, - }; - const tls = await mysqlTlsOptions(config); - if (tls) poolOptions.ssl = tls; - const pool = mysql.default.createPool(poolOptions); - const entry: PoolEntry = { type: "mysql", pool, timer: setTimeout(() => {}, 0) }; - pools.set(key, entry); - resetIdleTimer(key, entry); - return pool; -} - -type ProxyLayer = { type: "proxy" } & ProxyTunnelConfig; - -function hasActiveSshLayer(config: ConnectionConfig): boolean { - return config.transport_layers?.some((layer) => layer.type === "ssh" && layer.enabled !== false && !!layer.host) ?? false; -} - -function firstProxyLayer(config: ConnectionConfig): ProxyLayer | undefined { - return config.transport_layers?.find((layer): layer is ProxyLayer => layer.type === "proxy" && layer.enabled !== false && !!layer.host); -} - -function hasDirectRedisSupport(config: ConnectionConfig): boolean { - const mode = config.redis_connection_mode || "standalone"; - return config.db_type === "redis" && mode === "standalone" && !hasActiveSshLayer(config); -} - -async function connectionEndpoint(config: ConnectionConfig): Promise<{ host: string; port: number }> { - const proxy = firstProxyLayer(config); - if (!proxy) return { host: config.host, port: config.port }; - const existing = proxyTunnels.get(config.id); - if (existing) return { host: "127.0.0.1", port: existing.port }; - - const sockets = new Set(); - const server = createServer((inbound) => { - sockets.add(inbound); - inbound.once("close", () => sockets.delete(inbound)); - connectViaProxy(config, proxy) - .then((outbound) => { - sockets.add(outbound); - outbound.once("close", () => sockets.delete(outbound)); - inbound.pipe(outbound); - outbound.pipe(inbound); - }) - .catch(() => inbound.destroy()); - }); - const port = await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address && typeof address === "object") resolve(address.port); - else reject(new Error("Failed to bind proxy tunnel")); - }); - }); - proxyTunnels.set(config.id, { server, port, sockets }); - return { host: "127.0.0.1", port }; -} - -export function buildConnectionUrl(config: ConnectionConfig, endpoint: { host: string; port: number }): string { - const db = config.database || ""; - if (isMysqlType(config.db_type)) { - const params = buildMysqlUrlParams(config); - const suffix = params ? `?${params}` : ""; - return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`; - } - if (!isPostgresType(config.db_type)) { - throw new Error(`Unsupported pooled connection type: ${config.db_type}`); - } - const params = normalizePostgresUrlParams(config.url_params || "", config.ssl); - const suffix = params ? `?${params}` : ""; - return `postgres://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`; -} - -function normalizePostgresUrlParams(value: string, forceTls: boolean): string { - const parts: string[] = []; - let timezone: string | undefined; - let searchPath: string | undefined; - for (const part of value.trim().replace(/^\?/, "").split("&")) { - if (!part) continue; - const [rawKey, rawValue] = splitUrlParam(part); - const key = decodeUrlParamPart(rawKey); - const lowerKey = key.toLowerCase(); - if (lowerKey === "timezone" || lowerKey === "time_zone") { - const decoded = decodeUrlParamPart(rawValue).trim(); - if (decoded) timezone = decoded; - continue; - } - if (lowerKey === "schema" || lowerKey === "currentschema") { - const decoded = decodeUrlParamPart(rawValue).trim(); - if (decoded) searchPath = decoded; - continue; - } - if (lowerKey === "ssl-mode" || lowerKey === "sslmode") { - const value = decodeUrlParamPart(rawValue).toLowerCase().replaceAll("_", "-"); - if (value === "require" || value === "required") parts.push("sslmode=require"); - else if (value === "prefer" || value === "preferred") parts.push("sslmode=prefer"); - else if (value === "disable" || value === "disabled") parts.push("sslmode=disable"); - else if (value === "verify-ca") parts.push("sslmode=verify-ca"); - else if (value === "verify-full" || value === "verify-identity") parts.push("sslmode=verify-full"); - else if (lowerKey === "sslmode") parts.push(part); - continue; - } - if (lowerKey === "charset" || lowerKey === "require_ssl" || lowerKey === "verify_ca" || lowerKey === "verify_identity") { - continue; - } - parts.push(part); - } - - const connectionOptions: Array<{ needle: string; value: string }> = []; - if (searchPath) connectionOptions.push({ needle: "search_path=", value: `-c search_path=${searchPath}` }); - if (timezone) connectionOptions.push({ needle: "timezone=", value: `-c TimeZone=${timezone}` }); - if (connectionOptions.length > 0) { - const optionsIndex = parts.findIndex((part) => urlParamKeyIs(part, "options")); - if (optionsIndex >= 0) { - const [rawKey, rawValue] = splitUrlParam(parts[optionsIndex]); - const optionsValue = decodeUrlParamPart(rawValue); - const lowerOptions = optionsValue.toLowerCase(); - const appended = connectionOptions - .filter((option) => !lowerOptions.includes(option.needle)) - .map((option) => option.value) - .join(" "); - if (appended) { - const combined = `${optionsValue.trim()} ${appended}`.trim(); - parts[optionsIndex] = `${rawKey}=${encodeURIComponent(combined)}`; - } - } else { - parts.push(`options=${encodeURIComponent(connectionOptions.map((option) => option.value).join(" "))}`); - } - } - - if (forceTls && !parts.some((part) => urlParamKeyIs(part, "sslmode"))) { - parts.unshift("sslmode=require"); - } - return parts.join("&"); -} - -function urlParamKeyIs(part: string, expected: string): boolean { - const [rawKey] = splitUrlParam(part); - return decodeUrlParamPart(rawKey).toLowerCase() === expected.toLowerCase(); -} - -function splitUrlParam(part: string): [string, string] { - const index = part.indexOf("="); - if (index < 0) return [part, ""]; - return [part.slice(0, index), part.slice(index + 1)]; -} - -function decodeUrlParamPart(value: string): string { - try { - return decodeURIComponent(value.replace(/\+/g, " ")); - } catch { - return value; - } -} - -function urlParams(config: ConnectionConfig): URLSearchParams { - return new URLSearchParams((config.url_params || "").trim().replace(/^\?/, "")); -} - -function connectViaProxy(config: ConnectionConfig, proxy: ProxyLayer): Promise { - return new Promise((resolve, reject) => { - const socket = netConnect(proxy.port || 1080, proxy.host || "127.0.0.1"); - socket.once("error", reject); - socket.once("connect", () => { - if ((proxy.proxy_type || "socks5") === "http") { - httpConnect(socket, config, proxy, resolve, reject); - } else { - socks5Connect(socket, config, proxy, resolve, reject); - } - }); - }); -} - -function httpConnect(socket: Socket, config: ConnectionConfig, proxy: ProxyLayer, resolve: (socket: Socket) => void, reject: (err: Error) => void) { - const target = `${config.host}:${config.port}`; - const lines = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`]; - if (proxy.username || proxy.password) { - const token = Buffer.from(`${proxy.username || ""}:${proxy.password || ""}`).toString("base64"); - lines.push(`Proxy-Authorization: Basic ${token}`); - } - socket.write(`${lines.join("\r\n")}\r\n\r\n`); - let buffer = Buffer.alloc(0); - socket.on("data", function onData(chunk: Buffer) { - buffer = Buffer.concat([buffer, chunk]); - const end = buffer.indexOf("\r\n\r\n"); - if (end < 0) return; - socket.off("data", onData); - const head = buffer.subarray(0, end).toString("utf8"); - if (!/^HTTP\/1\.[01] 200\b/.test(head)) { - reject(new Error(`HTTP proxy CONNECT failed: ${head.split("\r\n")[0] || "invalid response"}`)); - socket.destroy(); - return; - } - const rest = buffer.subarray(end + 4); - if (rest.length) socket.unshift(rest); - resolve(socket); - }); -} - -function socks5Connect(socket: Socket, config: ConnectionConfig, proxy: ProxyLayer, resolve: (socket: Socket) => void, reject: (err: Error) => void) { - const wantsAuth = !!(proxy.username || proxy.password); - socket.write(Buffer.from(wantsAuth ? [0x05, 0x02, 0x00, 0x02] : [0x05, 0x01, 0x00])); - socket.once("data", (method) => { - if (method.length < 2 || method[0] !== 0x05) { - reject(new Error("Invalid SOCKS greeting")); - socket.destroy(); - return; - } - if (method[1] === 0x02) { - const user = Buffer.from(proxy.username || ""); - const pass = Buffer.from(proxy.password || ""); - socket.write(Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass])); - socket.once("data", (auth) => { - if (auth.length < 2 || auth[1] !== 0x00) { - reject(new Error("SOCKS proxy authentication failed")); - socket.destroy(); - return; - } - sendSocksConnect(socket, config, resolve, reject); - }); - } else if (method[1] === 0x00) { - sendSocksConnect(socket, config, resolve, reject); - } else { - reject(new Error("SOCKS proxy rejected authentication methods")); - socket.destroy(); - } - }); -} - -function sendSocksConnect(socket: Socket, config: ConnectionConfig, resolve: (socket: Socket) => void, reject: (err: Error) => void) { - const host = Buffer.from(config.host); - socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), host, portBytes(config.port)])); - socket.once("data", (res) => { - if (res.length < 4 || res[0] !== 0x05 || res[1] !== 0x00) { - reject(new Error(`SOCKS proxy connect failed with code ${res[1] ?? "unknown"}`)); - socket.destroy(); - return; - } - resolve(socket); - }); -} - -function portBytes(port: number): Buffer { - const buf = Buffer.alloc(2); - buf.writeUInt16BE(port); - return buf; -} - -function isMysqlType(dbType: string): boolean { - return dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "manticoresearch"; -} - -function isStarrocksConnection(config: ConnectionConfig): boolean { - return config.db_type === "starrocks" || config.driver_profile?.toLowerCase() === "starrocks"; -} - -function needsBareMysql(config: ConnectionConfig): boolean { - const profile = config.driver_profile?.toLowerCase(); - return config.db_type === "doris" || config.db_type === "starrocks" || config.db_type === "manticoresearch" || profile === "doris" || profile === "starrocks" || profile === "manticoresearch" || profile === "selectdb" || profile === "oceanbase"; -} - -function mysqlTlsFileParamIs(key: string, target: "cert" | "key"): boolean { - return key.toLowerCase().replace(/[-_]/g, "") === `ssl${target}`; -} - -function mysqlUrlParamsRequireTls(params: string): boolean { - for (const part of params.trim().replace(/^\?/, "").split("&")) { - if (!part) continue; - const [rawKey, rawValue = ""] = splitUrlParam(part); - const key = decodeUrlParamPart(rawKey); - const value = decodeUrlParamPart(rawValue); - if (key.toLowerCase() === "require_ssl" && value.toLowerCase() === "true") return true; - if (mysqlTlsFileParamIs(key, "cert") || mysqlTlsFileParamIs(key, "key")) return true; - if (key.toLowerCase() === "ssl-mode" || key.toLowerCase() === "sslmode") { - const mode = value.toLowerCase().replace(/-/g, "_"); - if (mode === "required" || mode === "require" || mode === "verify_ca" || mode === "verify_identity") return true; - } - } - return false; -} - -function mysqlUrlParamsTlsDisabled(params: string): boolean { - for (const part of params.trim().replace(/^\?/, "").split("&")) { - if (!part) continue; - const [rawKey, rawValue = ""] = splitUrlParam(part); - const key = decodeUrlParamPart(rawKey).toLowerCase(); - const value = decodeUrlParamPart(rawValue).toLowerCase(); - if (key === "require_ssl" && value === "false") return true; - if ((key === "ssl-mode" || key === "sslmode") && (value === "disabled" || value === "disable")) return true; - } - return false; -} - -function mysqlUsesTls(config: ConnectionConfig): boolean { - return !!config.ssl || mysqlUrlParamsRequireTls(config.url_params || ""); -} - -function bareMysqlUsesTls(config: ConnectionConfig): boolean { - if (!isStarrocksConnection(config)) { - return false; - } - if (mysqlUrlParamsTlsDisabled(config.url_params || "")) { - return false; - } - return mysqlUsesTls(config); -} - -function normalizeBareMysqlUrlParams(value: string): string { - return value - .trim() - .replace(/^\?/, "") - .split("&") - .filter((part) => { - if (!part) return false; - const key = decodeUrlParamPart(splitUrlParam(part)[0]).toLowerCase(); - return key !== "charset" && key !== "ssl-mode" && key !== "sslmode" && key !== "require_ssl" && key !== "verify_ca" && key !== "verify_identity"; - }) - .join("&"); -} - -function normalizeMysqlUrlParams(value: string, forceTls: boolean, acceptInvalidCerts: boolean): string { - const parts = value - .trim() - .replace(/^\?/, "") - .split("&") - .filter((part) => part.length > 0); - - if (forceTls) { - const filtered = parts.filter((part) => { - const key = decodeUrlParamPart(splitUrlParam(part)[0]).toLowerCase(); - return key !== "ssl-mode" && key !== "sslmode" && key !== "require_ssl"; - }); - filtered.unshift("require_ssl=true"); - if (acceptInvalidCerts && !filtered.some((part) => urlParamKeyIs(part, "verify_ca"))) { - filtered.push("verify_ca=false"); - } - if (!filtered.some((part) => urlParamKeyIs(part, "verify_identity"))) { - filtered.push("verify_identity=false"); - } - if (!filtered.some((part) => urlParamKeyIs(part, "charset"))) { - filtered.push("charset=utf8mb4"); - } - return filtered.join("&"); - } - - if (!parts.some((part) => urlParamKeyIs(part, "ssl-mode") || urlParamKeyIs(part, "sslmode") || urlParamKeyIs(part, "require_ssl"))) { - parts.unshift("ssl-mode=disabled"); - } - if (!parts.some((part) => urlParamKeyIs(part, "charset"))) { - parts.push("charset=utf8mb4"); - } - return parts.join("&"); -} - -function buildMysqlUrlParams(config: ConnectionConfig): string { - const raw = config.url_params || ""; - if (needsBareMysql(config)) { - if (bareMysqlUsesTls(config)) { - return normalizeMysqlUrlParams(raw, true, !config.ca_cert_path?.trim()); - } - return normalizeBareMysqlUrlParams(raw); - } - return raw; -} - -async function mysqlTlsOptions(config: ConnectionConfig): Promise { - if (!bareMysqlUsesTls(config)) return undefined; - - const params = urlParams(config); - const tls: SslOptions = {}; - const verifyCa = (params.get("verify_ca") || "").toLowerCase() === "true"; - const verifyIdentity = (params.get("verify_identity") || "").toLowerCase() === "true"; - if (!verifyCa && !verifyIdentity) { - tls.rejectUnauthorized = false; - } - if (config.ca_cert_path) tls.ca = await readFile(config.ca_cert_path); - const certPath = params.get("ssl-cert") || params.get("sslcert"); - const keyPath = params.get("ssl-key") || params.get("sslkey"); - if (certPath) tls.cert = await readFile(certPath); - if (keyPath) tls.key = await readFile(keyPath); - return tls; -} - -function isPostgresType(dbType: string): boolean { - return dbType === "postgres" || dbType === "redshift" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "questdb"; -} - -interface BridgeQueryResult { - columns: string[]; - rows: unknown[][]; - affected_rows: number; - execution_time_ms: number; - truncated: boolean; -} - -interface BridgeTableInfo { - name: string; - table_type: string; - comment: string | null; -} - -interface BridgeColumnInfo { - name: string; - data_type: string; - is_nullable: boolean; - column_default: string | null; - is_primary_key: boolean; - comment: string | null; - numeric_precision?: number | null; - numeric_scale?: number | null; - character_maximum_length?: number | null; - enum_values?: string[] | null; - character_set?: string | null; - collation?: string | null; -} - -const POSTGRES_DESCRIBE_TABLE_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, CASE WHEN enum_t.oid IS NULL THEN NULL ELSE COALESCE((SELECT array_to_json(array_agg(e.enumlabel ORDER BY e.enumsortorder)) FROM pg_enum e WHERE e.enumtypid = enum_t.oid), '[]'::json) END AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) LEFT JOIN pg_namespace type_ns ON type_ns.nspname = c.udt_schema LEFT JOIN pg_type t ON t.typnamespace = type_ns.oid AND t.typname = c.udt_name LEFT JOIN pg_type enum_t ON enum_t.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype WHEN t.typtype = 'e' THEN t.oid ELSE NULL END AND enum_t.typtype = 'e' WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; -const POSTGRES_DESCRIBE_TABLE_COMPAT_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, NULL AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; -const MYSQL_DESCRIBE_TABLE_SQL = `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.COLUMN_TYPE AS column_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment, c.CHARACTER_SET_NAME AS character_set, c.COLLATION_NAME AS collation FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`; - -function normalizeEnumValues(value: unknown): string[] | null { - if (value == null) return null; - if (Array.isArray(value)) return value.map((item) => String(item)); - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) as unknown; - return Array.isArray(parsed) ? parsed.map((item) => String(item)) : null; - } catch { - return null; - } - } - return null; -} - -function parseMysqlEnumValues(columnType: unknown): string[] | null { - if (typeof columnType !== "string") return null; - const trimmed = columnType.trim(); - if (!trimmed.toLowerCase().startsWith("enum(") || !trimmed.endsWith(")")) return null; - - const inner = trimmed.slice(5, -1); - const values: string[] = []; - let index = 0; - - const skipWhitespace = () => { - while (index < inner.length && /\s/.test(inner[index] ?? "")) index += 1; - }; - - while (index < inner.length) { - skipWhitespace(); - if (inner[index] !== "'") return null; - index += 1; - - let value = ""; - while (index < inner.length) { - const char = inner[index++]; - if (char === "'") { - if (inner[index] === "'") { - value += "'"; - index += 1; - continue; - } - break; - } - if (char === "\\") { - if (index >= inner.length) return null; - const escaped = inner[index++]; - if (escaped === "0") value += "\0"; - else if (escaped === "b") value += "\b"; - else if (escaped === "n") value += "\n"; - else if (escaped === "r") value += "\r"; - else if (escaped === "t") value += "\t"; - else if (escaped === "Z") value += "\x1a"; - else value += escaped; - continue; - } - value += char; - } - values.push(value); - - skipWhitespace(); - if (index >= inner.length) return values; - if (inner[index] !== ",") return null; - index += 1; - } - - return values; -} - -function mapDescribeTableColumn( - row: { - name?: unknown; - data_type?: unknown; - is_nullable?: unknown; - column_default?: unknown; - is_primary_key?: unknown; - comment?: unknown; - numeric_precision?: number | null; - numeric_scale?: number | null; - character_maximum_length?: number | null; - character_set?: string | null; - collation?: string | null; - }, - enumValues: string[] | null, -): ColumnInfo { - const column: ColumnInfo = { - name: String(row.name || ""), - data_type: String(row.data_type || ""), - is_nullable: Boolean(row.is_nullable), - column_default: row.column_default != null ? String(row.column_default) : null, - is_primary_key: Boolean(row.is_primary_key), - comment: row.comment != null ? String(row.comment) : null, - enum_values: enumValues, - }; - if ("numeric_precision" in row) column.numeric_precision = row.numeric_precision; - if ("numeric_scale" in row) column.numeric_scale = row.numeric_scale; - if ("character_maximum_length" in row) column.character_maximum_length = row.character_maximum_length; - if ("character_set" in row) column.character_set = row.character_set ?? null; - if ("collation" in row) column.collation = row.collation ?? null; - return column; -} - -export function collectionListToTableInfos(collections: CollectionListEntry[]): TableInfo[] { - return collections.map((collection) => ({ - name: typeof collection === "string" ? collection : collection.name, - type: "COLLECTION", - })); -} - -interface MongoDocumentResult { - documents: unknown[]; - total: number; -} - -async function bridgeDataRequest(path: string, body: Record): Promise { - let bridgeUrl: string; - try { - const port = (await readFile(bridgePortFilePath(), "utf-8")).trim(); - bridgeUrl = `http://127.0.0.1:${port}`; - } catch { - throw new Error("DBX desktop app is not running. This database type requires DBX to be running for query execution."); - } - const res = await fetch(`${bridgeUrl}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = await res.text().catch(() => ""); - let errorMsg: string; - try { - const parsed = JSON.parse(errBody); - errorMsg = parsed.error || errBody; - } catch { - errorMsg = errBody; - } - throw new Error(errorMsg || `Bridge request failed: ${res.status}`); - } - return res.json() as Promise; -} - -function resolveMaxRows(options?: QueryOptions): number { - return options?.maxRows ?? MAX_ROWS; -} - -function resolveTimeoutMs(options?: QueryOptions): number { - return options?.timeoutMs ?? QUERY_TIMEOUT_MS; -} - -function convertBridgeQueryResult(result: BridgeQueryResult, options?: QueryOptions): QueryResult { - const rows = result.rows.slice(0, resolveMaxRows(options)).map((row) => { - const obj: Record = {}; - result.columns.forEach((col, i) => { - obj[col] = row[i]; - }); - return obj; - }); - return { columns: result.columns, rows, row_count: rows.length }; -} - -function withTimeout(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Query timed out after ${ms}ms`)), ms); - promise.then(resolve, reject).finally(() => clearTimeout(timer)); - }); -} - -async function queryWithRetry(config: ConnectionConfig, fn: () => Promise, options?: QueryOptions): Promise { - const timeoutMs = resolveTimeoutMs(options); - try { - return await withTimeout(fn(), timeoutMs); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - const retriable = /terminating connection|Connection lost|ECONNRESET|EPIPE|connection refused/i.test(msg); - if (retriable) { - const key = poolKey(config); - const entry = pools.get(key); - if (entry) evictPool(key, entry); - return withTimeout(fn(), timeoutMs); - } - throw e; - } -} - -async function pgQuery(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise { - const sslMode = postgresSslMode(config); - let usePlaintextFallback = false; - return queryWithRetry( - config, - async () => { - const pool = await getPgPool(config, usePlaintextFallback ? "disable" : undefined); - let result: import("pg").QueryResult; - try { - result = await pool.query(sql, params); - } catch (error) { - if (sslMode !== "prefer" || usePlaintextFallback || !postgresServerRejectedSsl(error)) throw error; - - // Prefer may downgrade only when PostgreSQL rejects the SSLRequest - // itself. Certificate, authentication, and pg_hba failures stay fatal. - usePlaintextFallback = true; - const key = poolKey(config); - const entry = pools.get(key); - // A concurrent query may already have replaced the rejected TLS pool. - // Never evict that newer plaintext pool from a late TLS failure. - if (entry?.pool === pool) evictPool(key, entry); - result = await (await getPgPool(config, "disable")).query(sql, params); - } - const rows = (result.rows || []).slice(0, resolveMaxRows(options)); - return { columns: result.fields?.map((f) => f.name) ?? [], rows, row_count: rows.length }; - }, - options, - ); -} - -async function mysqlQuery(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise { - return queryWithRetry( - config, - async () => { - const pool = await getMysqlPool(config); - const [results, fields] = await pool.query(sql, params); - const rows = (Array.isArray(results) ? results : []).slice(0, resolveMaxRows(options)) as Record[]; - return { columns: (fields as Array<{ name: string }>)?.map((f) => f.name) ?? [], rows, row_count: rows.length }; - }, - options, - ); -} - -async function query(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise { - if (config.db_type === "sqlite") return sqliteQuery(config, sql, options); - if (config.db_type === "rqlite") return rqliteQuery(config, sql, options); - if (isMysqlType(config.db_type)) return mysqlQuery(config, sql, params, options); - return pgQuery(config, sql, params, options); -} - -function sqlitePath(config: ConnectionConfig): string { - return expandTilde(config.host || config.database || ""); -} - -function expandTilde(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return join(homedir(), path.slice(2)); - return path; -} - -function quoteSqliteIdentifier(identifier: string): string { - return `"${identifier.replace(/"/g, '""')}"`; -} - -function sqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): QueryResult { - const db = new Database(sqlitePath(config), { readonly: !sqlSafetyFromEnv().allowWrites }); - try { - const stmt = db.prepare(sql); - if (stmt.reader) { - const rows = stmt.all().slice(0, resolveMaxRows(options)) as Record[]; - return { columns: stmt.columns().map((column) => column.name), rows, row_count: rows.length }; - } - const result = stmt.run(); - return { columns: [], rows: [], row_count: result.changes }; - } finally { - db.close(); - } -} - -async function rqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise { - const isReader = /^\s*(?:--[^\n]*\n|\s|\/\*[\s\S]*?\*\/)*(select|pragma|explain|with)\b/i.test(sql); - const endpoint = isReader ? "/db/query" : "/db/execute"; - const result = await rqliteRequest(config, endpoint, sql); - if (isReader) { - const columns = result.columns ?? []; - const rows = (result.values ?? []).slice(0, resolveMaxRows(options)).map((row) => { - const record: Record = {}; - columns.forEach((column, index) => { - record[column] = row[index]; - }); - return record; - }); - return { columns, rows, row_count: rows.length }; - } - return { columns: [], rows: [], row_count: result.rows_affected ?? 0 }; -} - -async function rqliteRequest(config: ConnectionConfig, endpoint: "/db/query" | "/db/execute", sql: string): Promise { - const { host, port } = await connectionEndpoint(config); - const scheme = config.ssl ? "https" : "http"; - const params = (config.url_params || "").trim().replace(/^\?/, ""); - const url = `${scheme}://${host}:${port}${endpoint}${params ? `?${params}` : ""}`; - const headers: Record = { "content-type": "application/json" }; - if (config.username) { - headers.authorization = `Basic ${Buffer.from(`${config.username}:${config.password || ""}`).toString("base64")}`; - } - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify([sql]), - }); - const text = await response.text(); - if (!response.ok) throw new Error(`rqlite error (${response.status}): ${text}`); - const payload = JSON.parse(text) as RqliteResponse; - const result = payload.results?.[0]; - if (!result) throw new Error("rqlite returned no result"); - if (result.error) throw new Error(`rqlite error: ${result.error}`); - return result; -} - -export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise { - if (hasActiveSshLayer(config)) { - const result = await withTimeout( - bridgeDataRequest("/data/execute-query", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - sql, - }), - resolveTimeoutMs(options), - ); - return convertBridgeQueryResult(result, options); - } - if (config.db_type === "mongodb") { - const version = parseMongoVersionCommand(sql); - if (version) { - const result = await withTimeout(mongoServerVersion(config), resolveTimeoutMs(options)); - return { columns: ["version"], rows: [{ version: result }], row_count: 1 }; - } - const count = parseMongoCountDocumentsCommand(sql); - if (count) { - const total = await withTimeout(mongoCountDocuments(config, count.collection, count.filter, count.mode), resolveTimeoutMs(options)); - return { columns: ["count"], rows: [{ count: total }], row_count: 1 }; - } - const find = parseMongoFindCommand(sql); - if (find) { - const result = await withTimeout(mongoFindDocuments(config, find.collection, find.skip, find.limit, find.filter, find.projection, find.sort), resolveTimeoutMs(options)); - return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total); - } - const aggregate = parseMongoAggregateCommand(sql); - if (aggregate) { - const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv()); - if (!safety.allowed) throw new Error(safety.reason); - const result = await withTimeout(mongoAggregateDocuments(config, aggregate.collection, aggregate.pipeline, resolveMaxRows(options), aggregate.options), resolveTimeoutMs(options)); - return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total); - } - const distinct = parseMongoDistinctCommand(sql); - if (distinct) { - const result = await withTimeout(mongoDistinct(config, distinct.collection, distinct.field, distinct.filter), resolveTimeoutMs(options)); - return mongoDistinctToQueryResult(distinct.field, result.documents.slice(0, resolveMaxRows(options))); - } - const getIndexes = parseMongoGetIndexesCommand(sql); - if (getIndexes) { - const result = await withTimeout(mongoAggregateDocuments(config, getIndexes.collection, '[{"$indexStats":{}}]', resolveMaxRows(options)), resolveTimeoutMs(options)); - return mongoDocumentsToQueryResult(result.documents.slice(0, resolveMaxRows(options)), result.total); - } - const collectionStats = parseMongoCollectionStatsCommand(sql); - if (collectionStats) { - const result = await withTimeout(mongoCollectionStats(config, collectionStats.collection, collectionStats.scale), resolveTimeoutMs(options)); - return mongoCollectionStatsToQueryResult(collectionStats.metric, result); - } - const write = parseMongoWriteCommand(sql); - if (write) { - const safety = evaluateMongoWriteSafety(write, sqlSafetyFromEnv()); - if (!safety.allowed) throw new Error(safety.reason); - const result = await withTimeout(executeMongoWrite(config, write), resolveTimeoutMs(options)); - if (write.kind === "createIndex") { - return { - columns: ["name"], - rows: [{ name: result.indexName ?? "" }], - row_count: 1, - }; - } - if (write.kind === "dropIndex" || write.kind === "dropIndexes") { - return { - columns: ["name"], - rows: (result.droppedNames ?? []).map((name) => ({ name })), - row_count: result.affectedRows, - }; - } - return { columns: [], rows: [], row_count: result.affectedRows }; - } - throw new Error(describeMongoCommandParseFailure(sql)); - } - if (isDirectQueryType(config.db_type)) { - return query(config, sql, undefined, options); - } - const result = await withTimeout( - bridgeDataRequest("/data/execute-query", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - sql, - }), - resolveTimeoutMs(options), - ); - return convertBridgeQueryResult(result, options); -} - -export async function executeRedisCommand(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise { - if (config.db_type !== "redis") { - throw new Error("Connection is not Redis."); - } - if (hasDirectRedisSupport(config)) { - return executeRedisCommandDirect(config, db, command, options); - } - return withTimeout( - bridgeDataRequest("/data/redis/execute-command", { - connection_id: config.id, - connection_name: config.name, - db, - command, - skip_safety_check: options?.skipSafetyCheck ?? false, - }), - resolveTimeoutMs(options), - ); -} - -async function executeRedisCommandDirect(config: ConnectionConfig, db: number, commandText: string, options?: RedisCommandOptions): Promise { - const argv = parseRedisCommandArgv(commandText); - const command = argv[0].toUpperCase(); - const safety = classifyRedisCommand(command) as RedisCommandSafety; - if (!options?.skipSafetyCheck && safety === "blocked") { - throw new Error("Redis command is blocked for safety. Enable dangerous commands with DBX_MCP_ALLOW_DANGEROUS_SQL=1."); - } - - const { Redis } = await import("ioredis"); - const endpoint = await connectionEndpoint(config); - const tls = await redisTlsOptions(config); - const client = new Redis({ - host: endpoint.host, - port: endpoint.port, - username: config.username || undefined, - password: config.password || undefined, - db, - tls, - lazyConnect: true, - enableReadyCheck: false, - maxRetriesPerRequest: 0, - enableOfflineQueue: false, - connectTimeout: Math.min(resolveTimeoutMs(options), 10_000), - commandTimeout: resolveTimeoutMs(options), - }); - - try { - await client.connect(); - const value = await client.call(command, ...argv.slice(1)); - return { command, safety, value: redisValueToJson(value) }; - } finally { - client.disconnect(); - } -} - -async function redisTlsOptions(config: ConnectionConfig): Promise { - if (!config.ssl) return undefined; - const params = urlParams(config); - const tls: import("node:tls").ConnectionOptions = { - servername: config.host, - }; - if ((params.get("insecure") || "").toLowerCase() === "true") { - tls.rejectUnauthorized = false; - } - if (config.ca_cert_path) tls.ca = await readFile(config.ca_cert_path); - if (config.client_cert_path) tls.cert = await readFile(config.client_cert_path); - if (config.client_key_path) tls.key = await readFile(config.client_key_path); - return tls; -} - -function redisValueToJson(value: unknown): unknown { - if (Buffer.isBuffer(value)) return redisTextToJson(value.toString("utf8")); - if (Array.isArray(value)) return value.map(redisValueToJson); - if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redisValueToJson(item)])); - } - if (typeof value === "string") return redisTextToJson(value); - return value; -} - -function redisTextToJson(value: string): unknown { - const trimmed = value.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - return JSON.parse(trimmed); - } catch { - return value; - } - } - return value; -} - -export async function listTables(config: ConnectionConfig, schema?: string): Promise { - if (config.db_type === "mongodb") { - const collections = await bridgeDataRequest("/data/mongo/list-collections", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - schema: schema || "", - }); - return collectionListToTableInfos(collections); - } - if (config.db_type === "sqlite" || config.db_type === "rqlite") { - const result = await query(config, `SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`); - return result.rows.map((r) => ({ name: String(r.name || ""), type: String(r.type || "table") })); - } - if (hasActiveSshLayer(config) || !isDirectQueryType(config.db_type)) { - const tables = await bridgeDataRequest("/data/list-tables", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - schema: schema || "", - }); - return tables.map((t) => ({ name: t.name, type: t.table_type || "TABLE" })); - } - let result: QueryResult; - if (isMysqlType(config.db_type)) { - result = await query(config, `SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME`); - } else { - result = await query(config, `SELECT table_name AS name, table_type AS type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name`, [schema || "public"]); - } - return result.rows.map((r) => ({ name: String(r.name || r.NAME), type: String(r.type || r.TYPE || "TABLE") })); -} - -export async function describeTable(config: ConnectionConfig, table: string, schema?: string): Promise { - if (config.db_type === "mongodb") { - const result = await mongoFindDocuments(config, table, 0, 20, "{}"); - return inferMongoColumns(result.documents); - } - if (config.db_type === "sqlite" || config.db_type === "rqlite") { - const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`); - return result.rows.map((r) => ({ - name: String(r.name || ""), - data_type: String(r.type || ""), - is_nullable: Number(r.notnull || 0) === 0, - column_default: r.dflt_value != null ? String(r.dflt_value) : null, - is_primary_key: Number(r.pk || 0) > 0, - comment: null, - })); - } - if (hasActiveSshLayer(config) || !isDirectQueryType(config.db_type)) { - const columns = await bridgeDataRequest("/data/describe-table", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - schema: schema || "", - table, - }); - return columns.map((column) => mapDescribeTableColumn(column, column.enum_values ?? null)); - } - let result: QueryResult; - if (isMysqlType(config.db_type)) { - result = await query(config, MYSQL_DESCRIBE_TABLE_SQL, [table]); - return result.rows.map((row) => mapDescribeTableColumn(row, String(row.data_type || "").toLowerCase() === "enum" ? parseMysqlEnumValues(row.column_type) : null)); - } else if (config.db_type === "postgres") { - try { - result = await query(config, POSTGRES_DESCRIBE_TABLE_SQL, [schema || "public", table]); - } catch { - result = await query(config, POSTGRES_DESCRIBE_TABLE_COMPAT_SQL, [schema || "public", table]); - } - } else { - result = await query(config, POSTGRES_DESCRIBE_TABLE_COMPAT_SQL, [schema || "public", table]); - } - return result.rows.map((row) => mapDescribeTableColumn(row, normalizeEnumValues(row.enum_values))); -} - -async function mongoFindDocuments(config: ConnectionConfig, collection: string, skip: number, limit: number, filter: string, projection?: string, sort?: string): Promise { - return bridgeDataRequest("/data/mongo/find-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection, - skip, - limit, - filter, - projection, - sort, - }); -} - -async function mongoCountDocuments(config: ConnectionConfig, collection: string, filter: string, mode: MongoCountDocumentsCommand["mode"]): Promise { - return bridgeDataRequest("/data/mongo/count-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection, - filter, - mode, - }); -} - -async function mongoServerVersion(config: ConnectionConfig): Promise { - return bridgeDataRequest("/data/mongo/server-version", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - }); -} - -async function mongoCollectionStats(config: ConnectionConfig, collection: string, scale?: number): Promise> { - return bridgeDataRequest>("/data/mongo/collection-stats", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection, - scale, - }); -} - -async function executeMongoWrite(config: ConnectionConfig, command: MongoWriteCommand): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> { - if (command.kind === "insert") { - const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/insert-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - docs_json: command.docsJson, - }); - return { affectedRows: result.affected_rows }; - } - if (command.kind === "update") { - const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/update-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - filter_json: command.filter, - update_json: command.update, - many: command.many, - options_json: command.options, - }); - return { affectedRows: result.affected_rows }; - } - if (command.kind === "createIndex") { - const result = await bridgeDataRequest<{ name: string }>("/data/mongo/create-index", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - keys_json: command.keys, - options_json: command.options, - }); - return { affectedRows: 1, indexName: result.name }; - } - if (command.kind === "dropIndex" || command.kind === "dropIndexes") { - const result = await bridgeDataRequest<{ dropped_names: string[]; affected_rows: number }>("/data/mongo/drop-indexes", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - indexes_json: command.kind === "dropIndex" ? command.index : command.indexes, - single: command.kind === "dropIndex", - }); - return { affectedRows: result.affected_rows, droppedNames: result.dropped_names }; - } - if (command.kind === "dropCollection") { - await bridgeDataRequest<{ ok: boolean }>("/data/mongo/drop-collection", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - }); - return { affectedRows: 1 }; - } - const result = await bridgeDataRequest<{ affected_rows: number }>("/data/mongo/delete-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection: command.collection, - filter_json: command.filter, - many: command.many, - }); - return { affectedRows: result.affected_rows }; -} - -async function mongoAggregateDocuments(config: ConnectionConfig, collection: string, pipelineJson: string, maxRows: number, optionsJson?: string): Promise { - return bridgeDataRequest("/data/mongo/aggregate-documents", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection, - pipeline_json: pipelineJson, - max_rows: maxRows, - ...(optionsJson ? { options_json: optionsJson } : {}), - }); -} - -async function mongoDistinct(config: ConnectionConfig, collection: string, field: string, filter?: string): Promise { - return bridgeDataRequest("/data/mongo/distinct", { - connection_id: config.id, - connection_name: config.name, - database: config.database || "", - collection, - field, - filter, - }); -} - -/** distinct returns bare values, so the single column is named after the field. */ -export function mongoDistinctToQueryResult(field: string, values: unknown[]): QueryResult { - return { - columns: [field], - rows: values.map((value) => ({ [field]: toCellValue(value) })), - row_count: values.length, - }; -} - -export function mongoCollectionStatsToQueryResult(metric: MongoCollectionStatsMetric, stats: Record): QueryResult { - if (metric === "stats") { - const columns = ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"]; - const row: Record = {}; - for (const column of columns) { - row[column] = column in stats ? toCellValue(stats[column]) : null; - } - return { columns, rows: [row], row_count: 1 }; - } - const sourceField = metric === "dataSize" ? "size" : metric; - return { - columns: [metric], - rows: [{ [metric]: sourceField in stats ? toCellValue(stats[sourceField]) : null }], - row_count: 1, - }; -} - -export function mongoDocumentsToQueryResult(documents: unknown[], _total: number): QueryResult { - const columns: string[] = []; - for (const doc of documents) { - if (isRecord(doc)) { - for (const key of Object.keys(doc)) { - if (!columns.includes(key)) columns.push(key); - } - } else if (!columns.includes("value")) { - columns.push("value"); - } - } - const rows = documents.map((doc) => { - const row: Record = {}; - for (const column of columns) { - row[column] = isRecord(doc) ? toCellValue(doc[column]) : column === "value" ? toCellValue(doc) : null; - } - return row; - }); - return { columns, rows, row_count: rows.length }; -} - -export function inferMongoColumns(documents: unknown[]): ColumnInfo[] { - const columns = new Map; nullable: boolean }>(); - for (const doc of documents) { - if (!isRecord(doc)) { - const entry = columns.get("value") ?? { types: new Set(), nullable: false }; - entry.types.add(mongoTypeName(doc)); - columns.set("value", entry); - continue; - } - for (const [name, value] of Object.entries(doc)) { - const entry = columns.get(name) ?? { types: new Set(), nullable: false }; - entry.types.add(mongoTypeName(value)); - if (value === null || value === undefined) entry.nullable = true; - columns.set(name, entry); - } - } - return Array.from(columns.entries()).map(([name, entry]) => ({ - name, - data_type: Array.from(entry.types).sort().join(" | ") || "unknown", - is_nullable: entry.nullable, - column_default: null, - is_primary_key: name === "_id", - comment: null, - })); -} - -interface MongoFindCommand { - collection: string; - filter: string; - projection?: string; - skip: number; - limit: number; - sort?: string; -} - -interface MongoCountDocumentsCommand { - collection: string; - filter: string; - mode: "accurate" | "legacy"; -} - -interface MongoDistinctCommand { - collection: string; - field: string; - filter?: string; -} - -interface MongoGetIndexesCommand { - collection: string; -} - -type MongoCollectionStatsMetric = "stats" | "dataSize" | "storageSize" | "totalIndexSize"; - -interface MongoCollectionStatsCommand { - collection: string; - metric: MongoCollectionStatsMetric; - scale?: number; -} - -export type MongoWriteCommand = - | { kind: "insert"; collection: string; docsJson: string } - | { kind: "update"; collection: string; filter: string; update: string; options?: string; many: boolean } - | { kind: "delete"; collection: string; filter: string; many: boolean } - | { kind: "createIndex"; collection: string; keys: string; options?: string } - | { kind: "dropIndex"; collection: string; index: string } - | { kind: "dropIndexes"; collection: string; indexes?: string } - | { kind: "dropCollection"; collection: string }; - -export function parseMongoFindCommand(input: string): MongoFindCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - const target = parseCollectionMethodTarget(source, "find"); - if (!target) return null; - const findOpenIndex = source.indexOf("(", target.methodCallIndex); - const findCloseIndex = findMatchingParen(source, findOpenIndex); - if (findCloseIndex < 0) return null; - const findArgs = splitTopLevel(source.slice(findOpenIndex + 1, findCloseIndex)); - if (findArgs.length > 2 && findArgs.slice(2).some((arg) => arg.trim())) return null; - const filter = normalizeJsonArgument(findArgs[0] || "{}"); - if (!filter) return null; - let projection: string | undefined; - if (findArgs[1]?.trim()) { - const parsedProjection = normalizeJsonArgument(findArgs[1]); - if (!parsedProjection) return null; - projection = parsedProjection; - } - const chain = source.slice(findCloseIndex + 1).trim(); - if (chain && !chain.startsWith(".")) return null; - if (findChainedMethodCallIndex(chain, "count") >= 0) return null; - const sortArg = readChainedCallArgument(chain, "sort"); - let sort: string | undefined; - if (sortArg !== undefined) { - const parsedSort = normalizeJsonArgument(sortArg); - if (!parsedSort) return null; - sort = parsedSort; - } - const skip = readChainedIntegerArgument(chain, "skip", 0); - const limit = readChainedIntegerArgument(chain, "limit", MAX_ROWS); - if (skip === null || limit === null) return null; - return { collection: target.collection, filter, ...(projection ? { projection } : {}), skip, limit, sort }; -} - -export function parseMongoVersionCommand(input: string): boolean { - const source = input.trim().replace(/;$/, "").trim(); - return /^db\s*\.\s*version\s*\(\s*\)$/i.test(source); -} - -export function parseMongoCountDocumentsCommand(input: string): MongoCountDocumentsCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - return parseCollectionCountCommand(source, "countDocuments") ?? parseCollectionCountCommand(source, "count") ?? parseFindCountCommand(source); -} - -function parseCollectionCountCommand(source: string, method: "countDocuments" | "count"): MongoCountDocumentsCommand | null { - const target = parseCollectionMethodTarget(source, method); - if (!target) return null; - const openIndex = source.indexOf("(", target.methodCallIndex); - const closeIndex = findMatchingParen(source, openIndex); - if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null; - const args = splitTopLevel(source.slice(openIndex + 1, closeIndex)); - if (args.length > 1 && args.slice(1).some((arg) => arg.trim())) return null; - const filter = normalizeJsonArgument(args[0] || "{}"); - return filter ? { collection: target.collection, filter, mode: method === "countDocuments" ? "accurate" : "legacy" } : null; -} - -function parseFindCountCommand(source: string): MongoCountDocumentsCommand | null { - const target = parseCollectionMethodTarget(source, "find"); - if (!target) return null; - const findOpenIndex = source.indexOf("(", target.methodCallIndex); - const findCloseIndex = findMatchingParen(source, findOpenIndex); - if (findCloseIndex < 0) return null; - const chain = source.slice(findCloseIndex + 1).trim(); - if (!hasSingleEmptyChainedCall(chain, "count")) return null; - const findArgs = splitTopLevel(source.slice(findOpenIndex + 1, findCloseIndex)); - if (findArgs.length > 2 && findArgs.slice(2).some((arg) => arg.trim())) return null; - const filter = normalizeJsonArgument(findArgs[0] || "{}"); - return filter ? { collection: target.collection, filter, mode: "legacy" } : null; -} - -export function parseMongoDistinctCommand(input: string): MongoDistinctCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - const target = parseCollectionMethodTarget(source, "distinct"); - if (!target) return null; - const args = parseMethodArgs(source, target.methodCallIndex); - if (!args || args.length < 1 || args.length > 2) return null; - - const fieldJson = normalizeJsonArgument(args[0] ?? ""); - if (!fieldJson) return null; - let field: unknown; - try { - field = JSON.parse(fieldJson); - } catch { - return null; - } - if (typeof field !== "string" || !field.trim()) return null; - - if (args.length === 1) return { collection: target.collection, field }; - const filter = normalizeJsonArgument(args[1] ?? ""); - return filter ? { collection: target.collection, field, filter } : null; -} - -export function parseMongoGetIndexesCommand(input: string): MongoGetIndexesCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - const target = parseCollectionMethodTarget(source, "getIndexes"); - if (!target) return null; - const args = parseMethodArgs(source, target.methodCallIndex); - if (!args || args.some((arg) => arg.trim())) return null; - return { collection: target.collection }; -} - -export function parseMongoCollectionStatsCommand(input: string): MongoCollectionStatsCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - for (const metric of ["stats", "dataSize", "storageSize", "totalIndexSize"] as const) { - const target = parseCollectionMethodTarget(source, metric); - if (!target) continue; - const args = parseMethodArgs(source, target.methodCallIndex); - if (!args) return null; - const scale = parseMongoCollectionStatsScale(args); - return scale === null ? null : { collection: target.collection, metric, ...(scale === undefined ? {} : { scale }) }; - } - return null; -} - -export function mongoAggregateWriteStage(pipelineJson: string): "$out" | "$merge" | null { - try { - const pipeline = JSON.parse(pipelineJson); - if (!Array.isArray(pipeline)) return null; - for (const stage of pipeline) { - if (!isRecord(stage)) continue; - if (Object.prototype.hasOwnProperty.call(stage, "$out")) return "$out"; - if (Object.prototype.hasOwnProperty.call(stage, "$merge")) return "$merge"; - } - return null; - } catch { - return null; - } -} - -export function parseMongoWriteCommand(input: string): MongoWriteCommand | null { - const source = input.trim().replace(/;$/, "").trim(); - const insertOne = parseCollectionMethodTarget(source, "insertOne"); - if (insertOne) { - const args = parseMethodArgs(source, insertOne.methodCallIndex); - if (!args || args.length !== 1) return null; - const doc = normalizeJsonArgument(args[0]); - return doc ? { kind: "insert", collection: insertOne.collection, docsJson: doc } : null; - } - - const insertMany = parseCollectionMethodTarget(source, "insertMany"); - if (insertMany) { - const args = parseMethodArgs(source, insertMany.methodCallIndex); - if (!args || args.length !== 1) return null; - const docs = normalizeJsonArgument(args[0]); - if (!docs) return null; - return Array.isArray(JSON.parse(docs)) ? { kind: "insert", collection: insertMany.collection, docsJson: docs } : null; - } - - const insert = parseCollectionMethodTarget(source, "insert"); - if (insert) { - const args = parseMethodArgs(source, insert.methodCallIndex); - if (!args || args.length !== 1 || !args[0]?.trim()) return null; - const docs = normalizeJsonArgument(args[0]); - if (!docs) return null; - const value = JSON.parse(docs); - return value !== null && typeof value === "object" ? { kind: "insert", collection: insert.collection, docsJson: docs } : null; - } - - for (const method of ["updateOne", "updateMany"] as const) { - const target = parseCollectionMethodTarget(source, method); - if (!target) continue; - const args = parseMethodArgs(source, target.methodCallIndex); - if (!args || args.length < 2 || args.length > 3) return null; - const filter = normalizeJsonArgument(args[0]); - const update = normalizeJsonArgument(args[1]); - if (!filter || !update) return null; - const options = args[2]?.trim() ? normalizeJsonArgument(args[2]) : undefined; - if (args[2]?.trim() && !options) return null; - return { kind: "update", collection: target.collection, filter, update, ...(options ? { options } : {}), many: method === "updateMany" }; - } - - for (const method of ["deleteOne", "deleteMany"] as const) { - const target = parseCollectionMethodTarget(source, method); - if (!target) continue; - const args = parseMethodArgs(source, target.methodCallIndex); - if (!args || args.length !== 1) return null; - const filter = normalizeJsonArgument(args[0]); - if (!filter) return null; - return { kind: "delete", collection: target.collection, filter, many: method === "deleteMany" }; - } - - const createIndex = parseCollectionMethodTarget(source, "createIndex"); - if (createIndex) { - const args = parseMethodArgs(source, createIndex.methodCallIndex); - if (!args || args.length < 1 || args.length > 2) return null; - const keys = normalizeJsonArgument(args[0]); - if (!keys) return null; - let options: string | undefined; - if (args[1]?.trim()) { - const parsedOptions = normalizeJsonArgument(args[1]); - if (!parsedOptions) return null; - options = parsedOptions; - } - return { kind: "createIndex", collection: createIndex.collection, keys, ...(options ? { options } : {}) }; - } - - const dropIndex = parseCollectionMethodTarget(source, "dropIndex"); - if (dropIndex) { - const args = parseMethodArgs(source, dropIndex.methodCallIndex); - if (!args) return null; - const index = parseMongoDropIndexArgument(args); - return index ? { kind: "dropIndex", collection: dropIndex.collection, index } : null; - } - - const dropIndexes = parseCollectionMethodTarget(source, "dropIndexes"); - if (dropIndexes) { - const args = parseMethodArgs(source, dropIndexes.methodCallIndex); - if (!args) return null; - const indexes = parseMongoDropIndexesArgument(args); - return indexes !== null ? { kind: "dropIndexes", collection: dropIndexes.collection, ...(indexes ? { indexes } : {}) } : null; - } - - const dropCollection = parseCollectionMethodTarget(source, "drop"); - if (dropCollection) { - const args = parseMethodArgs(source, dropCollection.methodCallIndex); - if (!args || args.some((arg) => arg.trim())) return null; - return { kind: "dropCollection", collection: dropCollection.collection }; - } - - return null; -} - -export function evaluateMongoWriteSafety(command: MongoWriteCommand, options: { allowWrites?: boolean; allowDangerous?: boolean }): { allowed: boolean; reason?: string } { - if (!options.allowWrites) { - return { - allowed: false, - reason: "MCP MongoDB execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.", - }; - } - if (!options.allowDangerous && (command.kind === "update" || command.kind === "delete") && isEmptyJsonObject(command.filter)) { - return { - allowed: false, - reason: "MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.", - }; - } - if (!options.allowDangerous && mongoDropIndexesRequiresDangerous(command)) { - return { - allowed: false, - reason: "MongoDB dropIndexes() without a specific single index requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.", - }; - } - if (!options.allowDangerous && command.kind === "dropCollection") { - return { - allowed: false, - reason: "MongoDB drop() requires DBX_MCP_ALLOW_DANGEROUS_SQL=1.", - }; - } - return { allowed: true }; -} - -export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, options: { allowWrites?: boolean; allowDangerous?: boolean }): { allowed: boolean; reason?: string } { - const writeStage = mongoAggregateWriteStage(command.pipeline); - if (!writeStage) return { allowed: true }; - if (!options.allowWrites) { - return { - allowed: false, - reason: `MongoDB aggregate stage "${writeStage}" writes data. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.`, - }; - } - if (!options.allowDangerous) { - return { - allowed: false, - reason: `MongoDB aggregate stage "${writeStage}" is dangerous. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`, - }; - } - return { allowed: true }; -} - -function parseMethodArgs(source: string, methodCallIndex: number): string[] | null { - const openIndex = source.indexOf("(", methodCallIndex); - const closeIndex = findMatchingParen(source, openIndex); - if (closeIndex < 0 || source.slice(closeIndex + 1).trim()) return null; - return splitTopLevel(source.slice(openIndex + 1, closeIndex)); -} - -function readChainedCallArgument(chain: string, method: string): string | undefined { - const match = chainedMethodCallPattern(method).exec(chain); - if (!match) return undefined; - const openIndex = chain.indexOf("(", match.index); - const closeIndex = findMatchingParen(chain, openIndex); - return closeIndex < 0 ? undefined : chain.slice(openIndex + 1, closeIndex); -} - -function hasSingleEmptyChainedCall(chain: string, method: string): boolean { - const trimmed = chain.trim(); - const match = chainedMethodCallPattern(method).exec(trimmed); - if (!match || match.index !== 0) return false; - const openIndex = trimmed.indexOf("(", match.index); - const closeIndex = findMatchingParen(trimmed, openIndex); - return closeIndex >= 0 && !trimmed.slice(openIndex + 1, closeIndex).trim() && !trimmed.slice(closeIndex + 1).trim(); -} - -function readChainedIntegerArgument(chain: string, method: string, fallback: number): number | null { - const arg = readChainedCallArgument(chain, method); - if (arg === undefined) return fallback; - if (!/^\d+$/.test(arg.trim())) return null; - return Number(arg.trim()); -} - -function parseMongoDropIndexArgument(args: string[]): string | null { - if (args.length !== 1 || !args[0]?.trim()) return null; - const normalized = normalizeJsonArgument(args[0]); - if (!normalized) return null; - const parsed = parseNormalizedJson(normalized); - if (typeof parsed === "string") return parsed === "*" ? null : normalized; - return isNonEmptyRecord(parsed) ? normalized : null; -} - -function parseMongoDropIndexesArgument(args: string[]): string | undefined | null { - if (args.length !== 1) return null; - if (!args[0]?.trim()) return undefined; - const normalized = normalizeJsonArgument(args[0]); - if (!normalized) return null; - const parsed = parseNormalizedJson(normalized); - if (typeof parsed === "string") return normalized; - if (isNonEmptyRecord(parsed)) return normalized; - return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === "string") ? normalized : null; -} - -function parseMongoCollectionStatsScale(args: string[]): number | undefined | null { - if (args.length === 1 && !args[0]?.trim()) return undefined; - if (args.length !== 1) return null; - const raw = args[0].trim(); - if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return null; - const scale = Number(raw); - if (!Number.isFinite(scale)) return null; - return scale; -} - -function parseNormalizedJson(json: string): unknown { - try { - return JSON.parse(json); - } catch { - return undefined; - } -} - -function isNonEmptyRecord(value: unknown): value is Record { - return isRecord(value) && Object.keys(value).length > 0; -} - -function isEmptyJsonObject(json: string): boolean { - try { - const parsed = JSON.parse(json); - return isRecord(parsed) && Object.keys(parsed).length === 0; - } catch { - return false; - } -} - -function mongoDropIndexesRequiresDangerous(command: MongoWriteCommand): boolean { - if (command.kind !== "dropIndexes") return false; - if (!command.indexes) return true; - const parsed = parseNormalizedJson(command.indexes); - if (parsed === "*") return true; - return Array.isArray(parsed) && parsed.length > 1; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function mongoTypeName(value: unknown): string { - if (value === null || value === undefined) return "null"; - if (Array.isArray(value)) return "array"; - if (isRecord(value)) return "object"; - return typeof value; -} - -function toCellValue(value: unknown): unknown { - return typeof value === "object" && value !== null ? JSON.stringify(value) : value; -} diff --git a/packages/node-core/src/diagnostics.ts b/packages/node-core/src/diagnostics.ts deleted file mode 100644 index 9b97e00d4..000000000 --- a/packages/node-core/src/diagnostics.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js"; -import { inspectConnectionStore } from "./connections.js"; - -export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "manticoresearch", "sqlite", "rqlite", "kwdb", "questdb"] as const; - -export type DirectQueryType = (typeof DIRECT_QUERY_TYPES)[number]; - -const DIRECT_QUERY_TYPE_SET = new Set(DIRECT_QUERY_TYPES); - -export function isDirectQueryType(dbType: string): dbType is DirectQueryType { - return DIRECT_QUERY_TYPE_SET.has(dbType); -} - -export const BRIDGE_REQUIRED_TYPES = [ - "cloudflare-d1", - "redis", - "mongodb", - "duckdb", - "clickhouse", - "sqlserver", - "oracle", - "elasticsearch", - "qdrant", - "milvus", - "weaviate", - "chromadb", - "etcd", - "dameng", - "kingbase", - "highgo", - "vastbase", - "goldendb", - "databend", - "gaussdb", - "yashandb", - "databricks", - "saphana", - "teradata", - "vertica", - "firebird", - "exasol", - "opengauss", - "oceanbase-oracle", - "gbase", - "tdengine", - "iotdb", - "h2", - "snowflake", - "trino", - "prestosql", - "hive", - "spark", - "db2", - "informix", - "iris", - "neo4j", - "cassandra", - "bigquery", - "kylin", - "sundb", - "oscar", - "xugu", - "jdbc", - "access", - "influxdb", - "zookeeper", -] as const; - -export interface DbxDiagnostics { - appDataDir: string; - dbPath: string; - dbPathExists: boolean; - connectionsTableExists: boolean; - connectionSecretsTableExists?: boolean; - connectionRowCount: number; - loadConnectionsOk: boolean; - loadedConnectionCount: number; - loadConnectionsError?: string; - loadConnectionsHint?: string; - bridgePortFile: string; - bridgePortFileExists: boolean; - bridgeUrl?: string; - directQueryTypes: string[]; - bridgeRequiredTypes: string[]; -} - -export async function getDbxDiagnostics(): Promise { - const portFile = bridgePortFilePath(); - const bridgePortFileExists = await exists(portFile); - let bridgeUrl: string | undefined; - if (bridgePortFileExists) { - const port = (await readFile(portFile, "utf-8")).trim(); - if (port) bridgeUrl = `http://127.0.0.1:${port}`; - } - - const path = dbPath(); - const connectionStore = await inspectConnectionStore({ path }); - return { - appDataDir: appDataDir(), - dbPath: path, - dbPathExists: connectionStore.dbPathExists, - connectionsTableExists: connectionStore.connectionsTableExists, - connectionSecretsTableExists: connectionStore.connectionSecretsTableExists, - connectionRowCount: connectionStore.connectionRowCount, - loadConnectionsOk: connectionStore.loadConnectionsOk, - loadedConnectionCount: connectionStore.loadedConnectionCount, - loadConnectionsError: connectionStore.loadConnectionsError, - loadConnectionsHint: connectionStore.loadConnectionsError ? connectionStoreHint(connectionStore.loadConnectionsError) : undefined, - bridgePortFile: portFile, - bridgePortFileExists, - bridgeUrl, - directQueryTypes: [...DIRECT_QUERY_TYPES], - bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES], - }; -} - -function connectionStoreHint(message: string): string | undefined { - if (/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; -} - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} diff --git a/packages/node-core/src/entrypoint.ts b/packages/node-core/src/entrypoint.ts deleted file mode 100644 index 4bfec99fc..000000000 --- a/packages/node-core/src/entrypoint.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { realpathSync } from "node:fs"; -import { normalize, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean { - if (!argvPath) return false; - return normalizeEntryPath(fileURLToPath(moduleUrl)) === normalizeEntryPath(argvPath); -} - -function normalizeEntryPath(path: string): string { - const normalized = normalize(realpathIfPossible(resolve(path))); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; -} - -function realpathIfPossible(path: string): string { - try { - return realpathSync.native(path); - } catch { - return path; - } -} diff --git a/packages/node-core/src/format.ts b/packages/node-core/src/format.ts deleted file mode 100644 index 4acf16c08..000000000 --- a/packages/node-core/src/format.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function mdTable(headers: string[], rows: string[][]): string { - const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3)); - const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`; - const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`; - const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n"); - return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`; -} - -export function formatCell(value: unknown): string { - if (value === null || value === undefined) return "NULL"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts deleted file mode 100644 index 3142f4095..000000000 --- a/packages/node-core/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from "./backend.js"; -export * from "./bridge.js"; -export * from "./connections.js"; -export * from "./database.js"; -export * from "./diagnostics.js"; -export * from "./entrypoint.js"; -export * from "./format.js"; -export * from "./paths.js"; -export * from "./production-safety.js"; -export * from "./redis-command.js"; -export * from "./schema-context.js"; -export * from "./sql-diagnostics.js"; -export * from "./sql-risk.js"; -export * from "./sql-safety.js"; diff --git a/packages/node-core/src/paths.ts b/packages/node-core/src/paths.ts deleted file mode 100644 index 1edcd6578..000000000 --- a/packages/node-core/src/paths.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { homedir, platform } from "node:os"; -import { join, posix, win32 } from "node:path"; - -export function appDataDir(): string { - // 支持 DBX_DATA_DIR 环境变量(与 Rust 侧 data_dir.rs 保持一致) - return appDataDirFromInputs({ - platform: platform(), - home: homedir(), - appData: process.env.APPDATA, - envDataDir: process.env.DBX_DATA_DIR, - }); -} - -export function appDataDirFromInputs(options: { platform: NodeJS.Platform; home: string; appData?: string; envDataDir?: string }): string { - if (options.envDataDir && options.envDataDir.trim() !== "") { - return options.envDataDir; - } - - switch (options.platform) { - case "darwin": - return posix.join(options.home, "Library", "Application Support", "com.dbx.app"); - case "win32": - return win32.join(options.appData || win32.join(options.home, "AppData", "Roaming"), "com.dbx.app"); - default: - return posix.join(options.home, ".local", "share", "com.dbx.app"); - } -} - -export function dbPath(): string { - return join(appDataDir(), "dbx.db"); -} - -export function bridgePortFilePath(): string { - return join(appDataDir(), "mcp-bridge-port"); -} diff --git a/packages/node-core/src/production-safety.ts b/packages/node-core/src/production-safety.ts deleted file mode 100644 index 39b5bb26c..000000000 --- a/packages/node-core/src/production-safety.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { ConnectionConfig } from "./connections.js"; -import { classifySqlRisk, isSqlRiskMutation, supportsHashLineComments } from "./sql-risk.js"; - -export interface ProductionSqlAssessment { - active: boolean; - isMutation: boolean; - databases: string[]; -} - -const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`; -const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`; -const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`; -const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i"); -const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi"); -const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi"); -const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi"); -const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi"); -const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i"); -const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi"); -const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi"); -const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi"); -const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi"); -const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi"); -const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi"); -const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi"); -const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i; -const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i; -const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i; -const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]); -const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]); -const SCHEMA_FIRST_QUALIFIER_TYPES = new Set([ - "postgres", - "redshift", - "gaussdb", - "kwdb", - "opengauss", - "kingbase", - "highgo", - "vastbase", - "yashandb", - "oracle", - "oceanbase-oracle", - "dameng", - "firebird", - "exasol", - "teradata", - "vertica", - "db2", - "informix", - "h2", - "iris", - "xugu", - "oscar", - "gbase", - "saphana", - "sqlserver", - "snowflake", - "trino", - "prestosql", - "databricks", - "bigquery", -]); - -interface ReferencedDatabaseAssessment { - databases: string[]; - uncertain: boolean; -} - -interface SqlTargetSafetyText { - text: string; - quotedIdentifiers: Map; -} - -/** Normalizes quoted database names before production scope comparison. */ -export function normalizeProductionDatabase(value: string | undefined | null): string { - return String(value ?? "") - .trim() - .replace(/^[`"[]|[`"\]]$/g, "") - .toLowerCase(); -} - -export function isProductionDatabase(config: ConnectionConfig | undefined, database?: string): boolean { - if (!config) return false; - if (config.is_production) return true; - const selected = normalizeProductionDatabase(database); - return !!selected && (config.production_databases ?? []).some((name) => normalizeProductionDatabase(name) === selected); -} - -/** - * Finds writes that target a marked production database, including a MySQL - * USE switch or a qualified database.table reference in a statement batch. - */ -export function assessProductionSql(sql: string, config: ConnectionConfig | undefined, activeDatabase?: string): ProductionSqlAssessment { - const targetText = sqlTargetSafetyText(sql); - const statements = splitTargetStatements(targetText.text); - const hashLineComments = supportsHashLineComments(config?.db_type); - const isMutation = isSqlRiskMutation(classifySqlRisk(sql, { hashLineComments }).risk); - if (!isMutation || !config) return { active: isProductionDatabase(config, activeDatabase), isMutation, databases: [] }; - if (config.is_production) return { active: true, isMutation, databases: [] }; - if (isProductionDatabase(config, activeDatabase)) return { active: true, isMutation, databases: activeDatabase ? [activeDatabase] : [] }; - - const marked = new Set((config.production_databases ?? []).map(normalizeProductionDatabase).filter(Boolean)); - if (!marked.size) return { active: false, isMutation, databases: [] }; - - const targets = referencedDatabases(statements, config.db_type, hashLineComments, activeDatabase, targetText.quotedIdentifiers); - const databases = targets.databases.filter((database) => marked.has(normalizeProductionDatabase(database))); - return { active: databases.length > 0 || targets.uncertain, isMutation, databases: databases.length > 0 ? databases : targets.uncertain ? [...marked] : [] }; -} - -function referencedDatabases(statements: string[], dbType: string, hashLineComments: boolean, activeDatabase: string | undefined, quotedIdentifiers: Map): ReferencedDatabaseAssessment { - const databases = new Set(); - let uncertain = false; - let useDatabase = ""; - const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase); - - for (const statement of statements) { - const statementDatabases = new Set(); - const statementAssessment = classifySqlRisk(statement, { hashLineComments }); - const statementIsMutation = isSqlRiskMutation(statementAssessment.risk); - const useMatch = statement.match(USE_RE); - if (useMatch?.[1]) { - useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers); - continue; - } - if (!statementIsMutation) continue; - const currentDatabase = useDatabase || normalizedActiveDatabase; - - collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE); - collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]); - for (const match of statement.matchAll(DATABASE_TARGET_RE)) { - const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : ""; - if (database) statementDatabases.add(database); - } - for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) { - const database = normalizeTargetDatabase(match[1], quotedIdentifiers); - if (database) statementDatabases.add(database); - } - const copyTarget = statement.match(COPY_TARGET_RE); - if (copyTarget?.[1]) { - const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase); - if (database) statementDatabases.add(database); - } - for (const database of statementDatabases) databases.add(database); - // The target regexes intentionally extract one object at a time. Until all - // list forms are parsed, never let a resolved first target disable fallback. - uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0); - } - return { databases: [...databases], uncertain }; -} - -function collectQualifiedTargetDatabases(statement: string, dbType: string, quotedIdentifiers: Map, currentDatabase: string, databases: Set, ...patterns: RegExp[]): void { - for (const pattern of patterns) { - collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]); - } -} - -function collectQualifiedTargetDatabaseGroups(statement: string, dbType: string, quotedIdentifiers: Map, currentDatabase: string, databases: Set, pattern: RegExp, captureIndexes: number[]): void { - pattern.lastIndex = 0; - for (const match of statement.matchAll(pattern)) { - for (const captureIndex of captureIndexes) { - const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase); - if (database) databases.add(database); - } - } -} - -function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: string, quotedIdentifiers: Map, currentDatabase: string): string { - const parts = String(qualifiedName ?? "") - .split(".") - .map((part) => normalizeTargetDatabase(part, quotedIdentifiers)) - .filter(Boolean); - if (parts.length < 2) return currentDatabase; - if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? ""; - return currentDatabase; -} - -function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map): string { - const normalized = normalizeProductionDatabase(value); - const quoted = quotedIdentifiers.get(normalized); - return quoted === undefined ? normalized : normalizeProductionDatabase(quoted); -} - -function qualifiedFirstPartIsDatabase(dbType: string, partCount: number): boolean { - const normalizedType = dbType.toLowerCase(); - if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(normalizedType)) return true; - if (SCHEMA_FIRST_QUALIFIER_TYPES.has(normalizedType)) return false; - return partCount >= 2; -} - -function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: string): boolean { - const normalizedKind = String(kind ?? "").toLowerCase(); - if (normalizedKind === "database" || normalizedKind === "catalog") return true; - if (normalizedKind !== "schema") return false; - return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType.toLowerCase()); -} - -function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType, hasResolvedTarget: boolean): boolean { - if (!isSqlRiskMutation(assessment.risk)) return false; - if (assessment.risk === "transaction") return false; - const firstKeyword = assessment.firstKeyword; - if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false; - return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget; -} - -function splitTargetStatements(sql: string): string[] { - return sql - .split(";") - .map((statement) => statement.trim()) - .filter(Boolean); -} - -function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map()): SqlTargetSafetyText { - let output = ""; - let index = 0; - while (index < sql.length) { - const char = sql[index] ?? ""; - const next = sql[index + 1] ?? ""; - if (char === "-" && next === "-") { - index += 2; - while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1; - output += " "; - continue; - } - if (char === "#") { - index += 1; - while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1; - output += " "; - continue; - } - if (char === "/" && next === "*") { - const close = sql.indexOf("*/", index + 2); - if (close < 0) return { text: output, quotedIdentifiers }; - const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index); - if (executablePrefixLength > 0) { - const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength); - output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `; - } else { - output += " "; - } - index = close + 2; - continue; - } - const dollarQuote = dollarQuoteTagAt(sql, index); - if (dollarQuote) { - const close = sql.indexOf(dollarQuote, index + dollarQuote.length); - index = close < 0 ? sql.length : close + dollarQuote.length; - output += " "; - continue; - } - if (char === "'") { - index = readQuotedEnd(sql, index, "'", "'"); - output += " "; - continue; - } - if (char === '"' || char === "`" || char === "[") { - const close = char === "[" ? "]" : char; - const end = readQuotedEnd(sql, index, char, close); - const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " "); - const token = `__dbxq${quotedIdentifiers.size}__`; - quotedIdentifiers.set(token.toLowerCase(), identifier); - output += ` ${token} `; - index = end; - continue; - } - output += char; - index += 1; - } - return { text: output, quotedIdentifiers }; -} - -function mysqlExecutableCommentPrefixLength(sql: string, index: number): number { - if (sql[index] !== "/" || sql[index + 1] !== "*") return 0; - if (sql[index + 2] === "!") return 3; - if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4; - return 0; -} - -function skipExecutableCommentVersion(sql: string, index: number): number { - let cursor = index; - while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1; - return cursor; -} - -function dollarQuoteTagAt(sql: string, index: number): string | undefined { - return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0]; -} - -function readQuotedEnd(sql: string, start: number, open: string, close: string): number { - let index = start + open.length; - while (index < sql.length) { - if (sql[index] === "\\" && (open === "'" || open === '"')) { - index += 2; - continue; - } - if (sql.startsWith(close, index)) { - if (sql.startsWith(close + close, index)) { - index += close.length * 2; - continue; - } - return index + close.length; - } - index += 1; - } - return sql.length; -} - -function unquoteIdentifier(value: string, open: string, close: string): string { - if (!value.startsWith(open) || !value.endsWith(close)) return value; - return value.slice(open.length, value.length - close.length).replaceAll(close + close, close); -} - -/** MCP receives Mongo shell text rather than SQL, so use a conservative write detector. */ -export function isLikelyMongoMutation(command: string): boolean { - return /\.(?:insert(?:One|Many)?|update(?:One|Many)?|replaceOne|delete(?:One|Many)?|findOneAnd(?:Update|Replace|Delete)|drop(?:Index|Indexes)?|renameCollection|createIndex)\s*\(|\bdb\.createCollection\s*\(/i.test(command); -} diff --git a/packages/node-core/src/redis-command.ts b/packages/node-core/src/redis-command.ts deleted file mode 100644 index e5801eb00..000000000 --- a/packages/node-core/src/redis-command.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { SqlSafetyOptions } from "./sql-safety.js"; - -export type RedisCommandSafety = "allowed" | "write" | "confirm" | "blocked"; - -export interface RedisCommandResult { - command: string; - safety: RedisCommandSafety; - value: unknown; -} - -export interface RedisCommandOptions { - skipSafetyCheck?: boolean; - timeoutMs?: number; -} - -export interface RedisCommandSafetyDecision { - allowed: boolean; - command?: string; - safety?: RedisCommandSafety; - reason?: string; - skipSafetyCheck?: boolean; -} - -const BLOCKED_REDIS_COMMANDS = new Set(["KEYS", "FLUSHALL", "SHUTDOWN", "CONFIG", "SAVE", "BGSAVE", "SLAVEOF", "REPLICAOF", "MIGRATE", "MODULE", "SCRIPT", "EVAL", "EVALSHA"]); - -const CONFIRM_REDIS_COMMANDS = new Set([ - "DEL", - "UNLINK", - "EXPIRE", - "EXPIREAT", - "PEXPIRE", - "PEXPIREAT", - "RENAME", - "RENAMENX", - "GETDEL", - "HDEL", - "LPOP", - "RPOP", - "LREM", - "LTRIM", - "SPOP", - "SREM", - "ZREM", - "ZPOPMAX", - "ZPOPMIN", - "ZMPOP", - "ZREMRANGEBYLEX", - "ZREMRANGEBYRANK", - "ZREMRANGEBYSCORE", - "XDEL", - "XTRIM", - "MOVE", - "SORT", - "SDIFFSTORE", - "SINTERSTORE", - "SUNIONSTORE", - "ZDIFFSTORE", - "ZINTERSTORE", - "ZRANGESTORE", - "ZUNIONSTORE", - "PFMERGE", - "GEOSEARCHSTORE", - "FLUSHDB", -]); - -const WRITE_REDIS_COMMANDS = new Set([ - "APPEND", - "BITFIELD", - "BITOP", - "COPY", - "DECR", - "DECRBY", - "GEOADD", - "GEORADIUS", - "GEORADIUSBYMEMBER", - "GETSET", - "INCR", - "INCRBY", - "INCRBYFLOAT", - "SET", - "SETEX", - "PSETEX", - "SETNX", - "SETRANGE", - "MSET", - "MSETNX", - "PERSIST", - "HSET", - "HMSET", - "HINCRBY", - "HINCRBYFLOAT", - "HSETNX", - "LINSERT", - "LSET", - "LMOVE", - "LPUSH", - "LPUSHX", - "PFADD", - "RPUSH", - "RPUSHX", - "RESTORE", - "SADD", - "ZADD", - "ZINCRBY", - "SETBIT", - "XADD", - "XACK", - "XAUTOCLAIM", - "XCLAIM", - "XSETID", -]); - -export function firstRedisCommandToken(commandText: string): string | undefined { - try { - return parseRedisCommandArgv(commandText)[0]?.toUpperCase(); - } catch { - const token = commandText.trim().match(/^\S+/)?.[0]?.toUpperCase(); - return token || undefined; - } -} - -export function classifyRedisCommand(commandText: string): RedisCommandSafety { - const command = firstRedisCommandToken(commandText); - if (!command) return "blocked"; - if (BLOCKED_REDIS_COMMANDS.has(command)) return "blocked"; - if (CONFIRM_REDIS_COMMANDS.has(command)) return "confirm"; - if (WRITE_REDIS_COMMANDS.has(command)) return "write"; - return "allowed"; -} - -export function evaluateRedisCommandSafety(commandText: string, options: SqlSafetyOptions = {}): RedisCommandSafetyDecision { - const command = firstRedisCommandToken(commandText); - if (!command) { - return { allowed: false, reason: "Redis command is empty." }; - } - - const safety = classifyRedisCommand(command); - if (safety === "blocked" && !options.allowDangerous) { - return { - allowed: false, - command, - safety, - reason: `Dangerous Redis command "${command}" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`, - }; - } - - if (safety !== "allowed" && !options.allowWrites) { - return { - allowed: false, - command, - safety, - reason: "MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.", - }; - } - - return { - allowed: true, - command, - safety, - skipSafetyCheck: safety === "blocked" && options.allowDangerous === true, - }; -} - -export function parseRedisCommandArgv(commandText: string): string[] { - const trimmed = commandText.trimEnd().replace(/;+$/, ""); - const argv: string[] = []; - let current = ""; - let quote: '"' | "'" | undefined; - let escaping = false; - - for (const ch of trimmed) { - if (escaping) { - if (ch === "n") current += "\n"; - else if (ch === "r") current += "\r"; - else if (ch === "t") current += "\t"; - else current += ch; - escaping = false; - continue; - } - - if (ch === "\\") { - escaping = true; - continue; - } - - if (quote) { - if (ch === quote) quote = undefined; - else current += ch; - continue; - } - - if (ch === '"' || ch === "'") { - quote = ch; - continue; - } - - if (/\s/.test(ch)) { - if (current) { - argv.push(current); - current = ""; - } - continue; - } - - current += ch; - } - - if (escaping) current += "\\"; - if (quote) throw new Error("Redis command has an unterminated quote"); - if (current) argv.push(current); - if (argv.length === 0) throw new Error("Redis command is empty"); - return argv; -} diff --git a/packages/node-core/src/schema-context.ts b/packages/node-core/src/schema-context.ts deleted file mode 100644 index b9f847ff4..000000000 --- a/packages/node-core/src/schema-context.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ConnectionConfig } from "./connections.js"; -import type { ColumnInfo, TableInfo } from "./database.js"; - -export interface SchemaContextBackend { - listTables(config: ConnectionConfig, schema?: string): Promise; - describeTable(config: ConnectionConfig, table: string, schema?: string): Promise; -} - -export interface SchemaContextOptions { - schema?: string; - tables?: string[]; - maxTables?: number; -} - -export interface SchemaContextTable { - name: string; - type: string; - columns: ColumnInfo[]; -} - -export interface SchemaContext { - connection: string; - database: string; - schema: string; - truncated: boolean; - tables: SchemaContextTable[]; -} - -const DEFAULT_MAX_TABLES = 8; - -export async function buildSchemaContext(backend: SchemaContextBackend, config: ConnectionConfig, options: SchemaContextOptions = {}): Promise { - const maxTables = Math.max(1, Math.min(options.maxTables ?? DEFAULT_MAX_TABLES, 20)); - const availableTables = await backend.listTables(config, options.schema); - const requested = new Set((options.tables ?? []).map((table) => table.toLowerCase())); - const selected = requested.size ? availableTables.filter((table) => requested.has(table.name.toLowerCase())) : availableTables.slice(0, maxTables); - - const limited = selected.slice(0, maxTables); - const tables = await Promise.all( - limited.map(async (table) => ({ - name: table.name, - type: table.type, - columns: await backend.describeTable(config, table.name, options.schema), - })), - ); - - return { - connection: config.name, - database: config.database || "", - schema: options.schema || "", - truncated: selected.length > limited.length || (!requested.size && availableTables.length > limited.length), - tables, - }; -} - -export function formatSchemaContext(context: SchemaContext): string { - const header = [`Connection: ${context.connection}`, context.database ? `Database: ${context.database}` : "", context.schema ? `Schema: ${context.schema}` : ""].filter(Boolean); - const sections = context.tables.map((table) => { - const lines = table.columns.map((column) => { - const parts = [column.name, column.data_type, column.is_nullable ? "NULL" : "NOT NULL", column.is_primary_key ? "PK" : ""].filter(Boolean); - return `- ${parts.join(" ")}${column.comment ? ` -- ${column.comment}` : ""}`; - }); - return [`## ${table.name}`, `Type: ${table.type}`, ...lines].join("\n"); - }); - const suffix = context.truncated ? "\n\nNote: table list was truncated; request specific table names for more context." : ""; - return `${header.join("\n")}\n\n${sections.join("\n\n")}${suffix}`; -} diff --git a/packages/node-core/src/sql-diagnostics.ts b/packages/node-core/src/sql-diagnostics.ts deleted file mode 100644 index 344292e46..000000000 --- a/packages/node-core/src/sql-diagnostics.ts +++ /dev/null @@ -1,130 +0,0 @@ -const DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS = 512; -const SENSITIVE_NAME_RE = /(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|authorization|bearer)/i; - -function boundedInput(sql: string, maxChars: number): [string, boolean] { - if (maxChars <= 0) return ["", sql.length > 0]; - - let end = 0; - let chars = 0; - for (const character of sql) { - if (chars === maxChars) return [sql.slice(0, end), true]; - end += character.length; - chars += 1; - } - return [sql, false]; -} - -function truncateForDiagnostic(value: string, maxChars: number, inputTruncated: boolean): string { - if (value.length > maxChars) return `${value.slice(0, maxChars)}…[truncated]`; - return inputTruncated ? `${value}…[truncated]` : value; -} - -function redactSqlLiterals(sql: string): string { - let result = ""; - let i = 0; - while (i < sql.length) { - const ch = sql[i]; - const next = sql[i + 1]; - if (ch === "'" || ch === '"' || ch === "`") { - const quote = ch; - result += `${quote}[REDACTED]${quote}`; - i += 1; - while (i < sql.length) { - const current = sql[i]; - if (current === quote) { - if (sql[i + 1] === quote) { - i += 2; - continue; - } - i += 1; - break; - } - if (current === "\\" && quote !== "`") { - i += 2; - } else { - i += 1; - } - } - continue; - } - if (ch === "$") { - const j = i + 1; - if (j >= sql.length) { - result += "$"; - i += 1; - continue; - } - if (sql[j] === "$") { - // $$...$$ empty-tag dollar-quoted string - result += "$$[REDACTED]$$"; - i += 2; - while (i + 1 < sql.length && !(sql[i] === "$" && sql[i + 1] === "$")) { - i += 1; - } - if (i + 1 < sql.length) { - i += 2; - } - continue; - } - // $tag$...$tag$ dollar-quoted string — tag must be ASCII alphanumerics + underscore only - const TAG_CHAR = /^[A-Za-z0-9_]$/; - let tagEnd = j; - while (tagEnd < sql.length && TAG_CHAR.test(sql[tagEnd])) { - tagEnd += 1; - } - if (tagEnd > j && tagEnd < sql.length && sql[tagEnd] === "$") { - const tag = sql.slice(j, tagEnd); - result += "$[REDACTED]$"; - i = tagEnd + 1; - const closing = "$" + tag + "$"; - while (i + closing.length <= sql.length) { - if (sql.slice(i, i + closing.length) === closing) { - i += closing.length; - break; - } - i += 1; - } - continue; - } - result += "$"; - i += 1; - continue; - } - if (ch === "-" && next === "-") { - result += "--[REDACTED_COMMENT]"; - i += 2; - while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1; - continue; - } - if (ch === "/" && next === "*") { - result += "/*[REDACTED_COMMENT]*/"; - i += 2; - while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i += 1; - if (i < sql.length) i += 2; - continue; - } - result += ch; - i += 1; - } - return result; -} - -export function redactSqlForDiagnostics(sql: string, maxChars = DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS): string { - const [boundedSql, inputTruncated] = boundedInput(sql, maxChars); - const literalRedacted = redactSqlLiterals(boundedSql); - const sensitiveRedacted = literalRedacted.replace(/\b([A-Za-z_][\w.-]*)(\s*[:=]\s*)([^\s,;)]+)/g, (match, key: string, separator: string) => { - if (!SENSITIVE_NAME_RE.test(key)) return match; - return `${key}${separator}[REDACTED]`; - }); - return truncateForDiagnostic(sensitiveRedacted, maxChars, inputTruncated); -} - -export function sqlDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - const value = env.DBX_SQL_DEBUG ?? env.DBX_DEBUG_SQL ?? env.DBX_MCP_DEBUG_SQL; - return value === "1" || value?.toLowerCase() === "true"; -} - -export function logSqlDiagnostic(scope: string, sql: string, details: Record = {}, env?: NodeJS.ProcessEnv): void { - if (!sqlDiagnosticsEnabled(env)) return; - console.error(`[${scope}] sql:`, JSON.stringify({ ...details, sql: redactSqlForDiagnostics(sql) })); -} diff --git a/packages/node-core/src/sql-risk.ts b/packages/node-core/src/sql-risk.ts deleted file mode 100644 index b796df674..000000000 --- a/packages/node-core/src/sql-risk.ts +++ /dev/null @@ -1,254 +0,0 @@ -export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown"; - -export interface SqlRiskStatementAssessment { - risk: SqlRiskLevel; - firstKeyword?: string; -} - -export interface SqlRiskAssessment extends SqlRiskStatementAssessment { - statements: SqlRiskStatementAssessment[]; -} - -/** Options for SQL text utilities that parse comments and literals. */ -export interface SqlTextOptions { - /** Whether `#` starts a line comment. Only MySQL-family databases support this. - * Default: false (fail-safe — `#` is treated as an operator, which may over-block - * MySQL classification but never under-blocks PostgreSQL). */ - hashLineComments?: boolean; -} - -/** Database types whose SQL dialect uses `#` for line comments (MySQL family). */ -const MYSQL_HASH_COMMENT_DB_TYPES = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]); - -/** Determine whether the given database type supports `#` line comments. - * Mirrors the Rust `is_mysql_compatible_database` dialect set: - * Mysql, Doris, StarRocks, ManticoreSearch, Goldendb. */ -export function supportsHashLineComments(dbType?: string): boolean { - if (!dbType) return false; - return MYSQL_HASH_COMMENT_DB_TYPES.has(dbType); -} - -interface SqlRiskToken { - text: string; - normalized: string; -} - -const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]); -const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]); -const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]); -const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]); -const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]); -const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]); -const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]); -const RISK_ORDER: Record = { read: 0, write: 1, ddl: 2, transaction: 3, unknown: 4 }; - -export function splitSqlStatementsForSafety(sql: string, options?: SqlTextOptions): string[] { - return sqlSafetyText(sql, options) - .split(";") - .map((statement) => statement.trim()) - .filter(Boolean); -} - -export function classifySqlRisk(sql: string, options?: SqlTextOptions): SqlRiskAssessment { - const statements = splitSqlStatementsForSafety(sql, options).map(classifySqlStatementRisk); - if (!statements.length) return { risk: "unknown", statements: [] }; - const highest = statements.reduce((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" }); - return { ...highest, statements }; -} - -export function classifySqlStatementRisk(sql: string): SqlRiskStatementAssessment { - return classifyTokens(tokenizeSqlForRisk(sql)); -} - -export function isSqlRiskMutation(risk: SqlRiskLevel): boolean { - return risk !== "read"; -} - -export function sqlSafetyText(sql: string, options?: SqlTextOptions): string { - let output = ""; - let index = 0; - const hashLineComments = options?.hashLineComments === true; - while (index < sql.length) { - const char = sql[index] ?? ""; - const next = sql[index + 1] ?? ""; - if (char === "-" && next === "-") { - index += 2; - while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1; - output += " "; - continue; - } - if (hashLineComments && char === "#") { - index += 1; - while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1; - output += " "; - continue; - } - if (char === "/" && next === "*") { - const close = sql.indexOf("*/", index + 2); - if (close < 0) return output; - const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index); - if (executablePrefixLength > 0) { - const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength); - output += ` ${sqlSafetyText(sql.slice(bodyStart, close), options)} `; - } else { - output += " "; - } - index = close + 2; - continue; - } - const dollarQuote = dollarQuoteTagAt(sql, index); - if (dollarQuote) { - const close = sql.indexOf(dollarQuote, index + dollarQuote.length); - index = close < 0 ? sql.length : close + dollarQuote.length; - output += " "; - continue; - } - if (char === "'") { - index = readQuotedEnd(sql, index, "'", "'"); - output += " "; - continue; - } - if (char === '"' || char === "`" || char === "[") { - const close = char === "[" ? "]" : char; - const end = readQuotedEnd(sql, index, char, close); - output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `; - index = end; - continue; - } - output += char; - index += 1; - } - return output; -} - -function tokenizeSqlForRisk(sql: string): SqlRiskToken[] { - const tokens: SqlRiskToken[] = []; - const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g; - for (const match of sql.matchAll(re)) { - const text = match[0] ?? ""; - tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text }); - } - return tokens; -} - -function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment { - const useful = trimWrappingParentheses(tokens); - const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized; - if (!firstKeyword) return { risk: "unknown" }; - if (READ_KEYWORDS.has(firstKeyword)) { - return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword }; - } - if (firstKeyword === "with") return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword }; - if (firstKeyword === "explain") return classifyExplainTokens(useful); - if (firstKeyword === "copy") return { risk: classifyCopyTokens(useful), firstKeyword }; - if (firstKeyword === "pragma") return { risk: classifyPragmaTokens(useful), firstKeyword }; - if (firstKeyword === "use") return { risk: "read", firstKeyword }; - if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword }; - if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword }; - if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword }; - return { risk: "unknown", firstKeyword }; -} - -function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment { - const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse"); - const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized)); - if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" }; - const inner = classifyTokens(tokens.slice(innerIndex)); - if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" }; - return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" }; -} - -function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel { - if (tokens.some((token) => token.normalized === "from")) return "write"; - if (tokens.some((token) => token.normalized === "to")) return "read"; - return "unknown"; -} - -function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel { - const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized; - if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read"; - return "write"; -} - -function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined { - let result: SqlRiskLevel | undefined; - for (const token of tokens) { - const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined; - if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk; - } - return result; -} - -function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean { - let depth = 0; - for (const token of tokens) { - if (token.text === "(") depth += 1; - if (token.text === ")") depth = Math.max(0, depth - 1); - if (depth !== 0) continue; - if (token.normalized === "into") return true; - } - return false; -} - -function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] { - let start = 0; - let end = tokens.length; - while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) { - start += 1; - end -= 1; - } - return tokens.slice(start, end); -} - -function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number { - let depth = 0; - for (let index = openIndex; index < tokens.length; index += 1) { - if (tokens[index]?.text === "(") depth += 1; - if (tokens[index]?.text === ")") { - depth -= 1; - if (depth === 0) return index; - } - } - return -1; -} - -function mysqlExecutableCommentPrefixLength(sql: string, index: number): number { - if (sql[index] !== "/" || sql[index + 1] !== "*") return 0; - if (sql[index + 2] === "!") return 3; - if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4; - return 0; -} - -function skipExecutableCommentVersion(sql: string, index: number): number { - let cursor = index; - while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1; - return cursor; -} - -function dollarQuoteTagAt(sql: string, index: number): string | undefined { - return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0]; -} - -function readQuotedEnd(sql: string, start: number, open: string, close: string): number { - let index = start + open.length; - while (index < sql.length) { - if (sql[index] === "\\" && (open === "'" || open === '"')) { - index += 2; - continue; - } - if (sql.startsWith(close, index)) { - if (sql.startsWith(close + close, index)) { - index += close.length * 2; - continue; - } - return index + close.length; - } - index += 1; - } - return sql.length; -} - -function unquoteIdentifier(value: string, open: string, close: string): string { - if (!value.startsWith(open) || !value.endsWith(close)) return value; - return value.slice(open.length, value.length - close.length).replaceAll(close + close, close); -} diff --git a/packages/node-core/src/sql-safety.ts b/packages/node-core/src/sql-safety.ts deleted file mode 100644 index 137600467..000000000 --- a/packages/node-core/src/sql-safety.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText, type SqlTextOptions } from "./sql-risk.js"; - -export interface SqlSafetyOptions { - allowWrites?: boolean; - allowDangerous?: boolean; - allowMultipleStatements?: boolean; - /** Whether `#` starts a line comment (MySQL family only). Default: false. */ - hashLineComments?: boolean; -} - -export interface SqlSafetyDecision { - allowed: boolean; - reason?: string; -} - -const DANGEROUS_RISKS = new Set(["ddl", "transaction", "unknown"]); - -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === "1" || normalized === "true") return true; - if (normalized === "0" || normalized === "false") return false; - return undefined; -} - -export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision { - const statements = splitSqlStatementsForSafety(sql, options); - if (statements.length === 0) return { allowed: false, reason: "SQL is empty." }; - if (statements.length > 1 && !options.allowMultipleStatements) { - return { allowed: false, reason: "Only one SQL statement is allowed per query." }; - } - - for (let i = 0; i < statements.length; i++) { - const decision = evaluateSingleSqlStatementSafety(statements[i], options); - if (!decision.allowed && statements.length > 1) { - return { - allowed: false, - reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`, - }; - } - if (!decision.allowed) return decision; - } - - return { allowed: true }; -} - -function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision { - const assessment = classifySqlStatementRisk(sql); - const firstKeyword = assessment.firstKeyword; - if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." }; - - if (DANGEROUS_RISKS.has(assessment.risk) && !options.allowDangerous) { - return { allowed: false, reason: `Dangerous SQL or unrecognized SQL statement "${firstKeyword.toUpperCase()}" is blocked.` }; - } - - if (!options.allowWrites && assessment.risk !== "read") { - return { - allowed: false, - reason: "MCP SQL execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.", - }; - } - - if (options.allowWrites && !options.allowDangerous) { - const tokens: string[] = sqlSafetyText(sql, options).toLowerCase().match(/[a-z_]+/g) ?? []; - if (firstKeyword === "update" && !tokens.includes("where")) { - return { allowed: false, reason: "UPDATE statements must include a WHERE clause." }; - } - if (firstKeyword === "delete" && !tokens.includes("where")) { - return { allowed: false, reason: "DELETE statements must include a WHERE clause." }; - } - } - - return { allowed: true }; -} - -export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafetyOptions { - const allowWrites = parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES); - const allowDangerous = parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL); - return { - allowWrites: allowWrites ?? true, - allowDangerous: allowDangerous ?? false, - }; -} - -export function splitSqlStatements(sql: string, options?: SqlTextOptions): string[] { - const statements: string[] = []; - let statementStart = 0; - let index = 0; - let state: "none" | "single" | "double" | "backtick" | "bracket" | "lineComment" | "blockComment" | "dollar" = "none"; - let dollarTag = ""; - const hashLineComments = options?.hashLineComments === true; - - const pushStatement = (end: number) => { - const statement = sql.slice(statementStart, end).trim(); - if (statement) statements.push(statement); - }; - - while (index < sql.length) { - const char = sql[index] ?? ""; - const next = sql[index + 1] ?? ""; - - if (state === "lineComment") { - if (char === "\n" || char === "\r") state = "none"; - index += 1; - continue; - } - if (state === "blockComment") { - if (char === "*" && next === "/") { - state = "none"; - index += 2; - } else { - index += 1; - } - continue; - } - if (state === "dollar") { - if (sql.startsWith(dollarTag, index)) { - index += dollarTag.length; - state = "none"; - } else { - index += 1; - } - continue; - } - if (state === "single" || state === "double" || state === "backtick") { - const quote = state === "single" ? "'" : state === "double" ? '"' : "`"; - if (char === quote) { - if (next === quote) { - index += 2; - continue; - } - state = "none"; - } else if (char === "\\" && next) { - // Preserve dialects that accept backslash escapes without letting an escaped quote end the literal. - index += 2; - continue; - } - index += 1; - continue; - } - if (state === "bracket") { - if (char === "]") { - if (next === "]") { - index += 2; - continue; - } - state = "none"; - } - index += 1; - continue; - } - - if (char === "-" && next === "-") { - state = "lineComment"; - index += 2; - continue; - } - if (hashLineComments && char === "#") { - state = "lineComment"; - index += 1; - continue; - } - if (char === "/" && next === "*") { - state = "blockComment"; - index += 2; - continue; - } - if (char === "'") state = "single"; - else if (char === '"') state = "double"; - else if (char === "`") state = "backtick"; - else if (char === "[") state = "bracket"; - else if (char === "$") { - const match = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(index)); - if (match) { - dollarTag = match[0]; - state = "dollar"; - index += dollarTag.length; - continue; - } - } else if (char === ";") { - pushStatement(index); - statementStart = index + 1; - } - index += 1; - } - - pushStatement(sql.length); - return statements; -} diff --git a/packages/node-core/src/web-backend.ts b/packages/node-core/src/web-backend.ts deleted file mode 100644 index 389f2276b..000000000 --- a/packages/node-core/src/web-backend.ts +++ /dev/null @@ -1,452 +0,0 @@ -import type { ConnectionConfig } from "./connections.js"; -import type { TableInfo, ColumnInfo, QueryOptions, QueryResult } from "./database.js"; -import { - collectionListToTableInfos, - evaluateMongoAggregateSafety, - evaluateMongoWriteSafety, - inferMongoColumns, - mongoCollectionStatsToQueryResult, - mongoDistinctToQueryResult, - mongoDocumentsToQueryResult, - parseMongoAggregateCommand, - parseMongoCollectionStatsCommand, - parseMongoCountDocumentsCommand, - parseMongoDistinctCommand, - parseMongoFindCommand, - parseMongoGetIndexesCommand, - parseMongoVersionCommand, - parseMongoWriteCommand, - type CollectionInfo, - type MongoWriteCommand, -} from "./database.js"; -import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js"; -import { sqlSafetyFromEnv } from "./sql-safety.js"; - -let sessionCookie: string | null = null; -let authChecked = false; - -interface AuthCheckResponse { - authenticated: boolean; - required: boolean; - setup_required: boolean; -} - -function baseUrl(): string { - return process.env.DBX_WEB_URL!.replace(/\/+$/, ""); -} - -function webPassword(): string { - return process.env.DBX_WEB_PASSWORD || ""; -} - -function extractSessionCookie(setCookie: string | null): string | null { - const match = setCookie?.match(/dbx_session=([^;]+)/); - return match?.[1] ?? null; -} - -async function checkAuth(): Promise { - const res = await fetch(`${baseUrl()}/api/auth/check`, { - method: "GET", - redirect: "manual", - }); - if (!res.ok) { - throw new Error(`Authentication check failed: ${res.status} ${res.statusText}`); - } - return (await res.json()) as AuthCheckResponse; -} - -async function ensureAuth(): Promise { - if (sessionCookie) return; - if (authChecked) return; - - const auth = await checkAuth(); - if (auth.setup_required) { - throw new Error("DBX Web password setup is required before MCP Web mode can access APIs."); - } - if (!auth.required || auth.authenticated) { - authChecked = true; - return; - } - - const password = webPassword(); - if (!password) { - throw new Error("DBX Web authentication is required. Set DBX_WEB_PASSWORD for MCP Web mode."); - } - - const res = await fetch(`${baseUrl()}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - redirect: "manual", - }); - - if (!res.ok) { - throw new Error(`Authentication failed: ${res.status} ${res.statusText}`); - } - - sessionCookie = extractSessionCookie(res.headers.get("set-cookie")); - if (!sessionCookie) { - throw new Error("Authentication failed: DBX Web did not return a session cookie."); - } - authChecked = true; -} - -function headers(extra?: Record): Record { - const h: Record = { "Content-Type": "application/json", ...extra }; - if (sessionCookie) { - h["Cookie"] = `dbx_session=${sessionCookie}`; - } - return h; -} - -async function apiFetch(path: string, init?: RequestInit): Promise { - await ensureAuth(); - let res = await fetch(`${baseUrl()}${path}`, { - ...init, - headers: headers(init?.headers as Record | undefined), - }); - if (res.status === 401 && sessionCookie && webPassword()) { - sessionCookie = null; - authChecked = false; - await ensureAuth(); - res = await fetch(`${baseUrl()}${path}`, { - ...init, - headers: headers(init?.headers as Record | undefined), - }); - } - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`API request ${path} failed: ${res.status} ${res.statusText} ${body}`); - } - return res; -} - -export function resetWebAuthForTests(): void { - sessionCookie = null; - authChecked = false; -} - -export async function loadConnections(): Promise { - const res = await apiFetch("/api/connection/list"); - return res.json(); -} - -export async function findConnection(name: string): Promise { - const connections = await loadConnections(); - return connections.find((c) => c.name.toLowerCase() === name.toLowerCase()); -} - -export async function addConnection(config: Omit): Promise { - const res = await apiFetch("/api/connection/save", { - method: "POST", - body: JSON.stringify({ configs: [config] }), - }); - const saved = (await res.json()) as ConnectionConfig; - return saved; -} - -export async function removeConnection(name: string): Promise { - const connection = await findConnection(name); - if (!connection) return false; - await apiFetch(`/api/connection/delete?id=${encodeURIComponent(connection.id)}`, { method: "DELETE" }); - return true; -} - -export async function removeConnectionById(id: string): Promise { - const connection = await loadConnections().then((cs) => cs.find((c) => c.id === id)); - if (!connection) return false; - await apiFetch(`/api/connection/delete?id=${encodeURIComponent(id)}`, { method: "DELETE" }); - return true; -} - -async function ensureConnected(config: ConnectionConfig): Promise { - await apiFetch("/api/connection/connect", { - method: "POST", - body: JSON.stringify({ config }), - }); -} - -export async function listTables(config: ConnectionConfig, schema?: string): Promise { - await ensureConnected(config); - if (config.db_type === "mongodb") { - const res = await apiFetch("/api/mongo/list-collections", { - method: "POST", - body: JSON.stringify({ connectionId: config.id, database: config.database || "" }), - }); - const collections = (await res.json()) as Array; - return collectionListToTableInfos(collections); - } - const params = new URLSearchParams({ - connection_id: config.id, - database: config.database || "", - schema: schema || "", - }); - const res = await apiFetch(`/api/schema/tables?${params}`); - return res.json(); -} - -export async function describeTable(config: ConnectionConfig, table: string, schema?: string): Promise { - await ensureConnected(config); - if (config.db_type === "mongodb") { - const res = await apiFetch("/api/mongo/find-documents", { - method: "POST", - body: JSON.stringify({ connectionId: config.id, database: config.database || "", collection: table, skip: 0, limit: 20, filter: "{}" }), - }); - const result = (await res.json()) as { documents: unknown[]; total: number }; - return inferMongoColumns(result.documents); - } - const params = new URLSearchParams({ - connection_id: config.id, - database: config.database || "", - schema: schema || "", - table, - }); - const res = await apiFetch(`/api/schema/columns?${params}`); - return res.json(); -} - -export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise { - await ensureConnected(config); - if (config.db_type === "mongodb") { - if (parseMongoVersionCommand(sql)) { - const res = await apiFetch("/api/mongo/server-version", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - }), - }); - const version = (await res.json()) as string; - return { columns: ["version"], rows: [{ version }], row_count: 1 }; - } - const count = parseMongoCountDocumentsCommand(sql); - if (count) { - const res = await apiFetch("/api/mongo/count-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: count.collection, - filter: count.filter, - mode: count.mode, - }), - }); - const total = (await res.json()) as number; - return { columns: ["count"], rows: [{ count: total }], row_count: 1 }; - } - const find = parseMongoFindCommand(sql); - if (find) { - const res = await apiFetch("/api/mongo/find-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: find.collection, - skip: find.skip, - limit: find.limit, - filter: find.filter, - projection: find.projection, - sort: find.sort, - }), - }); - const result = (await res.json()) as { documents: unknown[]; total: number }; - return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total); - } - const aggregate = parseMongoAggregateCommand(sql); - if (aggregate) { - const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv()); - if (!safety.allowed) throw new Error(safety.reason); - const res = await apiFetch("/api/mongo/aggregate-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: aggregate.collection, - pipelineJson: aggregate.pipeline, - maxRows: options?.maxRows ?? 100, - ...(aggregate.options ? { optionsJson: aggregate.options } : {}), - }), - }); - const result = (await res.json()) as { documents: unknown[]; total: number }; - return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total); - } - const distinct = parseMongoDistinctCommand(sql); - if (distinct) { - const res = await apiFetch("/api/mongo/distinct", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: distinct.collection, - field: distinct.field, - filter: distinct.filter, - }), - }); - const result = (await res.json()) as { documents: unknown[]; total: number }; - return mongoDistinctToQueryResult(distinct.field, result.documents.slice(0, options?.maxRows ?? result.documents.length)); - } - const getIndexes = parseMongoGetIndexesCommand(sql); - if (getIndexes) { - const res = await apiFetch("/api/mongo/aggregate-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: getIndexes.collection, - pipelineJson: '[{"$indexStats":{}}]', - maxRows: options?.maxRows ?? 100, - }), - }); - const result = (await res.json()) as { documents: unknown[]; total: number }; - return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total); - } - const collectionStats = parseMongoCollectionStatsCommand(sql); - if (collectionStats) { - const res = await apiFetch("/api/mongo/collection-stats", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: collectionStats.collection, - scale: collectionStats.scale, - }), - }); - const result = (await res.json()) as Record; - return mongoCollectionStatsToQueryResult(collectionStats.metric, result); - } - const write = parseMongoWriteCommand(sql); - if (write) { - const safety = evaluateMongoWriteSafety(write, sqlSafetyFromEnv()); - if (!safety.allowed) throw new Error(safety.reason); - const result = await executeMongoWrite(config, write); - if (write.kind === "createIndex") { - return { columns: ["name"], rows: [{ name: result.indexName ?? "" }], row_count: 1 }; - } - if (write.kind === "dropIndex" || write.kind === "dropIndexes") { - return { columns: ["name"], rows: (result.droppedNames ?? []).map((name) => ({ name })), row_count: result.affectedRows }; - } - return { columns: [], rows: [], row_count: result.affectedRows }; - } - throw new Error( - 'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.projects.aggregate([]), db.projects.aggregate([], {explain:true}), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.distinct("status"), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})', - ); - } - const res = await apiFetch("/api/query/execute", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - sql, - }), - }); - const data = (await res.json()) as { columns: string[]; rows: unknown[][] }; - const rows = data.rows.map((row: unknown[]) => { - const obj: Record = {}; - data.columns.forEach((col: string, i: number) => { - obj[col] = row[i]; - }); - return obj; - }); - const limitedRows = rows.slice(0, options?.maxRows ?? rows.length); - return { columns: data.columns, rows: limitedRows, row_count: limitedRows.length }; -} - -export async function executeRedisCommand(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise { - if (config.db_type !== "redis") { - throw new Error("Connection is not Redis."); - } - await ensureConnected(config); - const res = await apiFetch("/api/redis/execute-command", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - db, - command, - skipSafetyCheck: options?.skipSafetyCheck ?? false, - }), - }); - return (await res.json()) as RedisCommandResult; -} - -async function executeMongoWrite(config: ConnectionConfig, command: MongoWriteCommand): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> { - if (command.kind === "insert") { - const res = await apiFetch("/api/mongo/insert-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - docsJson: command.docsJson, - }), - }); - const result = (await res.json()) as { affected_rows: number }; - return { affectedRows: result.affected_rows }; - } - if (command.kind === "update") { - const res = await apiFetch("/api/mongo/update-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - filterJson: command.filter, - updateJson: command.update, - many: command.many, - optionsJson: command.options, - }), - }); - const result = (await res.json()) as { affected_rows: number }; - return { affectedRows: result.affected_rows }; - } - if (command.kind === "createIndex") { - const res = await apiFetch("/api/mongo/create-index", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - keysJson: command.keys, - optionsJson: command.options, - }), - }); - const result = (await res.json()) as { name: string }; - return { affectedRows: 1, indexName: result.name }; - } - if (command.kind === "dropIndex" || command.kind === "dropIndexes") { - const res = await apiFetch("/api/mongo/drop-indexes", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - indexesJson: command.kind === "dropIndex" ? command.index : command.indexes, - single: command.kind === "dropIndex", - }), - }); - const result = (await res.json()) as { dropped_names: string[]; affected_rows: number }; - return { affectedRows: result.affected_rows, droppedNames: result.dropped_names }; - } - if (command.kind === "dropCollection") { - await apiFetch("/api/mongo/drop-collection", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - }), - }); - return { affectedRows: 1 }; - } - const res = await apiFetch("/api/mongo/delete-documents", { - method: "POST", - body: JSON.stringify({ - connectionId: config.id, - database: config.database || "", - collection: command.collection, - filterJson: command.filter, - many: command.many, - }), - }); - const result = (await res.json()) as { affected_rows: number }; - return { affectedRows: result.affected_rows }; -} diff --git a/packages/node-core/tests/collections.test.ts b/packages/node-core/tests/collections.test.ts deleted file mode 100644 index 1f78568b1..000000000 --- a/packages/node-core/tests/collections.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { collectionListToTableInfos } from "../src/database.js"; - -test("maps legacy collection name responses to table infos", () => { - assert.deepEqual(collectionListToTableInfos(["projects", "users"]), [ - { name: "projects", type: "COLLECTION" }, - { name: "users", type: "COLLECTION" }, - ]); -}); - -test("maps collection info responses to table infos", () => { - assert.deepEqual( - collectionListToTableInfos([ - { name: "projects", id: "projects", dimension: null }, - { name: "embeddings", id: "uuid-123", dimension: 384 }, - ]), - [ - { name: "projects", type: "COLLECTION" }, - { name: "embeddings", type: "COLLECTION" }, - ], - ); -}); diff --git a/packages/node-core/tests/connection-store.test.ts b/packages/node-core/tests/connection-store.test.ts deleted file mode 100644 index 77aaf9405..000000000 --- a/packages/node-core/tests/connection-store.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "vitest"; -import Database from "better-sqlite3"; -import { inspectConnectionStore, loadConnections } from "../src/connections.js"; - -test("connection store diagnostics report rows even when loading fails", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-store-")); - const path = join(dir, "dbx.db"); - - try { - const db = new Database(path); - db.exec(` - CREATE TABLE connections (id TEXT PRIMARY KEY, config_json TEXT NOT NULL); - CREATE TABLE connection_secrets (connection_id TEXT, key TEXT, secret TEXT); - `); - db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run("broken", "{not json"); - db.close(); - - await assert.rejects(() => loadConnections({ path }), /Failed to load DBX connections/); - - const diagnostics = await inspectConnectionStore({ path }); - assert.equal(diagnostics.dbPath, path); - assert.equal(diagnostics.dbPathExists, true); - assert.equal(diagnostics.connectionsTableExists, true); - assert.equal(diagnostics.connectionRowCount, 1); - assert.equal(diagnostics.loadConnectionsOk, false); - assert.match(diagnostics.loadConnectionsError ?? "", /Failed to load DBX connections/); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("missing connection store is treated as an empty store", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-store-")); - const path = join(dir, "missing.db"); - - try { - assert.deepEqual(await loadConnections({ path }), []); - - const diagnostics = await inspectConnectionStore({ path }); - assert.equal(diagnostics.dbPathExists, false); - assert.equal(diagnostics.connectionsTableExists, false); - assert.equal(diagnostics.connectionRowCount, 0); - assert.equal(diagnostics.loadConnectionsOk, true); - assert.equal(diagnostics.loadedConnectionCount, 0); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/node-core/tests/describe-table.test.ts b/packages/node-core/tests/describe-table.test.ts deleted file mode 100644 index bd35d6521..000000000 --- a/packages/node-core/tests/describe-table.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import assert from "node:assert/strict"; -import { afterEach, test, vi } from "vitest"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ConnectionConfig } from "../src/connections.js"; - -const fakeMysqlQuery = vi.fn(); -const fakeMysqlEnd = vi.fn().mockResolvedValue(undefined); -const fakePgQuery = vi.fn(); -const fakePgEnd = vi.fn().mockResolvedValue(undefined); -const fakePgOn = vi.fn(); - -vi.mock("mysql2/promise", () => ({ - default: { - createPool: vi.fn(() => ({ - query: fakeMysqlQuery, - end: fakeMysqlEnd, - })), - }, -})); - -vi.mock("pg", () => ({ - default: { - Pool: vi.fn(function MockPool() { - return { - query: fakePgQuery, - end: fakePgEnd, - on: fakePgOn, - }; - }), - }, -})); - -import { closeDatabaseResources, describeTable } from "../src/database.js"; - -function mysqlConfig(): ConnectionConfig { - return { - id: "mysql-direct-test", - name: "mysql-direct", - db_type: "mysql", - host: "127.0.0.1", - port: 3306, - username: "root", - password: "secret", - database: "app", - ssl: false, - }; -} - -function postgresConfig(): ConnectionConfig { - return { - id: "postgres-direct-test", - name: "postgres-direct", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "postgres", - password: "postgres", - database: "app", - ssl: false, - }; -} - -const bridgeConfig: ConnectionConfig = { - id: "pg-bridge", - name: "bridge-postgres", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "postgres", - password: "postgres", - database: "postgres", - ssl: false, - transport_layers: [ - { - type: "ssh", - id: "jump", - enabled: true, - host: "bastion.internal", - port: 22, - user: "dbx", - }, - ], -}; - -afterEach(async () => { - await closeDatabaseResources(); - fakeMysqlQuery.mockReset(); - fakeMysqlEnd.mockClear(); - fakePgQuery.mockReset(); - fakePgEnd.mockClear(); - fakePgOn.mockClear(); -}); - -test("describeTable maps mysql enum_values from metadata", async () => { - fakeMysqlQuery.mockResolvedValue([ - [ - { - name: "state", - data_type: "enum", - column_type: "enum('pending','active','archived')", - is_nullable: 0, - column_default: "pending", - is_primary_key: 0, - comment: "workflow state", - }, - ], - [{ name: "name" }], - ]); - - const columns = await describeTable(mysqlConfig(), "orders"); - - assert.match(String(fakeMysqlQuery.mock.calls[0]?.[0] ?? ""), /COLUMN_TYPE AS column_type/); - assert.deepEqual(fakeMysqlQuery.mock.calls[0]?.[1], ["orders"]); - assert.deepEqual(columns, [ - { - name: "state", - data_type: "enum", - is_nullable: false, - column_default: "pending", - is_primary_key: false, - comment: "workflow state", - enum_values: ["pending", "active", "archived"], - }, - ]); -}); - -test("describeTable parses mysql enum literal edge cases", async () => { - fakeMysqlQuery.mockResolvedValue([ - [ - { - name: "empty_state", - data_type: "enum", - column_type: "enum('','a')", - is_nullable: 1, - column_default: null, - is_primary_key: 0, - comment: null, - }, - { - name: "quoted_state", - data_type: "enum", - column_type: "enum('x'',''y','z')", - is_nullable: 1, - column_default: null, - is_primary_key: 0, - comment: null, - }, - { - name: "escaped_state", - data_type: "enum", - column_type: String.raw`enum('it''s','quote\"d','back\\slash')`, - is_nullable: 1, - column_default: null, - is_primary_key: 0, - comment: null, - }, - ], - [{ name: "name" }], - ]); - - const columns = await describeTable(mysqlConfig(), "orders"); - - assert.deepEqual( - columns.map((column) => column.enum_values), - [ - ["", "a"], - ["x','y", "z"], - ["it's", 'quote"d', "back\\slash"], - ], - ); -}); - -test("describeTable preserves enum values from bridge metadata", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "dbx-node-core-")); - const previousDataDir = process.env.DBX_DATA_DIR; - - const server = createServer((req, res) => { - assert.equal(req.url, "/data/describe-table"); - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify([ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: ["pending", "active", "archived"], - }, - ]), - ); - }); - - try { - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("expected TCP bridge address"); - - process.env.DBX_DATA_DIR = tempDir; - await writeFile(join(tempDir, "mcp-bridge-port"), String(address.port)); - - const columns = await describeTable(bridgeConfig, "orders", "public"); - - assert.deepEqual(columns, [ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: ["pending", "active", "archived"], - }, - ]); - } finally { - server.close(); - if (previousDataDir === undefined) { - delete process.env.DBX_DATA_DIR; - } else { - process.env.DBX_DATA_DIR = previousDataDir; - } - await rm(tempDir, { recursive: true, force: true }); - } -}); - -test("describeTable reads postgres enum_values from the primary metadata query", async () => { - fakePgQuery.mockResolvedValueOnce({ - rows: [ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: ["pending", "active", "archived"], - }, - ], - fields: [{ name: "name" }], - }); - - const columns = await describeTable(postgresConfig(), "orders", "public"); - - assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/); - assert.equal(fakePgQuery.mock.calls.length, 1); - assert.deepEqual(columns, [ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: ["pending", "active", "archived"], - }, - ]); -}); - -test("describeTable falls back to compat postgres metadata query when enum joins fail", async () => { - fakePgQuery.mockRejectedValueOnce(new Error("pg_enum catalog unavailable")).mockResolvedValueOnce({ - rows: [ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: null, - }, - ], - fields: [{ name: "name" }], - }); - - const columns = await describeTable(postgresConfig(), "orders", "public"); - - assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/); - assert.match(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /NULL AS enum_values/); - assert.doesNotMatch(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /pg_enum/); - assert.deepEqual(columns, [ - { - name: "state", - data_type: "status", - is_nullable: false, - column_default: null, - is_primary_key: false, - comment: "workflow state", - enum_values: null, - }, - ]); -}); diff --git a/packages/node-core/tests/driver-manifest.test.ts b/packages/node-core/tests/driver-manifest.test.ts deleted file mode 100644 index 4e95c698d..000000000 --- a/packages/node-core/tests/driver-manifest.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { test } from "vitest"; -import { BRIDGE_REQUIRED_TYPES, DIRECT_QUERY_TYPES, isDirectQueryType } from "../src/diagnostics.js"; - -interface DriverManifest { - drivers: Array<{ - dbType: string; - mcpMode: "direct" | "bridge" | "unsupported"; - supportLevel: "connect" | "browse" | "understand" | "operate"; - capabilities: Record; - }>; -} - -const PRODUCT_CAPABILITY_KEYS = [ - "queryExecution", - "metadataBrowse", - "objectBrowser", - "objectSource", - "schemaSearch", - "diagram", - "tableDataEdit", - "tableStructureEdit", - "tableImport", - "dataTransfer", - "sqlFileExecution", - "databaseCreate", - "fieldLineage", - "sqlExplain", - "userAdmin", - "driverManagement", -] as const; -type DatabaseProductCapability = (typeof PRODUCT_CAPABILITY_KEYS)[number]; - -function loadManifest(): DriverManifest { - const path = fileURLToPath(new URL("../../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url)); - return JSON.parse(readFileSync(path, "utf8")) as DriverManifest; -} - -test("diagnostic query mode lists match the driver manifest", () => { - const manifest = loadManifest(); - const directTypes = manifest.drivers.filter((driver) => driver.mcpMode === "direct").map((driver) => driver.dbType); - const bridgeTypes = manifest.drivers.filter((driver) => driver.mcpMode === "bridge").map((driver) => driver.dbType); - - assert.deepEqual([...DIRECT_QUERY_TYPES].sort(), directTypes.sort()); - assert.deepEqual([...BRIDGE_REQUIRED_TYPES].sort(), bridgeTypes.sort()); -}); - -test("runtime direct query routing matches diagnostic direct query types", () => { - for (const dbType of DIRECT_QUERY_TYPES) { - assert.equal(isDirectQueryType(dbType), true, `${dbType} should use direct query routing`); - } - - for (const dbType of BRIDGE_REQUIRED_TYPES) { - assert.equal(isDirectQueryType(dbType), false, `${dbType} should not use direct query routing`); - } -}); - -test("Manticore Search is direct-query capable", () => { - assert.equal(isDirectQueryType("manticoresearch"), true); - assert.equal(DIRECT_QUERY_TYPES.includes("manticoresearch" as any), true); -}); - -test("GaussDB family requires the DBX Desktop bridge for MCP", () => { - for (const dbType of ["gaussdb", "opengauss"] as const) { - assert.equal(isDirectQueryType(dbType), false); - assert.equal(DIRECT_QUERY_TYPES.includes(dbType as any), false); - assert.equal(BRIDGE_REQUIRED_TYPES.includes(dbType as any), true); - } -}); - -test("driver manifest declares support levels and product capabilities", () => { - const manifest = loadManifest(); - - for (const driver of manifest.drivers) { - assert.match(driver.supportLevel, /^(connect|browse|understand|operate)$/); - for (const key of PRODUCT_CAPABILITY_KEYS) { - assert.equal(typeof driver.capabilities[key], "boolean", `${driver.dbType}.${key} should be a boolean`); - } - assert.equal(Object.keys(driver.capabilities).sort().join(","), [...PRODUCT_CAPABILITY_KEYS].sort().join(",")); - } - - const jdbc = manifest.drivers.find((driver) => driver.dbType === "jdbc"); - assert.equal(jdbc?.supportLevel, "browse"); - assert.equal(jdbc?.capabilities.metadataBrowse, true); - assert.equal(jdbc?.capabilities.tableStructureEdit, false); - - const manticore = manifest.drivers.find((driver) => driver.dbType === "manticoresearch"); - assert.equal(manticore?.supportLevel, "operate"); - assert.equal(manticore?.capabilities.queryExecution, true); - assert.equal(manticore?.capabilities.metadataBrowse, true); - assert.equal(manticore?.capabilities.objectBrowser, false); - assert.equal(manticore?.capabilities.tableDataEdit, true); - assert.equal(manticore?.capabilities.tableStructureEdit, true); - assert.equal(manticore?.capabilities.databaseCreate, false); - assert.equal(manticore?.capabilities.userAdmin, false); -}); diff --git a/packages/node-core/tests/entrypoint.test.ts b/packages/node-core/tests/entrypoint.test.ts deleted file mode 100644 index d62419454..000000000 --- a/packages/node-core/tests/entrypoint.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { test } from "vitest"; -import { isMainModule } from "../src/entrypoint.js"; - -test("matches a module invoked through its real file path", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); - try { - const entry = join(dir, "cli.js"); - await writeFile(entry, "", "utf-8"); - - assert.equal(isMainModule(pathToFileURL(entry).href, entry), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("matches a module invoked through an npm-style symlink", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); - try { - const entry = join(dir, "dist", "cli.js"); - const bin = join(dir, "dbx"); - await mkdir(join(dir, "dist")); - await writeFile(entry, "", "utf-8"); - try { - await symlink(entry, bin); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EPERM") throw error; - return; - } - - assert.equal(isMainModule(pathToFileURL(entry).href, bin), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("does not match a different entry file", async () => { - const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-")); - try { - const entry = join(dir, "cli.js"); - const other = join(dir, "other.js"); - await writeFile(entry, "", "utf-8"); - await writeFile(other, "", "utf-8"); - - assert.equal(isMainModule(pathToFileURL(entry).href, other), false); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/node-core/tests/format.test.ts b/packages/node-core/tests/format.test.ts deleted file mode 100644 index bfa21a49f..000000000 --- a/packages/node-core/tests/format.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { formatCell, mdTable } from "../src/format.js"; - -test("formats markdown tables", () => { - assert.equal(mdTable(["Name", "Type"], [["local", "postgres"]]), "| Name | Type |\n| ----- | -------- |\n| local | postgres |"); -}); - -test("formats database cell values", () => { - assert.equal(formatCell(null), "NULL"); - assert.equal(formatCell(undefined), "NULL"); - assert.equal(formatCell({ ok: true }), '{"ok":true}'); - assert.equal(formatCell("hello"), "hello"); -}); diff --git a/packages/node-core/tests/mongo-count-routing.test.ts b/packages/node-core/tests/mongo-count-routing.test.ts deleted file mode 100644 index 2c78ee2a8..000000000 --- a/packages/node-core/tests/mongo-count-routing.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "vitest"; - -import type { ConnectionConfig } from "../src/connections.js"; -import { executeQuery } from "../src/database.js"; - -const mongoConfig: ConnectionConfig = { - id: "mongo-bridge", - name: "mongo-bridge", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, -}; - -test("direct backend routes MongoDB count commands through the count bridge", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "dbx-node-core-count-")); - const previousDataDir = process.env.DBX_DATA_DIR; - let requestBody: unknown; - const server = createServer((req, res) => { - assert.equal(req.url, "/data/mongo/count-documents"); - let body = ""; - req.setEncoding("utf8"); - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => { - requestBody = JSON.parse(body); - res.writeHead(200, { "content-type": "application/json" }); - res.end("42"); - }); - }); - - try { - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("expected TCP bridge address"); - - process.env.DBX_DATA_DIR = tempDir; - await writeFile(join(tempDir, "mcp-bridge-port"), String(address.port)); - - const result = await executeQuery(mongoConfig, "db.projects.count({ active: true })"); - - assert.deepEqual(requestBody, { - connection_id: "mongo-bridge", - connection_name: "mongo-bridge", - database: "app", - collection: "projects", - filter: '{ "active": true }', - mode: "legacy", - }); - assert.deepEqual(result, { - columns: ["count"], - rows: [{ count: 42 }], - row_count: 1, - }); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - if (previousDataDir === undefined) delete process.env.DBX_DATA_DIR; - else process.env.DBX_DATA_DIR = previousDataDir; - await rm(tempDir, { recursive: true, force: true }); - } -}); diff --git a/packages/node-core/tests/mongo-query.test.ts b/packages/node-core/tests/mongo-query.test.ts deleted file mode 100644 index bef222c5b..000000000 --- a/packages/node-core/tests/mongo-query.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { - executeQuery, - inferMongoColumns, - mongoAggregateWriteStage, - mongoCollectionStatsToQueryResult, - mongoDocumentsToQueryResult, - describeMongoCommandParseFailure, - parseMongoAggregateCommand, - parseMongoCollectionStatsCommand, - parseMongoCountDocumentsCommand, - parseMongoFindCommand, - parseMongoGetIndexesCommand, - parseMongoVersionCommand, - parseMongoWriteCommand, -} from "../src/database.js"; - -test("parseMongoFindCommand accepts shell-style find commands", () => { - assert.deepEqual(parseMongoFindCommand('db.getCollection("operation_logs").find({"level":"info"}).sort({"ts":-1}).skip(5).limit(10)'), { - collection: "operation_logs", - filter: '{"level":"info"}', - skip: 5, - limit: 10, - sort: '{"ts":-1}', - }); -}); - -test("parseMongoFindCommand accepts line breaks before find and chained calls", () => { - const command = parseMongoFindCommand(`db.getCollection("operation_logs") -.find({ - "_id": ObjectId("68ad51ca84c8127bc7d44cb3") -}) -.sort({ ts: -1 }) -.skip(5) -.limit(10)`); - assert.ok(command); - assert.equal(command.collection, "operation_logs"); - assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "68ad51ca84c8127bc7d44cb3" } }); - assert.deepEqual(JSON.parse(command.sort || "{}"), { ts: -1 }); - assert.equal(command.skip, 5); - assert.equal(command.limit, 10); -}); - -test("parseMongoFindCommand accepts Compass-style unquoted keys and ObjectId", () => { - const command = parseMongoFindCommand("db.products.find({_id: ObjectId('6a045a92d2971e44243771a1')}).limit(1)"); - assert.ok(command); - assert.equal(command.collection, "products"); - assert.equal(command.limit, 1); - assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "6a045a92d2971e44243771a1" } }); -}); - -test("parseMongoFindCommand accepts projection arguments", () => { - const command = parseMongoFindCommand("db.jobs.find({status: 'open'}, {title: 1, _id: 0}).sort({title: 1})"); - assert.ok(command); - assert.equal(command.collection, "jobs"); - assert.deepEqual(JSON.parse(command.filter), { status: "open" }); - assert.deepEqual(JSON.parse(command.projection || "{}"), { title: 1, _id: 0 }); - assert.deepEqual(JSON.parse(command.sort || "{}"), { title: 1 }); -}); - -test("parseMongoVersionCommand accepts db.version", () => { - assert.equal(parseMongoVersionCommand("db.version();"), true); - assert.equal(parseMongoVersionCommand("db.jobs.version()"), false); -}); - -test("parseMongoWriteCommand accepts unquoted update operator keys", () => { - assert.deepEqual(parseMongoWriteCommand("db.projects.updateOne({_id: ObjectId('507f1f77bcf86cd799439011')}, {$set: {name: 'next'}})"), { - kind: "update", - collection: "projects", - filter: '{"_id": {"$oid":"507f1f77bcf86cd799439011"}}', - update: '{"$set": {"name": "next"}}', - many: false, - }); -}); - -test("parseMongoWriteCommand accepts updateMany arrayFilters options", () => { - assert.deepEqual(parseMongoWriteCommand('db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})'), { - kind: "update", - collection: "orders", - filter: '{"status": "open"}', - update: '{"$set": {"items.$[item].status": "done"}}', - options: '{"arrayFilters": [{"item.id": 7}]}', - many: true, - }); -}); - -test("parseMongoCountDocumentsCommand accepts shell-style count commands", () => { - assert.deepEqual(parseMongoCountDocumentsCommand('db.projects.countDocuments({"active":true})'), { - collection: "projects", - filter: '{"active":true}', - mode: "accurate", - }); -}); - -test("parseMongoCountDocumentsCommand accepts legacy count helpers", () => { - assert.deepEqual(parseMongoCountDocumentsCommand("db.projects.count({ active: true })"), { - collection: "projects", - filter: '{ "active": true }', - mode: "legacy", - }); - assert.deepEqual(parseMongoCountDocumentsCommand('db.getCollection("audit.logs").count()'), { - collection: "audit.logs", - filter: "{}", - mode: "legacy", - }); - assert.deepEqual(parseMongoCountDocumentsCommand("db.projects.find({ active: true }).count()"), { - collection: "projects", - filter: '{ "active": true }', - mode: "legacy", - }); - assert.equal(parseMongoFindCommand("db.projects.find({ active: true }).count()"), null); -}); - -test("parseMongoAggregateCommand accepts aggregate pipelines", () => { - assert.deepEqual(parseMongoAggregateCommand('db.projects.aggregate([{"$match":{"active":true}},{"$group":{"_id":"$owner","total":{"$sum":1}}}])'), { - collection: "projects", - pipeline: '[{"$match":{"active":true}},{"$group":{"_id":"$owner","total":{"$sum":1}}}]', - }); -}); - -test("parseMongoAggregateCommand accepts options including explain", () => { - const withExplain = parseMongoAggregateCommand("db.uc_user.aggregate([], {explain: true})"); - assert.equal(withExplain?.collection, "uc_user"); - assert.equal(withExplain?.pipeline, "[]"); - assert.deepEqual(JSON.parse(withExplain?.options ?? "null"), { explain: true }); - assert.equal(parseMongoAggregateCommand("db.uc_user.aggregate([], {explain: true"), null); - assert.equal(parseMongoAggregateCommand("db.products.aggregate([], [])"), null); - assert.equal(parseMongoAggregateCommand("db.products.aggregate([]).limit(10)"), null); - assert.deepEqual(parseMongoAggregateCommand("db.products.aggregate([], {})"), { - collection: "products", - pipeline: "[]", - options: "{}", - }); -}); - -test("describeMongoCommandParseFailure reports aggregate-specific issues", () => { - assert.match(describeMongoCommandParseFailure("db.uc_user.aggregate([], {explain: true"), /unclosed/i); - assert.match(describeMongoCommandParseFailure("db.products.aggregate([]).limit(10)"), /chaining|not supported/i); - assert.match(describeMongoCommandParseFailure('db.products.aggregate({"$match":{}})'), /pipeline must be a JSON array/i); - assert.match(describeMongoCommandParseFailure("db.products.aggregate([], [])"), /options must be a JSON object/i); - assert.match(describeMongoCommandParseFailure("SELECT 1"), /MongoDB shell-style commands/i); -}); - -test("parseMongoGetIndexesCommand accepts shell-style index commands", () => { - assert.deepEqual(parseMongoGetIndexesCommand("db.web_log.getIndexes();"), { - collection: "web_log", - }); - assert.deepEqual(parseMongoGetIndexesCommand('db.getCollection("audit.logs").getIndexes()'), { - collection: "audit.logs", - }); - assert.equal(parseMongoGetIndexesCommand("db.web_log.getIndexes({})"), null); -}); - -test("parseMongoCollectionStatsCommand accepts Mongo shell stats helpers", () => { - assert.deepEqual(parseMongoCollectionStatsCommand("db.users.dataSize()"), { - collection: "users", - metric: "dataSize", - }); - assert.deepEqual(parseMongoCollectionStatsCommand('db.getCollection("audit.logs").dataSize(1024)'), { - collection: "audit.logs", - metric: "dataSize", - scale: 1024, - }); - assert.deepEqual(parseMongoCollectionStatsCommand("db.users.storageSize(1024)"), { - collection: "users", - metric: "storageSize", - scale: 1024, - }); - assert.deepEqual(parseMongoCollectionStatsCommand("db.users.totalIndexSize()"), { - collection: "users", - metric: "totalIndexSize", - }); - assert.deepEqual(parseMongoCollectionStatsCommand("db.users.stats()"), { - collection: "users", - metric: "stats", - }); - assert.deepEqual(parseMongoCollectionStatsCommand("db.users.stats(1024)"), { - collection: "users", - metric: "stats", - scale: 1024, - }); -}); - -test("parseMongoCollectionStatsCommand rejects unsupported stats helper arguments", () => { - assert.equal(parseMongoCollectionStatsCommand("db.users.dataSize(1, 2)"), null); - assert.equal(parseMongoCollectionStatsCommand("db.users.storageSize({scale: 1024})"), null); - assert.equal(parseMongoCollectionStatsCommand("db.users.stats().limit(1)"), null); -}); - -test("mongoCollectionStatsToQueryResult maps dataSize helper to collStats size", () => { - assert.deepEqual(mongoCollectionStatsToQueryResult("dataSize", { size: 2048 }), { - columns: ["dataSize"], - rows: [{ dataSize: 2048 }], - row_count: 1, - }); - assert.deepEqual( - mongoCollectionStatsToQueryResult("stats", { - count: 3, - size: 128, - storageSize: 512, - totalIndexSize: 64, - }), - { - columns: ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"], - rows: [{ count: 3, size: 128, avgObjSize: null, storageSize: 512, totalIndexSize: 64, nindexes: null }], - row_count: 1, - }, - ); -}); - -test("mongoAggregateWriteStage detects write stages", () => { - assert.equal(mongoAggregateWriteStage('[{"$match":{"active":true}}]'), null); - assert.equal(mongoAggregateWriteStage('[{"$match":{}},{"$out":"projects_dump"}]'), "$out"); - assert.equal(mongoAggregateWriteStage('[{"$merge":{"into":"projects_dump"}}]'), "$merge"); -}); - -test("mongodb executeQuery blocks aggregate write stages until dangerous SQL is enabled", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - delete process.env.DBX_MCP_ALLOW_WRITES; - delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - const config = { - id: "mongo", - name: "mongo", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - } as const; - - await assert.rejects(executeQuery(config, 'db.projects.aggregate([{"$merge":{"into":"projects_dump"}}])'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; - if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous; -}); - -test("parseMongoWriteCommand accepts supported write commands", () => { - assert.deepEqual(parseMongoWriteCommand('db.projects.insertOne({"name":"demo"})'), { - kind: "insert", - collection: "projects", - docsJson: '{"name":"demo"}', - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.updateOne({"_id":"1"},{"$set":{"name":"next"}})'), { - kind: "update", - collection: "projects", - filter: '{"_id":"1"}', - update: '{"$set":{"name":"next"}}', - many: false, - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.deleteMany({"stale":true})'), { - kind: "delete", - collection: "projects", - filter: '{"stale":true}', - many: true, - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.createIndex({"email":1},{"unique":true,"name":"projects_email_unique"})'), { - kind: "createIndex", - collection: "projects", - keys: '{"email":1}', - options: '{"unique":true,"name":"projects_email_unique"}', - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndex("projects_email_unique")'), { - kind: "dropIndex", - collection: "projects", - index: '"projects_email_unique"', - }); - assert.deepEqual(parseMongoWriteCommand("db.projects.dropIndexes()"), { - kind: "dropIndexes", - collection: "projects", - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes({"email":1})'), { - kind: "dropIndexes", - collection: "projects", - indexes: '{"email":1}', - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes(["a_1","b_1"])'), { - kind: "dropIndexes", - collection: "projects", - indexes: '["a_1","b_1"]', - }); - assert.deepEqual(parseMongoWriteCommand("db.projects.drop()"), { - kind: "dropCollection", - collection: "projects", - }); - assert.deepEqual(parseMongoWriteCommand('db.getCollection("audit.logs").drop();'), { - kind: "dropCollection", - collection: "audit.logs", - }); -}); - -test("parseMongoWriteCommand accepts legacy insert commands", () => { - assert.deepEqual(parseMongoWriteCommand('db.getCollection("accounting_reconciliations").insert({"accountId":999,"status":"done"});'), { - kind: "insert", - collection: "accounting_reconciliations", - docsJson: '{"accountId":999,"status":"done"}', - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.insert([{"name":"first"},{"name":"second"}])'), { - kind: "insert", - collection: "projects", - docsJson: '[{"name":"first"},{"name":"second"}]', - }); - assert.deepEqual(parseMongoWriteCommand('db.projects.insertMany([{"name":"first"},{"name":"second"}])'), { - kind: "insert", - collection: "projects", - docsJson: '[{"name":"first"},{"name":"second"}]', - }); - assert.equal(parseMongoWriteCommand('db.projects.insert({"name":"demo"},{"writeConcern":{"w":1}})'), null); - assert.equal(parseMongoWriteCommand("db.projects.insert()"), null); -}); - -test("parseMongoWriteCommand rejects invalid dropIndex and dropIndexes commands", () => { - assert.equal(parseMongoWriteCommand("db.projects.dropIndex()"), null); - assert.equal(parseMongoWriteCommand('db.projects.dropIndex("*")'), null); - assert.equal(parseMongoWriteCommand('db.projects.dropIndex(["a_1"])'), null); - assert.equal(parseMongoWriteCommand('db.projects.dropIndexes([{"email":1}])'), null); - assert.equal(parseMongoWriteCommand("db.projects.drop({ writeConcern: 1 })"), null); -}); - -test("mongodb executeQuery blocks writes when writes are explicitly disabled", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - process.env.DBX_MCP_ALLOW_WRITES = "0"; - await assert.rejects( - executeQuery( - { - id: "mongo", - name: "mongo", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - }, - 'db.projects.insertOne({"name":"demo"})', - ), - /read-only/i, - ); - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; -}); - -test("mongodb executeQuery treats createIndex as a write when writes are explicitly disabled", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - process.env.DBX_MCP_ALLOW_WRITES = "0"; - await assert.rejects( - executeQuery( - { - id: "mongo", - name: "mongo", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - }, - 'db.projects.createIndex({"email":1})', - ), - /read-only/i, - ); - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; -}); - -test("mongodb executeQuery treats dropIndex as a write when writes are explicitly disabled", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - process.env.DBX_MCP_ALLOW_WRITES = "0"; - await assert.rejects( - executeQuery( - { - id: "mongo", - name: "mongo", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - }, - 'db.projects.dropIndex("projects_email_unique")', - ), - /read-only/i, - ); - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; -}); - -test("mongodb executeQuery blocks dangerous dropIndexes shapes until dangerous SQL is enabled", async () => { - const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; - const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - process.env.DBX_MCP_ALLOW_WRITES = "1"; - delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - - const config = { - id: "mongo", - name: "mongo", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - } as const; - - await assert.rejects(executeQuery(config, "db.projects.dropIndexes()"), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - await assert.rejects(executeQuery(config, 'db.projects.dropIndexes("*")'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - await assert.rejects(executeQuery(config, 'db.projects.dropIndexes(["a_1","b_1"])'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - await assert.rejects(executeQuery(config, "db.projects.drop()"), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/); - - if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; - else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; - if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL; - else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous; -}); - -test("mongoDocumentsToQueryResult turns documents into rows", () => { - assert.deepEqual( - mongoDocumentsToQueryResult( - [ - { _id: "1", nested: { ok: true } }, - { _id: "2", name: "demo" }, - ], - 2, - ), - { - columns: ["_id", "nested", "name"], - rows: [ - { _id: "1", nested: '{"ok":true}', name: undefined }, - { _id: "2", nested: undefined, name: "demo" }, - ], - row_count: 2, - }, - ); -}); - -test("inferMongoColumns marks _id as primary and reports observed types", () => { - assert.deepEqual( - inferMongoColumns([ - { _id: "1", active: true }, - { _id: "2", active: null }, - ]), - [ - { - name: "_id", - data_type: "string", - is_nullable: false, - column_default: null, - is_primary_key: true, - comment: null, - }, - { - name: "active", - data_type: "boolean | null", - is_nullable: true, - column_default: null, - is_primary_key: false, - comment: null, - }, - ], - ); -}); diff --git a/packages/node-core/tests/mysql-url.test.ts b/packages/node-core/tests/mysql-url.test.ts deleted file mode 100644 index 6cf2b850c..000000000 --- a/packages/node-core/tests/mysql-url.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import { buildConnectionUrl } from "../src/database.js"; - -function mysqlConfig(overrides: Partial = {}): ConnectionConfig { - return { - id: "mysql-test", - name: "mysql", - db_type: "mysql", - host: "mysql.example.com", - port: 3306, - username: "root", - password: "secret", - database: "app", - ssl: false, - ...overrides, - }; -} - -test("mysql MCP URL does not enable TLS by default", () => { - const url = buildConnectionUrl(mysqlConfig(), { host: "mysql.example.com", port: 3306 }); - - assert.equal(url, "mysql://root:secret@mysql.example.com:3306/app"); -}); - -test("mysql MCP URL preserves explicit preferred TLS mode", () => { - const url = buildConnectionUrl(mysqlConfig({ url_params: "ssl-mode=preferred" }), { - host: "mysql.example.com", - port: 3306, - }); - - assert.equal(url, "mysql://root:secret@mysql.example.com:3306/app?ssl-mode=preferred"); -}); diff --git a/packages/node-core/tests/paths.test.ts b/packages/node-core/tests/paths.test.ts deleted file mode 100644 index 248059152..000000000 --- a/packages/node-core/tests/paths.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { appDataDirFromInputs } from "../src/paths.js"; - -test("default app data dir matches Tauri local data dir on Linux", () => { - assert.equal(appDataDirFromInputs({ platform: "linux", home: "/home/dbx" }), "/home/dbx/.local/share/com.dbx.app"); -}); - -test("DBX_DATA_DIR overrides the default app data dir", () => { - assert.equal(appDataDirFromInputs({ platform: "linux", home: "/home/dbx", envDataDir: "/tmp/dbx-data" }), "/tmp/dbx-data"); -}); diff --git a/packages/node-core/tests/postgres-ssl.test.ts b/packages/node-core/tests/postgres-ssl.test.ts deleted file mode 100644 index 0c9dce440..000000000 --- a/packages/node-core/tests/postgres-ssl.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import assert from "node:assert/strict"; -import { afterEach, test, vi } from "vitest"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { ConnectionConfig } from "../src/connections.js"; - -const pgMocks = vi.hoisted(() => ({ - poolConfigs: [] as Array>, - query: vi.fn(), - end: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), -})); - -vi.mock("pg", () => ({ - default: { - Pool: vi.fn(function MockPool(config: Record) { - pgMocks.poolConfigs.push(config); - return { - query: (sql: string, params?: unknown[]) => pgMocks.query(config, sql, params), - end: pgMocks.end, - on: pgMocks.on, - }; - }), - }, -})); - -import { closeDatabaseResources, executeQuery } from "../src/database.js"; - -function postgresConfig(overrides: Partial = {}): ConnectionConfig { - return { - id: `pg-ssl-${Math.random()}`, - name: "pg-ssl", - db_type: "postgres", - host: "pg.example.com", - port: 5432, - username: "postgres", - password: "secret", - database: "app", - ssl: false, - ...overrides, - }; -} - -function successfulQuery() { - return { rows: [{ ok: true }], fields: [{ name: "ok" }] }; -} - -afterEach(async () => { - await closeDatabaseResources(); - pgMocks.poolConfigs.length = 0; - pgMocks.query.mockReset(); - pgMocks.end.mockClear(); - pgMocks.on.mockClear(); -}); - -test("explicit PostgreSQL prefer mode tries TLS and falls back only when SSL is unsupported", async () => { - pgMocks.query.mockImplementation((config: { ssl?: unknown }) => { - if (config.ssl === false) return Promise.resolve(successfulQuery()); - return Promise.reject(new Error("The server does not support SSL connections")); - }); - - const result = await executeQuery(postgresConfig({ url_params: "sslmode=prefer" }), "select true as ok"); - - assert.deepEqual(result.rows, [{ ok: true }]); - assert.equal(pgMocks.poolConfigs.length, 2); - assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false }); - assert.equal(pgMocks.poolConfigs[1]?.ssl, false); - assert.equal(pgMocks.query.mock.calls.length, 2); -}); - -test("explicit prefer does not downgrade on authentication or certificate errors", async () => { - pgMocks.query.mockRejectedValue(new Error('no pg_hba.conf entry for host "127.0.0.1", no encryption')); - - await assert.rejects(() => executeQuery(postgresConfig({ url_params: "sslmode=prefer" }), "select 1"), /no pg_hba\.conf entry/); - - assert.equal(pgMocks.poolConfigs.length, 1); - assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false }); -}); - -test("implicit PostgreSQL SSL mode remains disabled", async () => { - pgMocks.query.mockResolvedValue(successfulQuery()); - - await executeQuery(postgresConfig(), "select 1"); - - assert.equal(pgMocks.poolConfigs.length, 1); - assert.equal(pgMocks.poolConfigs[0]?.ssl, false); -}); - -test("explicit disable uses plaintext and never retries TLS negotiation failures", async () => { - pgMocks.query.mockRejectedValue(new Error("The server does not support SSL connections")); - - await assert.rejects(() => executeQuery(postgresConfig({ url_params: "sslmode=disable" }), "select 1"), /does not support SSL/); - - assert.equal(pgMocks.poolConfigs.length, 1); - assert.equal(pgMocks.poolConfigs[0]?.ssl, false); - assert.doesNotMatch(String(pgMocks.poolConfigs[0]?.connectionString), /sslmode=/); -}); - -test("require and verification modes never downgrade", async () => { - for (const mode of ["require", "verify-ca", "verify-full", "verify_identity"] as const) { - pgMocks.query.mockRejectedValueOnce(new Error("The server does not support SSL connections")); - - await assert.rejects(() => executeQuery(postgresConfig({ url_params: `sslmode=${mode}` }), "select 1"), /does not support SSL/); - } - - assert.equal(pgMocks.poolConfigs.length, 4); - assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false }); - assert.equal(typeof (pgMocks.poolConfigs[1]?.ssl as { checkServerIdentity?: unknown }).checkServerIdentity, "function"); - assert.deepEqual(pgMocks.poolConfigs[2]?.ssl, {}); - assert.deepEqual(pgMocks.poolConfigs[3]?.ssl, {}); -}); - -test("PostgreSQL SSL files are loaded into the ssl object and removed from the connection string", async () => { - const directory = await mkdtemp(join(tmpdir(), "dbx-pg-ssl-")); - const caPath = join(directory, "ca.pem"); - const certPath = join(directory, "client.pem"); - const keyPath = join(directory, "client.key"); - await Promise.all([writeFile(caPath, "ca"), writeFile(certPath, "cert"), writeFile(keyPath, "key")]); - pgMocks.query.mockResolvedValue(successfulQuery()); - - try { - await executeQuery( - postgresConfig({ - url_params: `sslmode=verify-full&sslrootcert=${encodeURIComponent(caPath)}&sslcert=${encodeURIComponent(certPath)}&sslkey=${encodeURIComponent(keyPath)}&application_name=dbx`, - }), - "select 1", - ); - } finally { - await rm(directory, { recursive: true, force: true }); - } - - const poolConfig = pgMocks.poolConfigs[0]; - assert.equal(poolConfig?.connectionString, "postgres://postgres:secret@pg.example.com:5432/app?application_name=dbx"); - assert.deepEqual(poolConfig?.ssl, { - ca: Buffer.from("ca"), - cert: Buffer.from("cert"), - key: Buffer.from("key"), - }); -}); diff --git a/packages/node-core/tests/postgres-url.test.ts b/packages/node-core/tests/postgres-url.test.ts deleted file mode 100644 index 91fb82d50..000000000 --- a/packages/node-core/tests/postgres-url.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import { buildConnectionUrl } from "../src/database.js"; - -function postgresConfig(overrides: Partial = {}): ConnectionConfig { - return { - id: "pg-test", - name: "pg", - db_type: "postgres", - host: "pg.example.com", - port: 5432, - username: "postgres", - password: "secret", - database: "app", - ssl: false, - ...overrides, - }; -} - -test("postgres MCP URL adds sslmode=require when TLS is enabled", () => { - const url = buildConnectionUrl(postgresConfig({ ssl: true }), { host: "pg.example.com", port: 5432 }); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require"); -}); - -test("postgres MCP URL keeps explicit sslmode", () => { - const url = buildConnectionUrl(postgresConfig({ ssl: true, url_params: "sslmode=verify-full&application_name=dbx" }), { - host: "pg.example.com", - port: 5432, - }); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=verify-full&application_name=dbx"); -}); - -test("postgres MCP URL maps ssl-mode to sslmode", () => { - const url = buildConnectionUrl(postgresConfig({ url_params: "ssl-mode=required" }), { - host: "pg.example.com", - port: 5432, - }); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require"); -}); - -test("postgres MCP URL normalizes direct sslmode aliases", () => { - const url = buildConnectionUrl(postgresConfig({ url_params: "sslmode=verify_identity" }), { - host: "pg.example.com", - port: 5432, - }); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=verify-full"); -}); - -test("postgres MCP URL drops MySQL-style TLS params", () => { - const url = buildConnectionUrl( - postgresConfig({ - url_params: "ssl-mode=required&verify_ca=false&verify_identity=false&require_ssl=true&charset=utf8mb4&application_name=dbx", - }), - { host: "pg.example.com", port: 5432 }, - ); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require&application_name=dbx"); -}); - -test("postgres MCP URL maps schema and timezone to options", () => { - const url = buildConnectionUrl(postgresConfig({ url_params: "schema=app&timezone=UTC" }), { - host: "pg.example.com", - port: 5432, - }); - - assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?options=-c%20search_path%3Dapp%20-c%20TimeZone%3DUTC"); -}); - -test("pooled URL builder rejects non-pooled direct types", () => { - assert.throws( - () => buildConnectionUrl(postgresConfig({ db_type: "sqlite", host: "/tmp/app.db", port: 0 }), { host: "/tmp/app.db", port: 0 }), - /Unsupported pooled connection type: sqlite/, - ); -}); diff --git a/packages/node-core/tests/production-safety.test.ts b/packages/node-core/tests/production-safety.test.ts deleted file mode 100644 index fcfe8a113..000000000 --- a/packages/node-core/tests/production-safety.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { assessProductionSql, isLikelyMongoMutation, isProductionDatabase } from "../src/production-safety.js"; -import type { ConnectionConfig } from "../src/connections.js"; - -interface ProductionSafetyCorpusCase { - name: string; - dialect: ConnectionConfig["db_type"]; - productionDatabases: string[]; - activeDatabase: string; - sql: string; - active: boolean; - isMutation: boolean; - databases: string[]; -} - -const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[]; - -function connection(overrides: Partial = {}): ConnectionConfig { - return { - id: "conn-1", - name: "Operations", - db_type: "mysql", - host: "db.internal", - port: 3306, - username: "readonly", - password: "", - production_databases: ["prod_app"], - ...overrides, - }; -} - -describe("production safety", () => { - it("keeps an explicit production connection in scope", () => { - expect(isProductionDatabase(connection({ is_production: true }), "scratch")).toBe(true); - }); - - it("detects a production write through USE and a qualified table name", () => { - expect(assessProductionSql("-- migrate\nUSE prod_app; /* delete old rows */ DELETE FROM users", connection(), "staging")).toMatchObject({ active: true, isMutation: true }); - expect(assessProductionSql("DELETE FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] }); - expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] }); - }); - - it("detects production writes hidden behind parser-sensitive SQL forms", () => { - for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) { - expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] }); - } - }); - - it("matches the shared SQL target safety corpus", () => { - for (const corpusCase of productionSafetyCorpus) { - const assessment = assessProductionSql( - corpusCase.sql, - connection({ - db_type: corpusCase.dialect, - production_databases: corpusCase.productionDatabases, - }), - corpusCase.activeDatabase, - ); - expect( - { - active: assessment.active, - isMutation: assessment.isMutation, - databases: assessment.databases, - }, - corpusCase.name, - ).toEqual({ - active: corpusCase.active, - isMutation: corpusCase.isMutation, - databases: corpusCase.databases, - }); - } - }); - - it("detects qualified procedure calls and privilege targets", () => { - for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) { - expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] }); - } - }); - - it("allows resolved non-production procedure and privilege targets", () => { - expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true }); - expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true }); - }); - - it("conservatively confirms ambiguous production targets", () => { - for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) { - expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] }); - } - }); - - it("does not treat read-only qualified references as write targets", () => { - expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true }); - }); - - it("treats unrecognized SQL as a mutation when production is selected", () => { - expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true }); - }); - - it("does not treat a read as a production write", () => { - expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false }); - }); - - it("recognizes Mongo write commands before MCP forwards them", () => { - expect(isLikelyMongoMutation("db.orders.updateOne({_id: 1}, {$set: {status: 'paid'}})")).toBe(true); - expect(isLikelyMongoMutation("db.orders.find({status: 'paid'})")).toBe(false); - }); -}); diff --git a/packages/node-core/tests/redis-command.test.ts b/packages/node-core/tests/redis-command.test.ts deleted file mode 100644 index cf1cff18f..000000000 --- a/packages/node-core/tests/redis-command.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { classifyRedisCommand, evaluateRedisCommandSafety, firstRedisCommandToken, parseRedisCommandArgv } from "../src/redis-command.js"; - -test("firstRedisCommandToken normalizes the command name", () => { - assert.equal(firstRedisCommandToken(" get session:1"), "GET"); -}); - -test("parseRedisCommandArgv handles quoted values and escapes", () => { - assert.deepEqual(parseRedisCommandArgv('SET session:1 "hello world"'), ["SET", "session:1", "hello world"]); - assert.deepEqual(parseRedisCommandArgv("SET key line\\nnext;"), ["SET", "key", "line\nnext"]); - assert.throws(() => parseRedisCommandArgv('GET "unterminated'), /unterminated quote/); -}); - -test("classifyRedisCommand mirrors DBX redis command safety classes", () => { - assert.equal(classifyRedisCommand("GET session:1"), "allowed"); - assert.equal(classifyRedisCommand("SET session:1 value"), "write"); - assert.equal(classifyRedisCommand("DEL session:1"), "confirm"); - assert.equal(classifyRedisCommand("KEYS *"), "blocked"); -}); - -test("evaluateRedisCommandSafety blocks write commands when writes are disabled", () => { - const decision = evaluateRedisCommandSafety("SET session:1 value", { allowWrites: false }); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /read-only/i); -}); - -test("evaluateRedisCommandSafety requires dangerous mode for blocked commands", () => { - const blocked = evaluateRedisCommandSafety("KEYS *", { allowWrites: true, allowDangerous: false }); - const allowed = evaluateRedisCommandSafety("KEYS *", { allowWrites: true, allowDangerous: true }); - - assert.equal(blocked.allowed, false); - assert.match(blocked.reason ?? "", /dangerous/i); - assert.equal(allowed.allowed, true); - assert.equal(allowed.skipSafetyCheck, true); -}); diff --git a/packages/node-core/tests/redis-direct.test.ts b/packages/node-core/tests/redis-direct.test.ts deleted file mode 100644 index c34ec4a9c..000000000 --- a/packages/node-core/tests/redis-direct.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer, type Socket } from "node:net"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import { executeRedisCommand } from "../src/database.js"; - -type RedisRequest = { command: string; args: string[] }; - -function redisConnection(port: number): ConnectionConfig { - return { - id: "redis-direct", - name: "redis-direct", - db_type: "redis", - host: "127.0.0.1", - port, - username: "", - password: "", - database: "0", - redis_connection_mode: "standalone", - ssh_enabled: false, - ssl: false, - }; -} - -async function withRedisServer(handler: (request: RedisRequest) => string, fn: (port: number, seen: RedisRequest[]) => Promise): Promise { - const seen: RedisRequest[] = []; - const server = createServer((socket) => { - let buffer = Buffer.alloc(0); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length > 0) { - const parsed = parseRespRequest(buffer); - if (!parsed) break; - buffer = buffer.subarray(parsed.bytes); - seen.push(parsed.request); - socket.write(handler(parsed.request)); - } - }); - }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const address = server.address(); - assert.equal(typeof address, "object"); - assert(address && "port" in address); - return await fn(address.port, seen); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -} - -function parseRespRequest(buffer: Buffer): { request: RedisRequest; bytes: number } | undefined { - let offset = 0; - const firstLine = readLine(buffer, offset); - if (!firstLine) return undefined; - offset = firstLine.next; - const count = Number(firstLine.line.slice(1)); - const parts: string[] = []; - - for (let i = 0; i < count; i++) { - const lengthLine = readLine(buffer, offset); - if (!lengthLine) return undefined; - offset = lengthLine.next; - const length = Number(lengthLine.line.slice(1)); - if (buffer.length < offset + length + 2) return undefined; - parts.push(buffer.subarray(offset, offset + length).toString("utf8")); - offset += length + 2; - } - - return { - request: { - command: parts[0]?.toUpperCase() ?? "", - args: parts.slice(1), - }, - bytes: offset, - }; -} - -function readLine(buffer: Buffer, offset: number): { line: string; next: number } | undefined { - const end = buffer.indexOf("\r\n", offset); - if (end < 0) return undefined; - return { line: buffer.subarray(offset, end).toString("utf8"), next: end + 2 }; -} - -function bulk(value: string): string { - return `$${Buffer.byteLength(value)}\r\n${value}\r\n`; -} - -test("executeRedisCommand runs standalone redis commands without the DBX bridge", async () => { - await withRedisServer( - (request) => { - if (request.command === "CLIENT") return "+OK\r\n"; - if (request.command === "SELECT") return "+OK\r\n"; - assert.deepEqual(request, { command: "GET", args: ["session:1"] }); - return bulk("value-1"); - }, - async (port, seen) => { - const result = await executeRedisCommand(redisConnection(port), 2, "GET session:1"); - - assert.deepEqual(result, { command: "GET", safety: "allowed", value: "value-1" }); - const dataCommands = seen.filter((request) => request.command !== "CLIENT"); - assert.deepEqual( - dataCommands.map((request) => request.command), - ["SELECT", "GET"], - ); - assert.deepEqual(dataCommands[0].args, ["2"]); - }, - ); -}); - -test("executeRedisCommand parses quoted arguments and JSON bulk replies", async () => { - await withRedisServer( - (request) => { - if (request.command === "CLIENT") return "+OK\r\n"; - if (request.command === "SET") { - assert.deepEqual(request.args, ["session:1", "hello world"]); - return "+OK\r\n"; - } - return bulk('{"ok":true}'); - }, - async (port) => { - const set = await executeRedisCommand(redisConnection(port), 0, 'SET session:1 "hello world"'); - const get = await executeRedisCommand(redisConnection(port), 0, "GET session:1"); - - assert.deepEqual(set, { command: "SET", safety: "write", value: "OK" }); - assert.deepEqual(get, { command: "GET", safety: "allowed", value: { ok: true } }); - }, - ); -}); - -test("executeRedisCommand keeps blocked redis commands behind skipSafetyCheck", async () => { - await withRedisServer( - (request) => { - if (request.command === "CLIENT") return "+OK\r\n"; - return bulk('["session:1"]'); - }, - async (port) => { - await assert.rejects(() => executeRedisCommand(redisConnection(port), 0, "KEYS *"), /blocked for safety/); - - const result = await executeRedisCommand(redisConnection(port), 0, "KEYS *", { skipSafetyCheck: true }); - - assert.deepEqual(result, { command: "KEYS", safety: "blocked", value: ["session:1"] }); - }, - ); -}); diff --git a/packages/node-core/tests/rqlite-direct.test.ts b/packages/node-core/tests/rqlite-direct.test.ts deleted file mode 100644 index 5832738d1..000000000 --- a/packages/node-core/tests/rqlite-direct.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import { describeTable, executeQuery, listTables } from "../src/database.js"; - -function rqliteConfig(port: number): ConnectionConfig { - return { - id: "rqlite-test", - name: "local-rqlite", - db_type: "rqlite", - host: "127.0.0.1", - port, - username: "dbx", - password: "secret", - database: "main", - ssh_enabled: false, - ssl: false, - }; -} - -async function withRqliteServer(handler: (req: IncomingMessage, res: ServerResponse, body: string) => void) { - const server = createServer((req, res) => { - let body = ""; - req.setEncoding("utf8"); - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => handler(req, res, body)); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assert.ok(address && typeof address === "object"); - return { - port: address.port, - close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), - }; -} - -function json(res: ServerResponse, body: unknown) { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(body)); -} - -test("executes rqlite query through the HTTP API", async () => { - const server = await withRqliteServer((req, res, body) => { - assert.equal(req.url, "/db/query"); - assert.equal(req.headers.authorization, "Basic ZGJ4OnNlY3JldA=="); - assert.deepEqual(JSON.parse(body), ["select id, name from users"]); - json(res, { - results: [ - { - columns: ["id", "name"], - values: [ - [1, "Ada"], - [2, "Linus"], - ], - }, - ], - }); - }); - - try { - const result = await executeQuery(rqliteConfig(server.port), "select id, name from users", { maxRows: 1 }); - assert.deepEqual(result, { columns: ["id", "name"], rows: [{ id: 1, name: "Ada" }], row_count: 1 }); - } finally { - await server.close(); - } -}); - -test("lists rqlite tables and describes columns", async () => { - const server = await withRqliteServer((_req, res, body) => { - const sql = JSON.parse(body)[0] as string; - if (sql.includes("sqlite_master")) { - json(res, { - results: [ - { - columns: ["name", "type"], - values: [ - ["users", "table"], - ["active_users", "view"], - ], - }, - ], - }); - return; - } - if (sql.includes("PRAGMA table_info")) { - json(res, { - results: [ - { - columns: ["cid", "name", "type", "notnull", "dflt_value", "pk"], - values: [ - [0, "id", "INTEGER", 1, null, 1], - [1, "name", "TEXT", 0, "'unknown'", 0], - ], - }, - ], - }); - return; - } - json(res, { results: [{ columns: [], values: [] }] }); - }); - - try { - assert.deepEqual(await listTables(rqliteConfig(server.port)), [ - { name: "users", type: "table" }, - { name: "active_users", type: "view" }, - ]); - assert.deepEqual(await describeTable(rqliteConfig(server.port), "users"), [ - { - name: "id", - data_type: "INTEGER", - is_nullable: false, - column_default: null, - is_primary_key: true, - comment: null, - }, - { - name: "name", - data_type: "TEXT", - is_nullable: true, - column_default: "'unknown'", - is_primary_key: false, - comment: null, - }, - ]); - } finally { - await server.close(); - } -}); diff --git a/packages/node-core/tests/schema-context.test.ts b/packages/node-core/tests/schema-context.test.ts deleted file mode 100644 index ff96db02d..000000000 --- a/packages/node-core/tests/schema-context.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import type { ColumnInfo, TableInfo } from "../src/database.js"; -import { buildSchemaContext, formatSchemaContext } from "../src/schema-context.js"; - -const config: ConnectionConfig = { - id: "pg", - name: "analytics", - db_type: "postgres", - host: "localhost", - port: 5432, - username: "app", - password: "", - database: "warehouse", - ssh_enabled: false, - ssl: false, -}; - -const tableRows: TableInfo[] = [ - { name: "users", type: "BASE TABLE" }, - { name: "orders", type: "BASE TABLE" }, - { name: "events", type: "BASE TABLE" }, -]; - -const columns: Record = { - users: [ - { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null }, - { name: "email", data_type: "text", is_nullable: false, column_default: null, is_primary_key: false, comment: "Login email" }, - ], - orders: [ - { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null }, - { name: "user_id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, comment: null }, - ], -}; - -test("builds schema context for requested tables", async () => { - const context = await buildSchemaContext( - { - listTables: async () => tableRows, - describeTable: async (_config, table) => columns[table] ?? [], - }, - config, - { tables: ["users", "orders"], maxTables: 5 }, - ); - - assert.equal(context.connection, "analytics"); - assert.deepEqual( - context.tables.map((table) => table.name), - ["users", "orders"], - ); - assert.equal(context.tables[0].columns[0].is_primary_key, true); -}); - -test("limits schema context when no table list is provided", async () => { - const context = await buildSchemaContext( - { - listTables: async () => tableRows, - describeTable: async (_config, table) => columns[table] ?? [], - }, - config, - { maxTables: 2 }, - ); - - assert.deepEqual( - context.tables.map((table) => table.name), - ["users", "orders"], - ); -}); - -test("formats schema context as compact markdown", async () => { - const context = await buildSchemaContext( - { - listTables: async () => tableRows, - describeTable: async (_config, table) => columns[table] ?? [], - }, - config, - { tables: ["users"] }, - ); - - const markdown = formatSchemaContext(context); - - assert.match(markdown, /Connection: analytics/); - assert.match(markdown, /## users/); - assert.match(markdown, /id integer NOT NULL PK/); - assert.match(markdown, /email text NOT NULL -- Login email/); -}); diff --git a/packages/node-core/tests/sql-diagnostics.test.ts b/packages/node-core/tests/sql-diagnostics.test.ts deleted file mode 100644 index 1d905a449..000000000 --- a/packages/node-core/tests/sql-diagnostics.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { logSqlDiagnostic, redactSqlForDiagnostics, sqlDiagnosticsEnabled } from "../src/sql-diagnostics.js"; - -test("SQL diagnostics are disabled unless explicitly enabled", () => { - assert.equal(sqlDiagnosticsEnabled({}), false); - assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "0" }), false); - assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "1" }), true); - assert.equal(sqlDiagnosticsEnabled({ DBX_MCP_DEBUG_SQL: "true" }), true); -}); - -test("redacts sensitive literals and bounds large SQL diagnostics", () => { - const sql = `select * from users where password = 'secret-123' and token="tok-456" and api_key=plain ${"x".repeat(900)}`; - const redacted = redactSqlForDiagnostics(sql); - - assert.doesNotMatch(redacted, /secret-123|tok-456|api_key=plain/); - assert.match(redacted, /\[REDACTED\]/); - assert.match(redacted, /api_key=\[REDACTED\]/); - assert.match(redacted, /truncated/); - assert.ok(redacted.length < sql.length); -}); - -test("disabled SQL diagnostic logging does not write statements", () => { - const original = console.error; - const messages: unknown[][] = []; - console.error = (...args: unknown[]) => messages.push(args); - try { - logSqlDiagnostic("test", "select 'secret-123'", {}, {}); - } finally { - console.error = original; - } - - assert.equal(messages.length, 0); -}); - -test("enabled SQL diagnostic logging emits redacted statements only", () => { - const original = console.error; - const messages: unknown[][] = []; - console.error = (...args: unknown[]) => messages.push(args); - try { - logSqlDiagnostic("test", "select 'secret-123' as password", {}, { DBX_SQL_DEBUG: "1" }); - } finally { - console.error = original; - } - - assert.equal(messages.length, 1); - const rendered = messages.flat().join(" "); - assert.doesNotMatch(rendered, /secret-123/); - assert.match(rendered, /\[REDACTED\]/); -}); - -test("dollar-quoted strings are redacted", () => { - const redacted = redactSqlForDiagnostics("select $$secret$$"); - assert.doesNotMatch(redacted, /secret/); - assert.match(redacted, /\[REDACTED\]/); -}); - -test("space-separated sensitive assignments are redacted", () => { - const redacted = redactSqlForDiagnostics("select * from t where password = mysecret"); - assert.doesNotMatch(redacted, /mysecret/); - assert.match(redacted, /\[REDACTED\]/); -}); - -test("postgres positional parameters are not treated as dollar quotes ($1, $2, ...)", () => { - const redacted = redactSqlForDiagnostics("select * from t where id = $1 and name = 'alice'"); - assert.match(redacted, /\$1\b/); - assert.doesNotMatch(redacted, /alice/); -}); - -test("multiple postgres positional parameters all survive redaction", () => { - const redacted = redactSqlForDiagnostics("select $1, $2, $3, $42 from t"); - assert.match(redacted, /\$1\b/); - assert.match(redacted, /\$2\b/); - assert.match(redacted, /\$3\b/); - assert.match(redacted, /\$42\b/); -}); - -test("empty-tag dollar quote $$secret$$ is redacted", () => { - const redacted = redactSqlForDiagnostics("select $$secret$$ from t"); - assert.match(redacted, /\$\$\[REDACTED\]\$\$/); - assert.doesNotMatch(redacted, /secret/); -}); - -test("named-tag dollar quote $tag$hello$tag$ is redacted", () => { - const redacted = redactSqlForDiagnostics("select $tag$hello$tag$ from t"); - assert.match(redacted, /\$\[REDACTED\]\$/); - assert.doesNotMatch(redacted, /hello/); -}); - -test("lone trailing dollar sign does not throw and passes through", () => { - const redacted = redactSqlForDiagnostics("select 1 $"); - assert.match(redacted, /\$/); -}); - -test("bounds redaction before scanning an unclosed literal", () => { - const redacted = redactSqlForDiagnostics(`select '${"secret-".repeat(1000)}`, 32); - assert.doesNotMatch(redacted, /secret-/); - assert.match(redacted, /\[REDACTED\]/); - assert.match(redacted, /truncated/); -}); - -test("does not leak a sensitive value cut at the diagnostic boundary", () => { - const redacted = redactSqlForDiagnostics(`password = ${"secret-token".repeat(1000)}`, 24); - assert.doesNotMatch(redacted, /secret-token|secret-/); - assert.match(redacted, /password = \[REDACTED\]/); - assert.match(redacted, /truncated/); -}); diff --git a/packages/node-core/tests/sql-safety.test.ts b/packages/node-core/tests/sql-safety.test.ts deleted file mode 100644 index b40a5a7d5..000000000 --- a/packages/node-core/tests/sql-safety.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import { evaluateSqlSafety, splitSqlStatements, sqlSafetyFromEnv } from "../src/sql-safety.js"; -import { supportsHashLineComments } from "../src/sql-risk.js"; - -test("allows read-only SQL by default", () => { - const decision = evaluateSqlSafety("select * from users limit 5"); - - assert.equal(decision.allowed, true); -}); - -test("allows read-only EXPLAIN without ANALYZE", () => { - const decision = evaluateSqlSafety("EXPLAIN SELECT * FROM users"); - - assert.equal(decision.allowed, true); -}); - -test("allows non-dangerous write SQL by default when scoped", () => { - const decision = evaluateSqlSafety("update users set role = 'admin' where id = 1", sqlSafetyFromEnv({})); - - assert.equal(decision.allowed, true); -}); - -test("blocks dangerous SQL even when writes are enabled", () => { - const decision = evaluateSqlSafety("drop table users", { allowWrites: true }); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /dangerous/i); -}); - -test("blocks update without where when writes are enabled", () => { - const decision = evaluateSqlSafety("update users set disabled = true", { allowWrites: true }); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /WHERE/i); -}); - -test("blocks writes that do not start with a write keyword in read-only mode", () => { - for (const sql of [ - "EXPLAIN ANALYZE DELETE FROM users WHERE id = 1", - "/*! DELETE FROM users WHERE id = 1 */", - "COPY users FROM '/tmp/users.csv'", - "SELECT * INTO backup_users FROM users", - "SELECT * FROM users INTO OUTFILE '/tmp/users.csv'", - ]) { - const decision = evaluateSqlSafety(sql); - assert.equal(decision.allowed, false, sql); - assert.match(decision.reason ?? "", /read-only|blocked/i); - } -}); - -test("blocks unrecognized SQL unless dangerous SQL is explicitly enabled", () => { - const decision = evaluateSqlSafety("MAINTAIN UNKNOWN THING", { allowWrites: true }); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /unrecognized/i); -}); - -test("blocks multiple SQL statements unless explicitly allowed", () => { - const decision = evaluateSqlSafety("select 1; select 2"); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /Only one SQL statement/); -}); - -test("allows multiple read-only SQL statements when enabled", () => { - const decision = evaluateSqlSafety("select 1; show tables", { allowMultipleStatements: true }); - - assert.equal(decision.allowed, true); -}); - -test("checks every statement in a multi-statement SQL string", () => { - const decision = evaluateSqlSafety("select 1; delete from users", { - allowMultipleStatements: true, - allowWrites: true, - }); - - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /Statement 2/i); - assert.match(decision.reason ?? "", /WHERE/i); -}); - -test("splits statements without altering SQL literals or comments", () => { - const sql = "SELECT 'a;b' AS value, ''abc'' AS quoted; -- keep comment\nSELECT $$c;d$$ AS dollar;"; - - assert.deepEqual(splitSqlStatements(sql), [ - "SELECT 'a;b' AS value, ''abc'' AS quoted", - "-- keep comment\nSELECT $$c;d$$ AS dollar", - ]); -}); - -test("keeps tagged dollar quotes and quoted identifiers intact", () => { - const sql = 'SELECT $body$begin; end$body$ AS body, "semi;colon" AS "quoted;column"; SELECT 2;'; - - assert.deepEqual(splitSqlStatements(sql), [ - 'SELECT $body$begin; end$body$ AS body, "semi;colon" AS "quoted;column"', - "SELECT 2", - ]); -}); - -test("sqlSafetyFromEnv allows writes by default but keeps dangerous SQL blocked", () => { - const options = sqlSafetyFromEnv({}); - - assert.equal(options.allowWrites, true); - assert.equal(options.allowDangerous, false); -}); - -test("sqlSafetyFromEnv supports explicitly disabling writes", () => { - const options = sqlSafetyFromEnv({ DBX_MCP_ALLOW_WRITES: "0" } as NodeJS.ProcessEnv); - - assert.equal(options.allowWrites, false); - assert.equal(options.allowDangerous, false); -}); - -// --- Dialect-aware `#` comment handling --- - -test("supportsHashLineComments matches Rust mysql-compatible dialect set", () => { - for (const dbType of ["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]) { - assert.equal(supportsHashLineComments(dbType), true, dbType); - } - for (const dbType of ["postgres", "sqlite", "sqlserver", "oracle", "duckdb", "bigquery", "redshift", ""]) { - assert.equal(supportsHashLineComments(dbType), false, dbType); - } - assert.equal(supportsHashLineComments(undefined), false); -}); - -test("splitSqlStatements splits PG `#` operator correctly (hashLineComments omitted/default)", () => { - assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2"), ["SELECT a # b", "SELECT 2"]); -}); - -test("splitSqlStatements splits PG `#` operator correctly (hashLineComments: false)", () => { - assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2", { hashLineComments: false }), [ - "SELECT a # b", - "SELECT 2", - ]); -}); - -test("splitSqlStatements treats `#` as comment with hashLineComments: true (MySQL)", () => { - // With hashLineComments: true, the `;` inside the `#` comment must NOT split. - // The comment text is preserved in the output (splitter only delimits on `;`, it doesn't strip). - assert.deepEqual( - splitSqlStatements("SELECT 1; # trailing ; comment\nSELECT 2", { hashLineComments: true }), - ["SELECT 1", "# trailing ; comment\nSELECT 2"], - ); -}); - -test("splitSqlStatements preserves JSONB operator text verbatim", () => { - const result = splitSqlStatements("SELECT data #>> '{a,b}' FROM t"); - assert.equal(result.length, 1); - assert.equal(result[0], "SELECT data #>> '{a,b}' FROM t"); -}); - -test("splitSqlStatements handles `#` as operator mid-statement (PG)", () => { - assert.deepEqual(splitSqlStatements("SELECT 1 # 2; DELETE FROM t"), [ - "SELECT 1 # 2", - "DELETE FROM t", - ]); -}); - -test("evaluateSqlSafety blocks PG injection that bypasses # as comment (regression)", () => { - // Before fix: # would strip "2; DELETE FROM t" as comment, classify as read-only. - // After fix: # is treated as an operator, so DELETE FROM t is seen as a second write statement. - const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", { - allowWrites: false, - allowMultipleStatements: true, - }); - assert.equal(decision.allowed, false); - assert.match(decision.reason ?? "", /read-only/i); -}); - -test("evaluateSqlSafety allows MySQL `#` comment with hashLineComments: true", () => { - const decision = evaluateSqlSafety("SELECT 1 # delete note", { - allowWrites: false, - allowMultipleStatements: true, - hashLineComments: true, - }); - assert.equal(decision.allowed, true); -}); - -test("evaluateSqlSafety with hashLineComments: false still sees DELETE after `#` operator", () => { - const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", { - allowWrites: false, - allowMultipleStatements: true, - hashLineComments: false, - }); - assert.equal(decision.allowed, false); -}); diff --git a/packages/node-core/tests/sqlite-direct.test.ts b/packages/node-core/tests/sqlite-direct.test.ts deleted file mode 100644 index 9c529fc0f..000000000 --- a/packages/node-core/tests/sqlite-direct.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "vitest"; -import Database from "better-sqlite3"; -import type { ConnectionConfig } from "../src/connections.js"; -import { describeTable, executeQuery, listTables } from "../src/database.js"; - -function sqliteConfig(path: string): ConnectionConfig { - return { - id: "sqlite-test", - name: "local-sqlite", - db_type: "sqlite", - host: path, - port: 0, - username: "", - password: "", - ssh_enabled: false, - ssl: false, - }; -} - -function mysqlSshConfig(): ConnectionConfig { - return { - id: "mysql-ssh-test", - name: "mysql-over-ssh", - db_type: "mysql", - host: "10.0.0.10", - port: 3306, - username: "root", - password: "secret", - ssl: false, - transport_layers: [ - { - type: "ssh", - id: "bastion", - enabled: true, - host: "bastion.example.com", - port: 22, - user: "root", - password: "ssh-secret", - }, - ], - }; -} - -test("queries SQLite connections without the DBX bridge", async () => { - const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-")); - const path = join(dir, "app.db"); - const db = new Database(path); - db.exec("create table users (id integer primary key, name text not null); insert into users (name) values ('Ada');"); - db.close(); - - try { - const result = await executeQuery(sqliteConfig(path), "select id, name from users"); - - assert.deepEqual(result.columns, ["id", "name"]); - assert.deepEqual(result.rows, [{ id: 1, name: "Ada" }]); - assert.equal(result.row_count, 1); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("applies query row limits to SQLite connections", async () => { - const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-")); - const path = join(dir, "app.db"); - const db = new Database(path); - db.exec("create table users (id integer primary key, name text not null); insert into users (name) values ('Ada'), ('Grace');"); - db.close(); - - try { - const result = await executeQuery(sqliteConfig(path), "select id, name from users order by id", { maxRows: 1 }); - - assert.deepEqual(result.columns, ["id", "name"]); - assert.deepEqual(result.rows, [{ id: 1, name: "Ada" }]); - assert.equal(result.row_count, 1); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("lists and describes SQLite tables without the DBX bridge", async () => { - const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-")); - const path = join(dir, "app.db"); - const db = new Database(path); - db.exec("create table users (id integer primary key, name text not null);"); - db.close(); - - try { - const tables = await listTables(sqliteConfig(path)); - const columns = await describeTable(sqliteConfig(path), "users"); - - assert.deepEqual(tables, [{ name: "users", type: "table" }]); - assert.deepEqual( - columns.map((column) => ({ - name: column.name, - data_type: column.data_type, - is_nullable: column.is_nullable, - is_primary_key: column.is_primary_key, - })), - [ - { name: "id", data_type: "INTEGER", is_nullable: true, is_primary_key: true }, - { name: "name", data_type: "TEXT", is_nullable: false, is_primary_key: false }, - ], - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("routes SSH transport layer connections through the DBX bridge", async () => { - const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-bridge-home-")); - const originalHome = process.env.HOME; - const originalDbxDataDir = process.env.DBX_DATA_DIR; - process.env.HOME = dir; - process.env.DBX_DATA_DIR = dir; - - try { - await assert.rejects(() => executeQuery(mysqlSshConfig(), "select 1"), /DBX desktop app is not running/); - await assert.rejects(() => listTables(mysqlSshConfig()), /DBX desktop app is not running/); - await assert.rejects(() => describeTable(mysqlSshConfig(), "users"), /DBX desktop app is not running/); - } finally { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalDbxDataDir === undefined) delete process.env.DBX_DATA_DIR; - else process.env.DBX_DATA_DIR = originalDbxDataDir; - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/node-core/tests/starrocks-url.test.ts b/packages/node-core/tests/starrocks-url.test.ts deleted file mode 100644 index 936248438..000000000 --- a/packages/node-core/tests/starrocks-url.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; -import type { ConnectionConfig } from "../src/connections.js"; -import { buildConnectionUrl } from "../src/database.js"; - -function starrocksConfig(overrides: Partial = {}): ConnectionConfig { - return { - id: "sr-1", - name: "starrocks", - db_type: "starrocks", - host: "fe-example.starrocks.aliyuncs.com", - port: 9030, - username: "admin", - password: "secret", - database: "analytics", - ssl: false, - ...overrides, - }; -} - -test("starrocks omits tls params when ssl is disabled", () => { - const url = buildConnectionUrl( - starrocksConfig({ - url_params: "ssl-mode=disabled&require_ssl=true&verify_ca=true&charset=utf8mb4", - }), - { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, - ); - - assert.equal(url, "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics"); -}); - -test("starrocks preserves tls params when ssl is enabled", () => { - const url = buildConnectionUrl( - starrocksConfig({ - ssl: true, - url_params: "verify_ca=true&verify_identity=false", - }), - { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, - ); - - assert.equal( - url, - "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4", - ); -}); - -test("mysql starrocks profile preserves tls params when ssl is enabled", () => { - const url = buildConnectionUrl( - starrocksConfig({ - db_type: "mysql", - driver_profile: "starrocks", - ssl: true, - ca_cert_path: "/tmp/ca.pem", - }), - { host: "fe-example.starrocks.aliyuncs.com", port: 9030 }, - ); - - assert.equal( - url, - "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_identity=false&charset=utf8mb4", - ); -}); diff --git a/packages/node-core/tests/web-backend.test.ts b/packages/node-core/tests/web-backend.test.ts deleted file mode 100644 index 948c73c99..000000000 --- a/packages/node-core/tests/web-backend.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import assert from "node:assert/strict"; -import { afterEach, test } from "vitest"; - -import { executeQuery, loadConnections, resetWebAuthForTests } from "../src/web-backend.js"; - -const originalFetch = globalThis.fetch; -const originalWebUrl = process.env.DBX_WEB_URL; -const originalWebPassword = process.env.DBX_WEB_PASSWORD; - -afterEach(() => { - globalThis.fetch = originalFetch; - if (originalWebUrl === undefined) delete process.env.DBX_WEB_URL; - else process.env.DBX_WEB_URL = originalWebUrl; - if (originalWebPassword === undefined) delete process.env.DBX_WEB_PASSWORD; - else process.env.DBX_WEB_PASSWORD = originalWebPassword; - resetWebAuthForTests(); -}); - -function jsonResponse(body: unknown, init?: ResponseInit): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, - ...init, - }); -} - -test("web backend rejects protected DBX Web access without DBX_WEB_PASSWORD", async () => { - process.env.DBX_WEB_URL = "http://127.0.0.1:4224"; - delete process.env.DBX_WEB_PASSWORD; - const calls: string[] = []; - globalThis.fetch = (async (input) => { - const url = String(input); - calls.push(url); - if (url.endsWith("/api/auth/check")) { - return jsonResponse({ authenticated: false, required: true, setup_required: false }); - } - throw new Error(`unexpected request: ${url}`); - }) as typeof fetch; - - await assert.rejects(loadConnections(), /DBX_WEB_PASSWORD/); - assert.deepEqual(calls, ["http://127.0.0.1:4224/api/auth/check"]); -}); - -test("web backend rejects DBX Web access before password setup is complete", async () => { - process.env.DBX_WEB_URL = "http://127.0.0.1:4224"; - delete process.env.DBX_WEB_PASSWORD; - const calls: string[] = []; - globalThis.fetch = (async (input) => { - const url = String(input); - calls.push(url); - if (url.endsWith("/api/auth/check")) { - return jsonResponse({ authenticated: false, required: false, setup_required: true }); - } - throw new Error(`unexpected request: ${url}`); - }) as typeof fetch; - - await assert.rejects(loadConnections(), /password setup is required/); - assert.deepEqual(calls, ["http://127.0.0.1:4224/api/auth/check"]); -}); - -test("web backend logs in with DBX_WEB_PASSWORD and sends the session cookie", async () => { - process.env.DBX_WEB_URL = "http://127.0.0.1:4224/"; - process.env.DBX_WEB_PASSWORD = "secret"; - const calls: Array<{ url: string; init?: RequestInit }> = []; - globalThis.fetch = (async (input, init) => { - const url = String(input); - calls.push({ url, init }); - if (url.endsWith("/api/auth/check")) { - return jsonResponse({ authenticated: false, required: true, setup_required: false }); - } - if (url.endsWith("/api/auth/login")) { - assert.equal(init?.method, "POST"); - assert.equal(init?.body, JSON.stringify({ password: "secret" })); - return jsonResponse({ ok: true }, { headers: { "set-cookie": "dbx_session=session-1; Path=/; HttpOnly" } }); - } - if (url.endsWith("/api/connection/list")) { - assert.equal((init?.headers as Record).Cookie, "dbx_session=session-1"); - return jsonResponse([ - { - id: "1", - name: "local", - db_type: "postgres", - host: "127.0.0.1", - port: 5432, - username: "app", - password: "", - database: "demo", - ssh_enabled: false, - ssl: false, - }, - ]); - } - throw new Error(`unexpected request: ${url}`); - }) as typeof fetch; - - const connections = await loadConnections(); - - assert.equal(connections[0]?.name, "local"); - assert.deepEqual( - calls.map((call) => call.url), - [ - "http://127.0.0.1:4224/api/auth/check", - "http://127.0.0.1:4224/api/auth/login", - "http://127.0.0.1:4224/api/connection/list", - ], - ); -}); - -test("web backend still allows DBX Web instances with password auth disabled", async () => { - process.env.DBX_WEB_URL = "http://127.0.0.1:4224"; - delete process.env.DBX_WEB_PASSWORD; - globalThis.fetch = (async (input, init) => { - const url = String(input); - if (url.endsWith("/api/auth/check")) { - return jsonResponse({ authenticated: true, required: false, setup_required: false }); - } - if (url.endsWith("/api/connection/list")) { - assert.equal((init?.headers as Record).Cookie, undefined); - return jsonResponse([]); - } - throw new Error(`unexpected request: ${url}`); - }) as typeof fetch; - - assert.deepEqual(await loadConnections(), []); -}); - -test("web backend routes MongoDB count commands through count-documents", async () => { - process.env.DBX_WEB_URL = "http://127.0.0.1:4224"; - delete process.env.DBX_WEB_PASSWORD; - const calls: Array<{ url: string; init?: RequestInit }> = []; - globalThis.fetch = (async (input, init) => { - const url = String(input); - calls.push({ url, init }); - if (url.endsWith("/api/auth/check")) { - return jsonResponse({ authenticated: true, required: false, setup_required: false }); - } - if (url.endsWith("/api/connection/connect")) { - return jsonResponse({ ok: true }); - } - if (url.endsWith("/api/mongo/count-documents")) { - assert.equal(init?.method, "POST"); - assert.deepEqual(JSON.parse(String(init?.body)), { - connectionId: "mongo-web", - database: "app", - collection: "projects", - filter: '{"active":true}', - mode: "accurate", - }); - return jsonResponse(42); - } - throw new Error(`unexpected request: ${url}`); - }) as typeof fetch; - - const result = await executeQuery( - { - id: "mongo-web", - name: "mongo-web", - db_type: "mongodb", - host: "127.0.0.1", - port: 27017, - username: "", - password: "", - database: "app", - ssh_enabled: false, - ssl: false, - }, - 'db.projects.countDocuments({"active":true})', - ); - - assert.deepEqual(result, { - columns: ["count"], - rows: [{ count: 42 }], - row_count: 1, - }); - assert.deepEqual( - calls.map((call) => call.url), - [ - "http://127.0.0.1:4224/api/auth/check", - "http://127.0.0.1:4224/api/connection/connect", - "http://127.0.0.1:4224/api/mongo/count-documents", - ], - ); -}); diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json deleted file mode 100644 index 9b2da4025..000000000 --- a/packages/node-core/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/packages/node-core/vitest.config.ts b/packages/node-core/vitest.config.ts deleted file mode 100644 index 4cfb6392f..000000000 --- a/packages/node-core/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/*.test.ts"], - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a7d3d90d..d36d467a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,22 +236,22 @@ importers: optionalDependencies: '@dbx-app/cli-darwin-arm64': specifier: 0.4.36 - version: link:../cli-darwin-arm64 + version: 0.4.36 '@dbx-app/cli-darwin-x64': specifier: 0.4.36 - version: link:../cli-darwin-x64 + version: 0.4.36 '@dbx-app/cli-linux-arm64-gnu': specifier: 0.4.36 - version: link:../cli-linux-arm64-gnu + version: 0.4.36 '@dbx-app/cli-linux-x64-gnu': specifier: 0.4.36 - version: link:../cli-linux-x64-gnu + version: 0.4.36 '@dbx-app/cli-win32-arm64': specifier: 0.4.36 - version: link:../cli-win32-arm64 + version: 0.4.36 '@dbx-app/cli-win32-x64': specifier: 0.4.36 - version: link:../cli-win32-x64 + version: 0.4.36 packages/cli-darwin-arm64: {} @@ -275,46 +275,31 @@ importers: packages/mcp-server: devDependencies: - '@dbx-app/node-core': - specifier: workspace:^ - version: link:../node-core - '@modelcontextprotocol/sdk': - specifier: ^1.12.1 - version: 1.29.0(zod@3.25.76) '@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 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)) - zod: - specifier: ^3.25.20 - version: 3.25.76 + version: 4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) optionalDependencies: '@dbx-app/mcp-darwin-arm64': specifier: 0.4.36 - version: link:../mcp-darwin-arm64 + version: 0.4.36 '@dbx-app/mcp-darwin-x64': specifier: 0.4.36 - version: link:../mcp-darwin-x64 + version: 0.4.36 '@dbx-app/mcp-linux-arm64-gnu': specifier: 0.4.36 - version: link:../mcp-linux-arm64-gnu + version: 0.4.36 '@dbx-app/mcp-linux-x64-gnu': specifier: 0.4.36 - version: link:../mcp-linux-x64-gnu + version: 0.4.36 '@dbx-app/mcp-win32-arm64': specifier: 0.4.36 - version: link:../mcp-win32-arm64 + version: 0.4.36 '@dbx-app/mcp-win32-x64': specifier: 0.4.36 - version: link:../mcp-win32-x64 + version: 0.4.36 packages/mcp-win32-arm64: {} @@ -326,43 +311,6 @@ importers: specifier: ^5.8.3 version: 5.9.3 - packages/node-core: - dependencies: - '@dbx-app/mongo-shell': - specifier: workspace:^ - version: link:../mongo-shell - better-sqlite3: - specifier: ^12.9.0 - version: 12.10.0 - ioredis: - specifier: ^5.11.1 - version: 5.11.1 - keytar: - specifier: ^7.9.0 - version: 7.9.0 - mysql2: - specifier: ^3.14.1 - version: 3.22.3(@types/node@22.19.19) - pg: - specifier: ^8.16.0 - version: 8.20.0 - devDependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/node': - specifier: ^22.15.21 - version: 22.19.19 - '@types/pg': - specifier: ^8.15.4 - version: 8.20.0 - tsx: - specifier: ^4.19.4 - version: 4.21.0 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - packages: '@babel/code-frame@7.29.7': @@ -551,74 +499,74 @@ packages: '@codemirror/view@6.43.0': resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} - '@dbx-app/cli-darwin-arm64@0.4.35': - resolution: {integrity: sha512-t4xYEfirHy+fQM8FE1TQl6WAwDdP81ggO3qPGW/ce531jw9wxlQrkj8niSzWyFx47bP4ewSOwti8fVD9ECD/dA==} + '@dbx-app/cli-darwin-arm64@0.4.36': + resolution: {integrity: sha512-9IfZgHdA76x6D2znEMmQeZYalnVYgkgsgMDmZHBwpJsYrL/NEi7kMxfRhM9MhUoOxrNULR+K1a1oREQQqnULmA==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/cli-darwin-x64@0.4.35': - resolution: {integrity: sha512-eRCMAvyxCcB650fZFR6I40biEonCY8i4guqZuxgelPMfQdoDl17L78ieNd6VlBLyr9F7z5TDOQIsOwyce7+tBg==} + '@dbx-app/cli-darwin-x64@0.4.36': + resolution: {integrity: sha512-SGIEMUPrySA9jh8uBx1AYl8wuA14f14ioynjpc3F75KbhPGHJ4kebGOjh8sVgjf4yWf1Rh9tsmffY9zLWeZQWQ==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/cli-linux-arm64-gnu@0.4.35': - resolution: {integrity: sha512-VTKmtizcoOQjK6WLa6E7VoJBbYMqZVkjXOxlGMrotm7to5vas199FDyqMNqXz+jNybOJJ7HnXmZD6Tn97q3qzA==} + '@dbx-app/cli-linux-arm64-gnu@0.4.36': + resolution: {integrity: sha512-zPHHluDmdqbb37kkNBwnAWhaVaA+mw8IhryeDElahJpYrX6fVU4hAZqzR6j1T3LrCVQcSCojkoChtoJ4MFcvVw==} cpu: [arm64] os: [linux] hasBin: true - '@dbx-app/cli-linux-x64-gnu@0.4.35': - resolution: {integrity: sha512-cyEjEiqD+EicImNCZ2sQpCHWnLvyU518lHGdC1+jwUQu2IGwPNPs1FlpyqAl0Ng5/7tnoXbtqLtDjZ0ZN9D4oQ==} + '@dbx-app/cli-linux-x64-gnu@0.4.36': + resolution: {integrity: sha512-aOn97qTOb67fLOCD1duWLsofncvGsMHFr3bKN1D0D8izfLTtzpUDwmdcbJelPwh0Xc3eu/SXq5Zc55v8AiH/Vg==} cpu: [x64] os: [linux] hasBin: true - '@dbx-app/cli-win32-arm64@0.4.35': - resolution: {integrity: sha512-UvqVllUYWmO4axhLDgRxnlaa/tViZVtB4OHv1qHSyU0Cg3zHpZV15IByiwI9tCfdZpXRYJ7Yi/oqpYhtyIQIGQ==} + '@dbx-app/cli-win32-arm64@0.4.36': + resolution: {integrity: sha512-j3NGer4CcO6lBtC41MjnRIaYcwr+Q+SP7otL9jX1IW05Kj+zX3y5URL6n9VX2dI4yXSCAaf04D01PviYHUK5uw==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/cli-win32-x64@0.4.35': - resolution: {integrity: sha512-c7kaKQLACIMJsdsRAqGE+IUzMXeid06vWFtwRrhTjmBv8v/A8w/4MAgsw5E0MaV1s9C+fp83DP2qS+m4FJWhiQ==} + '@dbx-app/cli-win32-x64@0.4.36': + resolution: {integrity: sha512-vgq3tWciNPi5yH4c4teQsUDV+r6XzsAOxMDz5dEpQzkQJbvUutqSvXz24hpGNX0F/jCI9EyExXsFab5g3k2qRg==} cpu: [x64] os: [win32] hasBin: true - '@dbx-app/mcp-darwin-arm64@0.4.35': - resolution: {integrity: sha512-Mn54w0iW3jF5HTtFA/lzOGHyR/WXmn7Mphi5r8A8YHnb3RWeVVt05QL80+X3nmWbw87bMBf5htDBrFfODHz8/Q==} + '@dbx-app/mcp-darwin-arm64@0.4.36': + resolution: {integrity: sha512-/B1b6DYv9eSTgMZx5qBF7Zsm8vRhba/s2tM+3jFNJanWgn/y+xJVnIZZIB5Qm3x0yhU7ONqgmeLl7HMu8y8v/w==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/mcp-darwin-x64@0.4.35': - resolution: {integrity: sha512-rgJAwfK0Z9s7hJtmSusjpX25YSJ4kCxDQHfsKERgK7g6+epu2IEuYRwVGfSuqXfcgsfjREWGa9TgYUfNCmJrUg==} + '@dbx-app/mcp-darwin-x64@0.4.36': + resolution: {integrity: sha512-ED11PzL7zMLCKfk/zA1gBDLf3qy/v764jmBHut/Cy9JQvMPlZlwTmnTkuW6sJ8SdvG5WRIFDHFtEP1vzezmx3w==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.35': - resolution: {integrity: sha512-hvZsN9YvHBwzjSWL5Ork+pvYxpFc5+aIQz9jH5MoLMijZ/sO3+XO2HxPMs5FUKzr16yHJMjD47ts17EU4jZJfA==} + '@dbx-app/mcp-linux-arm64-gnu@0.4.36': + resolution: {integrity: sha512-jjJTH5e5Ru8o+RsUsF9PzgN+SQ4xGemBCnaDtbupPKxgOib1iX4wwgjbVt8ycK0QiNLk0ezv3Pj4RhABbv0wBw==} cpu: [arm64] os: [linux] hasBin: true - '@dbx-app/mcp-linux-x64-gnu@0.4.35': - resolution: {integrity: sha512-Ua0dHFfCMNYT3v78ecno/F3PBnLjGLzSJ/VkxFe8WBWcwSXNdWz9Vxehaypkr1lbCZ1WRet9nENfI6xZgebISw==} + '@dbx-app/mcp-linux-x64-gnu@0.4.36': + resolution: {integrity: sha512-qy9EyXVUSne00U4oAvRFwxn7Ed9wCj54WHZX+g+6m1uEEXAcbMC7F3lwczIdAOvQHU/qvLjeAP0rsjanVBVRmA==} cpu: [x64] os: [linux] hasBin: true - '@dbx-app/mcp-win32-arm64@0.4.35': - resolution: {integrity: sha512-FP2E3qT2Uf5UPI4NVKJjgLWBHaXqFIRrTpXRPg40YMDG5rVke3b/bQqf0d87BP9N5ba0P5Vv6ObNZXUuwxygLg==} + '@dbx-app/mcp-win32-arm64@0.4.36': + resolution: {integrity: sha512-drAtzSUUaU7qF2BpTxw6czFc4bciM8Q3fNLgjM/uy7ehFw1ZsS1J8qBOCOiKnZXKJHzMnUYaPAl9hIqdsxINPQ==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/mcp-win32-x64@0.4.35': - resolution: {integrity: sha512-/hPa3/CetdbeUQFYd50svuesSFZbFjfCDB5DYbPI2i1TUF7kJNnYcobWnBzriO+IqWHm1pQlPTjcFe9xKdovew==} + '@dbx-app/mcp-win32-x64@0.4.36': + resolution: {integrity: sha512-6sPpFJKCGpVJgujPeQrtF+D679/5RUCMuNxU29FTvyswynoqF4KHY07k14bK+JZGHvh1UGGWvy1CfaiC35HkKA==} cpu: [x64] os: [win32] hasBin: true @@ -639,312 +587,156 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.0': resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} @@ -1041,9 +833,6 @@ packages: resolution: {integrity: sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==} engines: {node: '>= 22'} - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1696,9 +1485,6 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1732,9 +1518,6 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/splitpanes@2.2.6': resolution: {integrity: sha512-3dV5sO1Ht74iER4jJU03mreL3f+Q2h47ZqXS6Sfbqc6hkCvsDrX1GA0NbYWRdNvZemPyTDzUoApWKeoGbALwkQ==} @@ -2006,10 +1789,6 @@ packages: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} - aws-ssl-profiles@1.1.2: - resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} - engines: {node: '>= 6.0.0'} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2017,27 +1796,14 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.40: resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} hasBin: true - better-sqlite3@12.10.0: - resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} - engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -2065,9 +1831,6 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2116,9 +1879,6 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} @@ -2145,10 +1905,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} - engines: {node: '>=0.10.0'} - code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -2269,10 +2025,6 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2285,10 +2037,6 @@ packages: resolution: {integrity: sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2315,10 +2063,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2395,9 +2139,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.22.1: resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} engines: {node: '>=10.13.0'} @@ -2437,11 +2178,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -2525,10 +2261,6 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2581,9 +2313,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2619,9 +2348,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -2644,9 +2370,6 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - generate-function@2.3.1: - resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2682,9 +2405,6 @@ packages: resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2768,9 +2488,6 @@ packages: resolution: {integrity: sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==} engines: {node: '>=18'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2786,13 +2503,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} - ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -2855,9 +2565,6 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-property@1.0.2: - resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-regexp@3.1.0: resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} engines: {node: '>=12'} @@ -2934,9 +2641,6 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - keytar@7.9.0: - resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3055,9 +2759,6 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -3065,10 +2766,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.4: - resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} - engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3142,10 +2839,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3163,9 +2856,6 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - moo@0.5.3: resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} @@ -3175,16 +2865,6 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - mysql2@3.22.3: - resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==} - engines: {node: '>= 8.0'} - peerDependencies: - '@types/node': '>= 8' - - named-placeholders@1.1.6: - resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} - engines: {node: '>=8.0.0'} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3195,9 +2875,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3209,13 +2886,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-abi@3.92.0: - resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} - engines: {node: '>=10'} - - node-addon-api@4.3.0: - resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} - node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3393,40 +3063,6 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} - peerDependencies: - pg: '>=8.0' - - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} - - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - - pg@8.20.0: - resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - - pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3490,28 +3126,6 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} - - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3537,9 +3151,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3569,14 +3180,6 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3585,14 +3188,6 @@ packages: resolution: {integrity: sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA==} engines: {node: '>= 4'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3649,9 +3244,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3720,12 +3312,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3764,19 +3350,11 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - splitpanes@4.1.2: resolution: {integrity: sha512-frNchoCv7w0nMQEuaDGgMGMU6jv3NhN6bBGQVfCNgwJdVcYaRmi1dwYDjp7SifAoUFdiQjBRB254LJNidzo15Q==} peerDependencies: vue: ^3.2.0 - sql-escaper@1.3.3: - resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} - engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} - sql-formatter@15.8.0: resolution: {integrity: sha512-HnjdRHlSsO4Ap2erB5YXAvWggrnk/S4TezUn8zmpq9J/hEKn9+6gGaqiKPyDtI10Xf4zJmHYPREGjMjZmmP1fg==} hasBin: true @@ -3784,9 +3362,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3814,9 +3389,6 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -3836,10 +3408,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -3875,13 +3443,6 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - time-span@5.1.0: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} @@ -3932,19 +3493,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.22.4: resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -4227,10 +3780,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4577,40 +4126,40 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@dbx-app/cli-darwin-arm64@0.4.35': + '@dbx-app/cli-darwin-arm64@0.4.36': optional: true - '@dbx-app/cli-darwin-x64@0.4.35': + '@dbx-app/cli-darwin-x64@0.4.36': optional: true - '@dbx-app/cli-linux-arm64-gnu@0.4.35': + '@dbx-app/cli-linux-arm64-gnu@0.4.36': optional: true - '@dbx-app/cli-linux-x64-gnu@0.4.35': + '@dbx-app/cli-linux-x64-gnu@0.4.36': optional: true - '@dbx-app/cli-win32-arm64@0.4.35': + '@dbx-app/cli-win32-arm64@0.4.36': optional: true - '@dbx-app/cli-win32-x64@0.4.35': + '@dbx-app/cli-win32-x64@0.4.36': optional: true - '@dbx-app/mcp-darwin-arm64@0.4.35': + '@dbx-app/mcp-darwin-arm64@0.4.36': optional: true - '@dbx-app/mcp-darwin-x64@0.4.35': + '@dbx-app/mcp-darwin-x64@0.4.36': optional: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.35': + '@dbx-app/mcp-linux-arm64-gnu@0.4.36': optional: true - '@dbx-app/mcp-linux-x64-gnu@0.4.35': + '@dbx-app/mcp-linux-x64-gnu@0.4.36': optional: true - '@dbx-app/mcp-win32-arm64@0.4.35': + '@dbx-app/mcp-win32-arm64@0.4.36': optional: true - '@dbx-app/mcp-win32-x64@0.4.35': + '@dbx-app/mcp-win32-x64@0.4.36': optional: true '@dotenvx/dotenvx@1.75.1': @@ -4650,159 +4199,81 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.0': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.0': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.0': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.0': optional: true @@ -4902,8 +4373,6 @@ snapshots: '@intlify/shared@11.4.4': {} - '@ioredis/commands@1.10.0': {} - '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -5398,10 +4867,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 22.19.19 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5437,12 +4902,6 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/pg@8.20.0': - dependencies: - '@types/node': 22.19.19 - pg-protocol: 1.13.0 - pg-types: 2.2.0 - '@types/splitpanes@2.2.6': dependencies: vue: 2.7.16 @@ -5541,13 +5000,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4) '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))': dependencies: @@ -5809,33 +5268,14 @@ snapshots: atomically@1.7.0: {} - aws-ssl-profiles@1.1.2: {} - balanced-match@1.0.2: {} balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.40: {} - better-sqlite3@12.10.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - birpc@2.9.0: {} - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -5877,11 +5317,6 @@ snapshots: dependencies: '@types/node': 25.9.1 - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -5929,8 +5364,6 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: {} - citty@0.2.2: {} class-variance-authority@0.7.1: @@ -5954,8 +5387,6 @@ snapshots: clsx@2.1.1: {} - cluster-key-slot@1.1.1: {} - code-block-writer@13.0.3: {} codemirror@6.0.2: @@ -6064,16 +5495,10 @@ snapshots: decode-uri-component@0.2.2: {} - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - dedent@1.7.2: {} deep-diff@1.0.2: {} - deep-extend@0.6.0: {} - deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -6091,8 +5516,6 @@ snapshots: defu@6.1.7: {} - denque@2.1.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -6158,10 +5581,6 @@ snapshots: encodeurl@2.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 @@ -6190,35 +5609,6 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -6348,8 +5738,6 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - expand-template@2.0.3: {} - expect-type@1.3.0: {} express-rate-limit@8.4.1(express@5.2.1): @@ -6422,8 +5810,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -6464,8 +5850,6 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 @@ -6483,10 +5867,6 @@ snapshots: fuzzysort@3.1.0: {} - generate-function@2.3.1: - dependencies: - is-property: 1.0.2 - gensync@1.0.0-beta.2: {} get-east-asian-width@1.5.0: {} @@ -6521,8 +5901,6 @@ snapshots: giget@3.3.0: {} - github-from-package@0.0.0: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6622,8 +6000,6 @@ snapshots: dependencies: reserved-identifiers: 1.2.0 - ieee754@1.2.1: {} - ignore@5.3.2: {} imurmurhash@0.1.4: {} @@ -6635,20 +6011,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - - ioredis@5.11.1: - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3 - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -6688,8 +6050,6 @@ snapshots: is-promise@4.0.0: {} - is-property@1.0.2: {} - is-regexp@3.1.0: {} is-stream@2.0.1: {} @@ -6742,11 +6102,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - keytar@7.9.0: - dependencies: - node-addon-api: 4.3.0 - prebuild-install: 7.1.3 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6855,16 +6210,12 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - long@5.3.2: {} - lru-cache@11.5.1: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru.min@1.1.4: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6933,8 +6284,6 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@3.1.0: {} - minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -6949,36 +6298,16 @@ snapshots: mitt@3.0.1: {} - mkdirp-classic@0.5.3: {} - moo@0.5.3: {} ms@2.1.3: {} muggle-string@0.4.1: {} - mysql2@3.22.3(@types/node@22.19.19): - dependencies: - '@types/node': 22.19.19 - aws-ssl-profiles: 1.1.2 - denque: 2.1.0 - generate-function: 2.3.1 - iconv-lite: 0.7.2 - long: 5.3.2 - lru.min: 1.1.4 - named-placeholders: 1.1.6 - sql-escaper: 1.3.3 - - named-placeholders@1.1.6: - dependencies: - lru.min: 1.1.4 - nanoid@3.3.12: {} nanoid@3.3.15: {} - napi-build-utils@2.0.0: {} - natural-compare@1.4.0: {} nearley@2.20.1: @@ -6990,12 +6319,6 @@ snapshots: negotiator@1.0.0: {} - node-abi@3.92.0: - dependencies: - semver: 7.8.5 - - node-addon-api@4.3.0: {} - node-fetch-native@1.6.7: {} node-html-parser@7.1.0: @@ -7189,41 +6512,6 @@ snapshots: perfect-debounce@2.1.0: {} - pg-cloudflare@1.3.0: - optional: true - - pg-connection-string@2.12.0: {} - - pg-int8@1.0.1: {} - - pg-pool@3.13.0(pg@8.20.0): - dependencies: - pg: 8.20.0 - - pg-protocol@1.13.0: {} - - pg-types@2.2.0: - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - - pg@8.20.0: - dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.3.0 - - pgpass@1.0.5: - dependencies: - split2: 4.2.0 - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7289,31 +6577,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} - - postgres-bytea@1.0.1: {} - - postgres-date@1.0.7: {} - - postgres-interval@1.2.0: - dependencies: - xtend: 4.0.2 - - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.92.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - prelude-ls@1.2.1: {} prettier@2.8.8: @@ -7333,11 +6596,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} qs@6.15.1: @@ -7367,19 +6625,6 @@ snapshots: defu: 6.1.7 destr: 2.0.5 - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@5.0.0: {} recast-x@1.0.5: @@ -7389,12 +6634,6 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - redis-errors@1.2.0: {} - - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -7475,8 +6714,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} sax@1.2.4: {} @@ -7609,14 +6846,6 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - sisteransi@1.0.5: {} slice-ansi@4.0.0: @@ -7650,14 +6879,10 @@ snapshots: speakingurl@14.0.1: {} - split2@4.2.0: {} - splitpanes@4.1.2(vue@3.5.35(typescript@6.0.3)): dependencies: vue: 3.5.35(typescript@6.0.3) - sql-escaper@1.3.3: {} - sql-formatter@15.8.0: dependencies: argparse: 2.0.1 @@ -7665,8 +6890,6 @@ snapshots: stackback@0.0.2: {} - standard-as-callback@2.1.0: {} - statuses@2.0.2: {} std-env@4.1.0: {} @@ -7692,10 +6915,6 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -7718,8 +6937,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-json-comments@2.0.1: {} - style-mod@4.1.3: {} stylus@0.57.0: @@ -7759,21 +6976,6 @@ snapshots: tapable@2.3.3: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - time-span@5.1.0: dependencies: convert-hrtime: 5.0.0 @@ -7812,23 +7014,12 @@ snapshots: tslib@2.8.1: {} - tsx@4.21.0: - dependencies: - esbuild: 0.27.7 - get-tsconfig: 4.14.0 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.22.4: dependencies: esbuild: 0.28.0 optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -7908,7 +7099,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4): + vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -7921,7 +7112,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 stylus: 0.57.0 - tsx: 4.21.0 + tsx: 4.22.4 yaml: 2.8.4 vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4): @@ -7940,10 +7131,10 @@ snapshots: tsx: 4.22.4 yaml: 2.8.4 - vitest@4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -7960,7 +7151,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -8113,8 +7304,6 @@ snapshots: dependencies: is-wsl: 3.1.1 - xtend@4.0.2: {} - yallist@3.1.1: {} yaml@2.8.4: {} diff --git a/scripts/release.mjs b/scripts/release.mjs index fc81c0108..de0eb5d32 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,13 +10,6 @@ const PACKAGE_TAG_PREFIX = "packages-v"; const AGENT_TAG_PREFIX = "agents-v"; const APP_TAG_PREFIX = "v"; const PACKAGE_RELEASE_PATHS = [ - "packages/mongo-shell/src/", - "packages/mongo-shell/package.json", - "packages/mongo-shell/tsconfig.json", - "packages/node-core/src/", - "packages/node-core/README.md", - "packages/node-core/package.json", - "packages/node-core/tsconfig.json", "packages/cli/src/", "packages/cli/README.md", "packages/cli/package.json", @@ -27,12 +20,10 @@ const PACKAGE_RELEASE_PATHS = [ "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", "packages/mcp-server/package.json", "packages/mcp-server/server.json", - "packages/mcp-server/tsconfig.json", "packages/mcp-darwin-arm64/", "packages/mcp-darwin-x64/", "packages/mcp-linux-arm64-gnu/", @@ -277,8 +268,6 @@ function getLatestPackageVersion() { if (tag) return tag.versionText; const packageVersions = [ - "packages/mongo-shell/package.json", - "packages/node-core/package.json", "packages/cli/package.json", "packages/mcp-server/package.json", "packages/mcp-darwin-arm64/package.json", diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index 36eee4d60..cb51ef859 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -5,8 +5,6 @@ import { spawnSync } from "node:child_process"; const packageDirectory = resolve(process.argv[2] ?? "/tmp/dbx-pack-check"); const expectedPackages = [ - "@dbx-app/mongo-shell", - "@dbx-app/node-core", "@dbx-app/cli", "@dbx-app/mcp-server", ]; diff --git a/vitest.config.ts b/vitest.config.ts index 2bc103fa7..5a02afb50 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ }, }, test: { - include: ["packages/app-tests/*.test.ts", "packages/node-core/tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"], + include: ["packages/app-tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"], globalSetup: "packages/test-globals.ts", // Large store modules are dynamically imported in many specs. Limiting // concurrency prevents those imports from starving timers and making