diff --git a/.github/workflows/address-blog-review.yml b/.github/workflows/address-blog-review.yml deleted file mode 100644 index b224f89..0000000 --- a/.github/workflows/address-blog-review.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Address Blog Review - -on: - pull_request_review: - types: [submitted] - branches: - - 'blog/**' - -jobs: - address-review: - name: Address Review Comments - # Only run for reviews with comments, not plain approvals - if: github.event.review.state == 'commented' || github.event.review.state == 'changes_requested' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code - - - name: Address review comments - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - REVIEW_ID: ${{ github.event.review.id }} - run: | - claude --dangerously-skip-permissions -p " - You are addressing review comments on a blog post PR. - - Context: - - Repository: $REPO - - PR number: $PR_NUMBER - - Review ID: $REVIEW_ID - - Steps: - 1. Fetch the review comments using: gh api repos/$REPO/pulls/$PR_NUMBER/comments - Filter to comments from review ID $REVIEW_ID. - 2. Read the blog post file that was changed in this PR (check git diff --name-only to find it). - 3. Read the write-blog-post skill in .claude/skills/write-blog-post/skill.md for style guidelines. - 4. For each review comment, assess whether it is valid: - - Is it factually correct? - - Does it improve accuracy, clarity, or tone? - - Does it align with the writing style guidelines? - 5. For valid comments: edit the blog post to address the feedback. - 6. For invalid or subjective comments: prepare a brief explanation of why no change is needed. - 7. Reply to EACH review comment using: - gh api repos/$REPO/pulls/$PR_NUMBER/comments/{comment_id}/replies -f body='...' - - If fixed: 'Fixed. [what was changed]' - - If rejected: 'Keeping as-is. [brief reason]' - 8. Do NOT fabricate facts. If a review comment asks you to add information you cannot verify, say so in the reply. - " - - - name: Check for changes - id: check - run: | - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Commit and push changes - if: steps.check.outputs.has_changes == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add website/content/blog/ - git commit -m "blog: address review comments - - Co-Authored-By: Claude " - git push diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml deleted file mode 100644 index aada745..0000000 --- a/.github/workflows/deploy-demo.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Deploy Demo - -on: - workflow_dispatch: - workflow_run: - workflows: ["Publish"] - types: [completed] - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Get version from package.json - id: version - run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_OUTPUT - - - name: Update image tag in wrangler.toml - run: sed -i "s|pgplex/pgconsole:[^ \"]*|pgplex/pgconsole:${{ steps.version.outputs.VERSION }}|" worker/demo/wrangler.toml - - - uses: pnpm/action-setup@v4 - with: - version: 10 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - working-directory: worker/demo - - - name: Pull from Docker Hub and push to Cloudflare registry - run: | - docker pull pgplex/pgconsole:${{ steps.version.outputs.VERSION }} - docker tag pgplex/pgconsole:${{ steps.version.outputs.VERSION }} pgplex/pgconsole:${{ steps.version.outputs.VERSION }} - pnpm wrangler containers push pgplex/pgconsole:${{ steps.version.outputs.VERSION }} - working-directory: worker/demo - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - - name: Deploy to Cloudflare - run: pnpm wrangler deploy - working-directory: worker/demo - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/deploy-stripe-worker.yml b/.github/workflows/deploy-stripe-worker.yml deleted file mode 100644 index 67e7514..0000000 --- a/.github/workflows/deploy-stripe-worker.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Deploy Stripe Webhook Worker - -on: - push: - branches: - - main - paths: - - "worker/stripe-webhook/**" - - ".github/workflows/deploy-stripe-worker.yml" - workflow_dispatch: - -defaults: - run: - working-directory: worker/stripe-webhook - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - - run: npm ci - - - name: Deploy - run: npx wrangler deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - - name: Set secrets - run: | - echo "${{ secrets.STRIPE_WEBHOOK_SECRET_LIVE }}" | npx wrangler secret put STRIPE_WEBHOOK_SECRET_LIVE - echo "${{ secrets.STRIPE_WEBHOOK_SECRET_TEST }}" | npx wrangler secret put STRIPE_WEBHOOK_SECRET_TEST - echo "${{ secrets.RESEND_API_KEY }}" | npx wrangler secret put RESEND_API_KEY - echo "${{ secrets.KEYGEN_API_KEY }}" | npx wrangler secret put KEYGEN_API_KEY - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml deleted file mode 100644 index 36cce53..0000000 --- a/.github/workflows/deploy-website.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Deploy Website - -on: - push: - branches: [main] - paths: - - "website/**" - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Trigger Vercel Deploy Hook - run: curl -s -X POST "https://api.vercel.com/v1/integrations/deploy/prj_U7HMe4FDtMUT9oq6pWPDNbtvvAdG/wv2IkRFK2s" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f8d32b2..435e3c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,3 @@ -# Unified workflow for publishing pgconsole to Docker Hub and npm -# -# Docker: Always publishes 'latest' tag. Adds version tag when package.json version changes. -# npm: Only publishes when version changes (or manual trigger). Uses OIDC trusted publishing. -# -# Prerequisites for npm OIDC: Configure trusted publisher at https://www.npmjs.com/package/@pgplex/pgconsole/access -# - Workflow filename: publish.yml - name: Publish on: @@ -14,10 +6,6 @@ on: version: description: "Version tag (e.g. 1.2.0). If empty, auto-detected from package.json." required: false - npm_tag: - description: "NPM tag (e.g., latest, beta). Only used for npm publish." - required: false - default: "latest" push: branches: [main] paths-ignore: @@ -26,7 +14,8 @@ on: - "worker/**" env: - IMAGE_NAME: pgplex/pgconsole + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: check-version: @@ -60,28 +49,35 @@ jobs: docker: needs: check-version runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Prepare tags id: prep run: | - TAGS="${{ env.IMAGE_NAME }}:latest" + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]') + TAGS="${IMAGE}:latest" if [[ -n "${{ inputs.version }}" ]]; then VERSION="${{ inputs.version }}" - TAGS="$TAGS,${{ env.IMAGE_NAME }}:$VERSION" + TAGS="$TAGS,${IMAGE}:$VERSION" echo "Publishing with tags: latest, $VERSION (manual)" elif [[ "${{ needs.check-version.outputs.version_changed }}" == "true" ]]; then VERSION="${{ needs.check-version.outputs.version }}" - TAGS="$TAGS,${{ env.IMAGE_NAME }}:$VERSION" + TAGS="$TAGS,${IMAGE}:$VERSION" echo "Publishing with tags: latest, $VERSION" else echo "Publishing with tag: latest only" @@ -101,67 +97,3 @@ jobs: VERSION=${{ inputs.version || needs.check-version.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max - - npm: - needs: check-version - if: inputs.version || needs.check-version.outputs.version_changed == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 10 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "pnpm" - - # Upgrade npm for OIDC trusted publishing (requires npm 11.5+) - # Bootstrap without npm since Node 22.22.2 ships with broken npm - - name: Upgrade npm - run: | - npm_tarball=$(curl -fsSL https://registry.npmjs.org/npm/latest | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).dist.tarball") - curl -fsSL "$npm_tarball" | tar xz -C /tmp - node /tmp/package/bin/npm-cli.js install -g npm@latest - rm -rf /tmp/package - echo "npm version: $(npm --version)" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Prepare and publish - run: | - VERSION="${{ inputs.version || needs.check-version.outputs.version }}" - - # Check if already published - if npm view @pgplex/pgconsole@${VERSION} version &> /dev/null; then - echo "Version ${VERSION} already exists on npm. Skipping." - exit 0 - fi - - # Determine npm tag - if [[ -n "${{ inputs.npm_tag }}" && "${{ inputs.npm_tag }}" != "latest" ]]; then - TAG="${{ inputs.npm_tag }}" - elif [[ "${VERSION}" == *"-"* ]]; then - TAG=$(echo "${VERSION}" | cut -d'-' -f2 | cut -d'.' -f1) - else - TAG="latest" - fi - - echo "Publishing @pgplex/pgconsole@${VERSION} with tag ${TAG}" - - # Prepare package.json for npm - jq --arg v "$VERSION" \ - '.name = "@pgplex/pgconsole" | .version = $v | del(.private) | .files = ["dist/**/*", "LICENSE", "README.md"] | .bin = {"pgconsole": "dist/server.mjs"}' \ - package.json > package.json.tmp - mv package.json.tmp package.json - - npm publish --access public --tag "$TAG" --provenance diff --git a/.github/workflows/weekly-blog-post.yml b/.github/workflows/weekly-blog-post.yml deleted file mode 100644 index a86f18e..0000000 --- a/.github/workflows/weekly-blog-post.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Weekly Blog Post - -on: - schedule: - # Every Monday at 09:00 UTC - - cron: "0 9 * * 1" - workflow_dispatch: - -jobs: - write-blog-post: - name: Write Blog Post - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code - - - name: Generate blog post - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - claude --dangerously-skip-permissions -p " - You are writing a blog post for the pgconsole website. Follow the write-blog-post skill in .claude/skills/write-blog-post/skill.md exactly, with one exception: since this is running in CI with no human to confirm topic selection, you must autonomously pick the best topic. - - Steps: - 1. Browse https://commitfest.postgresql.org/commitfest_history/ and https://pgpedia.info/postgresql-versions/ to find committed patches for the latest PostgreSQL version. - 2. Check existing posts in website/content/blog/ to avoid duplicate topics. - 3. Pick the single best topic โ€” high impact, clear pain point, demonstrable with SQL examples. - 4. Research the commit(s), pgsql-hackers mailing list threads, and real-world context. - 5. Write the post to website/content/blog/{slug}.md following the structure, frontmatter, and writing style in the skill file. - 6. Use today's date in the frontmatter. - - Do NOT skip research. Every claim must be backed by a real commit hash and real mailing list link. Do NOT fabricate URLs. - " - - - name: Check for new post - id: check - run: | - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create Pull Request - if: steps.check.outputs.has_changes == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BRANCH="blog/weekly-$(date +%Y-%m-%d)" - git fetch origin main - git checkout -b "$BRANCH" origin/main - git add website/content/blog/ - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "blog: add weekly blog post - - Co-Authored-By: Claude " - git push -u origin "$BRANCH" - gh pr create \ - --title "Blog: weekly post $(date +%Y-%m-%d)" \ - --body "$(cat <<'EOF' - ## Summary - - Weekly auto-generated blog post about a recent PostgreSQL feature. - - **Please review for:** - - Factual accuracy (commit hashes, mailing list links) - - Writing quality and tone - - Technical correctness of SQL examples - - ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) - EOF - )" diff --git a/Dockerfile b/Dockerfile index 6564456..a60b9d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,11 @@ COPY . . ARG GIT_COMMIT=unknown RUN GIT_COMMIT=${GIT_COMMIT} pnpm build +ARG TARGETARCH +RUN unzip bin/pgschema-linux-${TARGETARCH}.zip -d /usr/local/bin \ + && mv /usr/local/bin/pgschema-*-linux-${TARGETARCH} /usr/local/bin/pgschema \ + && chmod +x /usr/local/bin/pgschema + # Runtime dependencies # Generated from esbuild externals + package.json versions (single source of truth). # Only rebuilds when package.json or build-server.mjs externals change. @@ -43,13 +48,16 @@ RUN node scripts/gen-runtime-package.mjs > runtime-package.json \ # Layers ordered least โ†’ most frequently changing for cache efficiency FROM alpine:3.21 -RUN apk add --no-cache libstdc++ +RUN apk add --no-cache libstdc++ git COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node +RUN addgroup -S pgconsole && adduser -S pgconsole -G pgconsole + WORKDIR /app -# 1. Entrypoint โ€” rarely changes +# 1. Binaries & entrypoint โ€” rarely change +COPY --from=builder /usr/local/bin/pgschema /usr/local/bin/pgschema COPY docker-entrypoint.sh /app/ # 2. Runtime node_modules โ€” changes only when externals or dep versions change @@ -74,4 +82,6 @@ ENV NODE_ENV=production ENV PORT=9876 EXPOSE 9876 +USER pgconsole + ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index ea22bc1..8be7b73 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -> [!NOTE] -> pgplex: Modern Developer Stack for Postgres - **pgconsole** ยท [pgtui](https://github.com/pgplex/pgtui) ยท [pgschema](https://github.com/pgplex/pgschema) ยท [pgparser](https://github.com/pgplex/pgparser) +> This is a fork of [pgplex/pgconsole](https://github.com/pgplex/pgconsole), the original open-source PostgreSQL management console by [pgplex](https://github.com/pgplex). All credit for the core project goes to the original authors. This fork adds container-based deployment via GHCR and Helm chart support for internal use.

