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
@@ -2,6 +2,16 @@
|
||||
|
||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||
|
||||
## [0.3.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Databases (DBX integration)** — the Go backend keeps `dbx-mcp` as a persistent stdio subprocess (`internal/dbx`, lazy start, one retry on failure) and exposes its tools as REST under `/projects/:id/db-connections/*` (list/create/delete/test, databases, tables, describe, schema context, SQL query, Redis command). DBX tool/subprocess failures surface as 502.
|
||||
- **Connection registry** — `core.db_connections` (migration 00003). Credentials are stored server-side and never serialized to the browser (`has_password` flag only). Non-default `ssl`, `driver_profile` supported.
|
||||
- **First-party connections** — `platform-postgres` and `platform-dragonfly` auto-register on every project (derived from `DATABASE_URL`/`DRAGONFLY_URL`) and cannot be deleted (409 `managed_connection`).
|
||||
- **Databases page** — SolidJS view with connection list, database/schema/table browser, column inspector, SQL editor (⌘/Ctrl+Enter), result grid with row-detail modal, and a Redis console. Demo mode stubs included.
|
||||
- **Backend image now Debian-based** — `dbx-mcp` requires glibc; the Dockerfile installs the pinned `packages-v0.4.88` release with SHA256 verification.
|
||||
|
||||
## [0.2.0] - 2026-09-15
|
||||
|
||||
### Added
|
||||
|
||||
+6
-4
@@ -46,14 +46,16 @@ in a 25 MB MCP server. Primora does not embed DBX; the Go backend keeps
|
||||
`@dbx-app/mcp-server` as a persistent stdio subprocess and exposes its tools
|
||||
as REST (pattern already proven in IMS's `internal/app/dbx.go`).
|
||||
|
||||
- **Connection registry** — per-project saved connections (Postgres, MySQL,
|
||||
- **Connection registry** ✅ — per-project saved connections (Postgres, MySQL,
|
||||
SQLite, Redis, MongoDB, …). Credentials stored server-side, never sent to
|
||||
the browser. `core.db_connections` table + endpoints.
|
||||
- **Databases page** — connection list → database/schema/table browser →
|
||||
- **Databases page** ✅ — connection list → database/schema/table browser →
|
||||
column/type/key inspector.
|
||||
- **SQL editor** — query editor with ⌘/Ctrl+Enter, result grid, row detail.
|
||||
- **SQL editor** ✅ — query editor with ⌘/Ctrl+Enter, result grid, row detail.
|
||||
- **Redis console** ✅ — command input + output history for redis-type
|
||||
connections.
|
||||
- **Visual overview** — schema graph: tables as nodes, foreign keys as edges.
|
||||
- **First-party connections** — the platform's own Postgres and Dragonfly
|
||||
- **First-party connections** ✅ — the platform's own Postgres and Dragonfly
|
||||
auto-register as connections on every project, so "put Postgres in it and
|
||||
it runs Postgres" works out of the box.
|
||||
- **DB-to-DB links** — DBX data transfer between two saved connections
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/auth",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+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]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Primora command-line client",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
AuthPage,
|
||||
type AuthUser,
|
||||
CollectionsPage,
|
||||
DatabasesPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
IconProjects,
|
||||
IconMembers,
|
||||
IconStorage,
|
||||
IconDatabases,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -1408,6 +1410,7 @@ export default function App() {
|
||||
{ id: "nav-dashboard", label: "Go to Overview", category: "Navigate", icon: <IconOverview class="w-4 h-4" />, keywords: ["home", "dashboard"], action: () => setActiveView("dashboard") },
|
||||
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <IconProjects class="w-4 h-4" />, keywords: ["project"], action: () => setActiveView("projects") },
|
||||
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
|
||||
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
|
||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||
@@ -1613,6 +1616,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "databases"}>
|
||||
<DatabasesPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconMembers,
|
||||
IconStorage,
|
||||
IconCollections,
|
||||
IconDatabases,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
IconSettings,
|
||||
@@ -24,6 +25,7 @@ export type ViewType =
|
||||
| "members"
|
||||
| "storage"
|
||||
| "collections"
|
||||
| "databases"
|
||||
| "auth"
|
||||
| "audit"
|
||||
| "settings";
|
||||
@@ -37,14 +39,15 @@ interface NavEntry {
|
||||
|
||||
const projectNav: NavEntry[] = [
|
||||
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
||||
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
||||
];
|
||||
|
||||
// Instagram-style bottom bar caps at five slots — Audit stays reachable via Menu.
|
||||
const bottomNav = projectNav.slice(0, 4);
|
||||
// Instagram-style bottom bar caps at five slots — Collections and Audit stay reachable via Menu.
|
||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections");
|
||||
|
||||
const workspaceNav: NavEntry[] = [
|
||||
{ id: "projects", label: "Projects", icon: IconProjects },
|
||||
@@ -80,6 +83,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
members: "Members",
|
||||
storage: "Storage",
|
||||
collections: "Collections",
|
||||
databases: "Databases",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
|
||||
@@ -63,6 +63,24 @@ export const IconCollections = icon(() => (
|
||||
</>
|
||||
));
|
||||
|
||||
export const IconDatabases = icon(() => (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 9v12" />
|
||||
<path d="M15 9v12" />
|
||||
</>
|
||||
));
|
||||
|
||||
export const IconTerminal = icon(() => (
|
||||
<>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</>
|
||||
));
|
||||
|
||||
export const IconPlay = icon(() => (<polygon points="6 3 20 12 6 21 6 3" />));
|
||||
|
||||
export const IconAuth = icon(() => (
|
||||
<>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Bucket,
|
||||
BucketObject,
|
||||
Collection,
|
||||
DBConnection,
|
||||
Document,
|
||||
MeResponse,
|
||||
OrganizationSummary,
|
||||
@@ -323,6 +324,52 @@ let documents: Document[] = [
|
||||
},
|
||||
];
|
||||
|
||||
let dbConnections: DBConnection[] = [
|
||||
{
|
||||
id: "dbc-1",
|
||||
project_id: "proj-1",
|
||||
name: "platform-postgres",
|
||||
db_type: "postgres",
|
||||
host: "postgres",
|
||||
port: 5432,
|
||||
database: "primora",
|
||||
username: "primora",
|
||||
ssl: null,
|
||||
is_managed: true,
|
||||
has_password: true,
|
||||
},
|
||||
{
|
||||
id: "dbc-2",
|
||||
project_id: "proj-1",
|
||||
name: "platform-dragonfly",
|
||||
db_type: "redis",
|
||||
host: "dragonfly",
|
||||
port: 6379,
|
||||
database: "",
|
||||
username: "",
|
||||
ssl: null,
|
||||
is_managed: true,
|
||||
has_password: false,
|
||||
},
|
||||
];
|
||||
|
||||
const demoTables = ["users", "projects", "api_keys", "audit_logs", "buckets"];
|
||||
|
||||
const demoColumns: Record<string, string[][]> = {
|
||||
users: [
|
||||
["id", "uuid", "NO", "PK"],
|
||||
["email", "text", "NO", "UNIQUE"],
|
||||
["name", "text", "YES", ""],
|
||||
["created_at", "timestamptz", "NO", ""],
|
||||
],
|
||||
projects: [
|
||||
["id", "uuid", "NO", "PK"],
|
||||
["organization_id", "uuid", "NO", "FK"],
|
||||
["name", "text", "NO", ""],
|
||||
["slug", "text", "NO", "UNIQUE"],
|
||||
],
|
||||
};
|
||||
|
||||
const pushAudit = (action: string, resourceType: string, resourceId: string, metadata: Record<string, unknown> = {}) => {
|
||||
auditLogs = [
|
||||
{
|
||||
@@ -715,6 +762,101 @@ class DemoService {
|
||||
}
|
||||
|
||||
async bootstrapPlatform() { await this.delay(); return {}; }
|
||||
|
||||
/* ---- databases (DBX) ---- */
|
||||
|
||||
async getDbxStatus() {
|
||||
await this.delay();
|
||||
return { available: true, command: "dbx-mcp" };
|
||||
}
|
||||
|
||||
async listDbConnections() {
|
||||
await this.delay();
|
||||
return { items: dbConnections };
|
||||
}
|
||||
|
||||
async createDbConnection(data: { requestBody?: { name?: string; db_type?: string; host?: string; port?: number; database?: string; username?: string; password?: string; ssl?: boolean } }) {
|
||||
await this.delay();
|
||||
const req = data.requestBody ?? {};
|
||||
const conn: DBConnection = {
|
||||
id: `dbc-${Date.now()}`,
|
||||
project_id: "proj-1",
|
||||
name: req.name ?? "connection",
|
||||
db_type: req.db_type ?? "postgres",
|
||||
host: req.host ?? "",
|
||||
port: req.port ?? null,
|
||||
database: req.database ?? "",
|
||||
username: req.username ?? "",
|
||||
ssl: req.ssl ?? null,
|
||||
is_managed: false,
|
||||
has_password: !!req.password,
|
||||
};
|
||||
dbConnections = [...dbConnections, conn];
|
||||
pushAudit("db_connection.created", "db_connection", conn.id);
|
||||
return conn;
|
||||
}
|
||||
|
||||
async deleteDbConnection(data: { connectionId?: string }) {
|
||||
await this.delay();
|
||||
dbConnections = dbConnections.filter((c) => c.id !== data.connectionId);
|
||||
pushAudit("db_connection.deleted", "db_connection", data.connectionId ?? "");
|
||||
return {};
|
||||
}
|
||||
|
||||
async testDbConnection() {
|
||||
await this.delay();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listDbDatabases() {
|
||||
await this.delay();
|
||||
return { databases: ["primora", "postgres"] };
|
||||
}
|
||||
|
||||
async listDbTables() {
|
||||
await this.delay();
|
||||
return {
|
||||
tables: demoTables.map((t) => ({ name: t, kind: "BASE TABLE" })),
|
||||
};
|
||||
}
|
||||
|
||||
async describeDbTable(data: { table?: string }) {
|
||||
await this.delay();
|
||||
return {
|
||||
table: {
|
||||
columns: ["Column", "Type", "Nullable", "Key"],
|
||||
rows: demoColumns[data.table ?? ""] ?? [
|
||||
["id", "uuid", "NO", "PK"],
|
||||
["created_at", "timestamptz", "NO", ""],
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getDbSchemaContext() {
|
||||
await this.delay();
|
||||
return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" };
|
||||
}
|
||||
|
||||
async executeDbQuery() {
|
||||
await this.delay();
|
||||
return {
|
||||
table: {
|
||||
columns: ["id", "email", "created_at"],
|
||||
rows: [
|
||||
["a4f3…", "[email protected]", "2024-01-01 00:00:00"],
|
||||
["b8c1…", "[email protected]", "2024-01-02 00:00:00"],
|
||||
],
|
||||
note: "2 rows in 4ms (demo)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async executeDbRedisCommand(data: { requestBody?: { command?: string } }) {
|
||||
await this.delay();
|
||||
const cmd = data.requestBody?.command ?? "";
|
||||
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
|
||||
}
|
||||
}
|
||||
|
||||
export const demoService = new DemoService();
|
||||
@@ -0,0 +1,847 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
DatabasesService,
|
||||
CreateDBConnectionRequest,
|
||||
type DBConnection,
|
||||
type DBMDTable,
|
||||
type DBObject,
|
||||
type DBQueryResponse,
|
||||
type DBXStatus,
|
||||
} from "@primora/api-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconDatabases,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTerminal,
|
||||
IconTrash,
|
||||
IconZap,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface DatabasesPageProps {
|
||||
projectID?: string;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceTab = "browse" | "query" | "console";
|
||||
|
||||
interface ConnForm {
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: ConnForm = {
|
||||
name: "",
|
||||
db_type: "postgres",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
ssl: false,
|
||||
};
|
||||
|
||||
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
|
||||
const sqlish = (t?: string) => t !== "redis";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/* the demo service mirrors the generated DatabasesService call shapes */
|
||||
const svc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof DatabasesService) : DatabasesService);
|
||||
|
||||
function ResultTable(props: { table: DBMDTable; onRow?: (row: string[]) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<For each={props.table.columns}>{(col) => <th>{col}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={props.table.rows}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={Math.max(1, props.table.columns.length)} class="py-8 text-center text-text-3">
|
||||
No rows returned
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(row) => (
|
||||
<tr
|
||||
class={props.onRow ? "clickable" : ""}
|
||||
onClick={() => props.onRow?.(row)}
|
||||
>
|
||||
<For each={row}>
|
||||
{(cell) => (
|
||||
<td class="font-mono text-xs text-text-1 whitespace-nowrap" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis">
|
||||
{cell}
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Show when={props.table.note}>
|
||||
<p class="px-4 py-2 text-xs text-text-3">{props.table.note}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabasesPage(props: DatabasesPageProps) {
|
||||
const [status, setStatus] = createSignal<DBXStatus | null>(null);
|
||||
const [conns, setConns] = createSignal<DBConnection[]>([]);
|
||||
const [activeID, setActiveID] = createSignal<string>();
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal("");
|
||||
const [message, setMessage] = createSignal("");
|
||||
|
||||
const [tab, setTab] = createSignal<WorkspaceTab>("browse");
|
||||
const [databases, setDatabases] = createSignal<string[]>([]);
|
||||
const [selectedDB, setSelectedDB] = createSignal("");
|
||||
const [schemaInput, setSchemaInput] = createSignal("");
|
||||
const [appliedSchema, setAppliedSchema] = createSignal("");
|
||||
const [tables, setTables] = createSignal<DBObject[] | null>(null);
|
||||
const [tablesRaw, setTablesRaw] = createSignal("");
|
||||
const [browseBusy, setBrowseBusy] = createSignal(false);
|
||||
const [described, setDescribed] = createSignal<{ name: string; table?: DBMDTable; raw?: string }>();
|
||||
const [describeBusy, setDescribeBusy] = createSignal(false);
|
||||
|
||||
const [sql, setSql] = createSignal("SELECT 1;");
|
||||
const [queryResult, setQueryResult] = createSignal<DBQueryResponse>();
|
||||
const [queryBusy, setQueryBusy] = createSignal(false);
|
||||
|
||||
const [redisCmd, setRedisCmd] = createSignal("");
|
||||
const [consoleLog, setConsoleLog] = createSignal<{ cmd: string; out: string }[]>([]);
|
||||
const [consoleBusy, setConsoleBusy] = createSignal(false);
|
||||
|
||||
const [detailRow, setDetailRow] = createSignal<{ columns: string[]; row: string[] }>();
|
||||
const [addOpen, setAddOpen] = createSignal(false);
|
||||
const [confirmDelete, setConfirmDelete] = createSignal<string>();
|
||||
const [form, setForm] = createSignal<ConnForm>({ ...emptyForm });
|
||||
const [addBusy, setAddBusy] = createSignal(false);
|
||||
const [testBusy, setTestBusy] = createSignal("");
|
||||
|
||||
const active = createMemo(() => conns().find((c) => c.id === activeID()));
|
||||
|
||||
const load = async () => {
|
||||
if (!props.projectID) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [st, list] = await Promise.all([
|
||||
svc(props.demoMode).getDbxStatus(),
|
||||
svc(props.demoMode).listDbConnections({ projectId: props.projectID }),
|
||||
]);
|
||||
setStatus(st);
|
||||
setConns(list.items);
|
||||
const first = list.items.find((c) => c.id === activeID()) ?? list.items[0];
|
||||
if (first) void select(first.id);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (conn: DBConnection, database: string, schema: string) => {
|
||||
setBrowseBusy(true);
|
||||
try {
|
||||
const r = await svc(props.demoMode).listDbTables({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
database: database || undefined,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
setTables(r.tables ?? null);
|
||||
setTablesRaw(r.raw ?? "");
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
setTables(null);
|
||||
} finally {
|
||||
setBrowseBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const select = async (id: string) => {
|
||||
setActiveID(id);
|
||||
setError("");
|
||||
setDatabases([]);
|
||||
setSelectedDB("");
|
||||
setSchemaInput("");
|
||||
setAppliedSchema("");
|
||||
setTables(null);
|
||||
setTablesRaw("");
|
||||
setDescribed(undefined);
|
||||
setQueryResult(undefined);
|
||||
setConsoleLog([]);
|
||||
const conn = conns().find((c) => c.id === id);
|
||||
if (!conn) return;
|
||||
if (!sqlish(conn.db_type)) {
|
||||
setTab("console");
|
||||
return;
|
||||
}
|
||||
setTab("browse");
|
||||
setBrowseBusy(true);
|
||||
try {
|
||||
const dbs = await svc(props.demoMode).listDbDatabases({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
});
|
||||
setDatabases(dbs.databases);
|
||||
const db =
|
||||
conn.database && dbs.databases.includes(conn.database)
|
||||
? conn.database
|
||||
: (dbs.databases[0] ?? conn.database ?? "");
|
||||
setSelectedDB(db);
|
||||
await loadTables(conn, db, "");
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBrowseBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const describe = async (name: string) => {
|
||||
const conn = active();
|
||||
if (!conn) return;
|
||||
setDescribeBusy(true);
|
||||
try {
|
||||
const r = await svc(props.demoMode).describeDbTable({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
table: name,
|
||||
database: selectedDB() || undefined,
|
||||
schema: appliedSchema() || undefined,
|
||||
});
|
||||
setDescribed({ name, table: r.table, raw: r.raw });
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setDescribeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runQuery = async () => {
|
||||
const conn = active();
|
||||
if (!conn || !sql().trim()) return;
|
||||
setQueryBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await svc(props.demoMode).executeDbQuery({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
requestBody: { sql: sql(), database: selectedDB() || undefined },
|
||||
});
|
||||
setQueryResult(r);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setQueryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runRedis = async () => {
|
||||
const conn = active();
|
||||
const cmd = redisCmd().trim();
|
||||
if (!conn || !cmd) return;
|
||||
setConsoleBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await svc(props.demoMode).executeDbRedisCommand({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
requestBody: { command: cmd },
|
||||
});
|
||||
setConsoleLog((l) => [{ cmd, out: r.output }, ...l]);
|
||||
setRedisCmd("");
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setConsoleBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createConnection = async () => {
|
||||
const f = form();
|
||||
if (!f.name.trim() || !f.host.trim()) return;
|
||||
setAddBusy(true);
|
||||
try {
|
||||
await svc(props.demoMode).createDbConnection({
|
||||
projectId: props.projectID!,
|
||||
requestBody: {
|
||||
name: f.name.trim(),
|
||||
db_type: f.db_type as CreateDBConnectionRequest.db_type,
|
||||
host: f.host.trim(),
|
||||
port: f.port ? Number(f.port) : undefined,
|
||||
database: f.database.trim() || undefined,
|
||||
username: f.username.trim() || undefined,
|
||||
password: f.password || undefined,
|
||||
ssl: f.ssl || undefined,
|
||||
},
|
||||
});
|
||||
setAddOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
setMessage("Connection added");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setAddBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeConnection = async (id: string) => {
|
||||
try {
|
||||
await svc(props.demoMode).deleteDbConnection({
|
||||
projectId: props.projectID!,
|
||||
connectionId: id,
|
||||
});
|
||||
setMessage("Connection removed");
|
||||
if (activeID() === id) setActiveID(undefined);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (conn: DBConnection) => {
|
||||
setTestBusy(conn.id);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const r = await svc(props.demoMode).testDbConnection({
|
||||
projectId: props.projectID!,
|
||||
connectionId: conn.id,
|
||||
});
|
||||
if (r.ok) setMessage(`${conn.name}: connection OK`);
|
||||
else setError(`${conn.name}: ${r.error ?? "connection failed"}`);
|
||||
} catch (e) {
|
||||
setError(`${conn.name}: ${err(e)}`);
|
||||
} finally {
|
||||
setTestBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.projectID) void load();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Databases</h1>
|
||||
<p class="page-description">
|
||||
Saved connections backed by DBX — browse schemas, inspect tables, and run queries.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => setAddOpen(true)}
|
||||
disabled={!props.canManage}
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Add connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={status() && !status()!.available}>
|
||||
<div class="message message-error">
|
||||
DBX is not available: {status()!.reason ?? "unknown reason"}
|
||||
<Show when={status()!.install}>
|
||||
<span> — install: {status()!.install}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<div class="message message-error">{error()}</div>
|
||||
</Show>
|
||||
<Show when={message()}>
|
||||
<div class="message message-neutral">{message()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="storage-grid">
|
||||
{/* connections */}
|
||||
<div class="card card-flush">
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1rem 0.75rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<span class="card-header-title">Connections</span>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<For
|
||||
each={conns()}
|
||||
fallback={
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-xs text-text-3">
|
||||
{loading() ? "Loading…" : "No connections yet"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(conn) => (
|
||||
<button
|
||||
class={`nav-item w-full ${conn.id === activeID() ? "active" : ""}`}
|
||||
onClick={() => void select(conn.id)}
|
||||
>
|
||||
<IconDatabases class="w-4 h-4" />
|
||||
<span class="flex-1 truncate">{conn.name}</span>
|
||||
<Badge variant={conn.is_managed ? "primary" : "neutral"}>{conn.db_type}</Badge>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* workspace */}
|
||||
<div class="card card-flush" style="min-height:28rem">
|
||||
<Show
|
||||
when={active()}
|
||||
fallback={
|
||||
<div class="empty-state" style="min-height:28rem">
|
||||
<div class="empty-state-icon">
|
||||
<IconDatabases class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">Select a connection</p>
|
||||
<p class="empty-state-description">
|
||||
Choose a connection on the left to browse its databases and tables.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1.25rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="card-header-title">{active()!.name}</span>
|
||||
<Show when={active()!.is_managed}>
|
||||
<Badge variant="primary">managed</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="card-header-description">
|
||||
{active()!.db_type}
|
||||
{active()!.host ? ` · ${active()!.host}` : ""}
|
||||
{active()!.port ? `:${active()!.port}` : ""}
|
||||
{active()!.database ? ` / ${active()!.database}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={() => void testConnection(active()!)}
|
||||
disabled={testBusy() === active()!.id}
|
||||
>
|
||||
<IconZap class="w-3.5 h-3.5" />
|
||||
{testBusy() === active()!.id ? "Testing…" : "Test"}
|
||||
</button>
|
||||
<Show when={!active()!.is_managed}>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Delete connection"
|
||||
onClick={() => setConfirmDelete(active()!.id)}
|
||||
disabled={!props.canManage}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<div class="flex items-center gap-1 px-4 pt-3 border-b" style="border-color:var(--border)">
|
||||
<Show when={sqlish(active()!.db_type)}>
|
||||
<button
|
||||
class={`btn btn-ghost btn-sm ${tab() === "browse" ? "text-accent" : ""}`}
|
||||
onClick={() => setTab("browse")}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
class={`btn btn-ghost btn-sm ${tab() === "query" ? "text-accent" : ""}`}
|
||||
onClick={() => setTab("query")}
|
||||
>
|
||||
Query
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!sqlish(active()!.db_type)}>
|
||||
<button class="btn btn-ghost btn-sm text-accent">
|
||||
<IconTerminal class="w-3.5 h-3.5" />
|
||||
Console
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* browse tab */}
|
||||
<Show when={tab() === "browse" && sqlish(active()!.db_type)}>
|
||||
<div class="flex items-end gap-2 px-4 pt-3 flex-wrap">
|
||||
<div style="min-width:10rem">
|
||||
<span class="label">Database</span>
|
||||
<Select
|
||||
class="input-sm"
|
||||
value={selectedDB()}
|
||||
onChange={(e) => {
|
||||
setSelectedDB(e.currentTarget.value);
|
||||
setDescribed(undefined);
|
||||
void loadTables(active()!, e.currentTarget.value, appliedSchema());
|
||||
}}
|
||||
>
|
||||
<For each={databases()}>{(d) => <option value={d}>{d}</option>}</For>
|
||||
</Select>
|
||||
</div>
|
||||
<div style="width:9rem">
|
||||
<span class="label">Schema</span>
|
||||
<Input
|
||||
class="input-sm"
|
||||
placeholder="default"
|
||||
value={schemaInput()}
|
||||
onInput={(e) => setSchemaInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setAppliedSchema(schemaInput().trim());
|
||||
setDescribed(undefined);
|
||||
void loadTables(active()!, selectedDB(), schemaInput().trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Refresh tables"
|
||||
disabled={browseBusy()}
|
||||
onClick={() => {
|
||||
setAppliedSchema(schemaInput().trim());
|
||||
setDescribed(undefined);
|
||||
void loadTables(active()!, selectedDB(), schemaInput().trim());
|
||||
}}
|
||||
>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container mt-2">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th style="width:1%">Kind</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={tables() ?? []}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={3} class="py-10 text-center text-text-3">
|
||||
{browseBusy()
|
||||
? "Loading…"
|
||||
: tablesRaw() ||
|
||||
"No tables found. Pick a database or enter a schema name."}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(t) => (
|
||||
<tr
|
||||
class={`clickable ${described()?.name === t.name ? "bg-surface-2" : ""}`}
|
||||
onClick={() => void describe(t.name)}
|
||||
>
|
||||
<td class="font-mono text-xs text-text-1">{t.name}</td>
|
||||
<td class="text-xs text-text-3 whitespace-nowrap">{t.kind}</td>
|
||||
<td>
|
||||
<IconChevronRight class="w-4 h-4 text-text-3" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Show when={described()}>
|
||||
<div class="px-4 py-3 border-t" style="border-color:var(--border)">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm font-medium text-text-1">{described()!.name}</span>
|
||||
<Show when={describeBusy()}>
|
||||
<span class="text-xs text-text-3">loading…</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={described()!.table}
|
||||
fallback={
|
||||
<pre class="text-xs text-text-2 whitespace-pre-wrap font-mono">
|
||||
{described()!.raw ?? "No column metadata returned."}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
{(t) => <ResultTable table={t()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* query tab */}
|
||||
<Show when={tab() === "query" && sqlish(active()!.db_type)}>
|
||||
<div class="px-4 pt-3">
|
||||
<Textarea
|
||||
class="font-mono text-sm"
|
||||
rows={5}
|
||||
value={sql()}
|
||||
onInput={(e) => setSql(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void runQuery();
|
||||
}
|
||||
}}
|
||||
placeholder="SELECT * FROM …"
|
||||
/>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onClick={() => void runQuery()}
|
||||
disabled={queryBusy() || !sql().trim()}
|
||||
>
|
||||
<IconPlay class="w-3.5 h-3.5" />
|
||||
{queryBusy() ? "Running…" : "Run query"}
|
||||
</button>
|
||||
<span class="text-xs text-text-3">⌘/Ctrl + Enter</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={queryResult()}>
|
||||
<div class="mt-3 border-t" style="border-color:var(--border)">
|
||||
<Show
|
||||
when={queryResult()!.table}
|
||||
fallback={
|
||||
<pre class="p-4 text-xs text-text-2 whitespace-pre-wrap font-mono">
|
||||
{queryResult()!.raw ?? "No result returned."}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
{(t) => (
|
||||
<ResultTable
|
||||
table={t()}
|
||||
onRow={(row) => setDetailRow({ columns: t().columns, row })}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* redis console */}
|
||||
<Show when={tab() === "console" || !sqlish(active()!.db_type)}>
|
||||
<div class="px-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class="input-sm font-mono flex-1"
|
||||
placeholder="PING, GET key, INFO…"
|
||||
value={redisCmd()}
|
||||
onInput={(e) => setRedisCmd(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void runRedis();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onClick={() => void runRedis()}
|
||||
disabled={consoleBusy() || !redisCmd().trim()}
|
||||
>
|
||||
<IconPlay class="w-3.5 h-3.5" />
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<For
|
||||
each={consoleLog()}
|
||||
fallback={<p class="text-xs text-text-3">Run a command to see output here.</p>}
|
||||
>
|
||||
{(entry) => (
|
||||
<div>
|
||||
<div class="font-mono text-xs text-accent">$ {entry.cmd}</div>
|
||||
<pre class="font-mono text-xs text-text-2 whitespace-pre-wrap mt-1">
|
||||
{entry.out}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* row detail */}
|
||||
<Modal
|
||||
open={!!detailRow()}
|
||||
onClose={() => setDetailRow(undefined)}
|
||||
title="Row detail"
|
||||
size="lg"
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<For each={detailRow()?.columns ?? []}>
|
||||
{(col, i) => (
|
||||
<tr>
|
||||
<td class="font-mono text-xs text-text-3 whitespace-nowrap">{col}</td>
|
||||
<td class="font-mono text-xs text-text-1 whitespace-pre-wrap">
|
||||
{detailRow()!.row[i()]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* add connection */}
|
||||
<Modal
|
||||
open={addOpen()}
|
||||
onClose={() => setAddOpen(false)}
|
||||
title="Add connection"
|
||||
description="Credentials are stored server-side and never sent back to the browser."
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<span class="label">Name</span>
|
||||
<Input
|
||||
placeholder="analytics-replica"
|
||||
value={form().name}
|
||||
onInput={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Type</span>
|
||||
<Select
|
||||
value={form().db_type}
|
||||
onChange={(e) => setForm((f) => ({ ...f, db_type: e.currentTarget.value }))}
|
||||
>
|
||||
<For each={dbTypes}>{(t) => <option value={t}>{t}</option>}</For>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<span class="label">{form().db_type === "sqlite" ? "File path" : "Host"}</span>
|
||||
<Input
|
||||
placeholder={form().db_type === "sqlite" ? "/data/app.db" : "db.internal"}
|
||||
value={form().host}
|
||||
onInput={(e) => setForm((f) => ({ ...f, host: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Port</span>
|
||||
<Input
|
||||
placeholder="5432"
|
||||
inputmode="numeric"
|
||||
value={form().port}
|
||||
onInput={(e) => setForm((f) => ({ ...f, port: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="label">Database</span>
|
||||
<Input
|
||||
value={form().database}
|
||||
onInput={(e) => setForm((f) => ({ ...f, database: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Username</span>
|
||||
<Input
|
||||
value={form().username}
|
||||
onInput={(e) => setForm((f) => ({ ...f, username: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Password</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={form().password}
|
||||
onInput={(e) => setForm((f) => ({ ...f, password: e.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-text-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form().ssl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ssl: e.currentTarget.checked }))}
|
||||
/>
|
||||
Require SSL
|
||||
</label>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<button class="btn btn-ghost" onClick={() => setAddOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => void createConnection()}
|
||||
disabled={addBusy() || !form().name.trim() || !form().host.trim()}
|
||||
>
|
||||
{addBusy() ? "Adding…" : "Add connection"}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* delete confirm */}
|
||||
<Modal
|
||||
open={!!confirmDelete()}
|
||||
onClose={() => setConfirmDelete(undefined)}
|
||||
title="Delete connection"
|
||||
size="sm"
|
||||
>
|
||||
<p class="text-sm text-text-2">
|
||||
This removes the saved connection and its DBX profile. This cannot be undone.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<button class="btn btn-ghost" onClick={() => setConfirmDelete(undefined)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onClick={() => {
|
||||
const id = confirmDelete();
|
||||
setConfirmDelete(undefined);
|
||||
if (id) void removeConnection(id);
|
||||
}}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { ProjectsPage } from "./ProjectsPage";
|
||||
export { MembersPage } from "./MembersPage";
|
||||
export { StoragePage } from "./StoragePage";
|
||||
export { CollectionsPage } from "./CollectionsPage";
|
||||
export { DatabasesPage } from "./DatabasesPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/mcp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "primora",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"packageManager": "[email protected]",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/api-client",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -23,15 +23,32 @@ export type { BucketObjectListResponse } from './models/BucketObjectListResponse
|
||||
export type { Collection } from './models/Collection';
|
||||
export type { CollectionID } from './models/CollectionID';
|
||||
export type { CollectionListResponse } from './models/CollectionListResponse';
|
||||
export type { ConnectionID } from './models/ConnectionID';
|
||||
export type { CopyBucketObjectRequest } from './models/CopyBucketObjectRequest';
|
||||
export type { CreateApiKeyRequest } from './models/CreateApiKeyRequest';
|
||||
export { CreateBucketRequest } from './models/CreateBucketRequest';
|
||||
export type { CreateCollectionRequest } from './models/CreateCollectionRequest';
|
||||
export type { CreatedApiKey } from './models/CreatedApiKey';
|
||||
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
||||
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
||||
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
||||
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
|
||||
export type { CreateProjectRequest } from './models/CreateProjectRequest';
|
||||
export type { DatabaseQuery } from './models/DatabaseQuery';
|
||||
export type { DBConnection } from './models/DBConnection';
|
||||
export type { DBConnectionListResponse } from './models/DBConnectionListResponse';
|
||||
export type { DBConnectionTestResult } from './models/DBConnectionTestResult';
|
||||
export type { DBDatabaseListResponse } from './models/DBDatabaseListResponse';
|
||||
export type { DBDescribeResponse } from './models/DBDescribeResponse';
|
||||
export type { DBMDTable } from './models/DBMDTable';
|
||||
export type { DBObject } from './models/DBObject';
|
||||
export type { DBQueryRequest } from './models/DBQueryRequest';
|
||||
export type { DBQueryResponse } from './models/DBQueryResponse';
|
||||
export type { DBRedisRequest } from './models/DBRedisRequest';
|
||||
export type { DBRedisResponse } from './models/DBRedisResponse';
|
||||
export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse';
|
||||
export type { DBTableListResponse } from './models/DBTableListResponse';
|
||||
export type { DBXStatus } from './models/DBXStatus';
|
||||
export type { Document } from './models/Document';
|
||||
export type { DocumentID } from './models/DocumentID';
|
||||
export type { DocumentListResponse } from './models/DocumentListResponse';
|
||||
@@ -55,6 +72,8 @@ export { ProjectMember } from './models/ProjectMember';
|
||||
export type { ProjectOverview } from './models/ProjectOverview';
|
||||
export type { ProjectSummary } from './models/ProjectSummary';
|
||||
export type { ReadinessStatus } from './models/ReadinessStatus';
|
||||
export type { SchemaQuery } from './models/SchemaQuery';
|
||||
export type { TableQuery } from './models/TableQuery';
|
||||
export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectRequest';
|
||||
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
||||
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
||||
@@ -82,15 +101,32 @@ export { $BucketObjectListResponse } from './schemas/$BucketObjectListResponse';
|
||||
export { $Collection } from './schemas/$Collection';
|
||||
export { $CollectionID } from './schemas/$CollectionID';
|
||||
export { $CollectionListResponse } from './schemas/$CollectionListResponse';
|
||||
export { $ConnectionID } from './schemas/$ConnectionID';
|
||||
export { $CopyBucketObjectRequest } from './schemas/$CopyBucketObjectRequest';
|
||||
export { $CreateApiKeyRequest } from './schemas/$CreateApiKeyRequest';
|
||||
export { $CreateBucketRequest } from './schemas/$CreateBucketRequest';
|
||||
export { $CreateCollectionRequest } from './schemas/$CreateCollectionRequest';
|
||||
export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
||||
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
||||
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
||||
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
||||
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
|
||||
export { $CreateProjectRequest } from './schemas/$CreateProjectRequest';
|
||||
export { $DatabaseQuery } from './schemas/$DatabaseQuery';
|
||||
export { $DBConnection } from './schemas/$DBConnection';
|
||||
export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse';
|
||||
export { $DBConnectionTestResult } from './schemas/$DBConnectionTestResult';
|
||||
export { $DBDatabaseListResponse } from './schemas/$DBDatabaseListResponse';
|
||||
export { $DBDescribeResponse } from './schemas/$DBDescribeResponse';
|
||||
export { $DBMDTable } from './schemas/$DBMDTable';
|
||||
export { $DBObject } from './schemas/$DBObject';
|
||||
export { $DBQueryRequest } from './schemas/$DBQueryRequest';
|
||||
export { $DBQueryResponse } from './schemas/$DBQueryResponse';
|
||||
export { $DBRedisRequest } from './schemas/$DBRedisRequest';
|
||||
export { $DBRedisResponse } from './schemas/$DBRedisResponse';
|
||||
export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse';
|
||||
export { $DBTableListResponse } from './schemas/$DBTableListResponse';
|
||||
export { $DBXStatus } from './schemas/$DBXStatus';
|
||||
export { $Document } from './schemas/$Document';
|
||||
export { $DocumentID } from './schemas/$DocumentID';
|
||||
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
||||
@@ -114,6 +150,8 @@ export { $ProjectMember } from './schemas/$ProjectMember';
|
||||
export { $ProjectOverview } from './schemas/$ProjectOverview';
|
||||
export { $ProjectSummary } from './schemas/$ProjectSummary';
|
||||
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
||||
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
||||
export { $TableQuery } from './schemas/$TableQuery';
|
||||
export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest';
|
||||
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
||||
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
||||
@@ -126,6 +164,7 @@ export { $UserID } from './schemas/$UserID';
|
||||
export { $UserSummary } from './schemas/$UserSummary';
|
||||
|
||||
export { CollectionsService } from './services/CollectionsService';
|
||||
export { DatabasesService } from './services/DatabasesService';
|
||||
export { HealthService } from './services/HealthService';
|
||||
export { OrganizationsService } from './services/OrganizationsService';
|
||||
export { PlatformService } from './services/PlatformService';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ConnectionID = string;
|
||||
@@ -0,0 +1,29 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type CreateDBConnectionRequest = {
|
||||
name: string;
|
||||
db_type: CreateDBConnectionRequest.db_type;
|
||||
host: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
ssl?: boolean;
|
||||
driver_profile?: string;
|
||||
};
|
||||
export namespace CreateDBConnectionRequest {
|
||||
export enum db_type {
|
||||
POSTGRES = 'postgres',
|
||||
MYSQL = 'mysql',
|
||||
SQLITE = 'sqlite',
|
||||
REDIS = 'redis',
|
||||
MONGODB = 'mongodb',
|
||||
SQLSERVER = 'sqlserver',
|
||||
DUCKDB = 'duckdb',
|
||||
CLICKHOUSE = 'clickhouse',
|
||||
CASSANDRA = 'cassandra',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBConnection = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port?: number | null;
|
||||
database?: string;
|
||||
username?: string;
|
||||
ssl?: boolean | null;
|
||||
is_managed: boolean;
|
||||
has_password: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { DBConnection } from './DBConnection';
|
||||
export type DBConnectionListResponse = {
|
||||
items: Array<DBConnection>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBConnectionTestResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBDatabaseListResponse = {
|
||||
databases: Array<string>;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { DBMDTable } from './DBMDTable';
|
||||
export type DBDescribeResponse = {
|
||||
table?: DBMDTable;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBMDTable = {
|
||||
columns: Array<string>;
|
||||
rows: Array<Array<string>>;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBObject = {
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBQueryRequest = {
|
||||
sql: string;
|
||||
database?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { DBMDTable } from './DBMDTable';
|
||||
export type DBQueryResponse = {
|
||||
table?: DBMDTable;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBRedisRequest = {
|
||||
command: string;
|
||||
db?: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBRedisResponse = {
|
||||
output: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBSchemaContextResponse = {
|
||||
context: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { DBMDTable } from './DBMDTable';
|
||||
import type { DBObject } from './DBObject';
|
||||
export type DBTableListResponse = {
|
||||
tables?: Array<DBObject>;
|
||||
table?: DBMDTable;
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DBXStatus = {
|
||||
available: boolean;
|
||||
command?: string;
|
||||
reason?: string;
|
||||
install?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type DatabaseQuery = string;
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type SchemaQuery = string;
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type TableQuery = string;
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $ConnectionID = {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $CreateDBConnectionRequest = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
db_type: {
|
||||
type: 'Enum',
|
||||
isRequired: true,
|
||||
},
|
||||
host: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
},
|
||||
ssl: {
|
||||
type: 'boolean',
|
||||
},
|
||||
driver_profile: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,52 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBConnection = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'uuid',
|
||||
},
|
||||
project_id: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'uuid',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
db_type: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
host: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
isNullable: true,
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
},
|
||||
ssl: {
|
||||
type: 'boolean',
|
||||
isNullable: true,
|
||||
},
|
||||
is_managed: {
|
||||
type: 'boolean',
|
||||
isRequired: true,
|
||||
},
|
||||
has_password: {
|
||||
type: 'boolean',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBConnectionListResponse = {
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'DBConnection',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBConnectionTestResult = {
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean',
|
||||
isRequired: true,
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,18 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBDatabaseListResponse = {
|
||||
properties: {
|
||||
databases: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'string',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
raw: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBDescribeResponse = {
|
||||
properties: {
|
||||
table: {
|
||||
type: 'DBMDTable',
|
||||
},
|
||||
raw: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBMDTable = {
|
||||
properties: {
|
||||
columns: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'string',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
rows: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
note: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBObject = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBQueryRequest = {
|
||||
properties: {
|
||||
sql: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBQueryResponse = {
|
||||
properties: {
|
||||
table: {
|
||||
type: 'DBMDTable',
|
||||
},
|
||||
raw: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBRedisRequest = {
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
db: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBRedisResponse = {
|
||||
properties: {
|
||||
output: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBSchemaContextResponse = {
|
||||
properties: {
|
||||
context: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBTableListResponse = {
|
||||
properties: {
|
||||
tables: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'DBObject',
|
||||
},
|
||||
},
|
||||
table: {
|
||||
type: 'DBMDTable',
|
||||
},
|
||||
raw: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,21 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DBXStatus = {
|
||||
properties: {
|
||||
available: {
|
||||
type: 'boolean',
|
||||
isRequired: true,
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
},
|
||||
install: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $DatabaseQuery = {
|
||||
type: 'string',
|
||||
} as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $SchemaQuery = {
|
||||
type: 'string',
|
||||
} as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $TableQuery = {
|
||||
type: 'string',
|
||||
} as const;
|
||||
@@ -0,0 +1,303 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { CreateDBConnectionRequest } from '../models/CreateDBConnectionRequest';
|
||||
import type { DBConnection } from '../models/DBConnection';
|
||||
import type { DBConnectionListResponse } from '../models/DBConnectionListResponse';
|
||||
import type { DBConnectionTestResult } from '../models/DBConnectionTestResult';
|
||||
import type { DBDatabaseListResponse } from '../models/DBDatabaseListResponse';
|
||||
import type { DBDescribeResponse } from '../models/DBDescribeResponse';
|
||||
import type { DBQueryRequest } from '../models/DBQueryRequest';
|
||||
import type { DBQueryResponse } from '../models/DBQueryResponse';
|
||||
import type { DBRedisRequest } from '../models/DBRedisRequest';
|
||||
import type { DBRedisResponse } from '../models/DBRedisResponse';
|
||||
import type { DBSchemaContextResponse } from '../models/DBSchemaContextResponse';
|
||||
import type { DBTableListResponse } from '../models/DBTableListResponse';
|
||||
import type { DBXStatus } from '../models/DBXStatus';
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
import { request as __request } from '../core/request';
|
||||
export class DatabasesService {
|
||||
/**
|
||||
* @returns DBXStatus DBX subprocess availability
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static getDbxStatus(): CancelablePromise<DBXStatus> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/db/status',
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBConnectionListResponse Saved database connections for the project
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listDbConnections({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string,
|
||||
}): CancelablePromise<DBConnectionListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/db-connections',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBConnection Connection created
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static createDbConnection({
|
||||
projectId,
|
||||
requestBody,
|
||||
}: {
|
||||
projectId: string,
|
||||
requestBody: CreateDBConnectionRequest,
|
||||
}): CancelablePromise<DBConnection> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/db-connections',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
409: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns void
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteDbConnection({
|
||||
projectId,
|
||||
connectionId,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
}): CancelablePromise<void> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
409: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBConnectionTestResult Connectivity probe result
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static testDbConnection({
|
||||
projectId,
|
||||
connectionId,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
}): CancelablePromise<DBConnectionTestResult> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/test',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBDatabaseListResponse Databases reachable through the connection
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listDbDatabases({
|
||||
projectId,
|
||||
connectionId,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
}): CancelablePromise<DBDatabaseListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/databases',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBTableListResponse Tables, views, or collections in the connection
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listDbTables({
|
||||
projectId,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
database?: string,
|
||||
schema?: string,
|
||||
}): CancelablePromise<DBTableListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/tables',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
query: {
|
||||
'database': database,
|
||||
'schema': schema,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBDescribeResponse Column definitions for a table
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static describeDbTable({
|
||||
projectId,
|
||||
connectionId,
|
||||
table,
|
||||
database,
|
||||
schema,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
table: string,
|
||||
database?: string,
|
||||
schema?: string,
|
||||
}): CancelablePromise<DBDescribeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/describe',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
query: {
|
||||
'table': table,
|
||||
'database': database,
|
||||
'schema': schema,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBSchemaContextResponse Compact schema context for the connection
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static getDbSchemaContext({
|
||||
projectId,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
database?: string,
|
||||
schema?: string,
|
||||
}): CancelablePromise<DBSchemaContextResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/schema',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
query: {
|
||||
'database': database,
|
||||
'schema': schema,
|
||||
},
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBQueryResponse Query result (at most 100 rows)
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static executeDbQuery({
|
||||
projectId,
|
||||
connectionId,
|
||||
requestBody,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
requestBody: DBQueryRequest,
|
||||
}): CancelablePromise<DBQueryResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/query',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns DBRedisResponse Redis command output
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static executeDbRedisCommand({
|
||||
projectId,
|
||||
connectionId,
|
||||
requestBody,
|
||||
}: {
|
||||
projectId: string,
|
||||
connectionId: string,
|
||||
requestBody: DBRedisRequest,
|
||||
}): CancelablePromise<DBRedisResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/db-connections/{connectionID}/redis',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'connectionID': connectionId,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
404: `Error response`,
|
||||
502: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/shared-types",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
Reference in new issue
Block a user