diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8cfe4c1b59..c39f909e34 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,4 +13,5 @@ # Generated code. These ownerless rules override the catch-all above so # CI-green sync PRs (e.g. Management API OpenAPI spec) can be auto-merged. +/apps/cli-go/pkg/api/*.gen.go /packages/api/src/generated/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 255d864933..7aa08a043b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,12 +65,6 @@ updates: - dependency-name: "axllent/mailpit" - dependency-name: "darthsim/imgproxy" - dependency-name: "timberio/vector" - # Held back: v2.109.0+ adds a setup_supabase_realtime_admin migration - # that fails against the CLI's local Postgres and breaks `supabase start`. - # Remove once the CLI's local stack is compatible with the new migration. - - dependency-name: "supabase/realtime" - versions: - - ">= 2.109.0" cooldown: default-days: 7 exclude: diff --git a/.github/workflows/api-package-sync.yml b/.github/workflows/api-package-sync.yml index 20a2f31178..bb1117b38a 100644 --- a/.github/workflows/api-package-sync.yml +++ b/.github/workflows/api-package-sync.yml @@ -65,14 +65,6 @@ jobs: branch: sync/api-package base: develop - - name: Approve a PR - if: steps.check.outputs.has_changes == 'true' && steps.cpr.outputs.pull-request-operation == 'created' - continue-on-error: true - run: gh pr review --approve --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} - - name: Enable Pull Request Automerge if: steps.check.outputs.has_changes == 'true' run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index 96db893c98..ff97ce69ec 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -77,7 +77,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -126,7 +126,7 @@ jobs: - name: Check existing build artifacts cache id: build-artifacts-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ @@ -137,7 +137,7 @@ jobs: - name: Save build artifacts cache if: steps.build-artifacts-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index f4656d09a4..f65a73c6e2 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -18,7 +18,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache: true @@ -61,14 +61,6 @@ jobs: branch: sync/api-types base: develop - - name: Approve a PR - if: steps.check.outputs.has_changes == 'true' && steps.cpr.outputs.pull-request-operation == 'created' - continue-on-error: true - run: gh pr review --approve --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} - - name: Enable Pull Request Automerge if: steps.check.outputs.has_changes == 'true' run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 1353d5b9e9..0fc58f8c37 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -23,7 +23,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/cli-go/go.mod cache: true @@ -37,7 +37,7 @@ jobs: - name: Check existing coverage cache id: coverage-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/cli-go/coverage.out key: cli-go-coverage-${{ github.run_id }}-v1 @@ -45,7 +45,7 @@ jobs: - name: Save coverage cache if: steps.coverage-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/cli-go/coverage.out key: cli-go-coverage-${{ github.run_id }}-v1 @@ -57,7 +57,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Restore coverage cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/cli-go/coverage.out key: cli-go-coverage-${{ github.run_id }}-v1 @@ -78,7 +78,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/cli-go/go.mod # Linter requires no cache @@ -98,7 +98,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/cli-go/go.mod cache: true @@ -124,7 +124,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/cli-go/go.mod cache: true @@ -142,7 +142,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: apps/cli-go/go.mod cache: true diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index 3d1c8dfa84..8999547a2e 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache: true diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml new file mode 100644 index 0000000000..4dfecad569 --- /dev/null +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -0,0 +1,57 @@ +name: Dispatch cli-e2e-ci + +# Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against +# a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). +# +# This is distinct from `live-e2e.yml`, which runs the cli-e2e package against +# real staging (api.supabase.green). Here the suite runs against a local supabox +# stack stood up inside the private cli-e2e-ci repo; we only fire the trigger and +# pass our head SHA — cli-e2e-ci checks that SHA out into its `cli` submodule. +# +# Opt-in by label to keep the expensive full-stack run off every PR: add the +# `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). +# cli-e2e-ci reports a `cli-e2e-ci / live` commit status back onto the head SHA. +# +# Fork PRs cannot dispatch (no access to the App secret); run cli-e2e-ci's own +# workflow_dispatch with `cli_ref` for those. +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + +jobs: + dispatch: + # Same-repo PRs only: fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so + # the App-token step would fail and leave a red check. Skip them cleanly — + # fork PRs use cli-e2e-ci's workflow_dispatch with `cli_ref` instead. + if: >- + contains(github.event.pull_request.labels.*.name, 'run-live-e2e-ci') + && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + # App token scoped to cli-e2e-ci with contents:write — the + # repository_dispatch REST endpoint requires write on the target repo. + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: supabase + repositories: cli-e2e-ci + permission-contents: write + + - name: Dispatch live run to cli-e2e-ci + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + CLI_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + echo "Dispatching cli-e2e-ci live run for PR #${PR_NUMBER} @ ${CLI_SHA}" + # Build the nested client_payload with jq — `gh api -f` sends a flat + # body and would not nest `client_payload.*` correctly. + jq -n --arg sha "$CLI_SHA" --argjson pr "$PR_NUMBER" \ + '{event_type: "cli-pr", client_payload: {cli_sha: $sha, pr_number: $pr}}' \ + | gh api -X POST repos/supabase/cli-e2e-ci/dispatches --input - diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 0aef0459ec..8b2d94e10f 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -56,7 +56,7 @@ jobs: - name: Check tested marker id: cache if: github.event_name == 'schedule' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .beta-marker key: live-e2e-beta-${{ steps.ver.outputs.version }} @@ -114,7 +114,7 @@ jobs: uses: ./.github/actions/setup - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum @@ -181,7 +181,7 @@ jobs: echo "${{ needs.gate.outputs.version }}" > .beta-marker/version - name: Save tested marker - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .beta-marker key: live-e2e-beta-${{ needs.gate.outputs.version }} diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 4c3a95c28e..e7486fc5f7 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -58,7 +58,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Restore preview build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 5a4577a695..3e917b971e 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -110,7 +110,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Restore build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ @@ -175,7 +175,7 @@ jobs: - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/smoke-docker-images.tar key: smoke-docker-images-debian-bookworm-slim-amazonlinux-2023-alpine-3.21-v1 @@ -246,7 +246,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Restore build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ @@ -305,7 +305,7 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Restore build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ @@ -382,7 +382,7 @@ jobs: done - name: Create draft GitHub Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: tag_name: v${{ inputs.version }} name: v${{ inputs.version }} @@ -475,7 +475,7 @@ jobs: # tarballs. Reading it here produced a formula whose sha256 rejected the # downloaded archive ("Formula reports different checksum"). - name: Restore build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ @@ -538,7 +538,7 @@ jobs: # tarballs. Reading it here would produce a manifest whose hash rejects the # downloaded archive. - name: Restore build artifacts cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e584a8075..e5da5adca0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum @@ -75,7 +75,7 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum @@ -138,7 +138,7 @@ jobs: - name: Cache Go CLI binary if: steps.detect.outputs.cli_e2e == 'true' id: cache-go-binary - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/cli-go/supabase-go key: go-cli-${{ runner.os }}-${{ hashFiles('apps/cli-go/**/*.go', @@ -147,7 +147,7 @@ jobs: - name: Setup Go if: steps.detect.outputs.cli_e2e == 'true' && steps.cache-go-binary.outputs.cache-hit != 'true' - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: apps/cli-go/go.mod cache-dependency-path: apps/cli-go/go.sum diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json index 374488d753..1fb4088bd8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json @@ -16,6 +16,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json index 153c80517e..289195ef7f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json @@ -16,6 +16,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json index 153c80517e..289195ef7f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json @@ -16,6 +16,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json index d0bd511ff9..4341b8a12a 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json @@ -1084,6 +1084,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json index ee925a30e0..b19751a226 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json @@ -1395,6 +1395,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json index 4d64509c06..263250637c 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json @@ -271,6 +271,9 @@ "s3Protocol": { "enabled": true }, + "purgeCache": { + "enabled": true + }, "icebergCatalog": { "enabled": true, "maxNamespaces": 10, diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index df82ea5be1..0e3dbc932f 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -5,6 +5,33 @@ import { testBehaviour, testParity } from "./test-context.ts"; const MIGRATION_NAME = "my_change"; +// The `migration … --local` parity cases deliberately exercise the +// connection-refused path (no local stack), and on that path the TS port's stderr +// does not yet byte-match the Go CLI: Go prints `Connecting to local database...`, +// the pgconn dial error, and `SetConnectSuggestion`'s Network-Restrictions hint, +// whereas the TS layer surfaces the `@effect/sql-pg` SqlError and the generic +// `--debug` suggestion. Porting that connect-error shaping is tracked separately +// (see the PR's local-connect parity note). Until then we keep exit code, stdout, +// request log, and filesystem under strict parity and canonicalize the known stderr +// divergence down to the shared `failed to connect to postgres:` prefix. The +// `exits non-zero on connection refused` behaviour tests below still assert the +// meaningful stderr substring and non-zero exit, so the contract stays covered. +const CONNECT_REFUSED_STDERR_STRIP: readonly RegExp[] = [ + // Go-only "Connecting to local database..." preamble (the TS port omits it). + /^Connecting to local database\.\.\.\n/m, + // Driver-specific detail after the shared "failed to connect to postgres:" prefix + // (Go: pgconn dial error; TS: effect/sql SqlError). + /(?<=failed to connect to postgres:).*/g, + // Go's SetConnectSuggestion: Network-Restrictions hint + dashboard URL line. + /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, + // TS's generic --debug suggestion. + /\nTry rerunning the command with --debug to troubleshoot the error\./g, +]; + +const connectRefusedParity = { + normalize: { stderr: { stripPatterns: CONNECT_REFUSED_STDERR_STRIP } }, +}; + describe("migrations", () => { describe("migration:new", () => { testBehaviour("creates timestamped sql file", async ({ run, workspace }) => { @@ -29,7 +56,7 @@ describe("migrations", () => { expect(result.stderr).toContain("failed to connect"); }); - testParity(["migration", "list", "--local"]); + testParity(["migration", "list", "--local"], connectRefusedParity); }); describe("migration:up", () => { @@ -39,7 +66,7 @@ describe("migrations", () => { expect(result.stderr).toContain("failed to connect"); }); - testParity(["migration", "up", "--local"]); + testParity(["migration", "up", "--local"], connectRefusedParity); }); describe("migration:down", () => { @@ -55,8 +82,8 @@ describe("migrations", () => { expect(result.stderr).toContain("failed to connect"); }); - testParity(["migration", "down", "--local"]); - testParity(["migration", "down", "--last", "2", "--local"]); + testParity(["migration", "down", "--local"], connectRefusedParity); + testParity(["migration", "down", "--last", "2", "--local"], connectRefusedParity); }); describe("migration:repair", () => { @@ -79,7 +106,10 @@ describe("migrations", () => { expect(result.stderr).toContain("failed to connect"); }); - testParity(["migration", "repair", "--status", "applied", "--local", "20230101000000"]); + testParity( + ["migration", "repair", "--status", "applied", "--local", "20230101000000"], + connectRefusedParity, + ); }); describe("migration:squash", () => { @@ -99,6 +129,6 @@ describe("migrations", () => { expect(result.stderr).toContain("failed to connect"); }); - testParity(["migration", "fetch", "--local"]); + testParity(["migration", "fetch", "--local"], connectRefusedParity); }); }); diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 3f8d3d82a8..df04364e44 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,8 +1,10 @@ package cmd import ( + "errors" "fmt" "os" + "path" "path/filepath" "github.com/spf13/afero" @@ -24,6 +26,7 @@ import ( "github.com/supabase/cli/legacy/branch/delete" "github.com/supabase/cli/legacy/branch/list" "github.com/supabase/cli/legacy/branch/switch_" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" ) @@ -300,15 +303,23 @@ var ( }, } - noSeed bool - lastVersion uint + noSeed bool + lastVersion uint + seedSqlPaths []string dbResetCmd = &cobra.Command{ Use: "reset", Short: "Resets the local database to current migrations", + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := validateDbResetSeedFlags(noSeed, seedSqlPaths); err != nil { + return err + } + warnRemoteResetSeedOverride(cmd, seedSqlPaths) + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { - if noSeed { - utils.Config.Db.Seed.Enabled = false + if err := applyDbResetSeedFlags(noSeed, seedSqlPaths); err != nil { + return err } return reset.Run(cmd.Context(), migrationVersion, lastVersion, flags.DbConfig, afero.NewOsFs()) }, @@ -470,6 +481,62 @@ func resolvePullDiffEngine(engineFlagChanged bool, engine string, pgDeltaDefault return pgDeltaDefault } +func validateDbResetSeedFlags(noSeed bool, patterns []string) error { + if noSeed && len(patterns) > 0 { + utils.CmdSuggestion = fmt.Sprintf("Use either %s to skip seeding or %s to override seed files, not both.", utils.Aqua("--no-seed"), utils.Aqua("--sql-paths")) + return errors.New("--no-seed cannot be used with --sql-paths") + } + for _, pattern := range patterns { + if len(pattern) == 0 { + utils.CmdSuggestion = fmt.Sprintf("Pass a non-empty file path or glob pattern to %s.", utils.Aqua("--sql-paths")) + return errors.New("--sql-paths requires a non-empty path or glob pattern") + } + } + return nil +} + +func warnRemoteResetSeedOverride(cmd *cobra.Command, patterns []string) { + if len(patterns) == 0 { + return + } + if cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url") { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "--sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.") + } +} + +func applyDbResetSeedFlags(noSeed bool, patterns []string) error { + if noSeed { + utils.Config.Db.Seed.Enabled = false + return nil + } + if len(patterns) == 0 { + return nil + } + resolved, err := resolveSeedSqlPaths(patterns) + if err != nil { + return err + } + utils.Config.Db.Seed.Enabled = true + utils.Config.Db.Seed.SqlPaths = resolved + return nil +} + +func resolveSeedSqlPaths(patterns []string) ([]string, error) { + resolved := make([]string, len(patterns)) + base := config.NewPathBuilder("").SupabaseDirPath + for i, pattern := range patterns { + if len(pattern) == 0 { + return nil, errors.New("--sql-paths requires a non-empty path or glob pattern") + } + if !filepath.IsAbs(pattern) { + resolved[i] = path.Join(base, pattern) + } else { + resolved[i] = pattern + } + } + return resolved, nil +} + func init() { // Build branch command dbBranchCmd.AddCommand(dbBranchCreateCmd) @@ -570,6 +637,7 @@ func init() { resetFlags.Bool("linked", false, "Resets the linked project with local migrations.") resetFlags.Bool("local", true, "Resets the local database with local migrations.") resetFlags.BoolVar(&noSeed, "no-seed", false, "Skip running the seed script after reset.") + resetFlags.StringArrayVar(&seedSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for this reset. May be repeated; each value accepts a SQL file path or glob pattern relative to the supabase directory and force-enables seeding.") dbResetCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") resetFlags.StringVar(&migrationVersion, "version", "", "Reset up to the specified version.") resetFlags.UintVar(&lastVersion, "last", 0, "Reset up to the last n migration versions.") diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 45f3f6aaaa..bcc689ca0b 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" @@ -157,6 +159,39 @@ func ensureLocalDatabaseStarted(ctx context.Context, local bool, isRunning func( return nil } +type inspectContainerFunc func(context.Context, string) (container.InspectResponse, error) + +func dockerImageTag(image string) string { + image = strings.TrimSpace(image) + index := strings.LastIndexByte(image, ':') + if index < 0 || index == len(image)-1 { + return "" + } + return image[index+1:] +} + +func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContainerFunc) error { + resp, err := inspect(ctx, utils.DbId) + if err != nil { + if errdefs.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to inspect local Postgres container: %w", err) + } + if resp.Config == nil || len(strings.TrimSpace(resp.Config.Image)) == 0 { + return nil + } + actual := strings.TrimSpace(resp.Config.Image) + expected := strings.TrimSpace(utils.GetRegistryImageUrl(utils.Config.Db.Image)) + actualTag := dockerImageTag(actual) + expectedTag := dockerImageTag(expected) + if len(actualTag) == 0 || len(expectedTag) == 0 || actualTag == expectedTag { + return nil + } + utils.CmdSuggestion = fmt.Sprintf("Run %s, then %s before syncing declarative schemas.", utils.Aqua("supabase stop --all --no-backup"), utils.Aqua("supabase start")) + return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) +} + // hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url. func hasExplicitTargetFlag(cmd *cobra.Command) bool { return cmd.Flags().Changed("local") || cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url") @@ -208,6 +243,11 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { // When an explicit target flag is provided, use the direct path. if hasExplicitTargetFlag(cmd) { + if cmd.Flags().Changed("local") { + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } + } if err := ensureLocalDatabaseStarted(ctx, declarativeLocal, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -267,6 +307,9 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { switch choice.Index { case 0: // Local database + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -309,6 +352,9 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { } } else { // No migrations — generate from local DB + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -325,9 +371,10 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { ctx := cmd.Context() fsys := afero.NewOsFs() console := utils.NewConsole() + declarativeFilesExist := hasDeclarativeFiles(fsys) // Step 1: Check if declarative dir has files - if !hasDeclarativeFiles(fsys) { + if !declarativeFilesExist { if !isTTY() && !viper.GetBool("YES") { return fmt.Errorf("no declarative schema found. Run %s first", utils.Aqua("supabase db schema declarative generate")) } @@ -416,6 +463,9 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { } if shouldApply { + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if applyErr := applyMigrationToLocal(ctx, path, fsys); applyErr != nil { fmt.Fprintln(os.Stderr, utils.Red("Migration failed to apply: "+applyErr.Error())) diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index a799ad0fb8..738e204111 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -194,6 +196,74 @@ func TestEnsureLocalDatabaseStarted(t *testing.T) { }) } +func TestDockerImageTag(t *testing.T) { + testCases := map[string]string{ + "public.ecr.aws/supabase/postgres:17.6.1.138": "17.6.1.138", + "localhost:5000/supabase/postgres:17.6.1.138": "17.6.1.138", + "supabase/postgres": "", + } + for image, expected := range testCases { + t.Run(image, func(t *testing.T) { + assert.Equal(t, expected, dockerImageTag(image)) + }) + } +} + +func TestEnsureLocalPostgresImageCurrent(t *testing.T) { + originalImage := utils.Config.Db.Image + originalSuggestion := utils.CmdSuggestion + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.CmdSuggestion = originalSuggestion + }) + utils.Config.Db.Image = "supabase/postgres:17.6.1.138" + expectedImage := utils.GetRegistryImageUrl(utils.Config.Db.Image) + + t.Run("passes when no local container exists", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{}, errdefs.ErrNotFound + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when local container image matches expected postgres image", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(_ context.Context, containerID string) (container.InspectResponse, error) { + assert.Equal(t, utils.DbId, containerID) + return container.InspectResponse{Config: &container.Config{Image: expectedImage}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when registry differs but postgres tag matches", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "docker.io/supabase/postgres:17.6.1.138"}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("fails when local container image is stale", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "public.ecr.aws/supabase/postgres:17.6.1.106"}}, nil + }) + + assert.ErrorContains(t, err, "local Postgres container image is stale") + assert.ErrorContains(t, err, "17.6.1.106") + assert.ErrorContains(t, err, "17.6.1.138") + assert.Contains(t, utils.CmdSuggestion, "supabase stop --all --no-backup") + assert.Contains(t, utils.CmdSuggestion, "supabase start") + }) +} + func TestHasDeclarativeFiles(t *testing.T) { t.Run("returns false when dir does not exist", func(t *testing.T) { assert.False(t, hasDeclarativeFiles(mockFsys())) diff --git a/apps/cli-go/cmd/db_test.go b/apps/cli-go/cmd/db_test.go index 654278d059..d12bb1cf8f 100644 --- a/apps/cli-go/cmd/db_test.go +++ b/apps/cli-go/cmd/db_test.go @@ -1,9 +1,12 @@ package cmd import ( + "path/filepath" "testing" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/utils" ) func TestResolvePullDiffEngine(t *testing.T) { @@ -45,3 +48,100 @@ func TestResolveDiffEngine(t *testing.T) { assert.False(t, resolveDiffEngine(false, true, false, true)) }) } + +func TestResolveSeedSqlPaths(t *testing.T) { + t.Run("resolves relative paths against the supabase directory", func(t *testing.T) { + absoluteSeedPath := filepath.Join(t.TempDir(), "seed.sql") + got, err := resolveSeedSqlPaths([]string{ + "./seeds/minimal.sql", + "./seeds/demo/*.sql", + "./seeds/tenant,one.sql", + absoluteSeedPath, + }) + + assert.NoError(t, err) + assert.Equal(t, []string{ + filepath.Join(utils.SupabaseDirPath, "seeds", "minimal.sql"), + filepath.Join(utils.SupabaseDirPath, "seeds", "demo", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "seeds", "tenant,one.sql"), + absoluteSeedPath, + }, got) + }) + + t.Run("rejects empty paths", func(t *testing.T) { + got, err := resolveSeedSqlPaths([]string{""}) + assert.Nil(t, got) + assert.EqualError(t, err, "--sql-paths requires a non-empty path or glob pattern") + }) +} + +func TestValidateDbResetSeedFlags(t *testing.T) { + t.Run("rejects no seed with sql paths", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + err := validateDbResetSeedFlags(true, []string{"./seed.sql"}) + + assert.EqualError(t, err, "--no-seed cannot be used with --sql-paths") + assert.Contains(t, utils.CmdSuggestion, "Use either") + assert.Contains(t, utils.CmdSuggestion, "--no-seed") + assert.Contains(t, utils.CmdSuggestion, "--sql-paths") + }) + + t.Run("rejects empty sql paths", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + err := validateDbResetSeedFlags(false, []string{""}) + + assert.EqualError(t, err, "--sql-paths requires a non-empty path or glob pattern") + assert.Contains(t, utils.CmdSuggestion, "non-empty") + assert.Contains(t, utils.CmdSuggestion, "--sql-paths") + }) +} + +func TestApplyDbResetSeedFlags(t *testing.T) { + oldSeed := utils.Config.Db.Seed + t.Cleanup(func() { utils.Config.Db.Seed = oldSeed }) + + t.Run("leaves config unchanged without seed flags", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = false + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(false, nil)) + assert.False(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{"supabase/original.sql"}, []string(utils.Config.Db.Seed.SqlPaths)) + }) + + t.Run("disables seed when no seed is set", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = true + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(true, nil)) + assert.False(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{"supabase/original.sql"}, []string(utils.Config.Db.Seed.SqlPaths)) + }) + + t.Run("force enables seed and overrides sql paths", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = false + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(false, []string{"./seeds/base.sql"})) + assert.True(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{filepath.Join(utils.SupabaseDirPath, "seeds", "base.sql")}, []string(utils.Config.Db.Seed.SqlPaths)) + }) +} + +func TestSeedSqlPathsFlagPreservesCommas(t *testing.T) { + var values []string + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.StringArrayVar(&values, "sql-paths", nil, "") + + assert.NoError(t, flags.Parse([]string{ + "--sql-paths", + "./seeds/tenant,one.sql", + "--sql-paths", + "./seeds/two.sql", + })) + assert.Equal(t, []string{"./seeds/tenant,one.sql", "./seeds/two.sql"}, values) +} diff --git a/apps/cli-go/docs/supabase/db/reset.md b/apps/cli-go/docs/supabase/db/reset.md index acb9b9832b..9a60a67711 100644 --- a/apps/cli-go/docs/supabase/db/reset.md +++ b/apps/cli-go/docs/supabase/db/reset.md @@ -6,4 +6,8 @@ Requires the local development stack to be started by running `supabase start`. Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. +Use the `--no-seed` flag to skip seeding entirely. To override `[db.seed].sql_paths` for a single reset, pass one or more `--sql-paths` flags. Each value accepts the same file path or glob pattern syntax as `sql_paths`, relative to the `supabase` directory. Passing `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. + When running db reset with `--linked` or `--db-url` flag, a SQL script is executed to identify and drop all user created entities in the remote database. Since Postgres roles are cluster level entities, any custom roles created through the dashboard or `supabase/roles.sql` will not be deleted by remote reset. + +If you combine `--sql-paths` with `--linked` or `--db-url`, the override seed files are applied to the selected remote database after migrations. Use this only when you intend to seed that remote target. diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 7eba170c0a..4bc46b5d07 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -5,7 +5,7 @@ go 1.25.5 require ( github.com/BurntSushi/toml v1.6.0 github.com/Netflix/go-env v0.1.2 - github.com/andybalholm/brotli v1.2.1 + github.com/andybalholm/brotli v1.2.2 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 @@ -40,9 +40,9 @@ require ( github.com/mithrandie/csvq-driver v1.7.0 github.com/muesli/reflow v0.3.0 github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 - github.com/oapi-codegen/nullable v1.1.0 + github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 - github.com/posthog/posthog-go v1.15.0 + github.com/posthog/posthog-go v1.16.1 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -334,7 +334,7 @@ require ( github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.19.1 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect - github.com/oapi-codegen/runtime v1.4.1 // indirect + github.com/oapi-codegen/runtime v1.4.2 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 15f5a0f2f2..e8ed2f0f72 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -70,8 +70,8 @@ github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEW github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= @@ -861,12 +861,12 @@ github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWX github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= -github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/nullable v1.2.0 h1:VflFkDW980KhBPiFF7nWSyjg+r4Obqj8lXipV0UkP5w= +github.com/oapi-codegen/nullable v1.2.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= -github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= -github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= +github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -941,8 +941,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= -github.com/posthog/posthog-go v1.15.0 h1:Fizkdct7zGg050hnYpxEiq/iD/OJO7tVaQE9Vyoh0q0= -github.com/posthog/posthog-go v1.15.0/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/posthog/posthog-go v1.16.1 h1:uEbaaYT361a3ImI0D1DYUyNLWN7Y9V9gLqCbQ/z5SxQ= +github.com/posthog/posthog-go v1.16.1/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index 9b17efa078..c79df6289f 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -363,8 +363,8 @@ func getGenerateBaselineCatalogRef(ctx context.Context, noCache bool, fsys afero // getMigrationsCatalogRef returns a catalog reference representing local // migrations applied to a shadow database. // -// A migration-content hash keys the cache so it is reused only when local -// migration state is unchanged. +// A migration-content hash plus setup-input token keys the cache so it is reused +// only when both local migration state and platform baseline inputs are unchanged. func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, prefix string, options ...func(*pgx.ConnConfig)) (string, error) { migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) if err != nil { @@ -390,7 +390,7 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p } } } - hash, err := pgcache.HashMigrations(fsys) + hash, err := migrationsCatalogCacheKey(fsys) if err != nil { return "", err } @@ -762,6 +762,18 @@ func declarativeCatalogCacheKey(fsys afero.Fs) (string, error) { return setup + "-" + schemaHash, nil } +func migrationsCatalogCacheKey(fsys afero.Fs) (string, error) { + migrationsHash, err := hashMigrations(fsys) + if err != nil { + return "", err + } + setup, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return setup + "-" + migrationsHash, nil +} + func sanitizedCatalogPrefix(prefix string) string { prefix = strings.TrimSpace(prefix) if len(prefix) == 0 { diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index fa17a12a2b..cbd67d29de 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -241,22 +241,27 @@ func TestGetMigrationsCatalogRefUsesCache(t *testing.T) { fsys := afero.NewMemMapFs() p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - hash, err := hashMigrations(fsys) + legacyHash, err := hashMigrations(fsys) require.NoError(t, err) + stalePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+legacyHash+"-1000.json") + require.NoError(t, afero.WriteFile(fsys, stalePath, []byte(`{"version":"stale"}`), 0644)) + hash, err := migrationsCatalogCacheKey(fsys) + require.NoError(t, err) cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+hash+"-1000.json") require.NoError(t, afero.WriteFile(fsys, cachePath, []byte(`{"version":1}`), 0644)) ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") require.NoError(t, err) assert.Equal(t, cachePath, ref) + assert.NotEqual(t, stalePath, ref) } func TestGetMigrationsCatalogRefUsesProjectPrefix(t *testing.T) { fsys := afero.NewMemMapFs() p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - hash, err := hashMigrations(fsys) + hash, err := migrationsCatalogCacheKey(fsys) require.NoError(t, err) cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-testproject-migrations-"+hash+"-1000.json") diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index aa286eea5b..5d2790e5b9 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/utils" + configpkg "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/parser" ) @@ -70,7 +71,11 @@ func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { } } if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { - return schemas.Files(afero.NewIOFS(fsys)) + return schemas.Files( + afero.NewIOFS(fsys), + configpkg.WithSkipEmptyGlobs(), + configpkg.WithErrorOnAllSkippedGlobs(), + ) } if exists, err := afero.DirExists(fsys, utils.SchemasDir); err != nil { return nil, errors.Errorf("failed to check schemas: %w", err) diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index e0df5b328a..2a6a2d4ca4 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -409,3 +409,52 @@ func TestLoadSchemas(t *testing.T) { assert.NoError(t, err) assert.ElementsMatch(t, expected, schemas) } + +func TestLoadSchemasSkipsEmptySchemaPathGlobs(t *testing.T) { + fsys := afero.NewMemMapFs() + matched := filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql") + require.NoError(t, afero.WriteFile(fsys, matched, nil, 0644)) + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "schemas", "materialized_views", "*.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.NoError(t, err) + assert.Equal(t, []string{filepath.ToSlash(matched)}, schemas) +} + +func TestLoadSchemasErrorsOnMissingLiteralSchemaPath(t *testing.T) { + fsys := afero.NewMemMapFs() + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, schemas) +} + +func TestLoadSchemasErrorsWhenAllSchemaPathGlobsAreEmpty(t *testing.T) { + fsys := afero.NewMemMapFs() + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "schemas", "views", "*.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, schemas) +} diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go index 528ea9d8bd..7ff5b0a26c 100644 --- a/apps/cli-go/pkg/api/client.gen.go +++ b/apps/cli-go/pkg/api/client.gen.go @@ -218,6 +218,9 @@ type ClientInterface interface { // V1GetProjectLogs request V1GetProjectLogs(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetProjectLogsAll request + V1GetProjectLogsAll(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetProjectUsageApiCount request V1GetProjectUsageApiCount(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1267,6 +1270,18 @@ func (c *Client) V1GetProjectLogs(ctx context.Context, ref string, params *V1Get return c.Client.Do(req) } +func (c *Client) V1GetProjectLogsAll(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1GetProjectLogsAllRequest(c.Server, ref, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1GetProjectUsageApiCount(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1GetProjectUsageApiCountRequest(c.Server, ref, params) if err != nil { @@ -5316,6 +5331,94 @@ func NewV1GetProjectLogsRequest(server string, ref string, params *V1GetProjectL return nil, err } + operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/logs", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Sql != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "sql", runtime.ParamLocationQuery, *params.Sql); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.IsoTimestampStart != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_start", runtime.ParamLocationQuery, *params.IsoTimestampStart); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.IsoTimestampEnd != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "iso_timestamp_end", runtime.ParamLocationQuery, *params.IsoTimestampEnd); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewV1GetProjectLogsAllRequest generates requests for V1GetProjectLogsAll +func NewV1GetProjectLogsAllRequest(server string, ref string, params *V1GetProjectLogsAllParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + operationPath := fmt.Sprintf("/v1/projects/%s/analytics/endpoints/logs.all", pathParam0) if operationPath[0] == '/' { operationPath = "." + operationPath @@ -11562,6 +11665,9 @@ type ClientWithResponsesInterface interface { // V1GetProjectLogsWithResponse request V1GetProjectLogsWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsParams, reqEditors ...RequestEditorFn) (*V1GetProjectLogsResponse, error) + // V1GetProjectLogsAllWithResponse request + V1GetProjectLogsAllWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, reqEditors ...RequestEditorFn) (*V1GetProjectLogsAllResponse, error) + // V1GetProjectUsageApiCountWithResponse request V1GetProjectUsageApiCountWithResponse(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*V1GetProjectUsageApiCountResponse, error) @@ -12844,6 +12950,28 @@ func (r V1GetProjectLogsResponse) StatusCode() int { return 0 } +type V1GetProjectLogsAllResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AnalyticsResponse +} + +// Status returns HTTPResponse.Status +func (r V1GetProjectLogsAllResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1GetProjectLogsAllResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1GetProjectUsageApiCountResponse struct { Body []byte HTTPResponse *http.Response @@ -16119,6 +16247,15 @@ func (c *ClientWithResponses) V1GetProjectLogsWithResponse(ctx context.Context, return ParseV1GetProjectLogsResponse(rsp) } +// V1GetProjectLogsAllWithResponse request returning *V1GetProjectLogsAllResponse +func (c *ClientWithResponses) V1GetProjectLogsAllWithResponse(ctx context.Context, ref string, params *V1GetProjectLogsAllParams, reqEditors ...RequestEditorFn) (*V1GetProjectLogsAllResponse, error) { + rsp, err := c.V1GetProjectLogsAll(ctx, ref, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1GetProjectLogsAllResponse(rsp) +} + // V1GetProjectUsageApiCountWithResponse request returning *V1GetProjectUsageApiCountResponse func (c *ClientWithResponses) V1GetProjectUsageApiCountWithResponse(ctx context.Context, ref string, params *V1GetProjectUsageApiCountParams, reqEditors ...RequestEditorFn) (*V1GetProjectUsageApiCountResponse, error) { rsp, err := c.V1GetProjectUsageApiCount(ctx, ref, params, reqEditors...) @@ -18567,6 +18704,32 @@ func ParseV1GetProjectLogsResponse(rsp *http.Response) (*V1GetProjectLogsRespons return response, nil } +// ParseV1GetProjectLogsAllResponse parses an HTTP response from a V1GetProjectLogsAllWithResponse call +func ParseV1GetProjectLogsAllResponse(rsp *http.Response) (*V1GetProjectLogsAllResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1GetProjectLogsAllResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AnalyticsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseV1GetProjectUsageApiCountResponse parses an HTTP response from a V1GetProjectUsageApiCountWithResponse call func ParseV1GetProjectUsageApiCountResponse(rsp *http.Response) (*V1GetProjectUsageApiCountResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 7f40e23798..838e1e776a 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -1427,6 +1427,7 @@ const ( // Defines values for V1ListEntitlementsResponseEntitlementsFeatureKey. const ( + V1ListEntitlementsResponseEntitlementsFeatureKeyApiMembersRoles V1ListEntitlementsResponseEntitlementsFeatureKey = "api.members.roles" V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseEntitlementsFeatureKey = "assistant.advance_model" V1ListEntitlementsResponseEntitlementsFeatureKeyAuditLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "audit_log_drains" V1ListEntitlementsResponseEntitlementsFeatureKeyAuthAdvancedAuthSettings V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.advanced_auth_settings" @@ -1485,6 +1486,7 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyStorageImageTransformations V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.image_transformations" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSize V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSizeConfigurable V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size.configurable" + V1ListEntitlementsResponseEntitlementsFeatureKeyStoragePurgeCache V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.purge_cache" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageVectorBuckets V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.vector_buckets" V1ListEntitlementsResponseEntitlementsFeatureKeyVanitySubdomain V1ListEntitlementsResponseEntitlementsFeatureKey = "vanity_subdomain" ) @@ -3488,10 +3490,13 @@ type PostgrestConfigWithJWTSecretResponse struct { DbExtraSearchPath string `json:"db_extra_search_path"` // DbPool If `null`, the value is automatically configured based on compute size. - DbPool nullable.Nullable[int] `json:"db_pool"` - DbSchema string `json:"db_schema"` - JwtSecret *string `json:"jwt_secret,omitempty"` - MaxRows int `json:"max_rows"` + DbPool nullable.Nullable[int] `json:"db_pool"` + + // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. + DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` + DbSchema string `json:"db_schema"` + JwtSecret *string `json:"jwt_secret,omitempty"` + MaxRows int `json:"max_rows"` } // ProjectClaimTokenResponse defines model for ProjectClaimTokenResponse. @@ -3961,6 +3966,9 @@ type StorageConfigResponse struct { ImageTransformation struct { Enabled bool `json:"enabled"` } `json:"imageTransformation"` + PurgeCache struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache"` S3Protocol struct { Enabled bool `json:"enabled"` } `json:"s3Protocol"` @@ -4563,6 +4571,9 @@ type UpdateStorageConfigBody struct { ImageTransformation *struct { Enabled bool `json:"enabled"` } `json:"imageTransformation,omitempty"` + PurgeCache *struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache,omitempty"` S3Protocol *struct { Enabled bool `json:"enabled"` } `json:"s3Protocol,omitempty"` @@ -4909,9 +4920,12 @@ type V1PostgrestConfigResponse struct { DbExtraSearchPath string `json:"db_extra_search_path"` // DbPool If `null`, the value is automatically configured based on compute size. - DbPool nullable.Nullable[int] `json:"db_pool"` - DbSchema string `json:"db_schema"` - MaxRows int `json:"max_rows"` + DbPool nullable.Nullable[int] `json:"db_pool"` + + // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. + DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` + DbSchema string `json:"db_schema"` + MaxRows int `json:"max_rows"` } // V1ProfileResponse defines model for V1ProfileResponse. @@ -5166,10 +5180,11 @@ type V1UpdatePasswordResponse struct { // V1UpdatePostgrestConfigBody defines model for V1UpdatePostgrestConfigBody. type V1UpdatePostgrestConfigBody struct { - DbExtraSearchPath *string `json:"db_extra_search_path,omitempty"` - DbPool *int `json:"db_pool,omitempty"` - DbSchema *string `json:"db_schema,omitempty"` - MaxRows *int `json:"max_rows,omitempty"` + DbExtraSearchPath *string `json:"db_extra_search_path,omitempty"` + DbPool *int `json:"db_pool,omitempty"` + DbPoolAcquisitionTimeout *int `json:"db_pool_acquisition_timeout,omitempty"` + DbSchema *string `json:"db_schema,omitempty"` + MaxRows *int `json:"max_rows,omitempty"` } // V1UpdateProjectBody defines model for V1UpdateProjectBody. @@ -5328,6 +5343,14 @@ type V1GetProjectLogsParams struct { IsoTimestampEnd *time.Time `form:"iso_timestamp_end,omitempty" json:"iso_timestamp_end,omitempty"` } +// V1GetProjectLogsAllParams defines parameters for V1GetProjectLogsAll. +type V1GetProjectLogsAllParams struct { + // Sql Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. + Sql *string `form:"sql,omitempty" json:"sql,omitempty"` + IsoTimestampStart *time.Time `form:"iso_timestamp_start,omitempty" json:"iso_timestamp_start,omitempty"` + IsoTimestampEnd *time.Time `form:"iso_timestamp_end,omitempty" json:"iso_timestamp_end,omitempty"` +} + // V1GetProjectUsageApiCountParams defines parameters for V1GetProjectUsageApiCount. type V1GetProjectUsageApiCountParams struct { Interval *V1GetProjectUsageApiCountParamsInterval `form:"interval,omitempty" json:"interval,omitempty"` diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index b2bf3f4a99..b9f253230a 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -97,11 +97,35 @@ func (p *RequestPolicy) UnmarshalText(text []byte) error { type Glob []string +type globOptions struct { + skipEmptyGlobs bool + errorOnAllSkipped bool +} + +type GlobOption func(*globOptions) + +func WithSkipEmptyGlobs() GlobOption { + return func(o *globOptions) { + o.skipEmptyGlobs = true + } +} + +func WithErrorOnAllSkippedGlobs() GlobOption { + return func(o *globOptions) { + o.errorOnAllSkipped = true + } +} + // Match the glob patterns in the given FS to get a deduplicated // array of all migrations files to apply in the declared order. -func (g Glob) Files(fsys fs.FS) ([]string, error) { +func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { + opts := globOptions{} + for _, apply := range options { + apply(&opts) + } var result []string var allErrors []error + var skipped []string set := make(map[string]struct{}) for _, pattern := range g { // Glob expects / as path separator on windows @@ -109,6 +133,10 @@ func (g Glob) Files(fsys fs.FS) ([]string, error) { if err != nil { allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err)) } else if len(matches) == 0 { + if opts.skipEmptyGlobs && hasGlobMeta(pattern) { + skipped = append(skipped, pattern) + continue + } allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) } sort.Strings(matches) @@ -121,9 +149,18 @@ func (g Glob) Files(fsys fs.FS) ([]string, error) { } } } + if opts.errorOnAllSkipped && len(result) == 0 && len(skipped) > 0 { + for _, pattern := range skipped { + allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) + } + } return result, errors.Join(allErrors...) } +func hasGlobMeta(pattern string) bool { + return strings.ContainsAny(pattern, `*?[`) +} + // We follow these rules when adding new config: // 1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage. // 2. Update config struct with new field and toml tag (spelled in snake_case). diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index fa38005152..6c2697ca0e 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -676,6 +676,34 @@ func TestGlobFiles(t *testing.T) { // Validate files assert.Empty(t, files) }) + + t.Run("skips empty globs when configured", func(t *testing.T) { + fsys := fs.MapFS{ + "supabase/schemas/tables/players.sql": &fs.MapFile{}, + } + g := Glob{ + "supabase/schemas/tables/*.sql", + "supabase/schemas/materialized_views/*.sql", + } + + files, err := g.Files(fsys, WithSkipEmptyGlobs()) + + assert.NoError(t, err) + assert.Equal(t, []string{"supabase/schemas/tables/players.sql"}, files) + }) + + t.Run("errors when all skipped globs are empty and configured to fail", func(t *testing.T) { + fsys := fs.MapFS{} + g := Glob{ + "supabase/schemas/tables/*.sql", + "supabase/schemas/materialized_views/*.sql", + } + + files, err := g.Files(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs()) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, files) + }) } func TestLoadFunctionImportMap(t *testing.T) { diff --git a/apps/cli-go/pkg/config/storage.go b/apps/cli-go/pkg/config/storage.go index 740c525f43..14bf5ff082 100644 --- a/apps/cli-go/pkg/config/storage.go +++ b/apps/cli-go/pkg/config/storage.go @@ -73,6 +73,9 @@ func (s *storage) ToUpdateStorageConfigBody() v1API.UpdateStorageConfigBody { ImageTransformation *struct { Enabled bool `json:"enabled"` } `json:"imageTransformation,omitempty"` + PurgeCache *struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache,omitempty"` S3Protocol *struct { Enabled bool `json:"enabled"` } `json:"s3Protocol,omitempty"` diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index ebbc983688..97dfc0dbea 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,19 +1,19 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.139 AS pg +FROM supabase/postgres:17.6.1.140 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.30.2 AS mailpit -FROM postgrest/postgrest:v14.13 AS postgrest +FROM postgrest/postgrest:v14.14 AS postgrest FROM supabase/postgres-meta:v0.96.6 AS pgmeta -FROM supabase/studio:2026.06.22-sha-2207d7f AS studio +FROM supabase/studio:2026.06.29-sha-20290c7 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.2 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor -FROM supabase/gotrue:v2.191.0 AS gotrue -FROM supabase/realtime:v2.108.0 AS realtime -FROM supabase/storage-api:v1.61.3 AS storage -FROM supabase/logflare:1.45.4 AS logflare +FROM supabase/gotrue:v2.192.0 AS gotrue +FROM supabase/realtime:v2.112.1 AS realtime +FROM supabase/storage-api:v1.61.7 AS storage +FROM supabase/logflare:1.45.6 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod index 1a3f84dd34..3de3c61ae8 100644 --- a/apps/cli-go/pkg/go.mod +++ b/apps/cli-go/pkg/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/andybalholm/brotli v1.2.1 + github.com/andybalholm/brotli v1.2.2 github.com/cenkalti/backoff/v4 v4.3.0 github.com/docker/go-units v0.5.0 github.com/ecies/go/v2 v2.0.11 @@ -19,8 +19,8 @@ require ( github.com/jackc/pgtype v1.14.4 github.com/jackc/pgx/v4 v4.18.3 github.com/joho/godotenv v1.5.1 - github.com/oapi-codegen/nullable v1.1.0 - github.com/oapi-codegen/runtime v1.4.1 + github.com/oapi-codegen/nullable v1.2.0 + github.com/oapi-codegen/runtime v1.4.2 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum index bed1b36aba..8394b09a98 100644 --- a/apps/cli-go/pkg/go.sum +++ b/apps/cli-go/pkg/go.sum @@ -3,8 +3,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -130,10 +130,10 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= -github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= -github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/nullable v1.2.0 h1:VflFkDW980KhBPiFF7nWSyjg+r4Obqj8lXipV0UkP5w= +github.com/oapi-codegen/nullable v1.2.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= +github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 9cebb1a5f5..f5969bd199 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -115,13 +115,13 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | | `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | | `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | Deliberate Go-proxy delegate (parity-preserving). A native port would emit pg-delta diff format instead of Go's `pg_dump` bytes (an accepted divergence, CLI-1597) and needs a bare-baseline shadow the seam does not yet expose; kept on the proxy for byte parity until CLI-1597's squash rewrite lands. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | | `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | | `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | | `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | @@ -274,13 +274,13 @@ Legend: | `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | | `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | | `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | -| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | -| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | -| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | -| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | +| `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | +| `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | +| `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) — deliberate Go delegate for byte parity (native pg-delta squash deferred to CLI-1597) | +| `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | +| `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | +| `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | | `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | | `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | | `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | @@ -302,7 +302,7 @@ Legend: | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | | `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | | `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index ac72985d2d..03f695883a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -39,12 +39,13 @@ "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, "dependencies": { + "eciesjs": "^0.5.0", "jose": "^6.2.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.177", - "@anthropic-ai/sdk": "^0.104.1", - "@clack/prompts": "^1.5.1", + "@anthropic-ai/claude-agent-sdk": "^0.3.186", + "@anthropic-ai/sdk": "^0.105.0", + "@clack/prompts": "^1.6.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/sql-pg": "catalog:", @@ -67,19 +68,19 @@ "dotenv": "^17.4.2", "effect": "catalog:", "esbuild": "^0.28.1", - "ink": "^7.0.6", + "ink": "^7.1.0", "ink-spinner": "^5.0.0", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", - "pg": "^8.21.0", + "pg": "^8.22.0", "pg-copy-streams": "^7.0.0", - "posthog-node": "^5.37.0", + "posthog-node": "^5.38.2", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.5", - "smol-toml": "^1.6.1", + "smol-toml": "^1.7.0", "tldts": "catalog:", "vitest": "catalog:", "yaml": "^2.9.0" @@ -112,7 +113,8 @@ "entry": [ "src/shared/cli/bin.ts", "src/**/*.test.ts", - "src/**/*.e2e.test.ts" + "src/**/*.e2e.test.ts", + "src/**/*.live.test.ts" ], "ignore": [ "scripts/*.ts", diff --git a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts new file mode 100644 index 0000000000..65422ad51b --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; + +import { + describeLiveProject, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 120_000; + +// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is +// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner +// does this; a control-plane-only stack, like local macOS, skips it). +// +// Entry point for the branching lifecycle tracked in CLI-1834 +// (create / switch / delete) — extend here once a provisioned project is +// available on the full stack. +describeLiveProject("supabase branches list (live)", () => { + test("lists branches for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { + const ref = requireLiveProjectRef(); + const { exitCode, stdout, stderr } = await runSupabaseLive([ + "branches", + "list", + "--project-ref", + ref, + ]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }); +}); diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 2955c46a24..0cc0ffe678 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -46,6 +46,8 @@ interface RouteOpts { readonly postgrestPatch?: { status: number; body: unknown } | "fail"; readonly postgresGet?: { status: number; body: unknown }; readonly postgresPut?: { status: number; body: unknown }; + readonly storageGet?: { status: number; body: unknown }; + readonly storagePatch?: { status: number; body: unknown }; } function setup(opts: { @@ -89,6 +91,14 @@ function setup(opts: { const p = routes.postgresPut ?? { status: 200, body: {} }; return Effect.succeed(legacyJsonResponse(request, p.status, p.body)); } + if (url.includes("/config/storage")) { + if (request.method === "GET") { + const g = routes.storageGet ?? { status: 200, body: {} }; + return Effect.succeed(legacyJsonResponse(request, g.status, g.body)); + } + const p = routes.storagePatch ?? { status: 200, body: {} }; + return Effect.succeed(legacyJsonResponse(request, p.status, p.body)); + } // Anything else (auth/storage/etc.) — succeed with empty so unconfigured // gated services don't hang if a test enables them. return Effect.succeed(legacyJsonResponse(request, 200, {})); @@ -119,6 +129,20 @@ enabled = false enabled = false `; +const STORAGE_CONFIG_WITHOUT_POOL_MODE = { + fileSizeLimit: 52428800, + features: { + imageTransformation: { enabled: false }, + s3Protocol: { enabled: false }, + purgeCache: { enabled: false }, + icebergCatalog: { enabled: false, maxNamespaces: 0, maxTables: 0, maxCatalogs: 0 }, + vectorBuckets: { enabled: false, maxBuckets: 0, maxIndexes: 0 }, + }, + capabilities: { list_v2: true, iceberg_catalog: false }, + external: { upstreamTarget: "main" }, + migrationVersion: "20240701", +}; + describe("legacy config push integration", () => { it.live("pushes local config (text, Go parity) and surfaces a PATCH failure", () => { const { layer, out } = setup({ @@ -307,6 +331,30 @@ project_id = "abcdefghijklmnopqrst" }).pipe(Effect.provide(layer)); }); + it.live("pushes storage when the remote response omits databasePoolMode", () => { + const { layer, api } = setup({ + toml: `project_id = "test" +[auth] +enabled = false +[storage] +enabled = true +file_size_limit = "50MiB" +`, + yes: true, + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + storageGet: { status: 200, body: STORAGE_CONFIG_WITHOUT_POOL_MODE }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect( + api.requests.some((r) => r.method === "GET" && r.url.includes("/config/storage")), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.live("flushes telemetry + linked-project cache on failure", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: API_ONLY_TOML, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 577ff3f6c1..c7dcdb5fad 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -11,6 +11,7 @@ import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; +import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; @@ -24,7 +25,7 @@ import { import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, -} from "../shared/legacy-migration-file.ts"; +} from "../../../shared/legacy-migration-file.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; @@ -277,9 +278,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // Create parent dirs first, matching Go's `writeOutput` → `utils.WriteFile` // (`internal/db/diff/explicit.go`, `internal/utils/misc.go`), so a nested // `--output tmp/diff.sql` doesn't fail when `tmp/` doesn't exist yet. - yield* fs - .makeDirectory(path.dirname(target), { recursive: true }) - .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + yield* legacyMakeDir(fs, path.dirname(target)).pipe( + Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })), + ); yield* fs .writeFileString(target, result.sql) .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); @@ -457,9 +458,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy timestamp, flags.file.value, ); - yield* fs - .makeDirectory(path.dirname(migrationPath), { recursive: true }) - .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message })), + ); yield* fs .writeFileString(migrationPath, out) .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index bb52810856..bc2d759e52 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -63,6 +63,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); return Effect.succeed({ diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 6936a83c26..aa7a44fc27 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -21,6 +21,7 @@ import { legacyResolveDeclarativeDir, } from "../../../shared/legacy-db-config.toml-read.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; +import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -39,7 +40,7 @@ import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, -} from "../shared/legacy-migration-file.ts"; +} from "../../../shared/legacy-migration-file.ts"; import { legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; import { type LegacyPgDeltaContext, @@ -62,8 +63,8 @@ import { legacyListRemoteMigrations, legacyLoadLocalVersions, legacyReconcileMigrations, - legacyUpdateMigrationHistory, -} from "./pull.sync.ts"; +} from "../../../shared/legacy-migration-history.ts"; +import { legacyUpdateMigrationHistory } from "./pull.sync.ts"; // pflag's `MarkDeprecated` emits `"Flag --%s has been deprecated, %s\n"` with the // registration message verbatim (`apps/cli-go/cmd/db.go:466`), which ends with a `.`. @@ -540,9 +541,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); } - yield* fs - .makeDirectory(path.dirname(migrationPath), { recursive: true }) - .pipe(Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message }))); + yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); yield* fs.writeFileString(migrationPath, out).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index f7ee1b0396..794b61ab5e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -81,6 +81,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { provisionCalls.push({ mode, usePgDelta, targetLocal, projectRef }); return Effect.succeed({ diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index 66da65778c..3e251e4c1b 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -1,176 +1,15 @@ import { Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; -import { legacyBold } from "../../../shared/legacy-colors.ts"; -import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { + MIGRATE_FILE_PATTERN, + UPSERT_MIGRATION_VERSION, + legacyCreateMigrationTable, +} from "../../../shared/legacy-migration-history.ts"; import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; -import { LegacyMigrationsReadError } from "../shared/legacy-pgdelta.errors.ts"; -import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; import { LegacyDbPullWriteError } from "./pull.errors.ts"; -/** `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version`. */ -const LIST_MIGRATION_VERSION = - "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; - -// Migration-history DDL/DML, verbatim from Go's `pkg/migration/history.go`. -const SET_LOCK_TIMEOUT = "SET lock_timeout = '4s'"; -const CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations"; -const CREATE_VERSION_TABLE = - "CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY)"; -const ADD_STATEMENTS_COLUMN = - "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]"; -const ADD_NAME_COLUMN = - "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text"; -const UPSERT_MIGRATION_VERSION = - "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3) ON CONFLICT (version) DO UPDATE SET name = EXCLUDED.name, statements = EXCLUDED.statements"; - -// `pkg/migration/file.go` — `_.sql`. -const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; - -/** The outcome of comparing remote vs local migration histories. */ -export type LegacyMigrationSync = - | { readonly kind: "in-sync" } - | { readonly kind: "missing" } - | { readonly kind: "conflict"; readonly suggestion: string }; - -/** - * Reconciles the remote and local migration version lists. Pure port of Go's - * `assertRemoteInSync` two-pointer comparison (`internal/db/pull/pull.go:212-258`): - * versions that fail to parse as integers are skipped (Go's `Atoi` error → - * `continue`); any extra remote/local version is a conflict; an empty local set - * is `missing`; otherwise in-sync. - */ -export function legacyReconcileMigrations( - remote: ReadonlyArray, - local: ReadonlyArray, -): LegacyMigrationSync { - // Go's `math.MaxInt` on a 64-bit build == math.MaxInt64; the exhausted side pins - // here. Use BigInt so the full int64 range compares EXACTLY — `Number` loses - // precision above `Number.MAX_SAFE_INTEGER` (e.g. `Number("9999999999999999")` - // rounds to 1e16), which would mis-order versions Go accepts. - const MAX = 9223372036854775807n; - const extraRemote: Array = []; - const extraLocal: Array = []; - let i = 0; - let j = 0; - // Matches Go's `strconv.Atoi`: digits only, no empty/whitespace/sign/float. A - // non-parseable version is skipped (Go's `Atoi` error → `continue`). On 64-bit - // builds `Atoi` parses the full int64 range and returns a range error ONLY for - // values above int64 max; reject only those (so e.g. `9999999999999999`, which Go - // accepts and surfaces as a conflict, is NOT skipped) while still rejecting - // 19+-digit values above the sentinel so they can never exceed the exhausted-side - // pin and stall the two-pointer scan. - const parseVersion = (v: string): bigint | undefined => { - if (!/^\d+$/u.test(v)) return undefined; - const parsed = BigInt(v); - return parsed > MAX ? undefined : parsed; - }; - while (i < remote.length || j < local.length) { - let remoteTs = MAX; - if (i < remote.length) { - const parsed = parseVersion(remote[i]!); - if (parsed === undefined) { - i++; - continue; - } - remoteTs = parsed; - } - let localTs = MAX; - if (j < local.length) { - const parsed = parseVersion(local[j]!); - if (parsed === undefined) { - j++; - continue; - } - localTs = parsed; - } - if (localTs < remoteTs) { - extraLocal.push(local[j]!); - j++; - } else if (remoteTs < localTs) { - extraRemote.push(remote[i]!); - i++; - } else { - i++; - j++; - } - } - if (extraRemote.length + extraLocal.length > 0) { - return { kind: "conflict", suggestion: legacySuggestMigrationRepair(extraRemote, extraLocal) }; - } - if (local.length === 0) { - return { kind: "missing" }; - } - return { kind: "in-sync" }; -} - -/** Go's `suggestMigrationRepair` (`internal/db/pull/pull.go:280-289`). */ -export function legacySuggestMigrationRepair( - extraRemote: ReadonlyArray, - extraLocal: ReadonlyArray, -): string { - let result = - "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n"; - for (const version of extraRemote) { - result += `${legacyBold(`supabase migration repair --status reverted ${version}`)}\n`; - } - for (const version of extraLocal) { - result += `${legacyBold(`supabase migration repair --status applied ${version}`)}\n`; - } - return result; -} - -/** - * Lists the remote project's applied migration versions. Mirrors Go's - * `migration.ListRemoteMigrations` (`pkg/migration/list.go:18-31`): ONLY a missing - * history table (`pgerrcode.UndefinedTable` = `42P01`) means the remote has no - * migrations and returns `[]`; any other error (e.g. a malformed table missing the - * `version` column, `42703`) propagates rather than being silently treated as an - * initial pull. We match the SQLSTATE like Go; if the driver didn't surface a code, - * fall back to a message check that matches a missing relation but NOT a missing - * column. - */ -export const legacyListRemoteMigrations = (session: LegacyDbSession) => - session.query(LIST_MIGRATION_VERSION).pipe( - Effect.map((rows) => rows.map((row) => String(row["version"]))), - Effect.catch((error) => - legacyIsUndefinedTableError(error) - ? Effect.succeed>([]) - : Effect.fail(new LegacyMigrationsReadError({ message: error.message })), - ), - ); - -/** Whether a query error is Postgres `undefined_table` (42P01), matching Go's `pgerrcode.UndefinedTable`. */ -const legacyIsUndefinedTableError = (error: LegacyDbExecError): boolean => { - if (error.code !== undefined) return error.code === "42P01"; - // No SQLSTATE surfaced: a relation-not-exist message counts, a column-not-exist - // one does not (Postgres phrases an undefined column as `column "x" does not exist`). - return ( - /relation .* does not exist/iu.test(error.message) && - !/column .* does not exist/iu.test(error.message) - ); -}; - -/** - * Loads the local migration versions (the `` prefixes). Mirrors Go's - * `LoadLocalVersions` (`internal/migration/list/list.go:72`) → `ListLocalMigrations` - * with a version-collecting filter. - */ -export const legacyLoadLocalVersions = ( - fs: FileSystem.FileSystem, - path: Path.Path, - migrationsDir: string, -) => - legacyListLocalMigrations(fs, path, migrationsDir).pipe( - Effect.map((paths) => - paths.flatMap((p) => { - const match = MIGRATE_FILE_PATTERN.exec(path.basename(p)); - return match?.[1] !== undefined ? [match[1]] : []; - }), - ), - ); - /** * Records the pulled migration as applied in `supabase_migrations.schema_migrations` * WITHOUT re-executing it (the schema already exists on the remote). Mirrors Go's @@ -213,11 +52,7 @@ export const legacyUpdateMigrationHistory = ( yield* Effect.gen(function* () { const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); - yield* session.exec(SET_LOCK_TIMEOUT); - yield* session.exec(CREATE_VERSION_SCHEMA); - yield* session.exec(CREATE_VERSION_TABLE); - yield* session.exec(ADD_STATEMENTS_COLUMN); - yield* session.exec(ADD_NAME_COLUMN); + yield* legacyCreateMigrationTable(session); yield* session.query(UPSERT_MIGRATION_VERSION, [version, name, statements]); }).pipe( Effect.mapError( diff --git a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts index 524d5786f1..8779891687 100644 --- a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -147,11 +147,21 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { readPipedBytes: Effect.succeed( opts.piped === undefined ? Option.none() : Option.some(new TextEncoder().encode(opts.piped)), ), + pipedBytesStream: + opts.piped === undefined + ? Stream.empty + : Stream.fromIterable([new TextEncoder().encode(opts.piped)]), readPipedText: Effect.succeed( opts.piped === undefined || opts.piped.trim() === "" ? Option.none() : Option.some(opts.piped.trim()), ), + readLine: () => + Effect.succeed( + opts.piped === undefined || opts.piped.split(/\r?\n/u)[0]!.trim() === "" + ? Option.none() + : Option.some(opts.piped.split(/\r?\n/u)[0]!.trim()), + ), }); } diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index a2c24f24d2..d1291e27a4 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config | SQL | unless `--no-seed` is set | +| Path | Format | When | +| --------------------------------------- | ---------- | ------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/migrations/` | directory | always, to load migration files | +| seed files from config or `--sql-paths` | SQL | unless `--no-seed` is set | ## Files Written @@ -52,6 +52,9 @@ Not applicable. ## Notes - `--no-seed` skips running the seed script after reset. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset; repeat it to seed multiple files or glob patterns. +- `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. +- With `--linked` or `--db-url`, `--sql-paths` seeds the selected remote database after migrations. - `--version` resets up to the specified migration version. - `--last` resets up to the last n migration versions; mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 11764e9db7..15a61c8d88 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -2,6 +2,8 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyDbReset } from "./reset.handler.ts"; +const noSqlPaths: ReadonlyArray = []; + const config = { dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -18,6 +20,13 @@ const config = { noSeed: Flag.boolean("no-seed").pipe( Flag.withDescription("Skip running the seed script after reset."), ), + sqlPaths: Flag.string("sql-paths").pipe( + Flag.atLeast(0), + Flag.withDescription( + "Override [db.seed].sql_paths for this reset. May be repeated; each value accepts a SQL file path or glob pattern relative to the supabase directory and force-enables seeding.", + ), + Flag.withDefault(noSqlPaths), + ), version: Flag.string("version").pipe( Flag.withDescription("Reset up to the specified version."), Flag.optional, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 406485fe53..de35923ac2 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -9,6 +9,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega if (flags.linked) args.push("--linked"); if (flags.local) args.push("--local"); if (flags.noSeed) args.push("--no-seed"); + for (const path of flags.sqlPaths) args.push("--sql-paths", path); if (Option.isSome(flags.version)) args.push("--version", flags.version.value); if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); yield* proxy.exec(args); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts new file mode 100644 index 0000000000..bd533d2adc --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { legacyDbResetCommand } from "./reset.command.ts"; +import { legacyDbReset } from "./reset.handler.ts"; +import type { LegacyDbResetFlags } from "./reset.command.ts"; + +function setupLegacyDbReset() { + const calls: Array> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push(args); + }), + execCapture: () => Effect.succeed(""), + }); + return { layer, calls }; +} + +const baseFlags: LegacyDbResetFlags = { + dbUrl: Option.none(), + linked: false, + local: false, + noSeed: false, + sqlPaths: [], + version: Option.none(), + last: Option.none(), +}; + +describe("legacy db reset", () => { + it.live("forwards the empty-array baseline without seed override flags", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset(baseFlags); + expect(calls).toEqual([["db", "reset"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --no-seed alone", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ ...baseFlags, noSeed: true }); + expect(calls).toEqual([["db", "reset", "--no-seed"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards a single --sql-paths flag", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: ["./seeds/base.sql"], + }); + expect(calls).toEqual([["db", "reset", "--sql-paths", "./seeds/base.sql"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards repeated --sql-paths flags in order", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: ["./seeds/base.sql", "./seeds/demo/*.sql"], + }); + expect(calls).toEqual([ + ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + noSeed: true, + sqlPaths: ["./seeds/base.sql"], + }); + expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: [""], + }); + expect(calls).toEqual([["db", "reset", "--sql-paths", ""]]); + }).pipe(Effect.provide(layer)); + }); + + it("parses repeated --sql-paths flags from the command surface", async () => { + const { layer, calls } = setupLegacyDbReset(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ + "--sql-paths", + "./seeds/base.sql", + "--sql-paths", + "./seeds/demo/*.sql", + ]); + expect(calls).toEqual([ + ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + ]); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + layer, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + ), + ), + ) as Effect.Effect, + ); + }); + + it("forwards mutually exclusive seed flags from the command surface", async () => { + const { layer, calls } = setupLegacyDbReset(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ + "--no-seed", + "--sql-paths", + "./seeds/base.sql", + ]); + expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + layer, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + ), + ), + ) as Effect.Effect, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index ff49d1be91..19da790379 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -29,6 +29,7 @@ function mockSeam(paths: Record) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 167520a3d4..7e6cbab323 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -19,6 +19,7 @@ import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, } from "./declarative.errors.ts"; +import type { LegacyDeclarativeShadowDbError } from "../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; /** @@ -84,10 +85,12 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( path: Path.Path, workdir: string, linkedRef: Option.Option, + beforeLocalTarget: Effect.Effect = Effect.void, ) { if (!hasMigrations) { // No migrations → generate from local. Go runs ensureLocalDatabaseStarted first // (db_schema_declarative.go:291), starting a stopped stack. + yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); return legacyLocalUrl(local); } @@ -149,6 +152,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset // prompt (db_schema_declarative.go:249), starting a stopped stack. + yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); let shouldReset = flags.reset; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 5e090a8633..fbcb89a721 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -132,14 +132,16 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec let targetUrl: string; let overwrite: boolean; if (hasExplicitTarget) { + const seam = yield* LegacyDeclarativeSeam; if (Option.isSome(flags.local)) { // Target selection keys off flag presence (Go's `Changed`), but the // auto-start gates on the boolean VALUE: Go passes `declarativeLocal` to // `ensureLocalDatabaseStarted` (`db_schema_declarative.go:190`), which // short-circuits `if !local { return nil }` (`:127-128`). So `--local=false` // selects the local target but must NOT start a stopped stack. + yield* seam.ensureLocalPostgresImageCurrent(); if (Option.getOrElse(flags.local, () => false)) { - yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); + yield* seam.ensureLocalDatabaseStarted(); } targetUrl = legacyLocalUrl(local); } else { @@ -206,6 +208,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec path, cliConfig.workdir, linkedRef, + (yield* LegacyDeclarativeSeam).ensureLocalPostgresImageCurrent(), ); overwrite = true; } diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index b49cf92592..938ceab885 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -58,6 +58,7 @@ interface SetupOpts { networkId?: Option.Option; projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; + staleLocalImage?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -71,6 +72,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const seamCalls: LegacyCatalogMode[] = []; const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; const execInheritCalls: ReadonlyArray[] = []; + const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, projectRef }) => { @@ -88,6 +90,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), + ensureLocalPostgresImageCurrent: () => + Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, + ), + ), provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); @@ -148,6 +164,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, resolverCalls, proxyCalls, + localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; }, @@ -228,6 +245,23 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("explicit --local checks the local Postgres image before generating", () => { + const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.ensureStartedCalls).toBe(0); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("honors --yes to overwrite existing declarative files without prompting", () => { // Pre-seed the declarative dir so the overwrite branch is reached. With --yes, // Go's confirmOverwrite returns true immediately (Console.PromptYesNo); the @@ -416,6 +450,7 @@ describe("legacy db schema declarative generate integration", () => { expect(s.seamCalls).toContain("baseline"); // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); + expect(s.localPostgresImageChecks).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -561,6 +596,27 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("smart mode: local target checks the local Postgres image before generating", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + promptSelectResponses: ["local"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "smart mode: caches the linked project even when the user picks local (Go PostRun)", () => { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 8213bd9df9..61f276e4c0 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -16,6 +16,7 @@ import { legacyReadDbToml, legacyResolveDeclarativeDir, } from "../../../../../shared/legacy-db-config.toml-read.ts"; +import { legacyMakeDir } from "../../../../../shared/legacy-make-dir.ts"; import { legacyApplyMigrationFile } from "../../../../../shared/legacy-migration-apply.ts"; import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -109,7 +110,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara pgDeltaEnabled: toml.pgDelta.enabled, configPath: path.join("supabase", "config.toml"), }); - // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. @@ -131,6 +131,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, noCache: flags.noCache, }; + const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); + const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); // Go's `saveApplyDebugBundle`: warn (rather than masking the apply error) and // treat the bundle path as empty when the debug directory cannot be created, so @@ -148,7 +150,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); // Step 1: declarative files must exist; in a TTY, offer to generate them. - if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { + if (!declarativeFilesExist) { const noFiles = new LegacyDeclarativeNonInteractiveError({ message: "no declarative schema found. Run supabase db schema declarative generate first", }); @@ -207,6 +209,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara path, cliConfig.workdir, linkedRef, + ensureLocalPostgresImageCurrent, ); const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); @@ -274,7 +277,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // Step 5: write the timestamped migration file. const timestamp = formatTimestamp(yield* Clock.currentTimeMillis); const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`); - yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* legacyMakeDir(fs, migrationsDir); yield* fs.writeFileString(migrationPath, result.diffSQL); yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr"); @@ -307,6 +310,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (!shouldApply) return; // Step 8: apply the migration to the local database (native). + yield* ensureLocalPostgresImageCurrent; const applyExit = yield* applyMigrationToLocal( { port: toml.port, password: toml.password, dnsResolver }, migrationPath, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 0420d274f0..aa2718ac62 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -24,10 +24,24 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; +const EXPORT_JSON = JSON.stringify({ + version: 1, + mode: "declarative", + files: [ + { + path: "schemas/public/tables/players.sql", + order: 0, + statements: 1, + sql: "create table players ();", + }, + ], +}); + interface SetupOpts { experimental?: boolean; yes?: boolean; @@ -40,6 +54,8 @@ interface SetupOpts { promptTextResponses?: ReadonlyArray; networkId?: string; projectId?: Option.Option; + staleLocalImage?: boolean; + exportJson?: string; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -51,6 +67,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); const execInheritCalls: ReadonlyArray[] = []; + const localPostgresImageChecks: Array = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode }) => Effect.succeed(`supabase/.temp/pgdelta/${mode}.json`), execInherit: (args) => @@ -59,12 +76,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { return opts.resetExitCode ?? 0; }), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => + Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, + ), + ), provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (_opts: LegacyEdgeRuntimeRunOpts) => - Effect.succeed({ stdout: opts.diffSql ?? "", stderr: "" }), + run: (runOpts: LegacyEdgeRuntimeRunOpts) => + Effect.succeed({ + stdout: + opts.exportJson !== undefined && + runOpts.errPrefix === "error exporting declarative schema" + ? opts.exportJson + : (opts.diffSql ?? ""), + stderr: "", + }), }); const dbExec: string[] = []; const dbConn = Layer.succeed(LegacyDbConnection, { @@ -123,7 +161,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), BunServices.layer, ); - return { layer, out, execInheritCalls, dbExec, cache }; + return { layer, out, execInheritCalls, dbExec, cache, localPostgresImageChecks }; } const flags = ( @@ -205,6 +243,59 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("non-interactive default dry-run does not check the local Postgres image", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags()); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(migrations).toHaveLength(1); + expect(s.localPostgresImageChecks).toEqual([]); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--apply checks the local Postgres image before applying", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--no-apply skips the local Postgres image check", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(migrations).toHaveLength(1); + expect(s.localPostgresImageChecks).toEqual([]); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("--yes bypasses the bootstrap prompt when no declarative files exist", () => { // Without --yes + non-TTY this fails at the "no declarative schema found" gate // (prior test). With --yes, Go's PromptYesNo auto-confirms, so the bootstrap is @@ -239,6 +330,90 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("bootstrap linked target does not run the local Postgres image check", () => { + // The stale-image guard only matters once bootstrap chooses a local source. A + // linked/custom bootstrap can build fresh catalogs and skip local apply, so it + // must reach the target prompt before any local-container inspection. + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + projectId: Option.some("abcdefghijklmnopqrst"), + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ); + expect(s.localPostgresImageChecks).toEqual([]); + expect(JSON.stringify(exit)).not.toContain("local Postgres container image is stale"); + expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ + "local", + "linked", + "custom", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("bootstrap linked target checks the local Postgres image before apply", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + projectId: Option.some("abcdefghijklmnopqrst"), + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + exportJson: EXPORT_JSON, + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ + noCache: true, + apply: Option.some(true), + name: Option.some("bootstrap_apply"), + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("bootstrap local target checks the local Postgres image", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["local"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("bootstrap: an unreadable migrations path is treated as no migrations", () => { // Go's delegated hasMigrationFiles returns false on ANY ListLocalMigrations error // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts index af023ed36a..b15c6612d2 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts @@ -3,8 +3,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Path } from "effect"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCollectMigrationsList, legacySaveDebugBundle } from "./legacy-debug-bundle.ts"; const save = (workdir: string, tempDir: string, migrationsDir: string, id: string) => @@ -16,7 +17,7 @@ const save = (workdir: string, tempDir: string, migrationsDir: string, id: strin error: "boom", migrationSql: "create table t();", }); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); describe("legacySaveDebugBundle", () => { it.effect("writes artifacts and returns the debug directory", () => { @@ -58,7 +59,7 @@ const collect = (migrationsDir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* legacyCollectMigrationsList(fs, path, migrationsDir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); describe("legacyCollectMigrationsList", () => { it.effect("returns migration filenames when the dir is readable", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts index 280bd67510..a15f683d02 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { Effect, type FileSystem, Option, type Path } from "effect"; -import { LegacyMigrationsReadError } from "./legacy-pgdelta.errors.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyMigrationsReadError } from "../../../shared/legacy-migration.errors.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported @@ -106,12 +107,19 @@ export function legacyPgDeltaTempPath(path: Path.Path, workdir: string): string * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are * sorted by name, directories skipped, a deprecated `<14-digit>_init.sql` first * migration (pre-2021-12-09) is skipped, and names must match `_*.sql`. + * + * Each skipped file emits a byte-exact stderr warning matching Go's + * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the + * deprecated-init and misnamed-file cases. Because this is the shared lister, + * the warning fires for the `db diff/pull/schema declarative` and pgcache paths + * too, not only the `migration` commands, exactly as in Go. */ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, migrationsDir: string, ) { + const output = yield* Output; // Mirror Go's single `fs.ReadDir` (`pkg/migration/list.go:34-37`): only a // not-exist directory is "no migrations"; every other read error (the path is a // file → `ENOTDIR`, permission denied, …) aborts rather than silently letting @@ -137,9 +145,21 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( if (Option.isSome(stat) && stat.value.type === "Directory") continue; if (index === 0) { const init = INIT_SCHEMA_PATTERN.exec(name); - if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) continue; + if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { + yield* output.raw( + `Skipping migration ${name}... (replace "init" with a different file name to apply this migration)\n`, + "stderr", + ); + continue; + } + } + if (!MIGRATE_FILE_PATTERN.test(name)) { + yield* output.raw( + `Skipping migration ${name}... (file name must match pattern "_name.sql")\n`, + "stderr", + ); + continue; } - if (!MIGRATE_FILE_PATTERN.test(name)) continue; result.push(path.join(migrationsDir, name)); } return result as ReadonlyArray; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts index 6262d5970d..9f2e57e3aa 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts @@ -4,8 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { type LegacySetupInputs, legacyBaselineCatalogFileName, @@ -110,11 +112,13 @@ describe("catalog keys + file names", () => { const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-decl-cache-")); -const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(BunServices.layer)) as Effect.Effect; +const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), + ) as Effect.Effect; const withServices = ( - body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, + body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, ) => run( Effect.gen(function* () { @@ -142,6 +146,42 @@ describe("legacyListLocalMigrations", () => { ); }); + it.effect( + "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", + () => { + // Mirrors Go's `ListLocalMigrations` warnings (`pkg/migration/list.go:45-53`): + // a `fmt.Fprintf(os.Stderr, …)` for the deprecated `_init.sql` first file and + // for any name that does not match `_name.sql`. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyListLocalMigrations(fs, path, migrationsDir); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); + expect(stderr).toContain( + 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', + ); + expect(stderr).toContain( + 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', + ); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ) as Effect.Effect; + }, + ); + it.effect("returns [] when the migrations dir is absent", () => { const dir = withTemp(); return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index 40c7f75ed4..b52d173fbe 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -48,17 +48,6 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( readonly message: string; }> {} -/** - * Listing local migrations failed for a reason other than the directory being - * absent. Byte-matches Go's `migration.ListLocalMigrations` - * (`apps/cli-go/pkg/migration/list.go:34-37`), which returns - * `"failed to read directory: " + err` for anything but `os.ErrNotExist` rather - * than treating an unreadable `supabase/migrations` as "no migrations". - */ -export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ - readonly message: string; -}> {} - /** * Materializing the declarative export on disk failed. Byte-matches Go's * `WriteDeclarativeSchemas` errors (`declarative.go:239`): diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 30c03b827f..634c4c7b56 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -6,7 +6,9 @@ import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legac import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { containerCliExitCode, spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyResolveLocalProjectId, localDbContainerId, @@ -265,6 +267,116 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } }), ), + ensureLocalPostgresImageCurrent: () => + Effect.scoped( + Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.mapError( + (error) => + new LegacyDeclarativeShadowDbError({ + message: `failed to read config for local Postgres image check: ${error.message}`, + }), + ), + ); + const image = yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ); + const tomlProjectId = toml.projectId; + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stdout, (chunk) => + Effect.sync(() => { + stdoutChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes).trim(); + }; + const stderr = decodeChunks(stderrChunks); + const stdout = decodeChunks(stdoutChunks); + if (inspectExit !== 0) { + if (legacyIsMissingContainerInspectError(stderr)) return; + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: + stderr.length > 0 + ? `failed to inspect local Postgres container: ${stderr}` + : "failed to inspect local Postgres container.", + }), + ); + } + const actual = legacyResolveContainerInspectImageName(stdout); + const expected = legacyGetRegistryImageUrl(image).trim(); + const actualTag = dockerImageTag(actual); + const expectedTag = dockerImageTag(expected); + if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + return; + } + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: `local Postgres container image is stale: running ${actual} but expected ${expected}. Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas.`, + }), + ); + }), + ), provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => Effect.scoped( Effect.gen(function* () { @@ -398,3 +510,39 @@ const failure = (exitCode?: number) => ? "failed to provision the shadow database." : `failed to provision the shadow database: exit ${exitCode}`, }); + +function dockerImageTag(image: string): string { + const trimmed = image.trim(); + const index = trimmed.lastIndexOf(":"); + if (index < 0 || index === trimmed.length - 1) return ""; + return trimmed.slice(index + 1); +} + +export function legacyIsMissingContainerInspectError(stderr: string): boolean { + return stderr.toLowerCase().includes("no such container"); +} + +export function legacyResolveContainerInspectImageName(stdout: string): string { + const trimmed = stdout.trim(); + if (trimmed.length === 0) return ""; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return trimmed; + } + const inspect = Array.isArray(parsed) ? parsed[0] : parsed; + if (!isJsonRecord(inspect)) return ""; + const imageName = inspect["ImageName"]; + if (typeof imageName === "string" && imageName.trim().length > 0) { + return imageName.trim(); + } + const config = inspect["Config"]; + if (!isJsonRecord(config)) return ""; + const configImage = config["Image"]; + return typeof configImage === "string" && configImage.trim().length > 0 ? configImage.trim() : ""; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts new file mode 100644 index 0000000000..b6b0251a10 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyIsMissingContainerInspectError, + legacyResolveContainerInspectImageName, +} from "./legacy-pgdelta.seam.layer.ts"; + +describe("legacyIsMissingContainerInspectError", () => { + it("matches Docker and Podman missing-container stderr", () => { + expect(legacyIsMissingContainerInspectError("Error: No such container: supabase_db_test")).toBe( + true, + ); + expect(legacyIsMissingContainerInspectError("Error: no such container: supabase_db_test")).toBe( + true, + ); + }); + + it("does not match unrelated inspect failures", () => { + expect(legacyIsMissingContainerInspectError("Cannot connect to the Docker daemon")).toBe(false); + }); +}); + +describe("legacyResolveContainerInspectImageName", () => { + it("reads Docker's config image from inspect JSON", () => { + expect( + legacyResolveContainerInspectImageName( + JSON.stringify([{ Config: { Image: "public.ecr.aws/supabase/postgres:17.4.1.056" } }]), + ), + ).toBe("public.ecr.aws/supabase/postgres:17.4.1.056"); + }); + + it("prefers Podman's image name from inspect JSON", () => { + expect( + legacyResolveContainerInspectImageName( + JSON.stringify([ + { + Image: "sha256:0123456789", + ImageName: "public.ecr.aws/supabase/postgres:17.4.1.056", + }, + ]), + ), + ).toBe("public.ecr.aws/supabase/postgres:17.4.1.056"); + }); + + it("keeps raw formatter output as a compatibility fallback", () => { + expect(legacyResolveContainerInspectImageName("supabase/postgres:15.1.0")).toBe( + "supabase/postgres:15.1.0", + ); + }); + + it("returns empty when JSON inspect output has no image-name field", () => { + expect(legacyResolveContainerInspectImageName(JSON.stringify([{ Image: "sha256:0123" }]))).toBe( + "", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index de657d0af7..16593f5f75 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -73,6 +73,15 @@ interface LegacyDeclarativeSeamShape { * of failing to connect, matching Go. */ readonly ensureLocalDatabaseStarted: () => Effect.Effect; + /** + * Checks the running local Postgres container image tag against the currently + * resolved Postgres image. A missing container is accepted: catalog cache keys + * self-invalidate on setup inputs, and local-apply paths will start/connect later. + */ + readonly ensureLocalPostgresImageCurrent: () => Effect.Effect< + void, + LegacyDeclarativeShadowDbError + >; /** * Provisions a live shadow database via the bundled Go binary's hidden * `db __shadow` command and returns it running (the container is NOT removed — diff --git a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts new file mode 100644 index 0000000000..2bfa93b86f --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "vitest"; + +import { + describeLive, + describeLiveProject, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 120_000; + +// Project-scoped read-only scenario. Skipped unless SUPABASE_LIVE_PROJECT_REF is +// set — i.e. a project has been provisioned on the stack (the cli-e2e-ci runner +// does this; a control-plane-only stack, like local macOS, skips it). +// +// This is the entry point for the broader edge-functions coverage tracked in +// CLI-1834 (deploy + invoke over :443 / {ref}.supabase.red), which needs the +// project's gateway reachable from the host — author those here as they become +// runnable on the full stack. +describeLiveProject("supabase functions list (live)", () => { + test("lists edge functions for the project", { timeout: LIVE_TIMEOUT_MS }, async () => { + const ref = requireLiveProjectRef(); + const { exitCode, stdout, stderr } = await runSupabaseLive([ + "functions", + "list", + "--project-ref", + ref, + ]); + expect(`${stdout}${stderr}`).not.toContain("Unauthorized"); + expect(exitCode).toBe(0); + }); +}); + +// Project-scoped error path that needs NO provisioned project: a valid token +// with an unknown `--project-ref` must reach the live Management API, come back +// 404, and surface as a non-zero exit (not a crash, not "Unauthorized"). This +// exercises the `--project-ref` request path + error mapping on a control-plane- +// only stack, so it runs under `describeLive`, not `describeLiveProject`. +describeLive("supabase functions list — unknown project (live)", () => { + test("fails with a 404 for an unknown project ref", { timeout: LIVE_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabaseLive([ + "functions", + "list", + "--project-ref", + "a".repeat(20), // well-formed (20 lowercase chars) but nonexistent ref + ]); + const out = `${stdout}${stderr}`; + expect(exitCode).not.toBe(0); + expect(out).not.toContain("Unauthorized"); + expect(out).toContain("404"); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index d234aa388f..be575f1409 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -477,6 +477,14 @@ describe("legacy functions serve integration", () => { expect(dockerRun.args).toContain("--add-host"); expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.73.13"); + expect( + extractFlagValues(dockerRun.args, "-v").some((value) => + value.endsWith(":/root/index.ts:ro"), + ), + ).toBe(true); + expect(dockerRun.args[dockerRun.args.length - 1]).toBe( + "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", + ); const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); expect(envs).toContain("HELLO=WORLD"); @@ -1147,7 +1155,14 @@ describe("legacy functions serve integration", () => { } const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; - expect(commandScript).toContain("cat <<'EOF' > /root/index.ts"); + expect(commandScript).toBe( + "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", + ); + expect( + extractFlagValues(dockerRun.args, "-v").some((value) => + value.endsWith(":/root/index.ts:ro"), + ), + ).toBe(true); expect(commandScript).not.toContain("@ts-nocheck"); expect(commandScript).not.toContain("declare const Deno"); expect(commandScript).not.toContain("declare const EdgeRuntime"); diff --git a/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md index acd5efd4b0..cdb4c5f903 100644 --- a/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md @@ -21,9 +21,10 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| Variable | Purpose | Required? | +| ------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `DOTENV_PRIVATE_KEY[_*]` | dotenvx private key(s) to decrypt `encrypted:` `[db.vault]` secrets before upsert | no (required only if a `[db.vault]` value is encrypted) | ## Exit Codes @@ -37,18 +38,33 @@ ### `--output-format text` (Go CLI compatible) -Prints progress as migrations are reverted. +Prints `Resetting database to version: ` to stderr, then drops every +user schema/object (the bundled `drop.sql` DO-block), upserts `[db.vault]` +secrets, and re-applies local migrations `<= version` plus seed files (each gated +on `db.migrations.enabled` / `db.seed.enabled`). Nothing is written to stdout. ### `--output-format json` -Not applicable. +Emits `output.success("Migrations reverted", { version, last })`. ### `--output-format stream-json` -Not applicable. +Same structured result delivered as an NDJSON `result` event. + +## Prompts + +- Prompts `Do you want to revert the following migrations?` with the bulleted + versions + a yellow `WARNING:` line (default **NO**). Declining exits non-zero + (`context canceled`). `--yes` auto-confirms; a non-interactive / machine-output + run takes the default (NO → cancel). ## Notes -- `--last` (default 1) resets up to the last n migration versions. +- `--last` (default 1) resets up to the last n migration versions; must be `> 0` + and `<` the number of applied migrations. - `--local` (default true), `--linked`, and `--db-url` are mutually exclusive. - Takes no positional arguments. +- Skips Go's best-effort `pgcache.TryCacheMigrationsCatalog` (documented divergence). +- Dotenvx-encrypted (`encrypted:`) `[db.vault]` values are decrypted during config + load using `DOTENV_PRIVATE_KEY[_*]`; an `encrypted:` value with no working key + aborts the command with `failed to parse config: …`, matching Go. diff --git a/apps/cli/src/legacy/commands/migration/down/down.command.ts b/apps/cli/src/legacy/commands/migration/down/down.command.ts index 421015a3e8..dd6be72039 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.command.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.command.ts @@ -1,11 +1,27 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyMigrationDbRuntimeLayer } from "../migration.layers.ts"; import { legacyMigrationDown } from "./down.handler.ts"; const config = { + // Go's `--last` is a `uint` (`down.go`), default 1. Effect has no uint, so reject + // negatives explicitly to reproduce cobra's `ParseUint` rejection (the message + // differs slightly — an accepted small divergence). last: Flag.integer("last").pipe( Flag.withDescription("Reset up to the last n migration versions."), - Flag.optional, + Flag.withDefault(1), + Flag.mapTryCatch( + (value) => { + if (value < 0) { + throw new Error(`invalid argument "${value}" for "--last" flag: must be greater than 0`); + } + return value; + }, + (err) => (err instanceof Error ? err.message : String(err)), + ), ), dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -18,6 +34,8 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Resets applied migrations on the local database."), + // Go: `downFlags.Bool("local", true, …)`. + Flag.withDefault(true), ), } as const; @@ -26,5 +44,18 @@ export type LegacyMigrationDownFlags = CliCommand.Command.Config.Infer legacyMigrationDown(flags)), + Command.withHandler((flags) => + legacyMigrationDown(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + last: flags.last, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyMigrationDbRuntimeLayer(["migration", "down"])), ); diff --git a/apps/cli/src/legacy/commands/migration/down/down.errors.ts b/apps/cli/src/legacy/commands/migration/down/down.errors.ts new file mode 100644 index 0000000000..a243205914 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/down/down.errors.ts @@ -0,0 +1,18 @@ +import { Data } from "effect"; + +/** `--last 0`. Byte-matches Go's `--last must be greater than 0` (`down.go:21`). */ +export class LegacyMigrationLastZeroError extends Data.TaggedError("LegacyMigrationLastZeroError")<{ + readonly message: string; +}> {} + +/** + * `--last` >= the number of applied migrations. Byte-matches Go's + * `--last must be smaller than total applied migrations: ` (`down.go:35`); + * the `supabase db reset` suggestion is attached separately. + */ +export class LegacyMigrationLastTooLargeError extends Data.TaggedError( + "LegacyMigrationLastTooLargeError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} diff --git a/apps/cli/src/legacy/commands/migration/down/down.handler.ts b/apps/cli/src/legacy/commands/migration/down/down.handler.ts index f90221f117..d646db1782 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -1,15 +1,173 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { + LegacyDnsResolverFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { + legacyLoadProjectEnv, + legacyReadDbToml, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { legacyDropUserSchemas } from "../../../shared/legacy-drop-objects.ts"; +import { legacyMigrateAndSeed } from "../../../shared/legacy-migrate-and-seed.ts"; +import { legacyListRemoteMigrations } from "../../../shared/legacy-migration-history.ts"; +import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacyMigrationTargetFlagsError, + LegacyOperationCanceledError, +} from "../migration.errors.ts"; +import { legacyMigrationConfirm } from "../migration.prompt.ts"; import type { LegacyMigrationDownFlags } from "./down.command.ts"; +import { LegacyMigrationLastTooLargeError, LegacyMigrationLastZeroError } from "./down.errors.ts"; + +/** Go's `confirmResetAll` (`internal/migration/down/down.go:64`). */ +const confirmResetAll = (pending: ReadonlyArray): string => { + let title = "Do you want to revert the following migrations?\n"; + for (const version of pending) title += ` • ${legacyBold(version)}\n`; + title += `${legacyYellow("WARNING:")} you will lose all data in this database.`; + return title; +}; + +const runDown = Effect.fnUntraced(function* ( + flags: LegacyMigrationDownFlags, + target: ReturnType, +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const connection = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + + // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at + // parse time, ahead of the root `PersistentPreRunE` (`cmd/migration.go:156`). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + + const connType = target.connType ?? "local"; // down defaults to `--local` (Go: `Bool("local", true)`). + + // Resolve the DB config BEFORE the `--last` validation — Go's root `PersistentPreRunE` + // runs `ParseDatabaseConfig` (`cmd/root.go:118`) before `down.Run`'s `last == 0` check + // (`internal/migration/down/down.go:20-23`), so an unlinked/invalid target surfaces + // before the `--last must be greater than 0` error. + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + }); + + // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), + // i.e. after the parse-time flag-group validation above — so a SUPABASE_YES set only in + // supabase/.env auto-confirms, but a flag conflict still surfaces before any .env read. + // Resolve --yes against the project env here, not just process.env (root.go:318-334). + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + + // Linked down caches the project ref (Go's `ensureProjectGroupsCached` from `Execute()`, + // gated on the ref loaded in pre-run, NOT on the RunE error). Load it now and attach the + // cache to the whole flow via `Effect.ensuring`, so it runs even on the `--last`/cancel + // failure paths. + const cacheLinkedRef = + connType === "linked" + ? yield* Effect.gen(function* () { + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const linkedRef = yield* projectRef.loadProjectRef(Option.none()); + return linkedProjectCache.cache(linkedRef); + }) + : undefined; + + const downFlow = Effect.gen(function* () { + // `--last` zero-value validation runs after DB-config resolution (Go's check is inside + // `down.Run`, after `PersistentPreRunE`). + if (flags.last === 0) { + return yield* Effect.fail( + new LegacyMigrationLastZeroError({ message: "--last must be greater than 0" }), + ); + } + + const ref = Option.getOrUndefined(cfg.ref ?? Option.none()); + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref); + + yield* Effect.scoped( + Effect.gen(function* () { + // Go's `utils.ConnectByConfig` prints this to stderr before dialing + // (`internal/utils/connect.go:343-348`), local/remote per `IsLocalDatabase`. + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* connection.connect(cfg.conn, { + isLocal: cfg.isLocal, + dnsResolver, + }); + + const remote = yield* legacyListRemoteMigrations(session); + const total = remote.length; + if (total <= flags.last) { + return yield* Effect.fail( + new LegacyMigrationLastTooLargeError({ + message: `--last must be smaller than total applied migrations: ${total}`, + suggestion: `Try ${legacyAqua("supabase db reset")} if you want to revert all migrations.`, + }), + ); + } + + const confirmed = yield* legacyMigrationConfirm( + confirmResetAll(remote.slice(total - flags.last)), + { + defaultValue: false, + yes, + }, + ); + if (!confirmed) { + return yield* Effect.fail( + new LegacyOperationCanceledError({ message: "context canceled" }), + ); + } + + const version = remote[total - flags.last - 1]!; + yield* output.raw(`Resetting database to version: ${version}\n`, "stderr"); + yield* legacyDropUserSchemas(session); + yield* legacyUpsertVaultSecrets(session, toml.vault); + yield* legacyMigrateAndSeed(session, fs, path, cliConfig.workdir, version, { + migrationsEnabled: toml.migrationsEnabled, + seed: toml.seed, + }); + + if (output.format !== "text") { + yield* output.success("Migrations reverted", { version, last: flags.last }); + } + }), + ); + }); + + return yield* cacheLinkedRef === undefined + ? downFlow + : downFlow.pipe(Effect.ensuring(cacheLinkedRef)); +}); export const legacyMigrationDown = Effect.fn("legacy.migration.down")(function* ( flags: LegacyMigrationDownFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["migration", "down"]; - if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); + const telemetryState = yield* LegacyTelemetryState; + const cliArgs = yield* CliArgs; + const target = resolveLegacyDbTargetFlags(cliArgs.args); + yield* runDown(flags, target).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts new file mode 100644 index 0000000000..57c15c999e --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -0,0 +1,409 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyMigrationDropError } from "../../../shared/legacy-drop-objects.ts"; +import { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; +import { legacyMigrationDown } from "./down.handler.ts"; +import type { LegacyMigrationDownFlags } from "./down.command.ts"; + +const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; + +interface SetupOpts { + readonly format?: OutputFormat; + readonly isTTY?: boolean; + readonly pipedInput?: string; + readonly args?: ReadonlyArray; + readonly yes?: boolean; + readonly confirm?: boolean; + readonly remote?: ReadonlyArray; + readonly failResolve?: boolean; + readonly failDrop?: boolean; + readonly failSeed?: boolean; + readonly config?: string; + readonly seedTable?: ReadonlyArray<{ path: string; hash: string }>; +} + +const SELECT_SEED = "SELECT path, hash FROM supabase_migrations.seed_files"; + +function setup(workdir: string, opts: SetupOpts = {}) { + if (opts.config !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.config); + } + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + opts.failResolve === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ) + : Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: true, + ref: Option.none(), + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + const connection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: (sql: string) => + Effect.suspend(() => { + execs.push(sql); + if (opts.failDrop === true && sql.startsWith("do $$")) { + return Effect.fail(new LegacyDbExecError({ message: "permission denied" })); + } + if (opts.failSeed === true && sql.startsWith("insert into")) { + return Effect.fail(new LegacyDbExecError({ message: "boom" })); + } + return Effect.void; + }), + query: (sql: string, params?: ReadonlyArray) => + Effect.suspend(() => { + queries.push({ sql, params }); + if (sql === LIST_SQL) + return Effect.succeed((opts.remote ?? []).map((version) => ({ version }))); + if (sql === SELECT_SEED) return Effect.succeed([...(opts.seedTable ?? [])]); + return Effect.succeed>>([]); + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }); + + const projectRef = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + cache.layer, + resolver, + connection, + projectRef, + mockLegacyCliConfig({ workdir }), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), + mockTty({ stdinIsTty: opts.isTTY ?? true }), + mockStdin( + opts.isTTY ?? true, + // Migration prompts read stdin directly (Go's PromptYesNo), so a confirm answer is + // supplied via piped stdin rather than the Output prompt mock. + opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), + ), + BunServices.layer, + ); + return { layer, out, telemetry, execs, queries }; +} + +const flags = (over: Partial = {}): LegacyMigrationDownFlags => ({ + last: over.last ?? 1, + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? true, +}); + +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +const seed = (workdir: string, name: string, body = "create table a;\n") => { + const dir = join(workdir, "supabase", "migrations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), body); +}; + +const tmp = useLegacyTempWorkdir(); + +describe("legacy migration down", () => { + it.live("rejects --last 0", () => { + const { layer } = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 0 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationLastZeroError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the DB target before rejecting --last 0", () => { + // Go runs ParseDatabaseConfig (PersistentPreRunE, root.go:118) before down.Run's + // last==0 check, so an unlinked/invalid target error wins over --last 0. + const { layer } = setup(tmp.current, { failResolve: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 0 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + // The target/config error surfaces first, NOT the --last 0 error. + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --last >= total applied migrations", () => { + const { layer } = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 1 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyMigrationLastTooLargeError", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("reverts to the target version on confirm (drop + migrate&seed)", () => { + seed(tmp.current, "20240101000000_a.sql"); + const { layer, out, execs, queries } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + // Go prints the connection banner to stderr before dialing (connect.go:343-348). + expect(stripAnsi(out.stderrText)).toContain("Connecting to local database..."); + expect(stripAnsi(out.stderrText)).toContain("Resetting database to version: 20240101000000"); + // dropped user schemas, then re-applied the migration <= target version. + expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(true); + expect( + queries.some( + (q) => + q.sql.includes("INSERT INTO supabase_migrations") && q.params?.[0] === "20240101000000", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("cancels on a declined prompt", () => { + seed(tmp.current, "20240101000000_a.sql"); + const { layer, execs } = setup(tmp.current, { + confirm: false, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 1 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); + } + expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("falls back to NO (cancels) without a TTY and no piped answer", () => { + // Go reads stdin regardless of TTY (IsTTY only changes the timeout); with no piped + // answer the empty read falls back to the default (NO) → cancel. + const { layer, out } = setup(tmp.current, { + isTTY: false, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 1 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); + } + expect(out.promptConfirmCalls.length).toBe(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a structured result in json with --yes", () => { + seed(tmp.current, "20240101000000_a.sql"); + const { layer, out } = setup(tmp.current, { + format: "json", + yes: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Migrations reverted", + data: { version: "20240101000000", last: 1 }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { + seed(tmp.current, "20240101000000_a.sql"); + // SUPABASE_YES lives only in supabase/.env, not the shell — Go's loadNestedEnv loads it + // before the prompt, so the revert auto-confirms with no --yes flag and no stdin answer. + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + const { layer, out } = setup(tmp.current, { + format: "json", + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Migrations reverted", + data: { version: "20240101000000", last: 1 }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a drop-schema failure", () => { + seed(tmp.current, "20240101000000_a.sql"); + const { layer } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + failDrop: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 1 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value instanceof LegacyMigrationDropError).toBe( + true, + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("seeds data from a new seed file and records its hash", () => { + seed(tmp.current, "20240101000000_a.sql"); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (1);\n"); + const { layer, out, queries } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + expect(stripAnsi(out.stderrText)).toContain("Seeding data from supabase/seed.sql..."); + expect( + queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a seed-apply failure", () => { + seed(tmp.current, "20240101000000_a.sql"); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (1);\n"); + const { layer } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + failSeed: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown(flags({ last: 1 })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value instanceof LegacyMigrationSeedError).toBe( + true, + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("skips an unchanged seed file", () => { + seed(tmp.current, "20240101000000_a.sql"); + const body = "insert into a values (1);\n"; + writeFileSync(join(tmp.current, "supabase", "seed.sql"), body); + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out, queries } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + seedTable: [{ path: "supabase/seed.sql", hash }], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + expect(stripAnsi(out.stderrText)).not.toContain("Seeding data from"); + expect( + queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("updates the recorded hash (without re-running) for a changed seed file", () => { + seed(tmp.current, "20240101000000_a.sql"); + writeFileSync(join(tmp.current, "supabase", "seed.sql"), "insert into a values (2);\n"); + const { layer, out, execs, queries } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + seedTable: [{ path: "supabase/seed.sql", hash: "stale-hash-does-not-match" }], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + // Dirty seed → "Updating seed hash" + hash UPSERT, but the seed SQL is NOT re-run. + expect(stripAnsi(out.stderrText)).toContain("Updating seed hash to supabase/seed.sql..."); + expect( + queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + expect(execs).not.toContain("insert into a values (2)"); + }).pipe(Effect.provide(layer)); + }); + + it.live("skips migration apply when db.migrations.enabled = false", () => { + seed(tmp.current, "20240101000000_a.sql"); + const { layer, queries } = setup(tmp.current, { + confirm: true, + remote: ["20240101000000", "20240102000000"], + config: "[db.migrations]\nenabled = false\n", + }); + return Effect.gen(function* () { + yield* legacyMigrationDown(flags({ last: 1 })); + // No migration re-applied when migrations are disabled. + expect(queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md index 5b7da97b12..7da5a26931 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md @@ -36,17 +36,39 @@ ### `--output-format text` (Go CLI compatible) -Prints the names of migration files fetched from the history table. +Silent on success (Go prints nothing). Reads +`SELECT version, coalesce(name, '') as name, statements FROM +supabase_migrations.schema_migrations` and writes each row to +`/supabase/migrations/_.sql` (statements joined with +`;\n` plus a trailing `;\n`, mode 0644). ### `--output-format json` -Not applicable. +Emits `output.success("Migration history fetched", { files: [] })`. ### `--output-format stream-json` -Not applicable. +Same structured `files` result delivered as an NDJSON `result` event. + +## Prompts + +- When the migrations directory is non-empty, prompts + `Do you want to overwrite existing files in supabase/migrations directory?` + (default **YES**). Declining exits non-zero (`context canceled`). `--yes` + auto-confirms; a non-interactive / machine-output run takes the default (YES). ## Notes - `--linked` (default true), `--local`, and `--db-url` are mutually exclusive. - Fetches migration file contents from the `supabase_migrations.schema_migrations` history table. +- **Empty-statements rows (Go parity):** a row whose `statements` array is empty + (NULL/`{}` — possible on older projects or manually-inserted rows) is written as + exactly `;\n`, because Go does `strings.Join(statements, ";\n") + ";\n"`. The port + reproduces these bytes verbatim rather than emitting an empty file; changing this + would be a deliberate divergence from the Go CLI. +- **Path-traversal hardening (TS-only):** before writing, each row's `version`/`name` + is validated (`version` is all digits; `name` has no `/`, `\`, or `..` segment). + A tampered/hostile remote could otherwise supply separators to escape the + migrations directory (CWE-22). Go has no such check; the guard is parity-neutral + for legitimate rows (real versions are digits and names are sanitized file stems) + and fails with `failed to write migration: invalid version/name in history table`. diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts index 91d9cd8e1b..7f80918625 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyMigrationDbRuntimeLayer } from "../migration.layers.ts"; import { legacyMigrationFetch } from "./fetch.handler.ts"; const config = { @@ -11,6 +15,8 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Fetches migration history from the linked project."), + // Go: `fetchFlags.Bool("linked", true, …)`. + Flag.withDefault(true), ), local: Flag.boolean("local").pipe( Flag.withDescription("Fetches migration history from the local database."), @@ -22,5 +28,17 @@ export type LegacyMigrationFetchFlags = CliCommand.Command.Config.Infer legacyMigrationFetch(flags)), + Command.withHandler((flags) => + legacyMigrationFetch(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyMigrationDbRuntimeLayer(["migration", "fetch"])), ); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts new file mode 100644 index 0000000000..ec8c28b274 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts @@ -0,0 +1,52 @@ +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +describe("supabase migration fetch (legacy)", () => { + let workdir: string; + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "sb-mig-fetch-e2e-")); + mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + writeFileSync( + join(workdir, "supabase", "migrations", "20240101000000_existing.sql"), + "select 1;\n", + ); + }); + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + // Real-subprocess guard for the production Stdin wiring + Go-style prompt: a piped + // answer to the overwrite prompt must actually be read, not auto-defaulted. A declined + // `n` cancels before connecting, so no DB is required. This is the boundary in-process + // tests cannot cover — they inject a mock Stdin, which masked a missing-service bug + // where the migration DB runtime never provided the real stdin layer. + test( + "reads a piped 'n' answer to the overwrite prompt and cancels", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase(["migration", "fetch", "--local"], { + entrypoint: "legacy", + cwd: workdir, + stdin: "n\n", + }); + + // Declined → cancelled (non-zero), and the Go-style prompt label reached stderr. + expect(exitCode).not.toBe(0); + expect(stripAnsi(stderr)).toContain("[Y/n]"); + // The existing file was NOT overwritten — the piped answer was honored. + expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ + "20240101000000_existing.sql", + ]); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts new file mode 100644 index 0000000000..3cc8289327 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts @@ -0,0 +1,11 @@ +import { Data } from "effect"; + +/** + * Writing a fetched migration file failed. Byte-matches Go's + * `failed to write migration: %w` (`internal/migration/fetch/fetch.go:38`). + */ +export class LegacyMigrationFetchWriteError extends Data.TaggedError( + "LegacyMigrationFetchWriteError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index 846293186e..ccc5b69f43 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -1,14 +1,175 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { LegacyDnsResolverFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { legacyReadMigrationTable } from "../../../shared/legacy-migration-history.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacyMigrationTargetFlagsError, + LegacyOperationCanceledError, +} from "../migration.errors.ts"; +import { legacyMigrationConfirm } from "../migration.prompt.ts"; import type { LegacyMigrationFetchFlags } from "./fetch.command.ts"; +import { LegacyMigrationFetchWriteError } from "./fetch.errors.ts"; + +const runFetch = Effect.fnUntraced(function* ( + flags: LegacyMigrationFetchFlags, + target: ReturnType, +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const connection = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + const yes = yield* legacyResolveYes; // --yes OR SUPABASE_YES (Go viper AutomaticEnv, root.go:318-334). + + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + + const connType = target.connType ?? "linked"; // fetch defaults to `--linked` (Go: `Bool("linked", true)`). + + // Resolve the DB config BEFORE any filesystem/prompt side effects — mirroring Go's + // root `PersistentPreRunE` (`apps/cli-go/cmd/root.go:118`), which parses the DB config + // before `migrationFetchCmd.RunE` calls `fetch.Run`. An invalid `--db-url`/`config.toml` + // then fails immediately, instead of first creating `supabase/migrations` or letting a + // declined overwrite prompt mask the real error with `context canceled`. Same fix as + // `migration repair`. + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + }); + + // Linked fetch caches the project ref on success (Go's `PersistentPostRun`). The ref is + // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, + // so a declined prompt returns before it runs — matching Go (PostRun is skipped on a + // non-nil RunE error). + const cacheLinkedRef = + connType === "linked" + ? yield* Effect.gen(function* () { + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const ref = yield* projectRef.loadProjectRef(Option.none()); + return linkedProjectCache.cache(ref); + }) + : undefined; + + const fetchBody = Effect.gen(function* () { + const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); + + // Go: `MkdirIfNotExistFS` then `afero.IsEmpty`; prompt before overwriting a + // non-empty migrations dir (default YES). Cancel → `context.Canceled`. + yield* fs + .makeDirectory(migrationsDir, { recursive: true }) + .pipe( + Effect.mapError((cause) => new LegacyMigrationFetchWriteError({ message: cause.message })), + ); + // Go's `fetch.Run` gates the overwrite prompt on `afero.IsEmpty`, which aborts on + // ANY read failure before fetching/writing (`internal/migration/fetch/fetch.go:21-22`). + // Only a missing directory counts as "empty"; a read error (e.g. an unreadable dir) + // must propagate — collapsing it to empty would skip the confirmation and clobber + // existing migrations. + const existing = yield* fs.readDirectory(migrationsDir).pipe( + Effect.catchTag("PlatformError", (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed>([]) + : Effect.fail( + new LegacyMigrationFetchWriteError({ + message: `failed to read migrations: ${cause.message}`, + }), + ), + ), + ); + if (existing.length > 0) { + const title = `Do you want to overwrite existing files in ${legacyBold("supabase/migrations")} directory?`; + const overwrite = yield* legacyMigrationConfirm(title, { defaultValue: true, yes }); + if (!overwrite) { + return yield* Effect.fail( + new LegacyOperationCanceledError({ message: "context canceled" }), + ); + } + } + + const migrations = yield* Effect.scoped( + Effect.gen(function* () { + // Go's `utils.ConnectByConfig` prints this to stderr before dialing + // (`internal/utils/connect.go:343-348`), local/remote per `IsLocalDatabase`. + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* connection.connect(cfg.conn, { + isLocal: cfg.isLocal, + dnsResolver, + }); + return yield* legacyReadMigrationTable(session); + }), + ); + + const written: Array = []; + for (const file of migrations) { + // The version/name come from the remote `schema_migrations` table. A + // tampered/hostile remote could supply path separators or `..` in EITHER field to + // escape the migrations dir on write (CWE-22). Go writes the raw column values + // verbatim (`fmt.Sprintf("%s_%s.sql", r.Version, r.Name)`, + // `internal/migration/fetch/fetch.go:36`) with no digit check, so reject only the + // actual traversal vectors — separators and `..` segments — in both fields. This + // keeps a Go-valid signed version like `-1` writable while closing the vector. + const escapes = (segment: string) => + /[/\\]/u.test(segment) || segment.split(/[/\\]/u).includes(".."); + if (escapes(file.version) || escapes(file.name)) { + return yield* Effect.fail( + new LegacyMigrationFetchWriteError({ + message: `failed to write migration: invalid version/name in history table: ${file.version}_${file.name}`, + }), + ); + } + const name = `${file.version}_${file.name}.sql`; + const filePath = path.join(migrationsDir, name); + // Go: `strings.Join(statements, ";\n") + ";\n"`. + const contents = `${file.statements.join(";\n")};\n`; + yield* fs.writeFileString(filePath, contents, { mode: 0o644 }).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationFetchWriteError({ + message: `failed to write migration: ${cause.message}`, + }), + ), + ); + written.push(filePath); + } + + // Go is silent on success in text mode. + if (output.format !== "text") { + yield* output.success("Migration history fetched", { files: written }); + } + }); + + return yield* cacheLinkedRef === undefined + ? fetchBody + : fetchBody.pipe(Effect.ensuring(cacheLinkedRef)); +}); export const legacyMigrationFetch = Effect.fn("legacy.migration.fetch")(function* ( flags: LegacyMigrationFetchFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["migration", "fetch"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); + const telemetryState = yield* LegacyTelemetryState; + const cliArgs = yield* CliArgs; + const target = resolveLegacyDbTargetFlags(cliArgs.args); + yield* runFetch(flags, target).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts new file mode 100644 index 0000000000..f0fcd50ccf --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -0,0 +1,365 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyMigrationFetch } from "./fetch.handler.ts"; +import type { LegacyMigrationFetchFlags } from "./fetch.command.ts"; + +const SELECT_SQL = + "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; + +interface MigrationRow { + readonly version: string; + readonly name: string; + readonly statements: ReadonlyArray; +} + +interface SetupOpts { + readonly format?: OutputFormat; + readonly isTTY?: boolean; + readonly pipedInput?: string; + readonly yes?: boolean; + readonly confirm?: boolean; + readonly rows?: ReadonlyArray; + readonly resolveFails?: boolean; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + opts.resolveFails === true + ? Effect.fail( + new LegacyDbConfigLoadError({ + message: "failed to parse config: invalid connection string", + }), + ) + : Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: false, + ref: Option.some(LEGACY_VALID_REF), + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + const connection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + query: (sql: string) => + Effect.suspend(() => + sql === SELECT_SQL + ? Effect.succeed((opts.rows ?? []).map((r) => ({ ...r }))) + : Effect.succeed([]), + ), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }); + + const projectRef = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + cache.layer, + resolver, + connection, + projectRef, + mockLegacyCliConfig({ workdir }), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: opts.isTTY ?? true }), + mockStdin( + opts.isTTY ?? true, + // Migration prompts read stdin directly (Go's PromptYesNo), so a confirm answer is + // supplied via piped stdin rather than the Output prompt mock. + opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), + ), + BunServices.layer, + ); + return { layer, out, telemetry }; +} + +const flags = (over: Partial = {}): LegacyMigrationFetchFlags => ({ + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? true, + local: over.local ?? false, +}); + +const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); +const tmp = useLegacyTempWorkdir(); + +describe("legacy migration fetch", () => { + it.live("writes migration files joined with the Go separator when the dir is empty", () => { + const { layer, out } = setup(tmp.current, { + rows: [ + { + version: "20240101000000", + name: "init", + statements: ["create table a", "create index b"], + }, + ], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + // Go prints the connection banner to stderr before dialing (connect.go:343-348). + expect(out.stderrText).toContain("Connecting to remote database..."); + const dir = migrationsDir(tmp.current); + const files = readdirSync(dir); + expect(files).toEqual(["20240101000000_init.sql"]); + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table a;\ncreate index b;\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes a lone separator for a row with no statements (Go parity)", () => { + // A `schema_migrations` row can legally have a NULL/empty `statements` array + // (older projects, manually-inserted rows). Go does `strings.Join(stmts, ";\n") + // + ";\n"`, so an empty array yields exactly ";\n" — a file with a stray + // semicolon, not an empty file. The strict-1:1 port keeps these bytes; lock it + // so a future "emit an empty file instead" refactor is a conscious divergence. + const { layer } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "empty", statements: [] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + const dir = migrationsDir(tmp.current); + expect(readFileSync(join(dir, "20240101000000_empty.sql"), "utf8")).toBe(";\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("prompts before overwriting a non-empty directory and proceeds on yes", () => { + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer } = setup(tmp.current, { + confirm: true, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }); + + it.live("cancels with context canceled when the overwrite prompt is declined", () => { + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer } = setup(tmp.current, { + confirm: false, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); + } + // No new file written on cancel. + expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped 'n' answer without a TTY (cancels the overwrite)", () => { + // The overwrite prompt defaults to YES; Go reads piped stdin even when non-interactive, + // so a piped `n` overrides the default and cancels (console.go:64-82). Proves the + // non-TTY path reads the answer instead of blindly taking the default. + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer } = setup(tmp.current, { + isTTY: false, + pipedInput: "n\n", + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); + } + expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer, out } = setup(tmp.current, { + yes: true, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(out.stderrText).toContain("[Y/n] y"); + expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }); + + it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { + // Go writes the prompt to stderr and reads stdin regardless of --output (console.go), + // so --output-format json must NOT silently auto-accept: the overwrite prompt fires on + // stderr and a piped `y` proceeds, while the json result still goes to stdout. + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer, out } = setup(tmp.current, { + format: "json", + pipedInput: "y\n", + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + // The prompt label reached stderr (it was NOT format-gated into a silent default). + expect(out.stderrText).toContain("[Y/n]"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Migration history fetched", + data: { files: [join(migrationsDir(tmp.current), "20240101000000_init.sql")] }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { + // Regression guard: before the fix, json mode routed through the non-interactive Output + // prompt and auto-accepted (default YES), overwriting. Now a piped `n` is honored. + mkdirSync(migrationsDir(tmp.current), { recursive: true }); + writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); + const { layer } = setup(tmp.current, { + format: "json", + pipedInput: "n\n", + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); + } + expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a hostile version/name from the history table (path traversal guard)", () => { + // A tampered remote `schema_migrations` row could use `..`/separators to + // escape the migrations dir (CWE-22). The guard rejects it before writing. + const { layer } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "../../../etc/passwd", statements: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationFetchWriteError"); + } + // Nothing is written when the guard fires. + expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes a Go-valid signed version verbatim (no all-digits requirement)", () => { + // Go writes the raw `version` column into `_.sql` with no digit check + // (`internal/migration/fetch/fetch.go:36`), so a malformed-but-safe value like `-1` + // (listable/repairable in Go) must fetch, not abort the whole run. + const { layer } = setup(tmp.current, { + rows: [{ version: "-1", name: "legacy", statements: ["select 1"] }], + }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags()); + expect(readdirSync(migrationsDir(tmp.current))).toEqual(["-1_legacy.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a hostile version from the history table (traversal guard on version)", () => { + // The traversal hardening covers the `version` field too: a separator/`..` there is + // rejected even though it is no longer required to be all-digits. + const { layer } = setup(tmp.current, { + rows: [{ version: "../../etc", name: "x", statements: [] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationFetchWriteError"); + } + expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a write failure", () => { + // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. + writeFileSync(join(tmp.current, "supabase"), "not a directory"); + const { layer } = setup(tmp.current, { rows: [] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationFetchWriteError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves DB config before creating the migrations dir or prompting", () => { + // Go's root PersistentPreRunE parses the DB config before fetch.Run (cmd/root.go:118), + // so an invalid target fails before any filesystem/prompt side effect. With the resolver + // failing, the supabase/migrations dir must NOT be created and no prompt is shown. + const { layer, out } = setup(tmp.current, { resolveFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyDbConfigLoadError"); + } + // The config failed before any side effect: no migrations dir, no overwrite prompt. + expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(out.promptConfirmCalls.length).toBe(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts new file mode 100644 index 0000000000..c84a16ea75 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts @@ -0,0 +1,91 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; + +import { + describeLiveDataPlane, + requireLiveProjectRef, + runSupabaseLive, +} from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 120_000; + +// A deterministic migration to seed into the remote history and fetch back. +const VERSION = "20240101000000"; +const NAME = "cli_live_roundtrip"; +const MIGRATION_FILE = `${VERSION}_${NAME}.sql`; + +// Data-plane scenario (Postgres over the pooler) — see the note in +// `../list/list.live.test.ts`. `describeLiveDataPlane` runs this only when the +// project instance is ACTIVE_HEALTHY (the full stack with supabase-postgres-17); +// it SKIPS on the control-plane-only CI that omits it (CLI-1825). +// +// Round-trip: `migration fetch` reads the remote `schema_migrations` history and +// writes each row to `supabase/migrations/_.sql`; `migration list` +// then reads those files back as the Local column. +// +// Unlike `migration list`, `migration fetch` does NOT tolerate a missing history +// table: Go's `ReadMigrationTable` has no `pgerrcode.UndefinedTable` fallback (only +// the list path does — `pkg/migration/list.go`), so against a freshly provisioned +// project with no `supabase_migrations.schema_migrations` table it exits non-zero +// (`relation … does not exist`). So we first SEED one migration into the remote +// history via `migration repair --status applied` (Go's `repair` runs +// `CreateMigrationTable` then upserts the version from the local file), establishing +// the table + a row for `fetch` to read back. The ref is supplied via +// SUPABASE_PROJECT_ID. The seed is idempotent (upsert) and the supabox stack is torn +// down per run, so it leaves no shared state behind. +describeLiveDataPlane("supabase migration fetch (live)", () => { + test( + "seeds remote history, fetches it back, and lists it (round-trip)", + { timeout: LIVE_TIMEOUT_MS }, + async () => { + const ref = requireLiveProjectRef(); + const seedDir = await mkdtemp(path.join(tmpdir(), "sb-migration-seed-live-")); + const fetchDir = await mkdtemp(path.join(tmpdir(), "sb-migration-fetch-live-")); + try { + // Seed: record one migration in the remote history. `repair --status applied` + // reads the local file for the version's name/statements, so write it first. + await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); + await writeFile( + path.join(seedDir, "supabase", "migrations", MIGRATION_FILE), + "create table if not exists public.cli_live_roundtrip (id int);\n", + ); + const repaired = await runSupabaseLive( + ["migration", "repair", VERSION, "--status", "applied"], + { cwd: seedDir, env: { SUPABASE_PROJECT_ID: ref } }, + ); + expect(`${repaired.stdout}${repaired.stderr}`).not.toContain("Unauthorized"); + expect(repaired.exitCode, `stdout:\n${repaired.stdout}\nstderr:\n${repaired.stderr}`).toBe( + 0, + ); + + // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the + // remote history and writes _.sql. + const fetched = await runSupabaseLive(["migration", "fetch"], { + cwd: fetchDir, + env: { SUPABASE_PROJECT_ID: ref }, + }); + expect(`${fetched.stdout}${fetched.stderr}`).not.toContain("Unauthorized"); + expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); + + // fetch wrote the seeded migration back, under its Go-compatible filename. + const files = await readdir(path.join(fetchDir, "supabase", "migrations")); + expect(files).toContain(MIGRATION_FILE); + + // The same dir feeds `migration list` as the Local column — exit 0 and the + // fetched version is reflected back. + const listed = await runSupabaseLive(["migration", "list"], { + cwd: fetchDir, + env: { SUPABASE_PROJECT_ID: ref }, + }); + expect(`${listed.stdout}${listed.stderr}`).not.toContain("Unauthorized"); + expect(listed.exitCode, `stdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`).toBe(0); + expect(listed.stdout).toContain(VERSION); + } finally { + await rm(seedDir, { recursive: true, force: true }); + await rm(fetchDir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md index f4489b05f6..dd463ab3a5 100644 --- a/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md @@ -38,15 +38,19 @@ ### `--output-format text` (Go CLI compatible) -Prints a table of local and remote migration versions with a status column. +Prints a Glamour ASCII table `|Local|Remote|Time (UTC)|` to stdout (byte-matching +Go's `glamour.RenderTable` with `AsciiStyle`; cells are backtick-wrapped inline +code). Queries `SELECT version FROM supabase_migrations.schema_migrations ORDER BY +version` (a missing table → empty Remote column). ### `--output-format json` -Not applicable. +Emits `output.success("Migrations listed", { migrations: [{ local, remote, time }] })`. +`local`/`remote` are empty strings when a version exists only on the other side. ### `--output-format stream-json` -Not applicable. +Same structured `migrations` result delivered as an NDJSON `result` event. ## Notes diff --git a/apps/cli/src/legacy/commands/migration/list/list.command.ts b/apps/cli/src/legacy/commands/migration/list/list.command.ts index 0c055ae9ef..1fb1bbc919 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.command.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyMigrationDbRuntimeLayer } from "../migration.layers.ts"; import { legacyMigrationList } from "./list.handler.ts"; const config = { @@ -11,6 +15,8 @@ const config = { ), linked: Flag.boolean("linked").pipe( Flag.withDescription("Lists migrations applied to the linked project."), + // Go: `listFlags.Bool("linked", true, …)`. + Flag.withDefault(true), ), local: Flag.boolean("local").pipe( Flag.withDescription("Lists migrations applied to the local database."), @@ -27,5 +33,20 @@ export type LegacyMigrationListFlags = CliCommand.Command.Config.Infer legacyMigrationList(flags)), + Command.withHandler((flags) => + legacyMigrationList(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyMigrationDbRuntimeLayer(["migration", "list"])), ); diff --git a/apps/cli/src/legacy/commands/migration/list/list.format.ts b/apps/cli/src/legacy/commands/migration/list/list.format.ts new file mode 100644 index 0000000000..172e7387d4 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/list/list.format.ts @@ -0,0 +1,76 @@ +import { + LEGACY_MIGRATION_VERSION_MAX, + legacyFormatTimestampVersion, + legacyParseMigrationVersion, +} from "../../../shared/legacy-migration-timestamp.format.ts"; + +/** A merged local/remote migration row. `local`/`remote` are empty when absent. */ +export interface LegacyMigrationListRow { + readonly local: string; + readonly remote: string; + readonly time: string; +} + +/** + * Two-pointer merge of remote + local migration versions into chronological + * rows. Pure port of Go's `makeTable` (`internal/migration/list/list.go:38-79`) + * minus the markdown framing: non-numeric versions are skipped, and the time + * column uses `FormatTimestampVersion`. + */ +export function legacyMakeMigrationListRows( + remote: ReadonlyArray, + local: ReadonlyArray, +): ReadonlyArray { + const rows: Array = []; + let i = 0; + let j = 0; + while (i < remote.length || j < local.length) { + let remoteTs = LEGACY_MIGRATION_VERSION_MAX; + if (i < remote.length) { + const parsed = legacyParseMigrationVersion(remote[i]!); + if (parsed === undefined) { + i++; + continue; + } + remoteTs = parsed; + } + let localTs = LEGACY_MIGRATION_VERSION_MAX; + if (j < local.length) { + const parsed = legacyParseMigrationVersion(local[j]!); + if (parsed === undefined) { + j++; + continue; + } + localTs = parsed; + } + if (localTs < remoteTs) { + rows.push({ local: local[j]!, remote: "", time: legacyFormatTimestampVersion(local[j]!) }); + j++; + } else if (remoteTs < localTs) { + rows.push({ local: "", remote: remote[i]!, time: legacyFormatTimestampVersion(remote[i]!) }); + i++; + } else { + rows.push({ + local: local[j]!, + remote: remote[i]!, + time: legacyFormatTimestampVersion(remote[i]!), + }); + i++; + j++; + } + } + return rows; +} + +/** + * Renders the merged rows as the backtick-wrapped Glamour markdown cells Go + * emits (`|``|` `|`