- + pgconsole @@ -16,42 +15,55 @@ **pgconsole** is a web-based PostgreSQL editor. Single binary, single config file, no database required. Connect your team to PostgreSQL with access control and audit logging built in. - - - - - Star History Chart - - - ## Installation -Visit https://docs.pgconsole.com/getting-started/quickstart - -### Prerequisites - -- Node.js 20+ - -### npm +### Docker ```bash -npm install -g @pgplex/pgconsole -pgconsole --config pgconsole.toml +docker run -p 9876:9876 -v /path/to/pgconsole.toml:/etc/pgconsole.toml ghcr.io/nfuchen/pgconsole ``` -### npx +Run without a config mount to start in demo mode with a bundled sample database. + +### Helm ```bash -npx @pgplex/pgconsole --config pgconsole.toml +helm install pgconsole ./helm-chart ``` -### Docker +Override config inline: ```bash -docker run -p 9876:9876 -v /path/to/pgconsole.toml:/etc/pgconsole.toml pgplex/pgconsole +helm install pgconsole ./helm-chart \ + --set-file config=pgconsole.toml ``` -Run without `--config` to start in demo mode with a bundled sample database. +Or customize via `values.yaml`: + +```yaml +image: + repository: ghcr.io/nfuchen/pgconsole + tag: "1.3.0" + +config: | + [[connections]] + id = "production" + name = "Production" + host = "db.example.com" + port = 5432 + database = "myapp" + username = "app" + password = "secret" + +ingress: + enabled: true + className: nginx + hosts: + - host: pgconsole.example.com + paths: + - path: / + pathType: Prefix +``` ## Features @@ -172,21 +184,21 @@ model = "claude-sonnet-4-20250514" api_key = "sk-ant-..." ``` -## Getting Help - -- [Docs](https://www.pgconsole.com) -- [GitHub Issues](https://github.com/pgplex/pgconsole/issues) - ## Development -> [!NOTE] -> **For external contributors**: If you want to request a feature, please create a GitHub issue to discuss first instead of creating a PR directly. - ```bash -git clone https://github.com/pgplex/pgconsole.git +git clone https://github.com/NFUChen/pgconsole.git cd pgconsole pnpm install pnpm dev # Start dev server (frontend + backend) pnpm build # Production build pnpm test # Run all tests ``` + +## Acknowledgements + +This project is a fork of [pgplex/pgconsole](https://github.com/pgplex/pgconsole), built and maintained by [pgplex](https://github.com/pgplex). The upstream project provides the full documentation, issue tracker, and community: + +- [Upstream Repository](https://github.com/pgplex/pgconsole) +- [Documentation](https://docs.pgconsole.com) +- [Original Issues](https://github.com/pgplex/pgconsole/issues) diff --git a/bin/pgschema-linux-amd64.zip b/bin/pgschema-linux-amd64.zip new file mode 100644 index 0000000..2f584fe Binary files /dev/null and b/bin/pgschema-linux-amd64.zip differ diff --git a/bin/pgschema-linux-arm64.zip b/bin/pgschema-linux-arm64.zip new file mode 100644 index 0000000..29a93c3 Binary files /dev/null and b/bin/pgschema-linux-arm64.zip differ diff --git a/docker-compose.yml b/docker-compose.yml index d7accf0..d4998f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: pgconsole: - image: pgplex/pgconsole + build: + context: . + dockerfile: Dockerfile ports: - "9876:9876" configs: diff --git a/helm-chart/.helmignore b/helm-chart/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/helm-chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 0000000..a94d0c2 --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +name: pgconsole +description: PostgreSQL management console with guardrails +type: application +version: 0.1.0 +appVersion: "1.3.0" +home: https://github.com/NFUChen/pgconsole +sources: + - https://github.com/NFUChen/pgconsole +keywords: + - postgresql + - database + - sql + - console diff --git a/helm-chart/templates/NOTES.txt b/helm-chart/templates/NOTES.txt new file mode 100644 index 0000000..e215029 --- /dev/null +++ b/helm-chart/templates/NOTES.txt @@ -0,0 +1,23 @@ +pgconsole has been deployed. + +{{- if .Values.ingress.enabled }} +Access pgconsole at: +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} +Get the URL by running: + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "pgconsole.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} +Get the URL by running: + kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "pgconsole.fullname" . }} +{{- else if contains "ClusterIP" .Values.service.type }} +Port-forward to access pgconsole: + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "pgconsole.fullname" . }} 9876:{{ .Values.service.port }} + +Then open http://localhost:9876 +{{- end }} diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..c551096 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "pgconsole.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "pgconsole.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "pgconsole.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "pgconsole.labels" -}} +helm.sh/chart: {{ include "pgconsole.chart" . }} +{{ include "pgconsole.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "pgconsole.selectorLabels" -}} +app.kubernetes.io/name: {{ include "pgconsole.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "pgconsole.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "pgconsole.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helm-chart/templates/configmap.yaml b/helm-chart/templates/configmap.yaml new file mode 100644 index 0000000..16b972a --- /dev/null +++ b/helm-chart/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "pgconsole.fullname" . }}-config + labels: + {{- include "pgconsole.labels" . | nindent 4 }} +data: + pgconsole.toml: | + {{- .Values.config | nindent 4 }} diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml new file mode 100644 index 0000000..dc526d7 --- /dev/null +++ b/helm-chart/templates/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "pgconsole.fullname" . }} + labels: + {{- include "pgconsole.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "pgconsole.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "pgconsole.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "pgconsole.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 9876 + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/pgconsole.toml + subPath: pgconsole.toml + readOnly: true + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "pgconsole.fullname" . }}-config + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm-chart/templates/hpa.yaml b/helm-chart/templates/hpa.yaml new file mode 100644 index 0000000..5a3ee8e --- /dev/null +++ b/helm-chart/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "pgconsole.fullname" . }} + labels: + {{- include "pgconsole.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "pgconsole.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/ingress.yaml b/helm-chart/templates/ingress.yaml new file mode 100644 index 0000000..cd36b58 --- /dev/null +++ b/helm-chart/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "pgconsole.fullname" . }} + labels: + {{- include "pgconsole.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "pgconsole.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm-chart/templates/service.yaml b/helm-chart/templates/service.yaml new file mode 100644 index 0000000..5a6a424 --- /dev/null +++ b/helm-chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "pgconsole.fullname" . }} + labels: + {{- include "pgconsole.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "pgconsole.selectorLabels" . | nindent 4 }} diff --git a/helm-chart/templates/serviceaccount.yaml b/helm-chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..c97e2d7 --- /dev/null +++ b/helm-chart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "pgconsole.serviceAccountName" . }} + labels: + {{- include "pgconsole.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml new file mode 100644 index 0000000..6d3deff --- /dev/null +++ b/helm-chart/values.yaml @@ -0,0 +1,85 @@ +replicaCount: 1 + +image: + repository: ghcr.io/nfuchen/pgconsole + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# pgconsole.toml configuration โ€” mounted to /etc/pgconsole.toml +config: | + [[connections]] + id = "default" + name = "Default PostgreSQL" + host = "localhost" + port = 5432 + database = "postgres" + username = "postgres" + password = "postgres" + +serviceAccount: + create: true + automount: false + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + +securityContext: {} + +service: + type: ClusterIP + port: 9876 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: pgconsole.local + paths: + - path: / + pathType: Prefix + tls: [] + +resources: {} + # limits: + # cpu: 500m + # memory: 256Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + +volumes: [] +volumeMounts: [] + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/package.json b/package.json index 86bd2ba..d823c13 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@pgplex/pgconsole", "description": "Postgres Editor with Guardrails", - "version": "1.2.1", + "version": "1.3.0", "type": "module", "bin": { "pgconsole": "dist/server.mjs" diff --git a/pgconsole.example.toml b/pgconsole.example.toml index f0647f3..cf2c44a 100644 --- a/pgconsole.example.toml +++ b/pgconsole.example.toml @@ -96,6 +96,12 @@ username = "app_user" password = "staging_password" ssl_mode = "require" labels = ["staging"] +# Schema migration source (optional) โ€” enables migration features +# [connections.schema_source] +# repo = "https://github.com/myorg/db-schema.git" +# branch = "main" +# path = "schema/main.sql" +# schema = "public" # Example with full SSL configuration and timeouts # [[connections]] diff --git a/proto/metadata.proto b/proto/metadata.proto new file mode 100644 index 0000000..a91a33e --- /dev/null +++ b/proto/metadata.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package metadata.v1; + +service MetadataService { + rpc InitMetadataTable(InitMetadataTableRequest) returns (InitMetadataTableResponse); + rpc GetMetadata(GetMetadataRequest) returns (GetMetadataResponse); + rpc SetMetadata(SetMetadataRequest) returns (SetMetadataResponse); + rpc DeleteMetadata(DeleteMetadataRequest) returns (DeleteMetadataResponse); + rpc ListMetadata(ListMetadataRequest) returns (ListMetadataResponse); +} + +message MetadataEntry { + string key = 1; + string value = 2; // JSON string + string updated_at = 3; // ISO 8601 timestamp +} + +message InitMetadataTableRequest { + string connection_id = 1; +} + +message InitMetadataTableResponse { + bool success = 1; +} + +message GetMetadataRequest { + string connection_id = 1; + string key = 2; +} + +message GetMetadataResponse { + MetadataEntry entry = 1; +} + +message SetMetadataRequest { + string connection_id = 1; + string key = 2; + string value = 3; // JSON string +} + +message SetMetadataResponse { + MetadataEntry entry = 1; +} + +message DeleteMetadataRequest { + string connection_id = 1; + string key = 2; +} + +message DeleteMetadataResponse { + bool success = 1; +} + +message ListMetadataRequest { + string connection_id = 1; + string prefix = 2; // Optional key prefix filter +} + +message ListMetadataResponse { + repeated MetadataEntry entries = 1; +} diff --git a/proto/migration.proto b/proto/migration.proto new file mode 100644 index 0000000..e72438f --- /dev/null +++ b/proto/migration.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package migration.v1; + +service MigrationService { + rpc PlanMigration(PlanMigrationRequest) returns (PlanMigrationResponse); + rpc ApplyMigration(ApplyMigrationRequest) returns (stream ApplyMigrationResponse); + rpc GetSchemaSourceStatus(GetSchemaSourceStatusRequest) returns (GetSchemaSourceStatusResponse); +} + +message PlanMigrationRequest { + string connection_id = 1; +} + +message SchemaDiff { + string sql = 1; + string type = 2; + string operation = 3; + string path = 4; + bool can_run_in_transaction = 5; +} + +message PlanMigrationResponse { + string plan_id = 1; + string branch = 2; + string commit_hash = 3; + string source_fingerprint = 4; + repeated SchemaDiff diffs = 5; + bool can_run_in_transaction = 6; + string summary = 7; +} + +message ApplyMigrationRequest { + string connection_id = 1; + string plan_id = 2; +} + +message ApplyMigrationResponse { + int32 step = 1; + int32 total_steps = 2; + string sql = 3; + string status = 4; + string error = 5; +} + +message GetSchemaSourceStatusRequest { + string connection_id = 1; +} + +message GetSchemaSourceStatusResponse { + bool configured = 1; + string repo = 2; + string branch = 3; + string path = 4; + string schema = 5; +} diff --git a/server/connect.ts b/server/connect.ts index c017c13..a8e3f1a 100644 --- a/server/connect.ts +++ b/server/connect.ts @@ -1,14 +1,28 @@ import { expressConnectMiddleware } from "@connectrpc/connect-express"; +import type { Interceptor } from "@connectrpc/connect"; import type { Request } from "express"; import { ConnectionService } from "../src/gen/connection_connect"; import { QueryService } from "../src/gen/query_connect"; import { AIService } from "../src/gen/ai_connect"; +import { MigrationService } from "../src/gen/migration_connect"; +import { MetadataService } from "../src/gen/metadata_connect"; import { connectionServiceHandlers } from "./services/connection-service"; import { queryServiceHandlers } from "./services/query-service"; import { aiServiceHandlers } from "./services/ai-service"; +import { migrationServiceHandlers } from "./services/migration-service"; +import { metadataServiceHandlers } from "./services/metadata-service"; import { getCurrentUser, type User } from "./lib/auth"; import { isAuthEnabled } from "./lib/config"; +const loggingInterceptor: Interceptor = (next) => async (req) => { + try { + return await next(req) + } catch (err) { + console.error(`[RPC] ${req.service.typeName}/${req.method.name}:`, err) + throw err + } +} + // Helper to get user from ConnectRPC context // Note: contextValues may be a Promise if contextValues factory is async export async function getUserFromContext(contextValues: Map | Promise>): Promise { @@ -25,9 +39,11 @@ const GUEST_USER: User = { email: 'guest', name: 'Guest' } */ export const connectRouter = expressConnectMiddleware({ routes: (router) => { - router.service(ConnectionService, connectionServiceHandlers); - router.service(QueryService, queryServiceHandlers); - router.service(AIService, aiServiceHandlers); + router.service(ConnectionService, connectionServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(QueryService, queryServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(AIService, aiServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(MigrationService, migrationServiceHandlers, { interceptors: [loggingInterceptor] }); + router.service(MetadataService, metadataServiceHandlers, { interceptors: [loggingInterceptor] }); }, // Set max message size to ~4GB for large query results readMaxBytes: 0xffffffff, diff --git a/server/index.ts b/server/index.ts index 6bc0626..96ea15a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -68,7 +68,9 @@ app.use((req, res, next) => { req.path.startsWith('/api/') || req.path.startsWith('/connection.v1.') || req.path.startsWith('/query.v1.') || - req.path.startsWith('/ai.v1.')) { + req.path.startsWith('/ai.v1.') || + req.path.startsWith('/migration.v1.') || + req.path.startsWith('/metadata.v1.')) { return next() } res.sendFile(path.join(clientDir, 'index.html')) diff --git a/server/lib/config.ts b/server/lib/config.ts index 18f94a8..3ff098a 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -10,6 +10,13 @@ export interface LabelConfig { color: string } +export interface SchemaSourceConfig { + repo: string + branch?: string + path: string + schema: string +} + export interface ConnectionConfig { id: string name: string @@ -406,6 +413,24 @@ export async function loadConfigFromString(content: string): Promise { } } + // Parse schema_source if provided + let schemaSource: SchemaSourceConfig | undefined = undefined + const rawSchemaSource = c.schema_source as Record | undefined + if (rawSchemaSource) { + if (!rawSchemaSource.repo || typeof rawSchemaSource.repo !== 'string') { + throw new Error(`Connection ${c.id} schema_source.repo is required and must be a string`) + } + if (!rawSchemaSource.path || typeof rawSchemaSource.path !== 'string') { + throw new Error(`Connection ${c.id} schema_source.path is required and must be a string`) + } + schemaSource = { + repo: rawSchemaSource.repo, + branch: typeof rawSchemaSource.branch === 'string' ? rawSchemaSource.branch : undefined, + path: rawSchemaSource.path, + schema: typeof rawSchemaSource.schema === 'string' ? rawSchemaSource.schema : 'public', + } + } + connections.push({ id: c.id, name: c.name, @@ -422,6 +447,7 @@ export async function loadConfigFromString(content: string): Promise { lock_timeout: typeof c.lock_timeout === 'string' ? c.lock_timeout : undefined, statement_timeout: typeof c.statement_timeout === 'string' ? c.statement_timeout : undefined, lazy: c.lazy === true, + schema_source: schemaSource, }) } diff --git a/server/lib/git.ts b/server/lib/git.ts new file mode 100644 index 0000000..6120765 --- /dev/null +++ b/server/lib/git.ts @@ -0,0 +1,70 @@ +import { execFile } from 'child_process' +import { access, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +function exec(cmd: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: 60_000 }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`git ${args[0]} failed: ${stderr || error.message}`)) + } else { + resolve(stdout.trim()) + } + }) + }) +} + +// Cache the repo URL per directory so we can detect config changes +const repoDirUrls = new Map() +// Per-connection lock to prevent concurrent clone/fetch races +const syncLocks = new Map>() + +export function getRepoDir(connectionId: string): string { + return join(tmpdir(), 'pgconsole-schema', connectionId) +} + +export async function syncRepo(connectionId: string, repo: string, branch?: string): Promise<{ commitHash: string }> { + // Serialize concurrent sync requests for the same connection + const existing = syncLocks.get(connectionId) + if (existing) { + return existing + } + + const promise = doSyncRepo(connectionId, repo, branch).finally(() => { + syncLocks.delete(connectionId) + }) + syncLocks.set(connectionId, promise) + return promise +} + +async function doSyncRepo(connectionId: string, repo: string, branch?: string): Promise<{ commitHash: string }> { + const repoDir = getRepoDir(connectionId) + + const exists = await access(join(repoDir, '.git')).then(() => true).catch(() => false) + + // If the repo URL changed, wipe the old checkout + if (exists) { + const cachedUrl = repoDirUrls.get(repoDir) + if (cachedUrl && cachedUrl !== repo) { + await rm(repoDir, { recursive: true, force: true }) + } + } + + const stillExists = await access(join(repoDir, '.git')).then(() => true).catch(() => false) + + if (stillExists) { + await exec('git', ['fetch', 'origin', ...(branch ? [branch] : [])], repoDir) + await exec('git', ['reset', '--hard', branch ? `origin/${branch}` : 'FETCH_HEAD'], repoDir) + } else { + const cloneArgs = ['clone', '--depth', '1'] + if (branch) cloneArgs.push('--branch', branch) + cloneArgs.push(repo, repoDir) + await exec('git', cloneArgs) + } + + repoDirUrls.set(repoDir, repo) + + const commitHash = await exec('git', ['rev-parse', 'HEAD'], repoDir) + return { commitHash } +} diff --git a/server/lib/pgschema.ts b/server/lib/pgschema.ts new file mode 100644 index 0000000..4385fd7 --- /dev/null +++ b/server/lib/pgschema.ts @@ -0,0 +1,162 @@ +import { execFile } from 'child_process' +import { writeFile } from 'fs/promises' +import { join } from 'path' +import type { ConnectionConfig } from './config' + +export interface PlanDiff { + sql: string + type: string + operation: string + path: string + canRunInTransaction: boolean +} + +export interface ParsedPlan { + sourceFingerprint: string + diffs: PlanDiff[] + canRunInTransaction: boolean + summary: string +} + +export interface PgSchemaPlanJson { + schemas?: Record + }> + }> +} + +export function parsePlanJson(json: PgSchemaPlanJson, schema: string): ParsedPlan { + const schemaData = json.schemas?.[schema] + const sourceFingerprint = schemaData?.source_fingerprint?.hash ?? '' + + const diffs: PlanDiff[] = [] + let canRunInTransaction = true + for (const group of schemaData?.groups ?? []) { + const groupTxn = group.can_run_in_transaction !== false + if (!groupTxn) canRunInTransaction = false + for (const step of group.steps) { + diffs.push({ + sql: step.sql, + type: step.type, + operation: step.operation, + path: step.path, + canRunInTransaction: groupTxn, + }) + } + } + + const counts = new Map() + for (const d of diffs) { + counts.set(d.operation, (counts.get(d.operation) || 0) + 1) + } + + let summary: string + if (diffs.length === 0) { + summary = 'No changes' + } else { + const parts: string[] = [] + for (const op of ['create', 'alter', 'drop']) { + const count = counts.get(op) + if (count) parts.push(`${count} to ${op}`) + } + summary = `${diffs.length} changes: ${parts.join(', ')}` + } + + return { sourceFingerprint, diffs, canRunInTransaction, summary } +} + +function connectionArgs(conn: ConnectionConfig): string[] { + return [ + '--host', conn.host, + '--port', String(conn.port), + '--db', conn.database, + '--user', conn.username, + '--sslmode', conn.ssl_mode || 'prefer', + ...(conn.ssl_ca ? ['--ssl-ca', conn.ssl_ca] : []), + ...(conn.ssl_cert ? ['--ssl-cert', conn.ssl_cert] : []), + ...(conn.ssl_key ? ['--ssl-key', conn.ssl_key] : []), + ...(conn.statement_timeout ? ['--statement-timeout', conn.statement_timeout] : []), + ] +} + +function connectionEnv(conn: ConnectionConfig): Record { + const env: Record = {} + if (conn.password) { + env.PGPASSWORD = conn.password + } + return env +} + +function execPgSchema(args: string[], timeoutMs: number, extraEnv?: Record, cwd?: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const env = extraEnv ? { ...process.env, ...extraEnv } : undefined + execFile('pgschema', args, { timeout: timeoutMs, env, cwd }, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)) + } else { + resolve({ stdout, stderr }) + } + }) + }) +} + +const PGSCHEMA_IGNORE = `[tables]\npatterns = ["_pgconsole"]\n` + +async function ensureIgnoreFile(repoDir: string): Promise { + await writeFile(join(repoDir, '.pgschemaignore'), PGSCHEMA_IGNORE) +} + +export async function runPgSchemaPlan( + conn: ConnectionConfig, + schemaFilePath: string, + outputJsonPath: string, + pgSchema: string, + repoDir?: string, +): Promise { + if (repoDir) { + await ensureIgnoreFile(repoDir) + } + try { + await execPgSchema([ + 'plan', + ...connectionArgs(conn), + '--schema', pgSchema, + '--file', schemaFilePath, + '--output-json', outputJsonPath, + '--no-color', + ], 120_000, connectionEnv(conn), repoDir) + } catch (err) { + throw new Error(`pgschema plan failed: ${(err as Error).message}`) + } +} + +export async function runPgSchemaApply( + conn: ConnectionConfig, + planJsonPath: string, + repoDir?: string, +): Promise { + if (repoDir) { + await ensureIgnoreFile(repoDir) + } + try { + const { stdout } = await execPgSchema([ + 'apply', + ...connectionArgs(conn), + '--plan', planJsonPath, + '--auto-approve', + '--no-color', + ...(conn.lock_timeout ? ['--lock-timeout', conn.lock_timeout] : []), + ], 300_000, connectionEnv(conn), repoDir) + return stdout + } catch (err) { + throw new Error(`pgschema apply failed: ${(err as Error).message}`) + } +} diff --git a/server/lib/plan-store.ts b/server/lib/plan-store.ts new file mode 100644 index 0000000..d241922 --- /dev/null +++ b/server/lib/plan-store.ts @@ -0,0 +1,48 @@ +import { randomUUID } from 'crypto' +import type { PgSchemaPlanJson } from './pgschema' + +const PLAN_TTL_MS = 30 * 60 * 1000 + +export interface StoredPlan { + connectionId: string + planJsonPath: string + planData: PgSchemaPlanJson + schema: string + createdAt: number +} + +const plans = new Map() + +function evictExpired(): void { + const now = Date.now() + for (const [id, plan] of plans) { + if (now - plan.createdAt > PLAN_TTL_MS) plans.delete(id) + } +} + +export function storePlan(opts: { connectionId: string; planJsonPath: string; planData: PgSchemaPlanJson; schema: string }): string { + evictExpired() + const id = randomUUID() + plans.set(id, { + connectionId: opts.connectionId, + planJsonPath: opts.planJsonPath, + planData: opts.planData, + schema: opts.schema, + createdAt: Date.now(), + }) + return id +} + +export function getPlan(planId: string): StoredPlan | undefined { + const plan = plans.get(planId) + if (!plan) return undefined + if (Date.now() - plan.createdAt > PLAN_TTL_MS) { + plans.delete(planId) + return undefined + } + return plan +} + +export function removePlan(planId: string): void { + plans.delete(planId) +} diff --git a/server/services/metadata-service.ts b/server/services/metadata-service.ts new file mode 100644 index 0000000..54f4f52 --- /dev/null +++ b/server/services/metadata-service.ts @@ -0,0 +1,203 @@ +import { ConnectError, Code } from "@connectrpc/connect" +import type { ServiceImpl } from "@connectrpc/connect" +import { MetadataService } from "../../src/gen/metadata_connect" +import { getConnectionById } from "../lib/config" +import { withConnection, type ConnectionDetails } from "../lib/db" +import { getUserFromContext } from "../connect" +import { requirePermission } from "../lib/iam" + +function getConnectionDetails(connectionId: string): ConnectionDetails { + const conn = getConnectionById(connectionId) + if (!conn) { + throw new ConnectError("Connection not found", Code.NotFound) + } + return { + host: conn.host, + port: conn.port, + database: conn.database, + username: conn.username, + password: conn.password, + sslMode: conn.ssl_mode || "prefer", + lockTimeout: conn.lock_timeout, + statementTimeout: conn.statement_timeout, + } +} + +async function requireMetadataTable(details: ConnectionDetails, email?: string): Promise { + const exists = await withConnection(details, async (sql) => { + const rows = await sql` + SELECT 1 FROM pg_class + WHERE relname = '_pgconsole' AND relkind = 'r' + ` + return rows.length > 0 + }, email) + + if (!exists) { + throw new ConnectError( + "Metadata table not initialized. Call InitMetadataTable first.", + Code.FailedPrecondition + ) + } +} + +export const metadataServiceHandlers: ServiceImpl = { + async initMetadataTable(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, "admin", "initialize metadata table") + + const details = getConnectionDetails(req.connectionId) + + await withConnection(details, async (sql) => { + await sql` + CREATE TABLE IF NOT EXISTS _pgconsole ( + key TEXT NOT NULL PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + }, user?.email) + + return { success: true } + }, + + async getMetadata(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument) + } + if (!req.key) { + throw new ConnectError("key is required", Code.InvalidArgument) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, "read", "read metadata") + + const details = getConnectionDetails(req.connectionId) + await requireMetadataTable(details, user?.email) + + const row = await withConnection(details, async (sql) => { + const rows = await sql` + SELECT key, value, updated_at + FROM _pgconsole + WHERE key = ${req.key} + ` + return rows[0] + }, user?.email) + + if (!row) { + throw new ConnectError(`Key not found: ${req.key}`, Code.NotFound) + } + + return { + entry: { + key: row.key as string, + value: JSON.stringify(row.value), + updatedAt: (row.updated_at as Date).toISOString(), + }, + } + }, + + async setMetadata(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument) + } + if (!req.key) { + throw new ConnectError("key is required", Code.InvalidArgument) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, "write", "write metadata") + + let parsedValue: unknown + try { + parsedValue = JSON.parse(req.value) + } catch { + throw new ConnectError("value must be valid JSON", Code.InvalidArgument) + } + + const details = getConnectionDetails(req.connectionId) + await requireMetadataTable(details, user?.email) + + const row = await withConnection(details, async (sql) => { + const rows = await sql` + INSERT INTO _pgconsole (key, value, updated_at) + VALUES (${req.key}, ${sql.json(parsedValue)}, now()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = now() + RETURNING key, value, updated_at + ` + return rows[0] + }, user?.email) + + return { + entry: { + key: row.key as string, + value: JSON.stringify(row.value), + updatedAt: (row.updated_at as Date).toISOString(), + }, + } + }, + + async deleteMetadata(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument) + } + if (!req.key) { + throw new ConnectError("key is required", Code.InvalidArgument) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, "write", "delete metadata") + + const details = getConnectionDetails(req.connectionId) + await requireMetadataTable(details, user?.email) + + const deleted = await withConnection(details, async (sql) => { + const rows = await sql` + DELETE FROM _pgconsole WHERE key = ${req.key} RETURNING 1 + ` + return rows.length > 0 + }, user?.email) + + return { success: deleted } + }, + + async listMetadata(req, context) { + if (!req.connectionId) { + throw new ConnectError("connection_id is required", Code.InvalidArgument) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, "read", "list metadata") + + const details = getConnectionDetails(req.connectionId) + await requireMetadataTable(details, user?.email) + + const rows = await withConnection(details, async (sql) => { + if (req.prefix) { + return sql` + SELECT key, value, updated_at + FROM _pgconsole + WHERE key LIKE ${req.prefix + '%'} + ORDER BY key + ` + } + return sql` + SELECT key, value, updated_at + FROM _pgconsole + ORDER BY key + ` + }, user?.email) + + return { + entries: rows.map((row) => ({ + key: row.key as string, + value: JSON.stringify(row.value), + updatedAt: (row.updated_at as Date).toISOString(), + })), + } + }, +} diff --git a/server/services/migration-service.ts b/server/services/migration-service.ts new file mode 100644 index 0000000..3f3ebae --- /dev/null +++ b/server/services/migration-service.ts @@ -0,0 +1,245 @@ +import { ConnectError, Code } from '@connectrpc/connect' +import type { ServiceImpl } from '@connectrpc/connect' +import { MigrationService } from '../../src/gen/migration_connect' +import { getConnectionById } from '../lib/config' +import type { ConnectionConfig } from '../lib/config' +import { withConnection, type ConnectionDetails } from '../lib/db' +import { getUserFromContext } from '../connect' +import { requirePermission } from '../lib/iam' +import { syncRepo, getRepoDir } from '../lib/git' +import { runPgSchemaPlan, runPgSchemaApply, parsePlanJson, type PgSchemaPlanJson } from '../lib/pgschema' +import { storePlan, getPlan, removePlan } from '../lib/plan-store' +import { readFile } from 'fs/promises' +import { join, resolve } from 'path' +import { tmpdir } from 'os' +import { randomUUID } from 'crypto' + +interface SchemaSource { + repo: string + branch: string + path: string + schema: string +} + +function getConnectionDetails(conn: ConnectionConfig): ConnectionDetails { + return { + host: conn.host, + port: conn.port, + database: conn.database, + username: conn.username, + password: conn.password, + sslMode: conn.ssl_mode || 'prefer', + lockTimeout: conn.lock_timeout, + statementTimeout: conn.statement_timeout, + } +} + +async function getSchemaSource(details: ConnectionDetails, email?: string): Promise { + return withConnection(details, async (sql) => { + const rows = await sql` + SELECT 1 FROM pg_class WHERE relname = '_pgconsole' AND relkind = 'r' + ` + if (rows.length === 0) return null + + const result = await sql` + SELECT value FROM _pgconsole WHERE key = 'schema_source' + ` + if (result.length === 0) return null + + const value = result[0].value as Record + if (!value.repo || typeof value.repo !== 'string') return null + if (!value.path || typeof value.path !== 'string') return null + + return { + repo: value.repo, + branch: typeof value.branch === 'string' ? value.branch : 'main', + path: value.path, + schema: typeof value.schema === 'string' ? value.schema : 'public', + } + }, email) +} + +function validateSchemaPath(repoDir: string, schemaPath: string): string { + const resolved = resolve(repoDir, schemaPath) + if (!resolved.startsWith(resolve(repoDir) + '/')) { + throw new ConnectError('schema_source.path escapes the repository directory', Code.InvalidArgument) + } + return resolved +} + +export const migrationServiceHandlers: ServiceImpl = { + async planMigration(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'read', 'plan migration') + + const details = getConnectionDetails(conn) + const schemaSource = await getSchemaSource(details, user?.email) + if (!schemaSource) { + throw new ConnectError( + 'No schema_source configured. Use SetMetadata to store a schema_source entry in the _pgconsole table.', + Code.FailedPrecondition, + ) + } + + const { repo, branch, path: schemaPath, schema: pgSchema } = schemaSource + + let commitHash: string + try { + const result = await syncRepo(req.connectionId, repo, branch) + commitHash = result.commitHash + } catch (err) { + throw new ConnectError( + `Failed to sync git repo: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + const repoDir = getRepoDir(req.connectionId) + const schemaFilePath = validateSchemaPath(repoDir, schemaPath) + const outputJsonPath = join(tmpdir(), `pgconsole-plan-${randomUUID()}.json`) + + try { + await runPgSchemaPlan(conn, schemaFilePath, outputJsonPath, pgSchema, repoDir) + } catch (err) { + throw new ConnectError( + `pgschema plan failed: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + let planJson: PgSchemaPlanJson + try { + const raw = await readFile(outputJsonPath, 'utf-8') + planJson = JSON.parse(raw) as PgSchemaPlanJson + } catch (err) { + throw new ConnectError( + `Failed to read plan output: ${err instanceof Error ? err.message : String(err)}`, + Code.Internal, + ) + } + + const parsed = parsePlanJson(planJson, pgSchema) + + const planId = storePlan({ + connectionId: req.connectionId, + planJsonPath: outputJsonPath, + planData: planJson, + schema: pgSchema, + }) + + return { + planId, + branch: branch || 'default', + commitHash, + sourceFingerprint: parsed.sourceFingerprint, + diffs: parsed.diffs.map(d => ({ + sql: d.sql, + type: d.type, + operation: d.operation, + path: d.path, + canRunInTransaction: d.canRunInTransaction, + })), + canRunInTransaction: parsed.canRunInTransaction, + summary: parsed.summary, + } + }, + + async *applyMigration(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + if (!req.planId) { + throw new ConnectError('plan_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'ddl', 'apply migration') + + const plan = getPlan(req.planId) + if (!plan) { + throw new ConnectError('Plan not found or expired. Please re-run plan.', Code.NotFound) + } + + if (plan.connectionId !== req.connectionId) { + throw new ConnectError('Plan does not match connection', Code.InvalidArgument) + } + + const parsed = parsePlanJson(plan.planData as Parameters[0], plan.schema) + const totalSteps = parsed.diffs.length + + yield { + step: 0, + totalSteps, + sql: '', + status: 'running', + error: '', + } + + try { + const repoDir = getRepoDir(req.connectionId) + await runPgSchemaApply(conn, plan.planJsonPath, repoDir) + } catch (err) { + yield { + step: totalSteps, + totalSteps, + sql: '', + status: 'failed', + error: err instanceof Error ? err.message : String(err), + } + return + } + + yield { + step: totalSteps, + totalSteps, + sql: '', + status: 'completed', + error: '', + } + + removePlan(req.planId) + }, + + async getSchemaSourceStatus(req, context) { + if (!req.connectionId) { + throw new ConnectError('connection_id is required', Code.InvalidArgument) + } + + const conn = getConnectionById(req.connectionId) + if (!conn) { + throw new ConnectError('Connection not found', Code.NotFound) + } + + const user = await getUserFromContext(context.values) + requirePermission(user, req.connectionId, 'read', 'check schema source status') + + const details = getConnectionDetails(conn) + const schemaSource = await getSchemaSource(details, user?.email) + + if (!schemaSource) { + return { configured: false, repo: '', branch: '', path: '', schema: '' } + } + + return { + configured: true, + repo: schemaSource.repo, + branch: schemaSource.branch, + path: schemaSource.path, + schema: schemaSource.schema, + } + }, +} diff --git a/src/components/sql-editor/ContextPanel.tsx b/src/components/sql-editor/ContextPanel.tsx index 510828c..e0b0a73 100644 --- a/src/components/sql-editor/ContextPanel.tsx +++ b/src/components/sql-editor/ContextPanel.tsx @@ -5,7 +5,7 @@ import { ScrollArea } from '../ui/scroll-area' import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Tooltip, TooltipTrigger, TooltipPopup, TooltipProvider } from '../ui/tooltip' -import { SQLDefinition } from './schema/shared' +import { SQLDefinition } from './schema' import { FunctionDefinitionModal } from './FunctionDefinitionModal' import { FunctionArgumentList } from './FunctionArgumentForm' import { parseFunctionArguments } from '@/lib/sql/parse-function-args' diff --git a/src/components/sql-editor/RightPanel.tsx b/src/components/sql-editor/RightPanel.tsx index 2988821..148c863 100644 --- a/src/components/sql-editor/RightPanel.tsx +++ b/src/components/sql-editor/RightPanel.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils' import { Chat } from './Chat' import { ContextPanel } from './ContextPanel' +import { MigrationPanel } from './schema' import type { SelectedObject } from './SQLEditorLayout' import type { PanelTab } from './hooks/useEditorTabs' import type { ObjectType } from './ObjectTree' @@ -53,11 +54,25 @@ export function RightPanel({ open, width, activeTab, onActiveTabChange, connecti > Chat +

