From 627bd89d5ecc9275054bd1720e723a0bad946cdd Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 16 Sep 2026 14:54:33 +0200 Subject: [PATCH] feat: phase 2 databases via DBX (v0.3.0) - dbx-mcp persistent stdio subprocess in Go backend (internal/dbx), lazy start + single retry, tool failures surface as 502 - core.db_connections registry: per-project connections, credentials server-side only, managed entries protected (409) - platform-postgres + platform-dragonfly auto-register per project - REST proxy: list/create/delete/test, databases, tables, describe, schema context, SQL query, redis command - Databases page: connection list, schema/table browser, column inspector, SQL editor with result grid + row detail, redis console - backend image moved to debian-slim for glibc; pinned dbx release with SHA256 verification Authored By: TDvorak --- CHANGELOG.md | 10 + ROADMAP.md | 10 +- apps/auth/package.json | 2 +- apps/backend/Dockerfile | 23 +- .../db/migrations/00003_db_connections.sql | 18 + apps/backend/db/queries/db_connections.sql | 30 + apps/backend/go.mod | 24 +- apps/backend/go.sum | 48 +- apps/backend/internal/app/app.go | 43 +- apps/backend/internal/config/config.go | 7 + .../database/db/db_connections.sql.go | 173 ++++ apps/backend/internal/database/db/models.go | 12 + apps/backend/internal/database/db/querier.go | 5 + apps/backend/internal/dbx/dbx.go | 318 +++++++ apps/backend/internal/handlers/databases.go | 234 +++++ apps/backend/internal/handlers/http.go | 11 + apps/backend/internal/services/databases.go | 414 +++++++++ .../internal/services/platform_service.go | 15 +- apps/backend/openapi/openapi.yaml | 432 +++++++++ apps/cli/package.json | 2 +- apps/frontend/package.json | 2 +- apps/frontend/src/App.tsx | 11 + apps/frontend/src/components/AppShell.tsx | 8 +- apps/frontend/src/components/Icons.tsx | 18 + apps/frontend/src/lib/demo-mode.ts | 142 +++ apps/frontend/src/pages/DatabasesPage.tsx | 847 ++++++++++++++++++ apps/frontend/src/pages/index.ts | 1 + apps/mcp/package.json | 2 +- package.json | 2 +- packages/api-client/package.json | 2 +- packages/api-client/src/generated/index.ts | 39 + .../src/generated/models/ConnectionID.ts | 5 + .../models/CreateDBConnectionRequest.ts | 29 + .../src/generated/models/DBConnection.ts | 18 + .../models/DBConnectionListResponse.ts | 9 + .../models/DBConnectionTestResult.ts | 9 + .../models/DBDatabaseListResponse.ts | 9 + .../generated/models/DBDescribeResponse.ts | 10 + .../src/generated/models/DBMDTable.ts | 10 + .../src/generated/models/DBObject.ts | 9 + .../src/generated/models/DBQueryRequest.ts | 9 + .../src/generated/models/DBQueryResponse.ts | 10 + .../src/generated/models/DBRedisRequest.ts | 9 + .../src/generated/models/DBRedisResponse.ts | 8 + .../models/DBSchemaContextResponse.ts | 8 + .../generated/models/DBTableListResponse.ts | 12 + .../src/generated/models/DBXStatus.ts | 11 + .../src/generated/models/DatabaseQuery.ts | 5 + .../src/generated/models/SchemaQuery.ts | 5 + .../src/generated/models/TableQuery.ts | 5 + .../src/generated/schemas/$ConnectionID.ts | 8 + .../schemas/$CreateDBConnectionRequest.ts | 38 + .../src/generated/schemas/$DBConnection.ts | 52 ++ .../schemas/$DBConnectionListResponse.ts | 15 + .../schemas/$DBConnectionTestResult.ts | 15 + .../schemas/$DBDatabaseListResponse.ts | 18 + .../generated/schemas/$DBDescribeResponse.ts | 14 + .../src/generated/schemas/$DBMDTable.ts | 28 + .../src/generated/schemas/$DBObject.ts | 16 + .../src/generated/schemas/$DBQueryRequest.ts | 15 + .../src/generated/schemas/$DBQueryResponse.ts | 14 + .../src/generated/schemas/$DBRedisRequest.ts | 15 + .../src/generated/schemas/$DBRedisResponse.ts | 12 + .../schemas/$DBSchemaContextResponse.ts | 12 + .../generated/schemas/$DBTableListResponse.ts | 20 + .../src/generated/schemas/$DBXStatus.ts | 21 + .../src/generated/schemas/$DatabaseQuery.ts | 7 + .../src/generated/schemas/$SchemaQuery.ts | 7 + .../src/generated/schemas/$TableQuery.ts | 7 + .../generated/services/DatabasesService.ts | 303 +++++++ packages/shared-types/package.json | 2 +- 71 files changed, 3685 insertions(+), 49 deletions(-) create mode 100644 apps/backend/db/migrations/00003_db_connections.sql create mode 100644 apps/backend/db/queries/db_connections.sql create mode 100644 apps/backend/internal/database/db/db_connections.sql.go create mode 100644 apps/backend/internal/dbx/dbx.go create mode 100644 apps/backend/internal/handlers/databases.go create mode 100644 apps/backend/internal/services/databases.go create mode 100644 apps/frontend/src/pages/DatabasesPage.tsx create mode 100644 packages/api-client/src/generated/models/ConnectionID.ts create mode 100644 packages/api-client/src/generated/models/CreateDBConnectionRequest.ts create mode 100644 packages/api-client/src/generated/models/DBConnection.ts create mode 100644 packages/api-client/src/generated/models/DBConnectionListResponse.ts create mode 100644 packages/api-client/src/generated/models/DBConnectionTestResult.ts create mode 100644 packages/api-client/src/generated/models/DBDatabaseListResponse.ts create mode 100644 packages/api-client/src/generated/models/DBDescribeResponse.ts create mode 100644 packages/api-client/src/generated/models/DBMDTable.ts create mode 100644 packages/api-client/src/generated/models/DBObject.ts create mode 100644 packages/api-client/src/generated/models/DBQueryRequest.ts create mode 100644 packages/api-client/src/generated/models/DBQueryResponse.ts create mode 100644 packages/api-client/src/generated/models/DBRedisRequest.ts create mode 100644 packages/api-client/src/generated/models/DBRedisResponse.ts create mode 100644 packages/api-client/src/generated/models/DBSchemaContextResponse.ts create mode 100644 packages/api-client/src/generated/models/DBTableListResponse.ts create mode 100644 packages/api-client/src/generated/models/DBXStatus.ts create mode 100644 packages/api-client/src/generated/models/DatabaseQuery.ts create mode 100644 packages/api-client/src/generated/models/SchemaQuery.ts create mode 100644 packages/api-client/src/generated/models/TableQuery.ts create mode 100644 packages/api-client/src/generated/schemas/$ConnectionID.ts create mode 100644 packages/api-client/src/generated/schemas/$CreateDBConnectionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$DBConnection.ts create mode 100644 packages/api-client/src/generated/schemas/$DBConnectionListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBConnectionTestResult.ts create mode 100644 packages/api-client/src/generated/schemas/$DBDatabaseListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBDescribeResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBMDTable.ts create mode 100644 packages/api-client/src/generated/schemas/$DBObject.ts create mode 100644 packages/api-client/src/generated/schemas/$DBQueryRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$DBQueryResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBRedisRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$DBRedisResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBSchemaContextResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBTableListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$DBXStatus.ts create mode 100644 packages/api-client/src/generated/schemas/$DatabaseQuery.ts create mode 100644 packages/api-client/src/generated/schemas/$SchemaQuery.ts create mode 100644 packages/api-client/src/generated/schemas/$TableQuery.ts create mode 100644 packages/api-client/src/generated/services/DatabasesService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5922525..b130968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [0.3.0] - 2026-09-16 + +### Added + +- **Databases (DBX integration)** — the Go backend keeps `dbx-mcp` as a persistent stdio subprocess (`internal/dbx`, lazy start, one retry on failure) and exposes its tools as REST under `/projects/:id/db-connections/*` (list/create/delete/test, databases, tables, describe, schema context, SQL query, Redis command). DBX tool/subprocess failures surface as 502. +- **Connection registry** — `core.db_connections` (migration 00003). Credentials are stored server-side and never serialized to the browser (`has_password` flag only). Non-default `ssl`, `driver_profile` supported. +- **First-party connections** — `platform-postgres` and `platform-dragonfly` auto-register on every project (derived from `DATABASE_URL`/`DRAGONFLY_URL`) and cannot be deleted (409 `managed_connection`). +- **Databases page** — SolidJS view with connection list, database/schema/table browser, column inspector, SQL editor (⌘/Ctrl+Enter), result grid with row-detail modal, and a Redis console. Demo mode stubs included. +- **Backend image now Debian-based** — `dbx-mcp` requires glibc; the Dockerfile installs the pinned `packages-v0.4.88` release with SHA256 verification. + ## [0.2.0] - 2026-09-15 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 44f48ec..806dec6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,14 +46,16 @@ in a 25 MB MCP server. Primora does not embed DBX; the Go backend keeps `@dbx-app/mcp-server` as a persistent stdio subprocess and exposes its tools as REST (pattern already proven in IMS's `internal/app/dbx.go`). -- **Connection registry** — per-project saved connections (Postgres, MySQL, +- **Connection registry** ✅ — per-project saved connections (Postgres, MySQL, SQLite, Redis, MongoDB, …). Credentials stored server-side, never sent to the browser. `core.db_connections` table + endpoints. -- **Databases page** — connection list → database/schema/table browser → +- **Databases page** ✅ — connection list → database/schema/table browser → column/type/key inspector. -- **SQL editor** — query editor with ⌘/Ctrl+Enter, result grid, row detail. +- **SQL editor** ✅ — query editor with ⌘/Ctrl+Enter, result grid, row detail. +- **Redis console** ✅ — command input + output history for redis-type + connections. - **Visual overview** — schema graph: tables as nodes, foreign keys as edges. -- **First-party connections** — the platform's own Postgres and Dragonfly +- **First-party connections** ✅ — the platform's own Postgres and Dragonfly auto-register as connections on every project, so "put Postgres in it and it runs Postgres" works out of the box. - **DB-to-DB links** — DBX data transfer between two saved connections diff --git a/apps/auth/package.json b/apps/auth/package.json index 513b499..ec9547c 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -1,6 +1,6 @@ { "name": "@primora/auth", - "version": "0.2.0", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 563450d..1dbaca7 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -10,10 +10,29 @@ RUN go mod download COPY apps/backend ./ RUN CGO_ENABLED=0 go build -o /primora-backend ./cmd/server -FROM alpine:3.21 +# glibc base — the DBX MCP binary does not run on musl. +FROM debian:bookworm-slim WORKDIR /app -RUN apk add --no-cache ca-certificates +ARG TARGETARCH +ARG DBX_VERSION=0.4.88 +# Pinned hashes from the packages-v0.4.88 release SHA256SUMS. +ARG DBX_SHA256_X64=dcfcc1c321b1e1db437bbc36bf4b07489e64c93aa9ce2e11ccdb1a3d88ee8662 +ARG DBX_SHA256_ARM64=fab32b1096d676d068b171079896791d658aaf5cb4d86b5196cad973f3aded58 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && case "$TARGETARCH" in \ + amd64) DBX_ARCH=x64; DBX_SHA256=$DBX_SHA256_X64 ;; \ + arm64) DBX_ARCH=arm64; DBX_SHA256=$DBX_SHA256_ARM64 ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://github.com/t8y2/dbx/releases/download/packages-v${DBX_VERSION}/dbx-mcp-linux-${DBX_ARCH}-gnu.tar.gz" -o /tmp/dbx-mcp.tar.gz \ + && echo "${DBX_SHA256} /tmp/dbx-mcp.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/dbx-mcp.tar.gz -C /usr/local/bin \ + && chmod +x /usr/local/bin/dbx-mcp \ + && rm /tmp/dbx-mcp.tar.gz COPY --from=builder /primora-backend /usr/local/bin/primora-backend COPY --from=builder /app/db ./db diff --git a/apps/backend/db/migrations/00003_db_connections.sql b/apps/backend/db/migrations/00003_db_connections.sql new file mode 100644 index 0000000..fc16df9 --- /dev/null +++ b/apps/backend/db/migrations/00003_db_connections.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE core.db_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + db_type TEXT NOT NULL, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + is_managed BOOLEAN NOT NULL DEFAULT FALSE, + created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +CREATE INDEX idx_db_connections_project_id ON core.db_connections(project_id); + +-- +goose Down +DROP TABLE IF EXISTS core.db_connections; diff --git a/apps/backend/db/queries/db_connections.sql b/apps/backend/db/queries/db_connections.sql new file mode 100644 index 0000000..60af5e6 --- /dev/null +++ b/apps/backend/db/queries/db_connections.sql @@ -0,0 +1,30 @@ +-- name: ListDBConnections :many +SELECT * FROM core.db_connections +WHERE project_id = $1 +ORDER BY is_managed DESC, name ASC; + +-- name: GetDBConnectionByID :one +SELECT * FROM core.db_connections +WHERE id = $1; + +-- name: GetDBConnectionByName :one +SELECT * FROM core.db_connections +WHERE project_id = $1 + AND name = $2; + +-- name: CreateDBConnection :one +INSERT INTO core.db_connections ( + project_id, + name, + db_type, + config, + is_managed, + created_by_user_id +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: DeleteDBConnection :one +DELETE FROM core.db_connections +WHERE id = $1 + AND project_id = $2 +RETURNING *; diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 44a473c..2cb441f 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -6,10 +6,11 @@ require ( github.com/MicahParks/keyfunc/v3 v3.7.0 github.com/gin-gonic/gin v1.11.0 github.com/go-playground/validator/v10 v10.28.0 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pressly/goose/v3 v3.26.0 github.com/redis/go-redis/v9 v9.16.0 github.com/resend/resend-go/v2 v2.15.0 @@ -31,6 +32,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -47,20 +49,24 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/backend/go.sum b/apps/backend/go.sum index b101ad6..dda1919 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -42,11 +42,13 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -77,6 +79,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -105,6 +109,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -121,33 +129,37 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index d145e2b..9532a8a 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -4,7 +4,10 @@ import ( "context" "fmt" "log/slog" + "net/url" "os" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -16,6 +19,7 @@ import ( "github.com/tdvorak/primora/apps/backend/internal/auth" "github.com/tdvorak/primora/apps/backend/internal/config" "github.com/tdvorak/primora/apps/backend/internal/database" + "github.com/tdvorak/primora/apps/backend/internal/dbx" "github.com/tdvorak/primora/apps/backend/internal/handlers" "github.com/tdvorak/primora/apps/backend/internal/middleware" "github.com/tdvorak/primora/apps/backend/internal/observability" @@ -30,6 +34,7 @@ type App struct { Logger *slog.Logger DB *pgxpool.Pool Redis *redis.Client + DBX *dbx.Client } func Bootstrap(ctx context.Context) (*App, error) { @@ -75,8 +80,9 @@ func Bootstrap(ctx context.Context) (*App, error) { if err != nil { return nil, err } + dbxClient := dbx.NewClient(logger, cfg.DBXDataDir) repo := repositories.NewCoreRepository(dbPool) - platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL")) + platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg)) if cfg.Env == "production" { gin.SetMode(gin.ReleaseMode) @@ -148,6 +154,7 @@ func Bootstrap(ctx context.Context) (*App, error) { Logger: logger, DB: dbPool, Redis: redisClient, + DBX: dbxClient, }, nil } @@ -157,7 +164,41 @@ func (a *App) Run() error { return a.Router.Run(address) } +// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into +// managed connection seeds registered on every project. +func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed { + var seeds []services.ManagedDBSeed + if u, err := url.Parse(cfg.DatabaseURL); err == nil && u.Hostname() != "" { + cfg2 := services.DBConnectionConfig{ + Host: u.Hostname(), + Database: strings.TrimPrefix(u.Path, "/"), + Username: u.User.Username(), + } + if p, ok := u.User.Password(); ok { + cfg2.Password = p + } + if port, err := strconv.Atoi(u.Port()); err == nil { + cfg2.Port = &port + } + seeds = append(seeds, services.ManagedDBSeed{Name: "platform-postgres", DBType: "postgres", Config: cfg2}) + } + if u, err := url.Parse(cfg.DragonflyURL); err == nil && u.Hostname() != "" { + cfg2 := services.DBConnectionConfig{Host: u.Hostname()} + if p, ok := u.User.Password(); ok { + cfg2.Password = p + } + if port, err := strconv.Atoi(u.Port()); err == nil { + cfg2.Port = &port + } + seeds = append(seeds, services.ManagedDBSeed{Name: "platform-dragonfly", DBType: "redis", Config: cfg2}) + } + return seeds +} + func (a *App) Close() error { + if a.DBX != nil { + a.DBX.Close() + } if a.Redis != nil { if err := a.Redis.Close(); err != nil { return fmt.Errorf("close redis: %w", err) diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 37fa5d0..3436b59 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" "strings" ) @@ -14,6 +15,7 @@ type Config struct { DatabaseURL string DragonflyURL string StorageRoot string + DBXDataDir string AuthInternalBaseURL string PublicURL string UserRateLimitPerMin int @@ -40,6 +42,7 @@ func Load() (Config, error) { DatabaseURL: os.Getenv("DATABASE_URL"), DragonflyURL: getenv("DRAGONFLY_URL", "redis://localhost:6379/0"), StorageRoot: getenv("BACKEND_STORAGE_ROOT", "./tmp/storage"), + DBXDataDir: os.Getenv("DBX_DATA_DIR"), AuthInternalBaseURL: getenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001"), PublicURL: getenv("VITE_APP_URL", "http://localhost"), UserRateLimitPerMin: 240, @@ -104,6 +107,10 @@ func Load() (Config, error) { return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", ")) } + if cfg.DBXDataDir == "" { + cfg.DBXDataDir = filepath.Join(cfg.StorageRoot, "dbx") + } + return cfg, nil } diff --git a/apps/backend/internal/database/db/db_connections.sql.go b/apps/backend/internal/database/db/db_connections.sql.go new file mode 100644 index 0000000..1559253 --- /dev/null +++ b/apps/backend/internal/database/db/db_connections.sql.go @@ -0,0 +1,173 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: db_connections.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createDBConnection = `-- name: CreateDBConnection :one +INSERT INTO core.db_connections ( + project_id, + name, + db_type, + config, + is_managed, + created_by_user_id +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, project_id, name, db_type, config, is_managed, created_by_user_id, created_at, updated_at +` + +type CreateDBConnectionParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + DbType string `json:"db_type"` + Config []byte `json:"config"` + IsManaged bool `json:"is_managed"` + CreatedByUserID pgtype.UUID `json:"created_by_user_id"` +} + +func (q *Queries) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) { + row := q.db.QueryRow(ctx, createDBConnection, + arg.ProjectID, + arg.Name, + arg.DbType, + arg.Config, + arg.IsManaged, + arg.CreatedByUserID, + ) + var i CoreDbConnection + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.DbType, + &i.Config, + &i.IsManaged, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteDBConnection = `-- name: DeleteDBConnection :one +DELETE FROM core.db_connections +WHERE id = $1 + AND project_id = $2 +RETURNING id, project_id, name, db_type, config, is_managed, created_by_user_id, created_at, updated_at +` + +type DeleteDBConnectionParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) { + row := q.db.QueryRow(ctx, deleteDBConnection, arg.ID, arg.ProjectID) + var i CoreDbConnection + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.DbType, + &i.Config, + &i.IsManaged, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDBConnectionByID = `-- name: GetDBConnectionByID :one +SELECT id, project_id, name, db_type, config, is_managed, created_by_user_id, created_at, updated_at FROM core.db_connections +WHERE id = $1 +` + +func (q *Queries) GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) { + row := q.db.QueryRow(ctx, getDBConnectionByID, id) + var i CoreDbConnection + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.DbType, + &i.Config, + &i.IsManaged, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getDBConnectionByName = `-- name: GetDBConnectionByName :one +SELECT id, project_id, name, db_type, config, is_managed, created_by_user_id, created_at, updated_at FROM core.db_connections +WHERE project_id = $1 + AND name = $2 +` + +type GetDBConnectionByNameParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) { + row := q.db.QueryRow(ctx, getDBConnectionByName, arg.ProjectID, arg.Name) + var i CoreDbConnection + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.DbType, + &i.Config, + &i.IsManaged, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDBConnections = `-- name: ListDBConnections :many +SELECT id, project_id, name, db_type, config, is_managed, created_by_user_id, created_at, updated_at FROM core.db_connections +WHERE project_id = $1 +ORDER BY is_managed DESC, name ASC +` + +func (q *Queries) ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) { + rows, err := q.db.Query(ctx, listDBConnections, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreDbConnection + for rows.Next() { + var i CoreDbConnection + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.DbType, + &i.Config, + &i.IsManaged, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index cbe3faa..dba7bf1 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -200,6 +200,18 @@ type CoreCollection struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type CoreDbConnection struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + DbType string `json:"db_type"` + Config []byte `json:"config"` + IsManaged bool `json:"is_managed"` + CreatedByUserID pgtype.UUID `json:"created_by_user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreDocument struct { ID uuid.UUID `json:"id"` CollectionID uuid.UUID `json:"collection_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 808c3e0..9358925 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -25,6 +25,7 @@ type Querier interface { CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) + CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) @@ -32,6 +33,7 @@ type Querier interface { DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error + DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) @@ -42,6 +44,8 @@ type Querier interface { GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error) + GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) + GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) @@ -57,6 +61,7 @@ type Querier interface { ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error) ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error) + ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) diff --git a/apps/backend/internal/dbx/dbx.go b/apps/backend/internal/dbx/dbx.go new file mode 100644 index 0000000..5dd67f3 --- /dev/null +++ b/apps/backend/internal/dbx/dbx.go @@ -0,0 +1,318 @@ +// Package dbx bridges Primora to the DBX MCP server (@dbx-app/mcp-server). +// The server is spawned lazily as a persistent stdio subprocess; all database +// inspection and queries flow through its tools. +package dbx + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Client struct { + mu sync.Mutex + session *mcp.ClientSession + cmd *exec.Cmd + cmdLine string + dataDir string + logger *slog.Logger + registered map[string]bool + lastErr string +} + +func NewClient(logger *slog.Logger, dataDir string) *Client { + return &Client{logger: logger, dataDir: dataDir, registered: map[string]bool{}} +} + +func (d *Client) connect(ctx context.Context) (*mcp.ClientSession, error) { + d.mu.Lock() + defer d.mu.Unlock() + if d.session != nil { + return d.session, nil + } + // jarvis: exec.Command, not CommandContext — the subprocess must outlive the + // request ctx or every HTTP request would SIGKILL it on return. + var cmd *exec.Cmd + if p, err := exec.LookPath("dbx-mcp"); err == nil { + cmd = exec.Command(p) + d.cmdLine = "dbx-mcp" + } else if p, err := exec.LookPath("dbx-mcp-server"); err == nil { + cmd = exec.Command(p) + d.cmdLine = "dbx-mcp-server" + } else { + npx, err := exec.LookPath("npx") + if err != nil { + d.lastErr = "neither dbx-mcp binary nor npx found — install @dbx-app/mcp-server" + return nil, fmt.Errorf("%s", d.lastErr) + } + cmd = exec.Command(npx, "-y", "@dbx-app/mcp-server") + d.cmdLine = "npx -y @dbx-app/mcp-server" + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if d.dataDir != "" { + if err := os.MkdirAll(d.dataDir, 0o755); err != nil { + return nil, fmt.Errorf("create dbx data dir: %w", err) + } + cmd.Env = append(os.Environ(), "DBX_DATA_DIR="+d.dataDir) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "primora", Version: "0.2.0"}, nil) + sess, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) + if err != nil { + d.lastErr = fmt.Sprintf("dbx spawn failed: %v %s", err, stderr.String()) + return nil, fmt.Errorf("%s", d.lastErr) + } + d.session = sess + d.cmd = cmd + d.registered = map[string]bool{} + d.lastErr = "" + return sess, nil +} + +func (d *Client) reset() { + d.mu.Lock() + defer d.mu.Unlock() + if d.session != nil { + d.session.Close() + d.session = nil + } + if d.cmd != nil && d.cmd.Process != nil { + d.cmd.Process.Kill() + } + d.cmd = nil + d.registered = map[string]bool{} +} + +// call invokes a DBX tool, transparently respawning the subprocess once on failure. +func (d *Client) call(ctx context.Context, tool string, args map[string]any) (string, error) { + sess, err := d.connect(ctx) + if err != nil { + return "", err + } + res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args}) + if err != nil { + d.reset() + sess, err2 := d.connect(ctx) + if err2 != nil { + return "", err + } + res, err = sess.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args}) + if err != nil { + return "", err + } + } + if res.IsError { + var msg string + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + msg += tc.Text + } + } + if msg == "" { + msg = "dbx tool error" + } + return "", fmt.Errorf("%s", msg) + } + var texts []string + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + texts = append(texts, tc.Text) + } + } + return strings.Join(texts, "\n"), nil +} + +// Call runs a DBX tool with a bounded timeout and returns its text output. +func (d *Client) Call(ctx context.Context, tool string, args map[string]any) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + return d.call(ctx, tool, args) +} + +// EnsureConnection registers the connection in DBX storage when missing. +// Registration is cached per subprocess session and cleared on respawn. +func (d *Client) EnsureConnection(ctx context.Context, name string, args map[string]any) error { + d.mu.Lock() + if d.registered[name] { + d.mu.Unlock() + return nil + } + d.mu.Unlock() + + _, err := d.Call(ctx, "dbx_add_connection", args) + if err == nil { + d.mu.Lock() + d.registered[name] = true + d.mu.Unlock() + return nil + } + // The add may have failed because the name is already in DBX storage — + // verify with a list before surfacing the error. + list, lerr := d.Call(ctx, "dbx_list_connections", nil) + if lerr == nil { + for _, row := range ParseConnectionRows(list) { + if row.Name == name { + d.mu.Lock() + d.registered[name] = true + d.mu.Unlock() + return nil + } + } + } + return err +} + +// RemoveConnection drops the connection from DBX storage, best-effort. +func (d *Client) RemoveConnection(ctx context.Context, name string) { + _, _ = d.Call(ctx, "dbx_remove_connection", map[string]any{"connection_name": name}) + d.mu.Lock() + delete(d.registered, name) + d.mu.Unlock() +} + +// Status reports whether the DBX subprocess can be spawned. +func (d *Client) Status(ctx context.Context) map[string]any { + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + if _, err := d.connect(ctx); err != nil { + return map[string]any{ + "available": false, + "reason": err.Error(), + "install": "npm install -g @dbx-app/mcp-server (https://github.com/t8y2/dbx)", + } + } + d.mu.Lock() + cmd := d.cmdLine + d.mu.Unlock() + return map[string]any{"available": true, "command": cmd} +} + +// Close terminates the subprocess. +func (d *Client) Close() { d.reset() } + +// --- response normalization: DBX tools return markdown tables / bullet lists --- + +func splitMDRow(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + parts := strings.Split(line, "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func isMDSeparator(line string) bool { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "|") { + return false + } + for _, r := range line { + if r != '|' && r != '-' && r != ':' && r != ' ' { + return false + } + } + return true +} + +type MDTable struct { + Columns []string `json:"columns"` + Rows [][]string `json:"rows"` + Note string `json:"note,omitempty"` +} + +func ParseMDTable(text string) (*MDTable, string) { + var cols []string + var rows [][]string + var rest []string + for _, line := range strings.Split(text, "\n") { + trim := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trim, "|") && len(cols) == 0: + cols = splitMDRow(trim) + case isMDSeparator(trim): + continue + case strings.HasPrefix(trim, "|"): + rows = append(rows, splitMDRow(trim)) + default: + if trim != "" { + rest = append(rest, trim) + } + } + } + if len(cols) == 0 { + return nil, text + } + return &MDTable{Columns: cols, Rows: rows, Note: strings.Join(rest, "\n")}, text +} + +type DBObject struct { + Name string `json:"name"` + Kind string `json:"kind"` +} + +// ParseBullets handles "- name (KIND)" lists (dbx_list_tables, dbx_list_databases). +func ParseBullets(text string) []DBObject { + var out []DBObject + for _, line := range strings.Split(text, "\n") { + trim := strings.TrimSpace(line) + if !strings.HasPrefix(trim, "- ") { + continue + } + item := strings.TrimPrefix(trim, "- ") + kind := "" + if i := strings.LastIndex(item, " ("); i > 0 && strings.HasSuffix(item, ")") { + kind = item[i+2 : len(item)-1] + item = item[:i] + } + out = append(out, DBObject{Name: item, Kind: kind}) + } + return out +} + +type ConnectionRow struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Host string `json:"host"` + Port string `json:"port"` + Database string `json:"database"` + Group string `json:"group"` +} + +// ParseConnectionRows reads the `dbx_list_connections` markdown table. +func ParseConnectionRows(text string) []ConnectionRow { + t, _ := ParseMDTable(text) + if t == nil { + return nil + } + idx := map[string]int{} + for i, c := range t.Columns { + idx[strings.ToLower(c)] = i + } + get := func(row []string, col string) string { + if i, ok := idx[col]; ok && i < len(row) { + return row[i] + } + return "" + } + out := []ConnectionRow{} + for _, r := range t.Rows { + out = append(out, ConnectionRow{ + ID: get(r, "id"), Name: get(r, "name"), Type: get(r, "type"), + Host: get(r, "host"), Port: get(r, "port"), Database: get(r, "database"), + Group: get(r, "group path"), + }) + } + return out +} diff --git a/apps/backend/internal/handlers/databases.go b/apps/backend/internal/handlers/databases.go new file mode 100644 index 0000000..a9c51cd --- /dev/null +++ b/apps/backend/internal/handlers/databases.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/tdvorak/primora/apps/backend/internal/middleware" + "github.com/tdvorak/primora/apps/backend/internal/models" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) dbxStatus(c *gin.Context) { + actor, ok := middleware.RequireActor(c) + if !ok { + return + } + result, err := h.Platform.DBXStatus(c.Request.Context(), actor) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) listDBConnections(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListDBConnections(c.Request.Context(), actor, projectID) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) createDBConnection(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.CreateDBConnectionInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.CreateDBConnection(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + apperrors.Abort(c, http.StatusConflict, "name_conflict", err.Error()) + return + } + h.handleError(c, err) + return + } + c.JSON(http.StatusCreated, result) +} + +func (h *HTTPHandler) deleteDBConnection(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + if err := h.Platform.DeleteDBConnection(c.Request.Context(), actor, projectID, connectionID, middleware.RequestIDFromContext(c)); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found") + return + } + if strings.Contains(err.Error(), "platform-managed") { + apperrors.Abort(c, http.StatusConflict, "managed_connection", err.Error()) + return + } + h.handleError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *HTTPHandler) testDBConnection(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.TestDBConnection(c.Request.Context(), actor, projectID, connectionID) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxDatabases(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.ListDBXDatabases(c.Request.Context(), actor, projectID, connectionID) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxTables(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.ListDBXTables(c.Request.Context(), actor, projectID, connectionID, + strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema"))) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxDescribe(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.DescribeDBXTable(c.Request.Context(), actor, projectID, connectionID, + strings.TrimSpace(c.Query("table")), strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema"))) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxSchema(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + result, err := h.Platform.DBXSchemaContext(c.Request.Context(), actor, projectID, connectionID, + strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema"))) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxQuery(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + var body struct { + SQL string `json:"sql" validate:"required"` + Database string `json:"database"` + } + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.ExecuteDBXQuery(c.Request.Context(), actor, projectID, connectionID, body.SQL, strings.TrimSpace(body.Database)) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) dbxRedis(c *gin.Context) { + actor, projectID, connectionID, ok := h.actorProjectConn(c) + if !ok { + return + } + var body struct { + Command string `json:"command" validate:"required"` + DB *int `json:"db"` + } + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.ExecuteDBXRedis(c.Request.Context(), actor, projectID, connectionID, body.Command, body.DB) + if err != nil { + h.dbxError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// actorAndProject resolves the actor and :projectID param. +func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) { + actor, ok := middleware.RequireActor(c) + if !ok { + return nil, uuid.Nil, false + } + projectID, ok := parseUUIDParam(c, "projectID") + if !ok { + return nil, uuid.Nil, false + } + return actor, projectID, true +} + +// actorProjectConn resolves the actor, :projectID and :connectionID params. +func (h *HTTPHandler) actorProjectConn(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return nil, uuid.Nil, uuid.Nil, false + } + connectionID, ok := parseUUIDParam(c, "connectionID") + if !ok { + return nil, uuid.Nil, uuid.Nil, false + } + return actor, projectID, connectionID, true +} + +// dbxError maps DBX subprocess/tool failures to 502, not-found rows to 404. +func (h *HTTPHandler) dbxError(c *gin.Context, err error) { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found") + return + } + if strings.Contains(err.Error(), "access denied") || strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "authentication required") { + h.handleError(c, err) + return + } + if strings.Contains(err.Error(), "table required") { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error()) + return + } + apperrors.Abort(c, http.StatusBadGateway, "dbx_error", err.Error()) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 0379c00..c158709 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -70,6 +70,17 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.GET("/collections/:collectionID/documents/:documentID", h.getDocument) api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument) api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument) + api.GET("/db/status", h.dbxStatus) + api.GET("/projects/:projectID/db-connections", h.listDBConnections) + api.POST("/projects/:projectID/db-connections", h.createDBConnection) + api.DELETE("/projects/:projectID/db-connections/:connectionID", h.deleteDBConnection) + api.POST("/projects/:projectID/db-connections/:connectionID/test", h.testDBConnection) + api.GET("/projects/:projectID/db-connections/:connectionID/databases", h.dbxDatabases) + api.GET("/projects/:projectID/db-connections/:connectionID/tables", h.dbxTables) + api.GET("/projects/:projectID/db-connections/:connectionID/describe", h.dbxDescribe) + api.GET("/projects/:projectID/db-connections/:connectionID/schema", h.dbxSchema) + api.POST("/projects/:projectID/db-connections/:connectionID/query", h.dbxQuery) + api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis) api.GET("/buckets/:bucketID/objects", h.listObjects) api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/object-copies", h.copyObject) diff --git a/apps/backend/internal/services/databases.go b/apps/backend/internal/services/databases.go new file mode 100644 index 0000000..7a7f2bb --- /dev/null +++ b/apps/backend/internal/services/databases.go @@ -0,0 +1,414 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/dbx" + "github.com/tdvorak/primora/apps/backend/internal/models" +) + +// DBConnectionConfig holds the server-side credential payload for a saved +// connection. It is stored in core.db_connections.config and never serialized +// to the client — see DBConnectionSummary. +type DBConnectionConfig struct { + Host string `json:"host"` + Port *int `json:"port,omitempty"` + Database string `json:"database,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SSL *bool `json:"ssl,omitempty"` + DriverProfile string `json:"driver_profile,omitempty"` +} + +// ManagedDBSeed describes a first-party connection auto-registered on every +// project (the platform's own Postgres and Dragonfly). +type ManagedDBSeed struct { + Name string + DBType string + Config DBConnectionConfig +} + +type CreateDBConnectionInput struct { + Name string `json:"name" validate:"required,min=1"` + DBType string `json:"db_type" validate:"required,oneof=postgres mysql sqlite redis mongodb sqlserver duckdb clickhouse cassandra"` + Host string `json:"host" validate:"required"` + Port *int `json:"port" validate:"omitempty,min=1,max=65535"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + SSL *bool `json:"ssl"` + DriverProfile string `json:"driver_profile"` +} + +// DBConnectionSummary is the client-facing shape — config minus secrets. +type DBConnectionSummary struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + DBType string `json:"db_type"` + Host string `json:"host"` + Port *int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + SSL *bool `json:"ssl"` + IsManaged bool `json:"is_managed"` + HasPassword bool `json:"has_password"` +} + +func parseDBConnectionConfig(raw []byte) DBConnectionConfig { + var cfg DBConnectionConfig + _ = json.Unmarshal(raw, &cfg) + return cfg +} + +func toDBConnectionSummary(row db.CoreDbConnection) DBConnectionSummary { + cfg := parseDBConnectionConfig(row.Config) + return DBConnectionSummary{ + ID: row.ID, + ProjectID: row.ProjectID, + Name: row.Name, + DBType: row.DbType, + Host: cfg.Host, + Port: cfg.Port, + Database: cfg.Database, + Username: cfg.Username, + SSL: cfg.SSL, + IsManaged: row.IsManaged, + HasPassword: cfg.Password != "", + } +} + +// dbxConnectionName derives the DBX-internal name. The project prefix keeps +// same-named connections in different projects from clobbering each other in +// DBX's shared storage. +func dbxConnectionName(projectID uuid.UUID, name string) string { + short := strings.ReplaceAll(projectID.String(), "-", "")[:8] + return "prj_" + short + "_" + name +} + +func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any { + cfg := parseDBConnectionConfig(row.Config) + args := map[string]any{ + "name": dbxConnectionName(row.ProjectID, row.Name), + "db_type": row.DbType, + "host": cfg.Host, + } + if cfg.Port != nil { + args["port"] = *cfg.Port + } + if cfg.Database != "" { + args["database"] = cfg.Database + } + if cfg.Username != "" { + args["username"] = cfg.Username + } + if cfg.Password != "" { + args["password"] = cfg.Password + } + if cfg.SSL != nil { + args["ssl"] = *cfg.SSL + } + if cfg.DriverProfile != "" { + args["driver_profile"] = cfg.DriverProfile + } + return args +} + +// seedManagedConnections registers the platform's own Postgres and Dragonfly +// on the project. Runs on list so existing projects pick them up too. +func (s *PlatformService) seedManagedConnections(ctx context.Context, projectID uuid.UUID) { + for _, seed := range s.managedDBSeeds { + _, err := s.repo.Queries().GetDBConnectionByName(ctx, db.GetDBConnectionByNameParams{ + ProjectID: projectID, + Name: seed.Name, + }) + if err == nil { + continue + } + if !errors.Is(err, pgx.ErrNoRows) { + continue + } + cfgBytes, _ := json.Marshal(seed.Config) + _, _ = s.repo.Queries().CreateDBConnection(ctx, db.CreateDBConnectionParams{ + ProjectID: projectID, + Name: seed.Name, + DbType: seed.DBType, + Config: cfgBytes, + IsManaged: true, + }) + } +} + +func (s *PlatformService) DBXStatus(ctx context.Context, actor *models.Actor) (map[string]any, error) { + if actor == nil { + return nil, errors.New("authentication required") + } + if s.dbx == nil { + return map[string]any{"available": false, "reason": "dbx not configured"}, nil + } + return s.dbx.Status(ctx), nil +} + +func (s *PlatformService) ListDBConnections(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]DBConnectionSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + s.seedManagedConnections(ctx, projectID) + rows, err := s.repo.Queries().ListDBConnections(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]DBConnectionSummary, 0, len(rows)) + for _, row := range rows { + out = append(out, toDBConnectionSummary(row)) + } + return out, nil +} + +func (s *PlatformService) CreateDBConnection(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateDBConnectionInput, requestID string) (DBConnectionSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return DBConnectionSummary{}, err + } + name := normalizeSlug(input.Name) + if name == "" { + return DBConnectionSummary{}, errors.New("name must contain letters or digits") + } + cfg := DBConnectionConfig{ + Host: strings.TrimSpace(input.Host), + Port: input.Port, + Database: strings.TrimSpace(input.Database), + Username: strings.TrimSpace(input.Username), + Password: input.Password, + SSL: input.SSL, + DriverProfile: strings.TrimSpace(input.DriverProfile), + } + cfgBytes, _ := json.Marshal(cfg) + var createdBy pgtype.UUID + if actor.UserID != nil { + createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true} + } + row, err := s.repo.Queries().CreateDBConnection(ctx, db.CreateDBConnectionParams{ + ProjectID: projectID, + Name: name, + DbType: input.DBType, + Config: cfgBytes, + CreatedByUserID: createdBy, + }) + if err != nil { + if strings.Contains(err.Error(), "db_connections_project_id_name_key") { + return DBConnectionSummary{}, fmt.Errorf("a connection named %s already exists", name) + } + return DBConnectionSummary{}, err + } + project, err := s.repo.Queries().GetProjectByID(ctx, projectID) + if err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "db_connection.created", "db_connection", row.ID.String(), map[string]any{ + "name": row.Name, "db_type": row.DbType, + })) + } + return toDBConnectionSummary(row), nil +} + +func (s *PlatformService) DeleteDBConnection(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + row, err := s.repo.Queries().GetDBConnectionByID(ctx, connectionID) + if err != nil { + return err + } + if row.ProjectID != projectID { + return fmt.Errorf("connection access denied") + } + if row.IsManaged { + return fmt.Errorf("platform-managed connections cannot be removed") + } + if _, err := s.repo.Queries().DeleteDBConnection(ctx, db.DeleteDBConnectionParams{ + ID: connectionID, + ProjectID: projectID, + }); err != nil { + return err + } + if s.dbx != nil { + s.dbx.RemoveConnection(ctx, dbxConnectionName(projectID, row.Name)) + } + project, _ := s.repo.Queries().GetProjectByID(ctx, projectID) + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "db_connection.deleted", "db_connection", connectionID.String(), map[string]any{ + "name": row.Name, + })) + return nil +} + +// connForTool resolves the connection row, checks access, and registers it +// with the DBX subprocess. Write tools require admin/developer. +func (s *PlatformService) connForTool(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, write bool) (db.CoreDbConnection, error) { + roles := []string{"admin", "developer", "viewer"} + if write { + roles = []string{"admin", "developer"} + } + if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil { + return db.CoreDbConnection{}, err + } + row, err := s.repo.Queries().GetDBConnectionByID(ctx, connectionID) + if err != nil { + return db.CoreDbConnection{}, err + } + if row.ProjectID != projectID { + return db.CoreDbConnection{}, fmt.Errorf("connection access denied") + } + if s.dbx == nil { + return db.CoreDbConnection{}, errors.New("dbx not configured") + } + if err := s.dbx.EnsureConnection(ctx, dbxConnectionName(projectID, row.Name), s.dbxArgsFor(row)); err != nil { + return db.CoreDbConnection{}, err + } + return row, nil +} + +func (s *PlatformService) TestDBConnection(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, true) + if err != nil { + return nil, err + } + name := dbxConnectionName(projectID, row.Name) + tool, args := "dbx_list_databases", map[string]any{"connection_name": name} + if row.DbType == "redis" { + tool, args = "dbx_execute_redis_command", map[string]any{"connection_name": name, "command": "PING"} + } + if _, err := s.dbx.Call(ctx, tool, args); err != nil { + return map[string]any{"ok": false, "error": err.Error()}, nil + } + return map[string]any{"ok": true}, nil +} + +func (s *PlatformService) ListDBXDatabases(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, false) + if err != nil { + return nil, err + } + text, err := s.dbx.Call(ctx, "dbx_list_databases", map[string]any{"connection_name": dbxConnectionName(projectID, row.Name)}) + if err != nil { + return nil, err + } + if objs := dbx.ParseBullets(text); objs != nil { + names := make([]string, len(objs)) + for i, o := range objs { + names[i] = o.Name + } + return map[string]any{"databases": names}, nil + } + return map[string]any{"databases": []string{}, "raw": text}, nil +} + +func (s *PlatformService) ListDBXTables(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, database, schema string) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, false) + if err != nil { + return nil, err + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name)} + if database != "" { + args["database"] = database + } + if schema != "" { + args["schema"] = schema + } + text, err := s.dbx.Call(ctx, "dbx_list_tables", args) + if err != nil { + return nil, err + } + if objs := dbx.ParseBullets(text); objs != nil { + return map[string]any{"tables": objs}, nil + } + if t, _ := dbx.ParseMDTable(text); t != nil { + return map[string]any{"table": t}, nil + } + return map[string]any{"tables": []dbx.DBObject{}, "raw": text}, nil +} + +func (s *PlatformService) DescribeDBXTable(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, table, database, schema string) (map[string]any, error) { + if table == "" { + return nil, errors.New("table required") + } + row, err := s.connForTool(ctx, actor, projectID, connectionID, false) + if err != nil { + return nil, err + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name), "table": table} + if database != "" { + args["database"] = database + } + if schema != "" { + args["schema"] = schema + } + text, err := s.dbx.Call(ctx, "dbx_describe_table", args) + if err != nil { + return nil, err + } + if t, _ := dbx.ParseMDTable(text); t != nil { + return map[string]any{"table": t}, nil + } + return map[string]any{"raw": text}, nil +} + +func (s *PlatformService) DBXSchemaContext(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, database, schema string) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, false) + if err != nil { + return nil, err + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name)} + if database != "" { + args["database"] = database + } + if schema != "" { + args["schema"] = schema + } + text, err := s.dbx.Call(ctx, "dbx_get_schema_context", args) + if err != nil { + return nil, err + } + return map[string]any{"context": text}, nil +} + +func (s *PlatformService) ExecuteDBXQuery(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, sql, database string) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, true) + if err != nil { + return nil, err + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name), "sql": sql} + if database != "" { + args["database"] = database + } + text, err := s.dbx.Call(ctx, "dbx_execute_query", args) + if err != nil { + return nil, err + } + if t, _ := dbx.ParseMDTable(text); t != nil { + return map[string]any{"table": t}, nil + } + return map[string]any{"raw": text}, nil +} + +func (s *PlatformService) ExecuteDBXRedis(ctx context.Context, actor *models.Actor, projectID, connectionID uuid.UUID, command string, dbIndex *int) (map[string]any, error) { + row, err := s.connForTool(ctx, actor, projectID, connectionID, true) + if err != nil { + return nil, err + } + args := map[string]any{"connection_name": dbxConnectionName(projectID, row.Name), "command": command} + if dbIndex != nil { + args["db"] = *dbIndex + } + text, err := s.dbx.Call(ctx, "dbx_execute_redis_command", args) + if err != nil { + return nil, err + } + return map[string]any{"output": text}, nil +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 8841704..3a42dc9 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -19,16 +19,19 @@ import ( "github.com/jackc/pgx/v5/pgtype" db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/dbx" "github.com/tdvorak/primora/apps/backend/internal/models" "github.com/tdvorak/primora/apps/backend/internal/repositories" "github.com/tdvorak/primora/apps/backend/internal/storage" ) type PlatformService struct { - repo *repositories.CoreRepository - store *storage.LocalStore - mailer *Mailer - publicURL string + repo *repositories.CoreRepository + store *storage.LocalStore + mailer *Mailer + publicURL string + dbx *dbx.Client + managedDBSeeds []ManagedDBSeed } type BootstrapInput struct { @@ -206,8 +209,8 @@ type InvitationSummary struct { Status string `json:"status"` } -func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string) *PlatformService { - return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL} +func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService { + return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds} } func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) { diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index d01eb38..0a98f77 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Projects - name: Storage - name: Collections + - name: Databases paths: /health/liveness: get: @@ -829,6 +830,244 @@ paths: responses: "204": description: Document deleted + /db/status: + get: + tags: [Databases] + operationId: getDbxStatus + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + "200": + description: DBX subprocess availability + content: + application/json: + schema: + $ref: "#/components/schemas/DBXStatus" + /projects/{projectID}/db-connections: + get: + tags: [Databases] + operationId: listDbConnections + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Saved database connections for the project + content: + application/json: + schema: + $ref: "#/components/schemas/DBConnectionListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Databases] + operationId: createDbConnection + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDBConnectionRequest" + responses: + "201": + description: Connection created + content: + application/json: + schema: + $ref: "#/components/schemas/DBConnection" + "401": + $ref: "#/components/responses/ErrorResponse" + "409": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}: + delete: + tags: [Databases] + operationId: deleteDbConnection + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + responses: + "204": + description: Connection deleted + "404": + $ref: "#/components/responses/ErrorResponse" + "409": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/test: + post: + tags: [Databases] + operationId: testDbConnection + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + responses: + "200": + description: Connectivity probe result + content: + application/json: + schema: + $ref: "#/components/schemas/DBConnectionTestResult" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/databases: + get: + tags: [Databases] + operationId: listDbDatabases + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + responses: + "200": + description: Databases reachable through the connection + content: + application/json: + schema: + $ref: "#/components/schemas/DBDatabaseListResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/tables: + get: + tags: [Databases] + operationId: listDbTables + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + - $ref: "#/components/parameters/DatabaseQuery" + - $ref: "#/components/parameters/SchemaQuery" + responses: + "200": + description: Tables, views, or collections in the connection + content: + application/json: + schema: + $ref: "#/components/schemas/DBTableListResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/describe: + get: + tags: [Databases] + operationId: describeDbTable + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + - $ref: "#/components/parameters/TableQuery" + - $ref: "#/components/parameters/DatabaseQuery" + - $ref: "#/components/parameters/SchemaQuery" + responses: + "200": + description: Column definitions for a table + content: + application/json: + schema: + $ref: "#/components/schemas/DBDescribeResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/schema: + get: + tags: [Databases] + operationId: getDbSchemaContext + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + - $ref: "#/components/parameters/DatabaseQuery" + - $ref: "#/components/parameters/SchemaQuery" + responses: + "200": + description: Compact schema context for the connection + content: + application/json: + schema: + $ref: "#/components/schemas/DBSchemaContextResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/query: + post: + tags: [Databases] + operationId: executeDbQuery + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DBQueryRequest" + responses: + "200": + description: Query result (at most 100 rows) + content: + application/json: + schema: + $ref: "#/components/schemas/DBQueryResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/db-connections/{connectionID}/redis: + post: + tags: [Databases] + operationId: executeDbRedisCommand + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/ConnectionID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DBRedisRequest" + responses: + "200": + description: Redis command output + content: + application/json: + schema: + $ref: "#/components/schemas/DBRedisResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + "502": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -934,6 +1173,31 @@ components: required: true schema: type: string + ConnectionID: + name: connectionID + in: path + required: true + schema: + type: string + format: uuid + DatabaseQuery: + name: database + in: query + required: false + schema: + type: string + SchemaQuery: + name: schema + in: query + required: false + schema: + type: string + TableQuery: + name: table + in: query + required: true + schema: + type: string responses: ErrorResponse: description: Error response @@ -1611,3 +1875,171 @@ components: items: $ref: "#/components/schemas/Document" required: [items] + DBXStatus: + type: object + properties: + available: + type: boolean + command: + type: string + reason: + type: string + install: + type: string + required: [available] + DBConnection: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + db_type: + type: string + host: + type: string + port: + type: integer + nullable: true + database: + type: string + username: + type: string + ssl: + type: boolean + nullable: true + is_managed: + type: boolean + has_password: + type: boolean + required: [id, project_id, name, db_type, host, is_managed, has_password] + CreateDBConnectionRequest: + type: object + properties: + name: + type: string + db_type: + type: string + enum: [postgres, mysql, sqlite, redis, mongodb, sqlserver, duckdb, clickhouse, cassandra] + host: + type: string + port: + type: integer + database: + type: string + username: + type: string + password: + type: string + ssl: + type: boolean + driver_profile: + type: string + required: [name, db_type, host] + DBConnectionListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/DBConnection" + required: [items] + DBConnectionTestResult: + type: object + properties: + ok: + type: boolean + error: + type: string + required: [ok] + DBObject: + type: object + properties: + name: + type: string + kind: + type: string + required: [name, kind] + DBMDTable: + type: object + properties: + columns: + type: array + items: + type: string + rows: + type: array + items: + type: array + items: + type: string + note: + type: string + required: [columns, rows] + DBDatabaseListResponse: + type: object + properties: + databases: + type: array + items: + type: string + raw: + type: string + required: [databases] + DBTableListResponse: + type: object + properties: + tables: + type: array + items: + $ref: "#/components/schemas/DBObject" + table: + $ref: "#/components/schemas/DBMDTable" + raw: + type: string + DBDescribeResponse: + type: object + properties: + table: + $ref: "#/components/schemas/DBMDTable" + raw: + type: string + DBSchemaContextResponse: + type: object + properties: + context: + type: string + required: [context] + DBQueryRequest: + type: object + properties: + sql: + type: string + database: + type: string + required: [sql] + DBQueryResponse: + type: object + properties: + table: + $ref: "#/components/schemas/DBMDTable" + raw: + type: string + DBRedisRequest: + type: object + properties: + command: + type: string + db: + type: integer + required: [command] + DBRedisResponse: + type: object + properties: + output: + type: string + required: [output] diff --git a/apps/cli/package.json b/apps/cli/package.json index 4c38c23..67eca38 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@primora/cli", - "version": "0.2.0", + "version": "0.3.0", "description": "Primora command-line client", "type": "module", "bin": { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index dc95442..c112970 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@primora/frontend", - "version": "0.2.0", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index dc341e7..6abe791 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -39,6 +39,7 @@ import { AuthPage, type AuthUser, CollectionsPage, + DatabasesPage, LoginPage, MembersPage, ProjectsPage, @@ -51,6 +52,7 @@ import { IconProjects, IconMembers, IconStorage, + IconDatabases, IconCollections, IconAudit, IconAuth, @@ -1408,6 +1410,7 @@ export default function App() { { id: "nav-dashboard", label: "Go to Overview", category: "Navigate", icon: , keywords: ["home", "dashboard"], action: () => setActiveView("dashboard") }, { id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: , keywords: ["project"], action: () => setActiveView("projects") }, { id: "nav-members", label: "Go to Members", category: "Navigate", icon: , keywords: ["team", "users", "invite"], action: () => setActiveView("members") }, + { id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: , keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: , keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: , keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: , keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, @@ -1613,6 +1616,14 @@ export default function App() { /> + + + + e.id !== "collections"); const workspaceNav: NavEntry[] = [ { id: "projects", label: "Projects", icon: IconProjects }, @@ -80,6 +83,7 @@ const viewTitle: Record = { members: "Members", storage: "Storage", collections: "Collections", + databases: "Databases", auth: "Authentication", audit: "Audit log", settings: "Settings", diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index ebedb94..6b34166 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -63,6 +63,24 @@ export const IconCollections = icon(() => ( )); +export const IconDatabases = icon(() => ( + <> + + + + + + )); + +export const IconTerminal = icon(() => ( + <> + + + + )); + +export const IconPlay = icon(() => ()); + export const IconAuth = icon(() => ( <> diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index 6e1898f..3906688 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -14,6 +14,7 @@ import type { Bucket, BucketObject, Collection, + DBConnection, Document, MeResponse, OrganizationSummary, @@ -323,6 +324,52 @@ let documents: Document[] = [ }, ]; +let dbConnections: DBConnection[] = [ + { + id: "dbc-1", + project_id: "proj-1", + name: "platform-postgres", + db_type: "postgres", + host: "postgres", + port: 5432, + database: "primora", + username: "primora", + ssl: null, + is_managed: true, + has_password: true, + }, + { + id: "dbc-2", + project_id: "proj-1", + name: "platform-dragonfly", + db_type: "redis", + host: "dragonfly", + port: 6379, + database: "", + username: "", + ssl: null, + is_managed: true, + has_password: false, + }, +]; + +const demoTables = ["users", "projects", "api_keys", "audit_logs", "buckets"]; + +const demoColumns: Record = { + users: [ + ["id", "uuid", "NO", "PK"], + ["email", "text", "NO", "UNIQUE"], + ["name", "text", "YES", ""], + ["created_at", "timestamptz", "NO", ""], + ], + projects: [ + ["id", "uuid", "NO", "PK"], + ["organization_id", "uuid", "NO", "FK"], + ["name", "text", "NO", ""], + ["slug", "text", "NO", "UNIQUE"], + ], +}; + const pushAudit = (action: string, resourceType: string, resourceId: string, metadata: Record = {}) => { auditLogs = [ { @@ -715,6 +762,101 @@ class DemoService { } async bootstrapPlatform() { await this.delay(); return {}; } + + /* ---- databases (DBX) ---- */ + + async getDbxStatus() { + await this.delay(); + return { available: true, command: "dbx-mcp" }; + } + + async listDbConnections() { + await this.delay(); + return { items: dbConnections }; + } + + async createDbConnection(data: { requestBody?: { name?: string; db_type?: string; host?: string; port?: number; database?: string; username?: string; password?: string; ssl?: boolean } }) { + await this.delay(); + const req = data.requestBody ?? {}; + const conn: DBConnection = { + id: `dbc-${Date.now()}`, + project_id: "proj-1", + name: req.name ?? "connection", + db_type: req.db_type ?? "postgres", + host: req.host ?? "", + port: req.port ?? null, + database: req.database ?? "", + username: req.username ?? "", + ssl: req.ssl ?? null, + is_managed: false, + has_password: !!req.password, + }; + dbConnections = [...dbConnections, conn]; + pushAudit("db_connection.created", "db_connection", conn.id); + return conn; + } + + async deleteDbConnection(data: { connectionId?: string }) { + await this.delay(); + dbConnections = dbConnections.filter((c) => c.id !== data.connectionId); + pushAudit("db_connection.deleted", "db_connection", data.connectionId ?? ""); + return {}; + } + + async testDbConnection() { + await this.delay(); + return { ok: true }; + } + + async listDbDatabases() { + await this.delay(); + return { databases: ["primora", "postgres"] }; + } + + async listDbTables() { + await this.delay(); + return { + tables: demoTables.map((t) => ({ name: t, kind: "BASE TABLE" })), + }; + } + + async describeDbTable(data: { table?: string }) { + await this.delay(); + return { + table: { + columns: ["Column", "Type", "Nullable", "Key"], + rows: demoColumns[data.table ?? ""] ?? [ + ["id", "uuid", "NO", "PK"], + ["created_at", "timestamptz", "NO", ""], + ], + }, + }; + } + + async getDbSchemaContext() { + await this.delay(); + return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" }; + } + + async executeDbQuery() { + await this.delay(); + return { + table: { + columns: ["id", "email", "created_at"], + rows: [ + ["a4f3…", "demo@primora.dev", "2024-01-01 00:00:00"], + ["b8c1…", "alice@example.com", "2024-01-02 00:00:00"], + ], + note: "2 rows in 4ms (demo)", + }, + }; + } + + async executeDbRedisCommand(data: { requestBody?: { command?: string } }) { + await this.delay(); + const cmd = data.requestBody?.command ?? ""; + return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" }; + } } export const demoService = new DemoService(); diff --git a/apps/frontend/src/pages/DatabasesPage.tsx b/apps/frontend/src/pages/DatabasesPage.tsx new file mode 100644 index 0000000..2531edf --- /dev/null +++ b/apps/frontend/src/pages/DatabasesPage.tsx @@ -0,0 +1,847 @@ +import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { + DatabasesService, + CreateDBConnectionRequest, + type DBConnection, + type DBMDTable, + type DBObject, + type DBQueryResponse, + type DBXStatus, +} from "@primora/api-client"; +import { demoService } from "../lib/demo-mode"; +import { Badge } from "../components/Badge"; +import { Modal, ModalFooter } from "../components/Modal"; +import { Input, Select, Textarea } from "../components/Input"; +import { + IconChevronRight, + IconDatabases, + IconPlay, + IconPlus, + IconRefresh, + IconTerminal, + IconTrash, + IconZap, +} from "../components/Icons"; + +interface DatabasesPageProps { + projectID?: string; + canManage: boolean; + demoMode: boolean; +} + +type WorkspaceTab = "browse" | "query" | "console"; + +interface ConnForm { + name: string; + db_type: string; + host: string; + port: string; + database: string; + username: string; + password: string; + ssl: boolean; +} + +const emptyForm: ConnForm = { + name: "", + db_type: "postgres", + host: "", + port: "", + database: "", + username: "", + password: "", + ssl: false, +}; + +const dbTypes = Object.values(CreateDBConnectionRequest.db_type); +const sqlish = (t?: string) => t !== "redis"; + +const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/* the demo service mirrors the generated DatabasesService call shapes */ +const svc = (demo: boolean) => + (demo ? (demoService as unknown as typeof DatabasesService) : DatabasesService); + +function ResultTable(props: { table: DBMDTable; onRow?: (row: string[]) => void }) { + return ( +
+
+ + + + {(col) => } + + + + + + + } + > + {(row) => ( + props.onRow?.(row)} + > + + {(cell) => ( + + )} + + + )} + + +
{col}
+ No rows returned +
+ {cell} +
+
+ +

