mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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:
1 parent
420502eec1
commit
627bd89d5e
71 files changed
+3685
-49
No files matched your search
+21
-2
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user