{activeTab === 'context' ? ( + ) : activeTab === 'migration' ? ( + ) : ( )} diff --git a/src/components/sql-editor/hooks/useEditorTabs.ts b/src/components/sql-editor/hooks/useEditorTabs.ts index 8adb2a4..f7a7de2 100644 --- a/src/components/sql-editor/hooks/useEditorTabs.ts +++ b/src/components/sql-editor/hooks/useEditorTabs.ts @@ -54,7 +54,7 @@ export interface TabState { foldedRanges?: string[] // Array of "from:to" strings for folded regions } -export type PanelTab = 'context' | 'chat' +export type PanelTab = 'context' | 'chat' | 'migration' export interface RightPanelState { open: boolean diff --git a/src/components/sql-editor/schema/MigrationPanel.tsx b/src/components/sql-editor/schema/MigrationPanel.tsx new file mode 100644 index 0000000..b3ef549 --- /dev/null +++ b/src/components/sql-editor/schema/MigrationPanel.tsx @@ -0,0 +1,394 @@ +import { useState } from 'react' +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog' +import { GitBranch, Play, RefreshCw, AlertTriangle, CircleCheck, Database } from 'lucide-react' +import { Button } from '../../ui/button' +import { Badge } from '../../ui/badge' +import { Input } from '../../ui/input' +import { ScrollArea } from '../../ui/scroll-area' +import { Spinner } from '../../ui/spinner' +import { toastManager } from '../../ui/toast' +import { usePlanMigration, useApplyMigration, useSchemaSourceStatus, useMetadataTableStatus, useInitMetadataTable, useSetSchemaSource } from '../../../hooks/useMigration' +import { useConnectionPermissions } from '../../../hooks/usePermissions' +import { useQueryClient } from '@tanstack/react-query' +import { invalidateSchemaQueries } from '../../../hooks/useQuery' +import type { SchemaDiff } from '@/gen/migration_pb' + +interface MigrationPanelProps { + connectionId: string +} + +const operationColor: Record = { + create: 'text-green-700 bg-green-50', + alter: 'text-blue-700 bg-blue-50', + drop: 'text-red-700 bg-red-50', +} + +const operationIcon: Record = { + create: '+', + alter: '~', + drop: '-', +} + +function DiffItem({ diff }: { diff: SchemaDiff }) { + const colorClass = operationColor[diff.operation] || 'text-gray-700 bg-gray-50' + const icon = operationIcon[diff.operation] || '?' + + return ( +
+ {icon} + {diff.path} + + {diff.operation.toUpperCase()} + + {diff.type} +
+ ) +} + +type PanelState = + | { kind: 'idle' } + | { kind: 'planning' } + | { kind: 'plan-error'; error: string } + | { kind: 'up-to-date'; plan: { branch: string; commitHash: string } } + | { kind: 'has-diffs'; plan: { branch: string; commitHash: string; summary: string; canRunInTransaction: boolean }; diffs: SchemaDiff[] } + | { kind: 'applying' } + | { kind: 'apply-error'; error: string } + | { kind: 'apply-success' } + +function derivePanelState( + planMutation: ReturnType, + applyMutation: ReturnType, +): PanelState { + if (applyMutation.isPending) return { kind: 'applying' } + if (applyMutation.isError) return { kind: 'apply-error', error: applyMutation.error.message } + if (applyMutation.isSuccess) return { kind: 'apply-success' } + if (planMutation.isPending) return { kind: 'planning' } + if (planMutation.isError) return { kind: 'plan-error', error: planMutation.error.message } + if (planMutation.data) { + const plan = planMutation.data + if (plan.diffs.length === 0) return { kind: 'up-to-date', plan } + return { kind: 'has-diffs', plan, diffs: plan.diffs } + } + return { kind: 'idle' } +} + +function StatusMessage({ icon, text, className }: { icon: React.ReactNode; text: string; className: string }) { + return ( +
+ {icon} + {text} +
+ ) +} + +function SchemaSourceSetup({ connectionId }: { connectionId: string }) { + const { hasAdmin } = useConnectionPermissions(connectionId) + const tableStatus = useMetadataTableStatus(connectionId) + const initTable = useInitMetadataTable() + const setSchemaSource = useSetSchemaSource() + const [confirmOpen, setConfirmOpen] = useState(false) + const [repo, setRepo] = useState('') + const [branch, setBranch] = useState('main') + const [path, setPath] = useState('') + const [schema, setSchema] = useState('public') + + if (tableStatus.isLoading) { + return ( +
+ + Checking metadata table... +
+ ) + } + + if (!tableStatus.data?.initialized) { + const handleInit = () => { + setConfirmOpen(false) + initTable.mutate(connectionId, { + onError: (err) => { + toastManager.add({ type: 'error', title: 'Failed to create metadata table', description: err.message }) + }, + }) + } + + return ( +
+ } text="Metadata table not initialized" className="text-gray-500" /> +

+ Migration features require a _pgconsole table in your database to store configuration. +

+ {hasAdmin ? ( + + ) : ( +

Admin permission required to initialize.

+ )} + + + + + + + + Create Metadata Table + + + This will create a _pgconsole table in your database to store pgconsole configuration. Continue? + +
+ + +
+
+
+
+
+
+ ) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!repo.trim() || !path.trim()) return + setSchemaSource.mutate( + { connectionId, source: { repo: repo.trim(), branch: branch.trim() || 'main', path: path.trim(), schema: schema.trim() || 'public' } }, + { + onSuccess: () => { + toastManager.add({ type: 'success', title: 'Schema source configured' }) + }, + onError: (err) => { + toastManager.add({ type: 'error', title: 'Failed to save schema source', description: err.message }) + }, + }, + ) + } + + return ( +
+ } text="Configure schema source" className="text-gray-600" /> +
+
+ + setRepo(e.target.value)} placeholder="https://github.com/org/repo.git" required /> +
+
+ + setBranch(e.target.value)} placeholder="main" /> +
+
+ + setPath(e.target.value)} placeholder="schema.sql" required /> +
+
+ + setSchema(e.target.value)} placeholder="public" /> +
+ +
+
+ ) +} + +export function MigrationPanel({ connectionId }: MigrationPanelProps) { + const { hasDdl } = useConnectionPermissions(connectionId) + const queryClient = useQueryClient() + const statusQuery = useSchemaSourceStatus(connectionId) + const planMutation = usePlanMigration() + const applyMutation = useApplyMigration() + const [showSql, setShowSql] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + + if (statusQuery.isLoading) { + return ( +
+ + Checking configuration... +
+ ) + } + + if (!statusQuery.data?.configured) { + return + } + + const state = derivePanelState(planMutation, applyMutation) + + const handlePlan = () => { + applyMutation.reset() + planMutation.mutate(connectionId) + } + + const handleApply = () => { + if (!planMutation.data) return + setConfirmOpen(false) + applyMutation.mutate( + { connectionId, planId: planMutation.data.planId }, + { + onSuccess: () => { + invalidateSchemaQueries(queryClient, connectionId) + planMutation.reset() + }, + }, + ) + } + + switch (state.kind) { + case 'idle': + return ( +
+ } text="Compare current database schema with git source" className="text-gray-600" /> + +
+ ) + + case 'planning': + return ( +
+ + Analyzing schema differences... +
+ ) + + case 'plan-error': + return ( +
+ } text="Failed to generate plan" className="text-red-600" /> +

{state.error}

+ +
+ ) + + case 'up-to-date': + return ( +
+ } text="Schema is up to date with git" className="text-green-600" /> +