{props.table.note}

+
+
+ ); +} + +export function DatabasesPage(props: DatabasesPageProps) { + const [status, setStatus] = createSignal(null); + const [conns, setConns] = createSignal([]); + const [activeID, setActiveID] = createSignal(); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(""); + const [message, setMessage] = createSignal(""); + + const [tab, setTab] = createSignal("browse"); + const [databases, setDatabases] = createSignal([]); + const [selectedDB, setSelectedDB] = createSignal(""); + const [schemaInput, setSchemaInput] = createSignal(""); + const [appliedSchema, setAppliedSchema] = createSignal(""); + const [tables, setTables] = createSignal(null); + const [tablesRaw, setTablesRaw] = createSignal(""); + const [browseBusy, setBrowseBusy] = createSignal(false); + const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>(); + const [describeBusy, setDescribeBusy] = createSignal(false); + + const [sql, setSql] = createSignal("SELECT 1;"); + const [queryResult, setQueryResult] = createSignal(); + const [queryBusy, setQueryBusy] = createSignal(false); + + const [redisCmd, setRedisCmd] = createSignal(""); + const [consoleLog, setConsoleLog] = createSignal<{ cmd: string; out: string }[]>([]); + const [consoleBusy, setConsoleBusy] = createSignal(false); + + const [detailRow, setDetailRow] = createSignal<{ columns: string[]; row: string[] }>(); + const [addOpen, setAddOpen] = createSignal(false); + const [confirmDelete, setConfirmDelete] = createSignal(); + const [form, setForm] = createSignal({ ...emptyForm }); + const [addBusy, setAddBusy] = createSignal(false); + const [testBusy, setTestBusy] = createSignal(""); + + const active = createMemo(() => conns().find((c) => c.id === activeID())); + + const load = async () => { + if (!props.projectID) return; + setLoading(true); + setError(""); + try { + const [st, list] = await Promise.all([ + svc(props.demoMode).getDbxStatus(), + svc(props.demoMode).listDbConnections({ projectId: props.projectID }), + ]); + setStatus(st); + setConns(list.items); + const first = list.items.find((c) => c.id === activeID()) ?? list.items[0]; + if (first) void select(first.id); + } catch (e) { + setError(err(e)); + } finally { + setLoading(false); + } + }; + + const loadTables = async (conn: DBConnection, database: string, schema: string) => { + setBrowseBusy(true); + try { + const r = await svc(props.demoMode).listDbTables({ + projectId: props.projectID!, + connectionId: conn.id, + database: database || undefined, + schema: schema || undefined, + }); + setTables(r.tables ?? null); + setTablesRaw(r.raw ?? ""); + } catch (e) { + setError(err(e)); + setTables(null); + } finally { + setBrowseBusy(false); + } + }; + + const select = async (id: string) => { + setActiveID(id); + setError(""); + setDatabases([]); + setSelectedDB(""); + setSchemaInput(""); + setAppliedSchema(""); + setTables(null); + setTablesRaw(""); + setDescribed(undefined); + setQueryResult(undefined); + setConsoleLog([]); + const conn = conns().find((c) => c.id === id); + if (!conn) return; + if (!sqlish(conn.db_type)) { + setTab("console"); + return; + } + setTab("browse"); + setBrowseBusy(true); + try { + const dbs = await svc(props.demoMode).listDbDatabases({ + projectId: props.projectID!, + connectionId: conn.id, + }); + setDatabases(dbs.databases); + const db = + conn.database && dbs.databases.includes(conn.database) + ? conn.database + : (dbs.databases[0] ?? conn.database ?? ""); + setSelectedDB(db); + await loadTables(conn, db, ""); + } catch (e) { + setError(err(e)); + } finally { + setBrowseBusy(false); + } + }; + + const describe = async (name: string) => { + const conn = active(); + if (!conn) return; + setDescribeBusy(true); + try { + const r = await svc(props.demoMode).describeDbTable({ + projectId: props.projectID!, + connectionId: conn.id, + table: name, + database: selectedDB() || undefined, + schema: appliedSchema() || undefined, + }); + setDescribed({ name, table: r.table, raw: r.raw }); + } catch (e) { + setError(err(e)); + } finally { + setDescribeBusy(false); + } + }; + + const runQuery = async () => { + const conn = active(); + if (!conn || !sql().trim()) return; + setQueryBusy(true); + setError(""); + try { + const r = await svc(props.demoMode).executeDbQuery({ + projectId: props.projectID!, + connectionId: conn.id, + requestBody: { sql: sql(), database: selectedDB() || undefined }, + }); + setQueryResult(r); + } catch (e) { + setError(err(e)); + } finally { + setQueryBusy(false); + } + }; + + const runRedis = async () => { + const conn = active(); + const cmd = redisCmd().trim(); + if (!conn || !cmd) return; + setConsoleBusy(true); + setError(""); + try { + const r = await svc(props.demoMode).executeDbRedisCommand({ + projectId: props.projectID!, + connectionId: conn.id, + requestBody: { command: cmd }, + }); + setConsoleLog((l) => [{ cmd, out: r.output }, ...l]); + setRedisCmd(""); + } catch (e) { + setError(err(e)); + } finally { + setConsoleBusy(false); + } + }; + + const createConnection = async () => { + const f = form(); + if (!f.name.trim() || !f.host.trim()) return; + setAddBusy(true); + try { + await svc(props.demoMode).createDbConnection({ + projectId: props.projectID!, + requestBody: { + name: f.name.trim(), + db_type: f.db_type as CreateDBConnectionRequest.db_type, + host: f.host.trim(), + port: f.port ? Number(f.port) : undefined, + database: f.database.trim() || undefined, + username: f.username.trim() || undefined, + password: f.password || undefined, + ssl: f.ssl || undefined, + }, + }); + setAddOpen(false); + setForm({ ...emptyForm }); + setMessage("Connection added"); + await load(); + } catch (e) { + setError(err(e)); + } finally { + setAddBusy(false); + } + }; + + const removeConnection = async (id: string) => { + try { + await svc(props.demoMode).deleteDbConnection({ + projectId: props.projectID!, + connectionId: id, + }); + setMessage("Connection removed"); + if (activeID() === id) setActiveID(undefined); + await load(); + } catch (e) { + setError(err(e)); + } + }; + + const testConnection = async (conn: DBConnection) => { + setTestBusy(conn.id); + setError(""); + setMessage(""); + try { + const r = await svc(props.demoMode).testDbConnection({ + projectId: props.projectID!, + connectionId: conn.id, + }); + if (r.ok) setMessage(`${conn.name}: connection OK`); + else setError(`${conn.name}: ${r.error ?? "connection failed"}`); + } catch (e) { + setError(`${conn.name}: ${err(e)}`); + } finally { + setTestBusy(""); + } + }; + + createEffect(() => { + if (props.projectID) void load(); + }); + + return ( +
+ + + +
+ DBX is not available: {status()!.reason ?? "unknown reason"} + + — install: {status()!.install} + +
+
+ +
{error()}
+
+ +
{message()}
+
+ +
+ {/* connections */} +
+
+ Connections +
+
+ +

+ {loading() ? "Loading…" : "No connections yet"} +

+
+ } + > + {(conn) => ( + + )} + +
+
+ + {/* workspace */} +
+ +
+ +
+

Select a connection

+

+ Choose a connection on the left to browse its databases and tables. +

+
+ } + > +
+
+
+
+ {active()!.name} + + managed + +
+ + {active()!.db_type} + {active()!.host ? ` · ${active()!.host}` : ""} + {active()!.port ? `:${active()!.port}` : ""} + {active()!.database ? ` / ${active()!.database}` : ""} + +
+
+ + + + +
+
+
+ + {/* tabs */} +
+ + + + + + + +
+ + {/* browse tab */} + +
+
+ Database + +
+
+ Schema + setSchemaInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setAppliedSchema(schemaInput().trim()); + setDescribed(undefined); + void loadTables(active()!, selectedDB(), schemaInput().trim()); + } + }} + /> +
+ +
+ +
+ + + + + + + + + + + + } + > + {(t) => ( + void describe(t.name)} + > + + + + + )} + + +
TableKind +
+ {browseBusy() + ? "Loading…" + : tablesRaw() || + "No tables found. Pick a database or enter a schema name."} +
{t.name}{t.kind} + +
+
+ + +
+
+ {described()!.name} + + loading… + +
+ + {described()!.raw ?? "No column metadata returned."} + + } + > + {(t) => } + +
+
+
+ + {/* query tab */} + +
+