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 <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 14:54:33 +02:00
1 parent 420502eec1
commit 627bd89d5e
71 files changed
+3685 -49

No files matched your search

+21 -2
View File
@@ -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
@@ -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;
@@ -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 *;
+15 -9
View File
@@ -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
)
+30 -18
View File
@@ -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=
+42 -1
View File
@@ -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)
+7
View File
@@ -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
}
@@ -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
}
@@ -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"`
@@ -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)
+318
View File
@@ -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
}
+234
View File
@@ -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())
}
+11
View File
@@ -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)
+414
View File
@@ -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
}
@@ -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) {
+432
View File
@@ -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]