+ Branch: {state.plan.branch} · Commit: {state.plan.commitHash.slice(0, 7)} +

+ +
+ ) + + case 'applying': + return ( +
+ + Applying migration... +
+ ) + + case 'apply-error': + return ( +
+ } text="Migration failed" className="text-red-600" /> +

{state.error}

+ +
+ ) + + case 'apply-success': + return ( +
+ } text="Migration applied successfully" className="text-green-600" /> + +
+ ) + + case 'has-diffs': + return ( +
+
+
+ {state.plan.summary} +
+ + {hasDdl && ( + + )} +
+
+

+ Branch: {state.plan.branch} · Commit: {state.plan.commitHash.slice(0, 7)} +

+
+ + +
+ {state.diffs.map((diff, i) => ( + + ))} +
+
+ +
+ + {showSql && ( +
+
+                  {state.diffs.map(d => d.sql).join('\n\n')}
+                
+
+ )} +
+ + + + + + + + Apply Migration + + + This will execute {state.diffs.length} DDL statement{state.diffs.length > 1 ? 's' : ''} against the database. + {!state.plan.canRunInTransaction && ( + + Warning: Some operations cannot run in a transaction and will be applied individually. + + )} + +
+ + +
+
+
+
+
+
+ ) + } +} diff --git a/src/components/sql-editor/schema/index.ts b/src/components/sql-editor/schema/index.ts index f173eca..cef1840 100644 --- a/src/components/sql-editor/schema/index.ts +++ b/src/components/sql-editor/schema/index.ts @@ -1,4 +1,5 @@ export { TableSchemaContent } from './TableSchemaContent' export { ViewSchemaContent } from './ViewSchemaContent' export { FunctionSchemaContent } from './FunctionSchemaContent' +export { MigrationPanel } from './MigrationPanel' export * from './shared' diff --git a/src/hooks/useMigration.ts b/src/hooks/useMigration.ts new file mode 100644 index 0000000..a73590c --- /dev/null +++ b/src/hooks/useMigration.ts @@ -0,0 +1,86 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { migrationClient, metadataClient } from '@/lib/connect-client' +import { ConnectError } from '@connectrpc/connect' + +export function useSchemaSourceStatus(connectionId: string) { + return useQuery({ + queryKey: ['migration', 'schema-source-status', connectionId], + queryFn: () => migrationClient.getSchemaSourceStatus({ connectionId }), + enabled: !!connectionId, + }) +} + +export function usePlanMigration() { + return useMutation({ + mutationFn: (connectionId: string) => + migrationClient.planMigration({ connectionId }), + }) +} + +export function useMetadataTableStatus(connectionId: string) { + return useQuery({ + queryKey: ['metadata', 'table-status', connectionId], + queryFn: async () => { + try { + await metadataClient.listMetadata({ connectionId, prefix: '' }) + return { initialized: true } + } catch (err) { + if (err instanceof ConnectError && err.code === 9 /* FailedPrecondition */) { + return { initialized: false } + } + throw err + } + }, + enabled: !!connectionId, + }) +} + +export function useInitMetadataTable() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (connectionId: string) => + metadataClient.initMetadataTable({ connectionId }), + onSuccess: (_, connectionId) => { + qc.invalidateQueries({ queryKey: ['metadata', 'table-status', connectionId] }) + }, + }) +} + +export function useSetSchemaSource() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ connectionId, source }: { connectionId: string; source: { repo: string; branch: string; path: string; schema: string } }) => + metadataClient.setMetadata({ + connectionId, + key: 'schema_source', + value: JSON.stringify(source), + }), + onSuccess: (_, { connectionId }) => { + qc.invalidateQueries({ queryKey: ['migration', 'schema-source-status', connectionId] }) + }, + }) +} + +export function useApplyMigration() { + return useMutation({ + mutationFn: async ( + params: { connectionId: string; planId: string }, + ) => { + const results: Array<{ step: number; totalSteps: number; sql: string; status: string; error: string }> = [] + for await (const response of migrationClient.applyMigration(params)) { + results.push({ + step: response.step, + totalSteps: response.totalSteps, + sql: response.sql, + status: response.status, + error: response.error, + }) + } + const failed = results.find(r => r.status === 'failed') + if (failed) { + throw new Error(failed.error || 'Migration apply failed') + } + return results + }, + }) +} diff --git a/src/lib/connect-client.ts b/src/lib/connect-client.ts index 5f575db..80108ed 100644 --- a/src/lib/connect-client.ts +++ b/src/lib/connect-client.ts @@ -3,6 +3,8 @@ import { createPromiseClient } from '@connectrpc/connect'; import { ConnectionService } from '../gen/connection_connect'; import { QueryService } from '../gen/query_connect'; import { AIService } from '../gen/ai_connect'; +import { MigrationService } from '../gen/migration_connect'; +import { MetadataService } from '../gen/metadata_connect'; // Create transport for browser // Uses relative URLs - works with Vite proxy in dev and same-origin server in prod @@ -15,3 +17,5 @@ const transport = createConnectTransport({ export const connectionClient = createPromiseClient(ConnectionService, transport); export const queryClient = createPromiseClient(QueryService, transport); export const aiClient = createPromiseClient(AIService, transport); +export const migrationClient = createPromiseClient(MigrationService, transport); +export const metadataClient = createPromiseClient(MetadataService, transport); diff --git a/tests/config-schema-source.test.ts b/tests/config-schema-source.test.ts new file mode 100644 index 0000000..69e7fb9 --- /dev/null +++ b/tests/config-schema-source.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { loadConfigFromString } from '../server/lib/config' +import { getConnections } from '../server/lib/config' + +describe('schema_source config parsing', () => { + it('parses connection without schema_source', async () => { + await loadConfigFromString(` +[[connections]] +id = "local" +name = "Local" +host = "localhost" +port = 5432 +database = "postgres" +username = "postgres" +`) + const conns = getConnections() + expect(conns[0].schema_source).toBeUndefined() + }) + + it('parses connection with full schema_source', async () => { + await loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +branch = "main" +path = "schema/main.sql" +schema = "public" +`) + const conns = getConnections() + expect(conns[0].schema_source).toEqual({ + repo: 'https://github.com/myorg/db-schema.git', + branch: 'main', + path: 'schema/main.sql', + schema: 'public', + }) + }) + + it('defaults branch to undefined and schema to public', async () => { + await loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +path = "schema/main.sql" +`) + const conns = getConnections() + expect(conns[0].schema_source).toEqual({ + repo: 'https://github.com/myorg/db-schema.git', + branch: undefined, + path: 'schema/main.sql', + schema: 'public', + }) + }) + + it('throws when schema_source.repo is missing', async () => { + await expect(loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +path = "schema/main.sql" +`)).rejects.toThrow('schema_source.repo') + }) + + it('throws when schema_source.path is missing', async () => { + await expect(loadConfigFromString(` +[[connections]] +id = "staging" +name = "Staging" +host = "staging.example.com" +port = 5432 +database = "myapp" +username = "app_user" + +[connections.schema_source] +repo = "https://github.com/myorg/db-schema.git" +`)).rejects.toThrow('schema_source.path') + }) +}) diff --git a/tests/pgschema.test.ts b/tests/pgschema.test.ts new file mode 100644 index 0000000..aead582 --- /dev/null +++ b/tests/pgschema.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { parsePlanJson } from '../server/lib/pgschema' + +describe('parsePlanJson', () => { + it('parses a plan with diffs', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [ + { + can_run_in_transaction: true, + steps: [ + { + sql: 'ALTER TABLE users ADD COLUMN name varchar(100);', + type: 'table', + operation: 'alter', + path: 'public.users', + }, + ], + }, + { + can_run_in_transaction: false, + steps: [ + { + sql: 'CREATE INDEX CONCURRENTLY idx_users_email ON users(email);', + type: 'index', + operation: 'create', + path: 'public.idx_users_email', + }, + ], + }, + ], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.sourceFingerprint).toBe('abc123') + expect(result.diffs).toHaveLength(2) + expect(result.diffs[0]).toEqual({ + sql: 'ALTER TABLE users ADD COLUMN name varchar(100);', + type: 'table', + operation: 'alter', + path: 'public.users', + canRunInTransaction: true, + }) + expect(result.canRunInTransaction).toBe(false) + expect(result.summary).toBe('2 changes: 1 to create, 1 to alter') + }) + + it('parses an empty plan', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.diffs).toHaveLength(0) + expect(result.canRunInTransaction).toBe(true) + expect(result.summary).toBe('No changes') + }) + + it('generates correct summary with all operation types', () => { + const json = { + schemas: { + public: { + source_fingerprint: { hash: 'abc123' }, + groups: [ + { + can_run_in_transaction: true, + steps: [ + { sql: 'CREATE TABLE a ();', type: 'table', operation: 'create', path: 'public.a' }, + { sql: 'CREATE TABLE b ();', type: 'table', operation: 'create', path: 'public.b' }, + { sql: 'ALTER TABLE c ADD COLUMN x int;', type: 'table', operation: 'alter', path: 'public.c' }, + { sql: 'DROP TABLE d;', type: 'table', operation: 'drop', path: 'public.d' }, + ], + }, + ], + }, + }, + } + + const result = parsePlanJson(json, 'public') + expect(result.summary).toBe('4 changes: 2 to create, 1 to alter, 1 to drop') + }) +}) diff --git a/tests/plan-store.test.ts b/tests/plan-store.test.ts new file mode 100644 index 0000000..4e9bb6b --- /dev/null +++ b/tests/plan-store.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { storePlan, getPlan, removePlan } from '../server/lib/plan-store' + +describe('plan-store', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('stores and retrieves a plan', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + const plan = getPlan(planId) + expect(plan).toBeDefined() + expect(plan!.connectionId).toBe('staging') + expect(plan!.planJsonPath).toBe('/tmp/plan.json') + }) + + it('returns undefined for unknown plan', () => { + expect(getPlan('nonexistent')).toBeUndefined() + }) + + it('removes a plan', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + removePlan(planId) + expect(getPlan(planId)).toBeUndefined() + }) + + it('expires plans after 30 minutes', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + vi.advanceTimersByTime(31 * 60 * 1000) + + expect(getPlan(planId)).toBeUndefined() + }) + + it('returns plan before expiry', () => { + const planId = storePlan({ + connectionId: 'staging', + planJsonPath: '/tmp/plan.json', + planData: { schemas: {} }, + schema: 'public', + }) + + vi.advanceTimersByTime(29 * 60 * 1000) + + expect(getPlan(planId)).toBeDefined() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 6369bc8..8bffc7d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,6 +77,8 @@ export default defineConfig(({ command }) => { '/connection.v1.ConnectionService': 'http://localhost:9876', '/query.v1.QueryService': 'http://localhost:9876', '/ai.v1.AIService': 'http://localhost:9876', + '/migration.v1.MigrationService': 'http://localhost:9876', + '/metadata.v1.MetadataService': 'http://localhost:9876', }, }, }