diff --git a/.github/workflows/api-package-sync.yml b/.github/workflows/api-package-sync.yml index cdba3fd3c7..d0bae534da 100644 --- a/.github/workflows/api-package-sync.yml +++ b/.github/workflows/api-package-sync.yml @@ -25,6 +25,8 @@ jobs: - name: Regenerate API package run: pnpm generate working-directory: packages/api + env: + SUPABASE_API_URL: https://api.supabase.com - name: Format API package run: pnpm exec nx run @supabase/api:fmt:fix @@ -32,7 +34,7 @@ jobs: - name: Check for generated changes id: check run: | - if git diff --ignore-space-at-eol --exit-code --quiet packages/api/src/generated; then + if git diff --ignore-space-at-eol --exit-code --quiet packages/api/src/generated packages/api/scripts/openapi-source.json; then echo "No generated changes detected." echo "has_changes=false" >> "$GITHUB_OUTPUT" else @@ -61,7 +63,7 @@ jobs: body: | This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. - Changes were detected in the upstream OpenAPI document exposed by `https://api.supabase.com/api/v1-json`. + Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. branch: sync/api-package base: develop diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index 03b4de7418..00a2a7b2a7 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -67,7 +67,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -95,7 +95,7 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{matrix.language}}" defaults: diff --git a/AGENTS.md b/AGENTS.md index 7207168209..b487bc3700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,14 @@ Key references: - `.repos/effect/packages/vitest/` — `@effect/vitest` test helpers - `.repos/effect/MIGRATION.md` — V3 to V4 migration guide +### Effect-native by default + +Write new runtime code Effect-native from the start; do not build a sync or Promise-based core and wrap it in Effect afterwards. Retrofitting Effect onto a Promise core is expensive and error-prone: it resurfaces as blocking waits where a `Schedule` belongs, interruption gaps around resource acquisition, and untyped failures leaking through `Effect.tryPromise`. + +- Model failures as `Data.TaggedError` classes with typed error channels, dependencies as services provided through `Layer`, retries/polling as `Schedule`s, and resource lifecycles with scopes and interruption-safe masks — never `Atomics.wait`, ad-hoc `setTimeout` loops, or manual try/finally resource juggling in core code. +- Expose Promise-based facades only at the outermost package edge (public entrypoints for non-Effect consumers), acquired asynchronously — never inside the core. +- A small leaf primitive with no Effect semantics of its own (a pure function, a single-syscall fs helper) may stay plain async and be wrapped at its call boundary; everything with failure modes, retries, resources, or concurrency belongs in Effect. + ## Code Quality Run quality checks from the workspace directory you changed. Do not consider a task complete until all relevant scripts pass. diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..b6f68e5ccc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -200,76 +200,6 @@ var ( }, } - shadowMode string - shadowTargetLocal bool - shadowUsePgDelta bool - shadowSchema []string - shadowProjectRef string - - // dbShadowCmd is a hidden seam used by the native-TypeScript db diff/pull - // commands to provision the throwaway shadow database that the diff "source" - // runs against, then leave it running so the TS caller can run the differ - // (migra or pg-delta) itself and remove the container afterwards. It prints - // three newline-separated lines to stdout: the container id, the source - // Postgres URL, and an optional target-override URL (empty unless the - // local-target declarative branch redirects the diff target to a second - // shadow database). The URLs are emitted WITHOUT the password - // (ToPostgresURLWithoutPassword) so we never log a credential to stdout - // (CWE-312); the TS caller re-injects the local Postgres password it already - // resolves from config.toml, which is the same value the shadow uses. Shadow - // provisioning (start.SetupDatabase) is not yet ported, which is why this - // stays in Go. - dbShadowCmd = &cobra.Command{ - Use: "__shadow", - Hidden: true, - Short: "Internal: provision a shadow database for the native db diff/pull commands", - RunE: func(cmd *cobra.Command, args []string) error { - // The hidden __shadow command carries none of the db-url/local/linked - // target flags, so the root PersistentPreRunE's ParseDatabaseConfig - // never loads supabase/config.toml (it only loads when a target flag - // is set, internal/utils/flags/db_url.go:46-90). Load it explicitly so - // the shadow is provisioned from the project's [db] settings — shadow - // port, Postgres version, service baseline, and especially the - // password: the native-TS caller injects the config.toml password into - // the seam URLs, so the shadow must be created with that same password. - fsys := afero.NewOsFs() - // On the linked path the native-TS caller passes the resolved project - // ref via --project-ref so the shadow is built from the same - // remote-merged config the Go monolith uses: LoadConfig seeds - // utils.Config.ProjectId from flags.ProjectRef and merges the matching - // [remotes.] block (pkg/config/config.go). Omitted on local/db-url - // shadows, which the monolith never remote-merges, so the base config is - // used exactly as before. - if len(shadowProjectRef) > 0 { - flags.ProjectRef = shadowProjectRef - } - if err := flags.LoadConfig(fsys); err != nil { - return err - } - var src diff.ShadowSource - var err error - switch shadowMode { - case "declarative": - src, err = diff.PrepareRawShadow(cmd.Context()) - case "diff", "": - src, err = diff.PrepareShadowSource(cmd.Context(), shadowSchema, shadowTargetLocal, shadowUsePgDelta, fsys) - default: - return fmt.Errorf("unknown shadow mode: %s", shadowMode) - } - if err != nil { - return err - } - fmt.Println(src.Container) - fmt.Println(utils.ToPostgresURLWithoutPassword(src.Source)) - if src.TargetOverride != nil { - fmt.Println(utils.ToPostgresURLWithoutPassword(*src.TargetOverride)) - } else { - fmt.Println("") - } - return nil - }, - } - dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -612,14 +542,6 @@ func init() { pullFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", pullFlags.Lookup("password"))) dbCmd.AddCommand(dbPullCmd) - // Build hidden shadow-provisioning seam command - shadowFlags := dbShadowCmd.Flags() - shadowFlags.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations) or declarative (bare shadow).") - shadowFlags.BoolVar(&shadowTargetLocal, "target-local", false, "Whether the diff target is the local database (enables the declarative-schema branch).") - shadowFlags.BoolVar(&shadowUsePgDelta, "use-pg-delta", false, "Whether pg-delta is the active diff engine (selects the declarative-apply path).") - shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") - shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") - dbCmd.AddCommand(dbShadowCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/cmd/start.go b/apps/cli-go/cmd/start.go index 1431d5b0aa..6d9c557e61 100644 --- a/apps/cli-go/cmd/start.go +++ b/apps/cli-go/cmd/start.go @@ -5,7 +5,9 @@ package cmd // talks to Docker directly for `start` and never delegates to this binary // for it, and no other still-live TS->Go delegation seam (db test, db // branch/remote, db diff --use-pgadmin/--use-pg-schema, db pull -// --experimental, the hidden db __shadow/__catalog seams -- the sibling +// --experimental, the hidden db __catalog seam (baseline/declarative modes +// only -- the migrations mode was removed by CLI-1959, and the sibling +// hidden db __shadow seam was removed outright by CLI-1956) -- the sibling // hidden db __db-bootstrap seam was removed outright by CLI-1955, once // native `db reset --local` became its last remaining caller -- etc.) ever // called into internal/start either -- see diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index ecc302ebf5..026a8078ec 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -52,7 +52,7 @@ require ( github.com/tidwall/jsonc v0.3.3 github.com/withfig/autocomplete-tools/packages/cobra v1.2.0 github.com/zalando/go-keyring v0.2.8 - go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel v1.45.0 golang.org/x/mod v0.38.0 golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 @@ -160,7 +160,7 @@ require ( github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect @@ -359,10 +359,10 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 2271dbf731..3956d4c360 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -306,8 +306,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= @@ -1004,8 +1004,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= @@ -1014,16 +1014,16 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index 406e515370..6dad6c5c4a 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -26,17 +26,6 @@ func ToPostgresURL(config pgconn.Config) string { return toPostgresURL(config, url.UserPassword(config.User, config.Password)) } -// ToPostgresURLWithoutPassword renders the connection URL exactly like -// ToPostgresURL but omits the password from the userinfo. Use it for callers that -// print the URL to stdout (the hidden `db __shadow` seam): embedding the password -// there is clear-text logging of a credential (CWE-312, flagged by CodeQL). The -// password is never the seam's to share — the TS caller that consumes the seam -// output re-injects the local Postgres password it already resolves from -// config.toml (`utils.Config.Db.Password`). -func ToPostgresURLWithoutPassword(config pgconn.Config) string { - return toPostgresURL(config, url.User(config.User)) -} - func toPostgresURL(config pgconn.Config, userinfo *url.Userinfo) string { timeoutSecond := int64(config.ConnectTimeout.Seconds()) if timeoutSecond == 0 { diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index 80684df8d1..876d7ea7c7 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -398,23 +398,6 @@ func TestPostgresURL(t *testing.T) { assert.Equal(t, `postgresql://postgres:%21%40%23$%25%5E&%2A%28%29@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) } -func TestPostgresURLWithoutPassword(t *testing.T) { - config := pgconn.Config{ - Host: "2406:da18:4fd:9b0d:80ec:9812:3e65:450b", - Port: 5432, - User: "postgres", - Password: "!@#$%^&*()", - RuntimeParams: map[string]string{ - "options": "test", - }, - } - url := ToPostgresURLWithoutPassword(config) - // Same as ToPostgresURL but with the password omitted from the userinfo, so a - // credential is never written to stdout by the db __shadow seam. - assert.Equal(t, `postgresql://postgres@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) - assert.NotContains(t, url, "%21%40%23") -} - func TestPreserveTLSConfig(t *testing.T) { const dsn = "postgresql://postgres:pw@example.com:5432/postgres" diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 4eb05cad99..ac091812af 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -3,17 +3,17 @@ FROM supabase/postgres:17.6.1.158 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.16 AS postgrest -FROM supabase/postgres-meta:v0.96.8 AS pgmeta -FROM supabase/studio:2026.08.03-sha-022b374 AS studio +FROM postgrest/postgrest:v16.1 AS postgrest +FROM supabase/postgres-meta:v0.97.0 AS pgmeta +FROM supabase/studio:2026.08.10-sha-5b68af1 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.74.3 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.195.0 AS gotrue -FROM supabase/realtime:v2.124.2 AS realtime -FROM supabase/storage-api:v1.68.10 AS storage -FROM supabase/logflare:1.50.1 AS logflare +FROM supabase/realtime:v2.124.4 AS realtime +FROM supabase/storage-api:v1.69.0 AS storage +FROM supabase/logflare:1.50.2 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/.gitignore b/apps/cli/.gitignore index e0e598aff6..79b5f27b24 100644 --- a/apps/cli/.gitignore +++ b/apps/cli/.gitignore @@ -1,3 +1,4 @@ dist/ coverage/ supabase/ +!docs/supabase/ diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 4c54f56556..3717b5c798 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -34,6 +34,32 @@ Both call `runCli(root)` from `shared/cli/run.ts`. --- +## Legacy Port Status and Go CLI Authority + +`src/legacy/` started as a from-scratch 1:1 port of the Go CLI (`apps/cli-go/`) and is now largely +there: see [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md#legacy-shell-command-status) +for the live per-command tracker. As of writing, 95 of 103 legacy leaf commands are natively ported; +only a small residual set is still a Phase 0 proxy to the Go binary. + +This changes what "Go CLI is authoritative" means day to day. `apps/cli-go/` is required reading +only when: + +- Working on one of the remaining Phase 0 wrapped commands — maintaining its command/flag + definition and proxy handler (these gate which invocations reach the Go binary and must still + match it exactly) or replacing the wrapper with a native implementation, or +- Changing something on an already-ported command's established parity surface: command/flag + names, stdout/stderr text, exit codes, all documented side effects (filesystem, database, + Docker/subprocess, API requests), or telemetry semantics (which events fire, when, and their + payload shape). + +For everything else in `src/legacy/` — bug fixes that don't touch that surface, internal refactors, +hoisting shared helpers, adding a documented TS-only flag/feature, tests, tooling — treat it like any +other TypeScript workspace. Go behavior is not the deciding standard, and there's no need to consult +`apps/cli-go/`. See [ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) +for the full rationale. + +--- + ## Learning more about the "effect" library This project uses **Effect V4**. The full source code for the `effect` library is in `.repos/effect/`. @@ -86,7 +112,7 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w | `shared/output/json-error-handling.ts` | `withJsonErrorHandling` middleware | | `shared/output/errors.ts` | `NonInteractiveError` | | `shared/runtime/` | `Browser`, `Stdin`, `Tty`, `ProcessControl`, `RuntimeInfo` services + layers | -| `shared/telemetry/` | `withCommandInstrumentation`, `Analytics`, tracing | +| `shared/telemetry/` | `withCommandInstrumentation`, `Analytics`, tracing, `error-actionability.ts` | Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: @@ -103,7 +129,16 @@ Also check the following `legacy/` infrastructure before writing equivalent help ## Phase 0: Go Binary Wrapper -Before any command is natively implemented in TypeScript, the first step for each command is to **wrap** it: define the command in the TS command tree and proxy all invocations to the bundled Go binary via subprocess. +This phase is now the exception, not the default: only the commands listed as `wrapped` in the +[Legacy Shell Command Status table](./docs/go-cli-porting-status.md#legacy-shell-command-status) +still need it. A Go CLI command that exists in `apps/cli-go/` but has no TS surface yet is rare at +this point in the port; when one does show up, the first step is to **wrap** it: define the command +in the TS command tree and proxy all invocations to the bundled Go binary via subprocess. Wrapping +only works for a command the Go CLI already implements — there is nothing to proxy to otherwise. A +genuinely TS-only addition with no Go equivalent (for example a TS-only flag on an already-ported +command, per the "Flag divergences from the Go reference" list in +[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md)) is implemented natively and does +not go through Phase 0 at all. ### Proxy handler pattern @@ -256,6 +291,15 @@ The legacy shell is a **strict 1:1 port** — not a redesign. The compatibility When in doubt about expected output or behavior, run the equivalent command against the Go CLI reference at `apps/cli-go/` and match it exactly. +This contract governs behavior a command has already established — it does not mean every change in +`src/legacy/` requires consulting Go. It applies when working on one of the remaining Phase 0 +wrapped commands (its command/flag definition or its native replacement), or changing an +already-ported command in a way that could affect the surface above — which, per each command's +`SIDE_EFFECTS.md`, also includes database mutations and Docker/subprocess behavior, and per the +Telemetry Parity section below also includes which events fire and when, not just payload shape. It +does not apply to internal refactors, TS-only additions, or bug fixes that leave that surface +unchanged — see [Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority). + --- ## Legacy Port: Go Parity Checklist @@ -341,6 +385,35 @@ Use the template at `src/legacy/SIDE_EFFECTS_TEMPLATE.md`. This document is the --- +## Error Classification + +Every error the CLI raises carries a classification used for KPI reporting: is this failure the user's to fix, an external service problem, or a CLI bug? `shared/telemetry/error-actionability.ts` owns the closed vocabulary. Classification is declared on the error class itself and is never inferred later from message text. This applies to both shells. + +**When you add an error class anywhere in `apps/cli/src`**, export it and give it an own `[ErrorActionabilityId]` getter: + +```ts +export class LegacyThingMissingError extends Data.TaggedError("LegacyThingMissingError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} +``` + +- **Reuse a preset from `actionability`** rather than assembling fields by hand: `authLogin`, `authToken`, `provideFlags`, `invalidInput`, `invalidConfig`, `dbConnection`, `dbFinding`, `migrationDrift`, `permission`, `accountAccess`, `planLimit`, `projectNotLinked`, `missingProjectRef`, `relinkProject`, `dockerNotRunning`, `startStack`, `stopStack`, `externalNetwork`, `apiStatus`, `cancelled`, `internalPanic`, `impossibleState`, `unknown`. For an error carrying a Management API status, return `statusCodeActionability(this.status)` instead of mapping status codes yourself. +- **Branch only on typed fields the error already carries** — `this.status`, a closed `reason` union, a boolean the producer set. Never parse `message`. +- **Nothing user-controlled may appear in a declaration.** The result is sent to PostHog, so no paths, SQL, project refs, hostnames, URLs, tokens, or response bodies — only closed enum values. +- **Split materially different causes with `fingerprint_suffix`**, choosing a value from `CLI_ERROR_FINGERPRINT_SUFFIXES` (module-private — extend it in place), so unrelated failures sharing one class do not group together as repeats. +- **An instance-dependent getter must stay valid when its fields are absent** — the drift guard evaluates it against a field-less probe. +- **A plain `Error` subclass (no `_tag`) also declares its fingerprint identifier**: `static readonly [ErrorActionabilityFingerprintId] = ""`, matching the export name exactly. Tagged errors skip this — their fingerprint comes from the tag. The static identifier is what keeps `error:` fingerprints stable in minified release builds, where `constructor.name` is renamed. + +**Errors defined outside `apps/cli/src`** (`@supabase/stack`, `@supabase/config`, `@supabase/process-compose`, `@supabase/api`, `effect`) cannot carry a declaration. Add a structural adapter keyed by `_tag` to `externalActionabilityByTag` in that same module, branching on the producer's typed fields. + +`error-actionability-coverage.unit.test.ts` enforces this. It scans every `TaggedError("Tag")`, every `*Error("Tag")` factory, and every `class X extends Error` under `apps/cli/src`, and fails when a class is unexported, has no own declaration, or is untagged without its matching static fingerprint identifier. A failure there is the guard working: classify the new error rather than loosening the guard, because `unknown` in production telemetry must mean a genuinely unforeseen failure, not one nobody categorized. + +--- + ## Output Format: `--output-format` The `--output-format` global flag is defined in `shared/cli/global-flags.ts` (`OutputFormatFlag`) and is already wired into `legacy/cli/root.ts`. It accepts three values: @@ -429,6 +502,7 @@ Read https://www.effect.solutions/testing for Effect testing patterns. Note that - If a test needs multiple service replacements or `Layer.mergeAll(...)`, it likely belongs in `*.integration.test.ts`. - Prefer assertions on outputs and accumulated state over spy-heavy interaction tests. - Keep `*.e2e.test.ts` focused on golden paths, CLI surface behavior, and subprocess correctness, not branch-by-branch coverage. +- **Hermeticity:** a test whose layer graph includes a real filesystem (`BunServices.layer`) and code that reads or writes under `RuntimeInfo.homeDir` or `TelemetryRuntime.configDir` must pin those paths to a per-test temp dir — never rely on the mock defaults (`mockRuntimeInfo` / `mockTelemetryRuntime` default to a path that is intentionally never created). Use `useLegacyTempWorkdir` for the temp dir, and `legacyIsolatedHomeLayer` (in `tests/helpers/legacy-mocks.ts`) when the test builds the real `legacyCliConfigLayer` / `legacyCredentialsLayer`, since those also resolve `SUPABASE_HOME` / `SUPABASE_PROFILE` / tokens from ambient `process.env`. - **Forbidden pattern (do not add):** spawning the CLI to assert that `--help` renders a flag. Help text is dynamic over flag wiring and is exercised by the integration test's flag parser. The two backups e2e files removed alongside this guidance update are the canonical example of what not to write. ### Live tests (`*.live.test.ts`) @@ -482,4 +556,13 @@ bun run --parallel "*:check" ### `apps/cli-go/` -The [old Supabase CLI](https://github.com/supabase/cli) written in Go. When porting a command to the legacy shell, use this as the authoritative source for expected output, flags, and behavior. Match it exactly. Exception: `internal/start` (Go's `supabase start`) was deleted outright as unreachable once ported (CLI-1966) — for that command, the last commit with the source intact (`a253ccba25c21356ccd33044c4474aecb77d1ae4`) is the authoritative reference instead. +The [old Supabase CLI](https://github.com/supabase/cli) written in Go. Use this as the authoritative +source, matched exactly, when working on one of the remaining wrapped commands (maintaining its +command/flag definition, or replacing the wrapper with native TS), or when changing an +already-ported legacy command's established output/flags/behavior/side-effects. It is not required +reading for other legacy-shell work — see +[Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority) and +[ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md). Exception: +`internal/start` (Go's `supabase start`) was deleted outright as unreachable once ported (CLI-1966) +— for that command, the last commit with the source intact +(`a253ccba25c21356ccd33044c4474aecb77d1ae4`) is the authoritative reference instead. diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md new file mode 100644 index 0000000000..6fad698e4e --- /dev/null +++ b/apps/cli/docs/README.md @@ -0,0 +1,24 @@ +# CLI reference + +Source content for the [CLI reference](https://supabase.com/docs/reference/cli) published on the Supabase docs site. + +- `supabase/` — long-form command descriptions, one file per command path +- `templates/examples.yaml` — per-command usage examples, keyed by doc id + +## Build + +```bash +bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml +``` + +## Release + +1. Clone the [supabase/supabase](https://github.com/supabase/supabase) repo +2. Copy over the CLI reference and reformat + +```bash +mv ../cli/apps/cli/cli_v1_commands.yaml apps/docs/spec/ +npx prettier -w apps/docs/spec/cli_v1_commands.yaml +``` + +3. If there are new commands added, update [common-cli-sections.json](https://github.com/supabase/supabase/blob/master/apps/docs/spec/common-cli-sections.json) manually diff --git a/apps/cli/docs/analytics.md b/apps/cli/docs/analytics.md index ce27542995..395bd5b052 100644 --- a/apps/cli/docs/analytics.md +++ b/apps/cli/docs/analytics.md @@ -63,6 +63,29 @@ It is emitted once per handled command invocation and includes: - `exit_code` - `duration_ms` +Failed invocations (`exit_code != 0`) handled by the TS shells also carry a +sanitized error classification: + +- `error_kind` +- `error_category` +- `error_fingerprint` +- `has_suggestion` +- `suggestion_type` +- `suggested_command` (only when the remediation is an allowlisted command) + +These values come exclusively from the closed taxonomy in +`src/shared/telemetry/error-actionability.ts` — never from raw error text — +and the KPI query semantics (strict recovery, repeat errors, internal/unknown +bug rate) are documented there in `CliErrorActionabilityMetricDefinitions`. +A `workflow` property is reserved in the catalog but not emitted yet. + +Not every failure is classified: pure Go-proxy commands report through the Go +binary, which does not emit these fields, and events from CLI versions before +they existed never carry them. KPI queries therefore scope to +`error_kind IS NOT NULL`, and the `classificationCoverage` metric definition +reports the classified share of failures so the covered fraction is explicit +rather than assumed. + Flag capture is intentionally conservative: - `flags_used` is always captured diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index 3eef8bcb85..ee7dceb9ce 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -101,7 +101,7 @@ This: ### Removed commands -`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pgadmin`/`--use-pg-schema`, `db pull --experimental`, the hidden `db __shadow`/`__catalog` seams — the sibling hidden `db __db-bootstrap` seam was removed outright by CLI-1955, once native `db reset --local` became its last remaining caller — etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. +`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pg-schema`, `db pull --experimental`, the hidden `db __catalog` seam (baseline/declarative modes only — the migrations mode was removed by CLI-1959, and the sibling hidden `db __shadow` seam was removed outright by CLI-1956) — the sibling hidden `db __db-bootstrap` seam was removed outright by CLI-1955, once native `db reset --local` became its last remaining caller — etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. ## See Also diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6b2470e42b..ce27ec807d 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -7,6 +7,8 @@ Reference: - Old Go CLI help dump: [`go-cli-reference.md`](./go-cli-reference.md) - Current TS root command: [`../src/next/cli/root.ts`](../src/next/cli/root.ts) +The legacy shell's port is largely complete (see [Legacy Shell Command Status](#legacy-shell-command-status) below). This tracker records what's still `wrapped`; it does not mean every legacy-shell change requires consulting the Go CLI — see [ADR 0016](../../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) and [`AGENTS.md`](../AGENTS.md#legacy-port-status-and-go-cli-authority) for the scope of when Go is actually authoritative. + ## Legend - `ported`: TS command exists and the flag/parameter surface is materially aligned with the old Go CLI @@ -17,23 +19,23 @@ Percentages and counts below are based on final leaf commands only. Command grou ## Summary -| Metric | Count | Percent | -| ------------------------- | ------: | ------: | -| Fully ported commands | 11 / 94 | 11.7% | -| Partially ported commands | 61 / 94 | 64.9% | +| Metric | Count | Percent | +| ------------------------- | -------: | ------: | +| Fully ported commands | 51 / 118 | 43.2% | +| Partially ported commands | 59 / 118 | 50.0% | ## Family Summary -| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | -| ------------------------- | -------------: | --------: | --------: | ---------: | ----------------: | -| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | -| Project / Stack Lifecycle | 9 | 2 (22.2%) | 7 (77.8%) | 0 (0%) | 9 (100%) | -| Database | 19 | 5 (26.3%) | 0 (0%) | 14 (73.7%) | 5 (26.3%) | -| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | -| Functions | 6 | 0 (0%) | 6 (100%) | 0 (0%) | 6 (100%) | -| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | -| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | -| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | +| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | +| ------------------------- | -------------: | --------: | --------: | --------: | ----------------: | +| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | +| Project / Stack Lifecycle | 9 | 4 (44.4%) | 5 (55.6%) | 0 (0%) | 9 (100%) | +| Database | 43 | 43 (100%) | 0 (0%) | 0 (0%) | 43 (100%) | +| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | +| Functions | 6 | 0 (0%) | 6 (100%) | 0 (0%) | 6 (100%) | +| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | +| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | +| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | ## Global Flags Overview @@ -80,51 +82,53 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | -| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | -| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | -| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Fully native TS port (CLI-1955 removed the last Go delegation on this command — the hidden `db __db-bootstrap` seam's `recreate`/`await-storage` modes no longer exist). Remote path: drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override. Local path: running check, then a reset-specific PG14/PG15 recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`) over the same container-bootstrap primitives `db start` uses — container/volume remove-then-recreate (PG15) or a template1 `DROP`/`CREATE DATABASE` sequence + `InitSchema14`/`ApplyApiPrivileges` (PG14) — followed by a concurrent satellite-container restart (storage/auth/realtime/pooler) and a Kong `nginx` reload that FAILS the whole command on error (`legacy/shared/db-bootstrap/restart-services.ts`), then storage-gated bucket seeding (reuses `seed buckets`, native storage-health gate in `await-storage-ready.ts`) and the git-branch `Finished…` line. Only the niche `--experimental` schema-files path with no resolved version still delegates to the Go binary, and only for the REMOTE target (the local target's `--experimental` path is fully native via `legacyMigrateAndSeed`'s existing declarative-schema-files branch). The local-reset composition is hoisted into `legacy/shared/db-bootstrap/reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062); `db schema declarative`'s smart-target and `db schema sync` now call it in-process too, instead of the removed `LegacyDeclarativeSeam.execInherit` seam that used to shell out to a second `supabase-go db reset --local` child. The best-effort pg-delta migrations-catalog cache warmup (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) IS ported too, same as `db start`. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | -| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline (including its best-effort pg-delta migrations-catalog warmup), and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `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` | `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` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--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/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | -| `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). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db advisors` | `ported` | `legacy/commands/db/advisors/` | `n/a` | `--project-ref` | Native TS port. Two backends: `--local`/`--db-url` run the lint query directly against Postgres inside a rolled-back transaction; `--linked` fetches from the Management API (`/v1/projects/{ref}/advisors/{security,performance}`). `--type`/`--level`/`--fail-on` filter/gate the result the same way on both backends. | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `--project-ref` | Native TS port. Native pg-delta / migra / pgAdmin diff engines, all against a natively-provisioned live shadow (CLI-1956 removed the last Go delegation on shadow-database provisioning — the hidden `db __shadow` seam no longer exists; CLI-1968 ported `--use-pgadmin` itself to a native differ-container invocation). `--use-pg-schema` is now the CLI's sole remaining Go delegation on this command, and is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. | +| `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `--project-ref` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `--project-ref` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `--project-ref` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `--skip-vault`, `--project-ref` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--skip-vault` bypasses Vault resolution and sync; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | +| `db query` | `ported` | `legacy/commands/db/query/` | `n/a` | `--project-ref` | Native TS port. Executes SQL against the local/`--db-url` database directly, or the linked project via the Management API (`/v1/projects/{ref}/database/query`); renders JSON/table/CSV, with a best-effort agent-mode RLS advisory check on the local path. | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `--project-ref` | Fully native TS port — no remaining Go delegation on either target (CLI-1955 removed it for local, CLI-1958 for remote). Remote path: drop user schemas, vault upsert, MigrateAndSeed — including the `--experimental` declarative `[db.migrations].schema_paths` apply branch — `--version`/`--last`, `--sql-paths` seed override, best-effort pg-delta migrations-catalog cache. Local path: running check, then a reset-specific PG14/PG15 recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`) over the same container-bootstrap primitives `db start` uses — container/volume remove-then-recreate (PG15) or a template1 `DROP`/`CREATE DATABASE` sequence + `InitSchema14`/`ApplyApiPrivileges` (PG14) — followed by a concurrent satellite-container restart (storage/auth/realtime/pooler) and a Kong `nginx` reload that FAILS the whole command on error (`legacy/shared/db-bootstrap/restart-services.ts`), then storage-gated bucket seeding (reuses `seed buckets`, native storage-health gate in `await-storage-ready.ts`) and the git-branch `Finished…` line — including its own native `--experimental` schema-files branch via `legacyMigrateAndSeed`. The local-reset composition is hoisted into `legacy/shared/db-bootstrap/reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062); `db schema declarative`'s smart-target and `db schema sync` now call it in-process too, instead of the removed `LegacyDeclarativeSeam.execInherit` seam that used to shell out to a second `supabase-go db reset --local` child. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline (including its best-effort pg-delta migrations-catalog warmup), and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `--project-ref` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `--project-ref` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `--project-ref` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `--project-ref` | 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` | `--project-ref` | 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` | `--project-ref` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `--project-ref` | 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` | `--project-ref` | 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` | `--project-ref` | 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` | `--project-ref` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `ported` | `legacy/commands/migration/squash/` | `n/a` | `--project-ref` | Native TS port. Native shadow DB (CLI-1956) + three one-shot `pg_dump` containers; squashes local migrations into the target file (full dump + auth/storage line diff), then suggests `migration repair` locally or prompts to baseline the remote history; defaults to `--local`. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `--project-ref` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `--project-ref` | 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/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `n/a` | `--project-ref` | 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 is honored. `[images]` config override not modeled (documented divergence). | +| `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). | ## Code Generation @@ -138,27 +142,20 @@ These commands exist in the TS CLI today but have no direct top-level equivalent The old Go `functions` family mixed linked-project operations (`list`, `deploy`, `download`, `delete`) with local-development workflows (`new`, `serve`). -**This section is stale beyond the scope of this pass and needs its own dedicated -audit:** `next/` now has a registered `functions` command tree +`next/` has a registered `functions` command tree ([`next/commands/functions/`](../src/next/commands/functions/functions.command.ts), wired in [`next/cli/root.ts`](../src/next/cli/root.ts)) with `list`, `delete`, -`deploy`, `download`, `new`, and `dev` subcommands — the "still no dedicated -`functions` CLI surface in `next/`" premise below and the blanket `missing` status -on every row predate that and are not accurate as written. Fixing this properly -needs a flag-by-flag comparison against the old Go CLI per subcommand (this pass -only confirmed the command paths exist, not their flag-parity level), so the rows -below are marked `partial` rather than `ported`: the whole `next/` root already -diverges from Go's global flag surface (see -[Global Flags Overview](#global-flags-overview)), so none of these can be called -materially aligned yet, but `missing` would wrongly claim no TS surface exists at -all now that the command paths resolve. Treat `partial` here as "exists, -leaf-flag parity unaudited," not as a confirmed parity gap. +`deploy`, `download`, `new`, and `dev` subcommands, so these rows are `partial`, +not `missing`. `partial` here means "TS command surface exists, leaf-flag parity +against the Go CLI not yet audited" — the whole `next/` root already diverges +from Go's global flag surface (see [Global Flags Overview](#global-flags-overview)), +so none of these can be called materially aligned yet. | Old command | TS status | New TS counterpart(s) | Notes | | -------------------- | --------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `functions delete` | `partial` | [`../src/next/commands/functions/delete/`](../src/next/commands/functions/delete/delete.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions deploy` | `partial` | [`../src/next/commands/functions/deploy/`](../src/next/commands/functions/deploy/deploy.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | -| `functions download` | `partial` | [`../src/next/commands/functions/download/`](../src/next/commands/functions/download/download.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Hybrid in the legacy shell: native for `--use-api`, delegates wholesale to Go for the default (`--use-docker`) and `--legacy-bundle` paths — see [Legacy Shell Command Status](#legacy-shell-command-status) below. | +| `functions download` | `partial` | [`../src/next/commands/functions/download/`](../src/next/commands/functions/download/download.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Native for `--use-api` and the default Docker-unbundle path (`--use-docker`, CLI-1963) in both shells; hidden `--legacy-bundle` still delegates to Go — see [Legacy Shell Command Status](#legacy-shell-command-status) below. | | `functions list` | `partial` | [`../src/next/commands/functions/list/`](../src/next/commands/functions/list/list.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions new` | `partial` | [`../src/next/commands/functions/new/`](../src/next/commands/functions/new/new.command.ts) | Command surface exists in `next/`; flag-parity against Go not yet audited (see section note above). Natively ported in the legacy shell. | | `functions serve` | `partial` | [`../src/next/commands/functions/dev/`](../src/next/commands/functions/dev/dev.command.ts) | `next/`'s `functions dev` is a TS-native local Functions workflow (`--stack`, `--env-file`, `--no-verify-jwt`) rather than a flag-parity port of Go's `serve` — kept `partial` here pending a decision on whether it counts as this row's counterpart or belongs in [TS-only Commands](#ts-only-commands) instead. Natively ported in the legacy shell. | @@ -169,12 +166,12 @@ The old Go `storage` family could target either the linked project or the local Current TS only exposes low-level Management API routes under [`api`](../src/next/commands/platform/api.command.ts). This tracker does not count those routes as parity for the old `storage` object-management CLI surface, especially because there is no TS equivalent for the old local Storage API workflow. -| Old command | TS status | New TS counterpart(s) | Notes | -| ------------ | --------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `storage cp` | `missing` | `missing` | No TS object copy command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/cp/`](../src/legacy/commands/storage/cp/cp.command.ts)) — `--recursive`, `--local`, `--linked`, `--cache-control`, `--content-type`, `--jobs` (Go has no `--copy-metadata`). Adds TS-only `--output-format json\|stream-json`. | -| `storage ls` | `missing` | `missing` | No TS object listing command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/ls/`](../src/legacy/commands/storage/ls/ls.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`. | -| `storage mv` | `missing` | `missing` | No TS object move command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/mv/`](../src/legacy/commands/storage/mv/mv.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`. | -| `storage rm` | `missing` | `missing` | No TS object remove command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/rm/`](../src/legacy/commands/storage/rm/rm.command.ts)) — `--recursive`, `--local`, `--linked`; global `--yes`/`SUPABASE_YES` skips the confirmation. Adds TS-only `--output-format json\|stream-json`. | +| Old command | TS status | New TS counterpart(s) | Notes | +| ------------ | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `storage cp` | `missing` | `missing` | No TS object copy command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/cp/`](../src/legacy/commands/storage/cp/cp.command.ts)) — `--recursive`, `--local`, `--linked`, `--cache-control`, `--content-type`, `--jobs` (Go has no `--copy-metadata`). Adds TS-only `--output-format json\|stream-json`, `--project-ref`. | +| `storage ls` | `missing` | `missing` | No TS object listing command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/ls/`](../src/legacy/commands/storage/ls/ls.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`, `--project-ref`. | +| `storage mv` | `missing` | `missing` | No TS object move command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/mv/`](../src/legacy/commands/storage/mv/mv.command.ts)) — `--recursive`, `--local`, `--linked`. Adds TS-only `--output-format json\|stream-json`, `--project-ref`. | +| `storage rm` | `missing` | `missing` | No TS object remove command in `next/`. Natively ported in the legacy shell ([`legacy/commands/storage/rm/`](../src/legacy/commands/storage/rm/rm.command.ts)) — `--recursive`, `--local`, `--linked`; global `--yes`/`SUPABASE_YES` skips the confirmation. Adds TS-only `--output-format json\|stream-json`, `--project-ref`. | ## Management APIs @@ -218,13 +215,13 @@ These route-first equivalents are intentionally lower-level than the old Go comm ## Legacy Shell Command Status Per-command status for the legacy shell (`src/legacy/`), which mirrors the old Go CLI 1:1. -The `migration` command group also accepts Go's top-level `migrations` alias and forwards singular `migration` argv to Go. +The `migration` command group also accepts Go's top-level `migrations` alias (`Command.withAlias`); every subcommand in the family is native TS as of CLI-1969 (`squash` was the last proxy delegate), so the alias no longer forwards any argv to the bundled Go binary. Legend: - `ported`: Phase 1+ native TS implementation exists (Effect-based business logic in `.handler.ts`). An internal, flag-gated seam that still shells out to the Go - binary for one specific sub-path (e.g. `db diff --use-pgadmin`, `db pull --experimental`) + binary for one specific sub-path (e.g. `db diff --use-pg-schema`, `db pull --experimental`) does not disqualify a command from `ported` — what matters is whether the handler itself is native, not whether every code path is Go-binary-free. - `wrapped`: Phase 0 proxy wrapper — the handler's own body forwards the whole @@ -232,114 +229,136 @@ Legend: logic of its own. - `missing`: no legacy shell command yet. -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (fully native, including the `db push` step) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `ported` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) — native; orchestrates the 14-container local dev stack via direct Docker/Podman subprocess spawning (no Docker Compose), mirroring Go's sequential per-container `DockerStart`. Edge Runtime container bring-up, the fresh-volume DB schema/migration/seed setup pipeline (including its best-effort pg-delta migrations-catalog warmup), and fresh-volume storage bucket seeding are all implemented; only the linked-project version-check suggestion is out of scope for this port (tracked follow-up). Intentional divergence (CLI-1987, ruled 2026-07-30): with `--ignore-health-check`, Go swallowed a pre-pull image-pull/daemon failure (its `IsUnhealthyError` matched any `errors.Join` shape, an unintended quirk) and exited 0 with the success banner + status table; TS deliberately keeps that scenario fatal — exit 1, no status table — and downgrades health-check timeouts only. (`internal/start`, including `IsUnhealthyError`, was deleted outright as unreachable in CLI-1966 — this description reflects its last behavior, still viewable at commit `a253ccba25c21356ccd33044c4474aecb77d1ae4`.) See `start/SIDE_EFFECTS.md` ("Notes") and `start.rollback.ts`. | -| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | -| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | -| `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` | `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) | -| `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) — sanctioned intentional divergence (CLI-1988 parity ruling): non-TypeScript `--lang` on project-ref paths (`--linked`/`--project-id`/implicit) runs pg-meta locally with project credentials instead of Go's hard error `Unable to generate types for selected project. Try using --db-url flag instead.` (resolves CLI-1623). All Go flag guards are otherwise enforced byte-exactly: the `--postgrest-v9-compat must used together with --db-url` PreRun gate and all four cobra mutually-exclusive flag groups (`cmd/gen.go:153-162`) in cobra's sorted group order. | -| `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` | `ported` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) — native; fully local signing-key resolution (built-in default ES256 dev key, or `[auth].signing_keys_path` with stdin/TTY key selection), no Docker/network (CLI-1961) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `ported` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) — native for `--use-api` (lists, downloads, and extracts via the Management API directly); default (`--use-docker`) and `--legacy-bundle` delegate wholesale to Go | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go. `--use-pg-schema` is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent), not yet the sole remaining Go delegation (`--use-pgadmin` and other in-flight M9 issues still delegate too); migrate to the pg-delta engine or the default migra engine. | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `ported` | [`../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); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; `--experimental` structured dump still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) | -| `db reset` | `ported` | [`../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` | `ported` | [`../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) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `ported` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) — hidden Go-parity alias reusing `test db`'s flag config and handler verbatim (CLI-1962) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (fully native, including the `db push` step) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `ported` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) — native; orchestrates the 14-container local dev stack via direct Docker/Podman subprocess spawning (no Docker Compose), mirroring Go's sequential per-container `DockerStart`. Edge Runtime container bring-up, the fresh-volume DB schema/migration/seed setup pipeline (including its best-effort pg-delta migrations-catalog warmup), and fresh-volume storage bucket seeding are all implemented; only the linked-project version-check suggestion is out of scope for this port (tracked follow-up). Intentional divergence (CLI-1987, ruled 2026-07-30): with `--ignore-health-check`, Go swallowed a pre-pull image-pull/daemon failure (its `IsUnhealthyError` matched any `errors.Join` shape, an unintended quirk) and exited 0 with the success banner + status table; TS deliberately keeps that scenario fatal — exit 1, no status table — and downgrades health-check timeouts only. (`internal/start`, including `IsUnhealthyError`, was deleted outright as unreachable in CLI-1966 — this description reflects its last behavior, still viewable at commit `a253ccba25c21356ccd33044c4474aecb77d1ae4`.) See `start/SIDE_EFFECTS.md` ("Notes") and `start.rollback.ts`. Also an intentional divergence (CLI-2179, ruled 2026-08-12; PR #6164): Edge Runtime bind mounts go through the same spec-strict import-map key matching described in the `functions deploy` row (`walkImportPaths`/`substituteImportMapValue`); no runtime-resolvable import is affected. | +| `stop` | `ported` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `status` | `ported` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) — native; talks directly to Docker/Podman via subprocess, replicating Go's label-filter and container-naming scheme | +| `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` | `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` | `ported` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) — native; shadow-DB squash + auth/storage line diff, optional remote baseline | +| `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) — sanctioned intentional divergence (CLI-1988 parity ruling): non-TypeScript `--lang` on project-ref paths (`--linked`/`--project-id`/implicit) runs pg-meta locally with project credentials instead of Go's hard error `Unable to generate types for selected project. Try using --db-url flag instead.` (resolves CLI-1623). All Go flag guards are otherwise enforced byte-exactly: the `--postgrest-v9-compat must used together with --db-url` PreRun gate and all four cobra mutually-exclusive flag groups (`cmd/gen.go:153-162`) in cobra's sorted group order. | +| `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` | `ported` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) — native; fully local signing-key resolution (built-in default ES256 dev key, or `[auth].signing_keys_path` with stdin/TTY key selection), no Docker/network (CLI-1961) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `ported` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) — native for `--use-api` (lists, downloads, and extracts via the Management API directly) and the default Docker-unbundle path (`--use-docker`, CLI-1963); hidden `--legacy-bundle` still delegates to the Go binary (pre-1.120.0 fallback requiring a host Deno-binary install with no precedent elsewhere in this codebase — tracked separately, see CLI-1963) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) — Intentional divergence (CLI-2179, ruled 2026-08-12; PR #6164): the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions serve` and `start`'s Edge Runtime bring-up) matches import-map keys per the import-maps spec that Deno/edge-runtime implement — a key matches exactly, or as a prefix only when it ends with `/`, and a `/`-suffixed key normalizes away entirely when its value does not also end in `/` — diverging from Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Because the runtime is itself spec-compliant, Go's lax matching only ever fabricated paths the runtime could never resolve, producing the ENOTDIR crash family and mounting/uploading files no import can reach. Deploy upload sets and serve/start bind mounts may shrink for maps that relied on bare-key prefix matching; no runtime-resolvable import is affected. | +| `functions new` | `ported` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `ported` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) — Intentional divergence (CLI-2179, ruled 2026-08-12; PR #6164): shares the spec-strict import-map key matching described in the `functions deploy` row (`walkImportPaths`/`substituteImportMapValue`); bind mounts may shrink for maps that relied on Go's bare-key `strings.HasPrefix` matching (`pkg/function/deno.go:150-155`), but no runtime-resolvable import is affected. | +| `storage ls` | `ported` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `ported` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `ported` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `ported` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra / pgAdmin (CLI-1968 ported `--use-pgadmin` to a native differ-container invocation); `--use-pg-schema` is now the sole remaining Go delegation on this command. It is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent); migrate to the pg-delta engine or the default migra engine. | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `ported` | [`../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); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; `--experimental` structured dump still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) | +| `db reset` | `ported` | [`../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` | `ported` | [`../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) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `ported` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) — hidden Go-parity alias reusing `test db`'s flag config and handler verbatim (CLI-1962) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | Flag divergences from the Go reference: +- `db push` has a TS-only `--skip-vault` flag. It applies migrations without + resolving or updating `[db.vault]` secrets; default behavior still matches Go. +- Every legacy command that resolves a linked project ref for its own database + connection has a TS-only `--project-ref` flag (no Go equivalent on any + user-facing command — only the `SUPABASE_PROJECT_ID` env var could override + the linked ref; the sole Go registration is a hidden, non-user-facing seam, + `db declarative __catalog --project-ref`, `cmd/pgdelta_catalog.go:44`). This + covers: `db push`, `db pull`, `db diff`, `db dump`, `db reset`, `db lint`, + `db advisors`, `db query`; `migration list`/`up`/`down`/`repair`/`fetch`/`squash`; + `seed buckets`; `storage ls`/`cp`/`mv`/`rm`; every `inspect db` subcommand and + `inspect report`; and `test db` (and its hidden `db test` alias, which shares + `test db`'s flag config verbatim). It feeds + `LegacyProjectRefResolver.loadProjectRef`, keeping Go's precedence (flag > + `SUPABASE_PROJECT_ID` > `supabase/.temp/project-ref`) and taking effect only on + the linked path. It shares ONLY that ref-resolution precedence with + `SUPABASE_PROJECT_ID` — unlike the env var, it does not affect the local + container id or the pg-delta project id, and it does not imply `--linked`: + passing it alongside `--local`/`--db-url` (or, for `db diff`, in explicit mode + without `--from`/`--to linked`) is a hard error rather than a silently ignored + flag, since Go's env var going unused on a non-linked target has no TS-only + flag counterpart to accidentally discard. Default behavior (omitted flag) + matches Go exactly. - `projects api-keys` has a TS-only `--reveal` flag (no Go equivalent). It sends `reveal=true` so the Management API returns the full secret keys (`sb_secret_...`) in full instead of redacting them, addressing issue #4775. Default behavior (omitted flag) diff --git a/apps/cli/docs/supabase/config/push.md b/apps/cli/docs/supabase/config/push.md new file mode 100644 index 0000000000..859d340ad7 --- /dev/null +++ b/apps/cli/docs/supabase/config/push.md @@ -0,0 +1,5 @@ +# supabase-config-push + +Updates the configurations of a linked Supabase project with the local `supabase/config.toml` file. + +This command allows you to manage project configuration as code by defining settings locally and then pushing them to your remote project. diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md new file mode 100644 index 0000000000..b434c9f26f --- /dev/null +++ b/apps/cli/docs/supabase/db/advisors.md @@ -0,0 +1,3 @@ +## supabase-db-advisors + +Inspects the database for common security and performance issues such as missing RLS policies, unindexed foreign keys, exposed auth.users, and more. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md new file mode 100644 index 0000000000..0c0cf05a4d --- /dev/null +++ b/apps/cli/docs/supabase/db/diff.md @@ -0,0 +1,19 @@ +## supabase-db-diff + +Diffs schema changes made to the local or remote database. + +Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. + +Runs [djrobstep/migra](https://github.com/djrobstep/migra) in a container to compare schema differences between the target database and a shadow database. The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. + +By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. + +Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. + +With the pg-delta engine the diff SQL is formatted by default with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned); execution-aware transaction boundaries are preserved as per-unit header comments in the output. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. + +While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains: + +- Changes to publication +- Changes to storage buckets +- Views with `security_invoker` attributes diff --git a/apps/cli/docs/supabase/db/dump.md b/apps/cli/docs/supabase/db/dump.md new file mode 100644 index 0000000000..3d5c4fae54 --- /dev/null +++ b/apps/cli/docs/supabase/db/dump.md @@ -0,0 +1,18 @@ +## supabase-db-dump + +Dumps contents from a remote database. + +Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. + +Runs `pg_dump` in a container with additional flags to exclude Supabase managed schemas. The ignored schemas include auth, storage, and those created by extensions. + +The default dump does not contain any data or custom roles. To dump those contents explicitly, specify either the `--data-only` and `--role-only` flag. + +### Note on Privilege Migration + +When restoring to a new project, tables inherit ALL privileges from default privileges in the target database. To preserve specific privileges from your dump, revoke defaults before restoring: + +```sql +-- Run BEFORE restoring your schema +ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated; +``` diff --git a/apps/cli/docs/supabase/db/lint.md b/apps/cli/docs/supabase/db/lint.md new file mode 100644 index 0000000000..851d1fcef1 --- /dev/null +++ b/apps/cli/docs/supabase/db/lint.md @@ -0,0 +1,17 @@ +## supabase-db-lint + +Lints local database for schema errors. + +Requires the local development stack to be running when linting against the local database. To lint against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. + +Runs `plpgsql_check` extension in the local Postgres container to check for errors in all schemas. The default lint level is `warning` and can be raised to error via the `--level` flag. + +To lint against specific schemas only, pass in the `--schema` flag. + +The `--fail-on` flag can be used to control when the command should exit with a non-zero status code. The possible values are: + +- `none` (default): Always exit with a zero status code, regardless of lint results. +- `warning`: Exit with a non-zero status code if any warnings or errors are found. +- `error`: Exit with a non-zero status code only if errors are found. + +This flag is particularly useful in CI/CD pipelines where you want to fail the build based on certain lint conditions. diff --git a/apps/cli/docs/supabase/db/pull.md b/apps/cli/docs/supabase/db/pull.md new file mode 100644 index 0000000000..e10c0679f7 --- /dev/null +++ b/apps/cli/docs/supabase/db/pull.md @@ -0,0 +1,41 @@ +# supabase-db-pull + +Pulls schema changes from a remote database. A new migration file will be created under `supabase/migrations` directory. + +Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. + +> Note this command requires Docker Desktop (or a running Docker daemon), as it starts a local Postgres container to diff your remote schema. + +Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. + +If no entries exist in the migration history table, the default diff engine uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. + +Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. + +pg-delta plans are execution-aware: when a plan crosses a transaction boundary — for example `ALTER TYPE ... ADD VALUE` followed by a statement that uses the new enum value, which cannot run in the same transaction — `db pull` writes one ordered migration file per plan unit instead of a single file (for example `_remote_schema_schema_changes.sql` and `_remote_schema_after_enum_values.sql`), each recorded in the migration history. The common case (a single unit) still produces exactly one `_remote_schema.sql` file. + +By default the emitted SQL is formatted with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned). Configure overrides with `[experimental.pgdelta] format_options` in `config.toml`, or set `format_options = "null"` to opt out and emit raw, unformatted statements. + +When `[experimental.pgdelta] enabled = true` (the default for projects created by a recent `supabase init`), the migration-file `db pull` workflow uses pg-delta for the shadow diff step by default; it does not switch to declarative output. Existing projects without the section are unaffected and keep using migra. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--diff-engine migra` for a single run. + +When pulling from a remote database with `--db-url`, prefer a direct connection (`db..supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. + +## Debugging empty pg-delta pulls + +If `db pull --diff-engine pg-delta` reports `No schema changes found` but you expect schema output, set `PGDELTA_DEBUG=1` before running the command. Unlike `--debug`, this keeps SSL enabled for remote Supabase connections. + +```sh +PGDELTA_DEBUG=1 supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta +``` + +When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/debug//`: + +- `source-catalog.json` — shadow database baseline pg-delta extracted +- `target-catalog.json` — remote database pg-delta extracted +- `pgdelta-stderr.txt` — pg-delta script diagnostics (statement count, schemas) +- `connection.txt` — redacted connection metadata +- `error.txt` — error summary + +Catalog files are not written during normal `db pull` runs. The `.temp/pgdelta` directory is also used by migration catalog caching (`db push`, local `db start`) when `[experimental.pgdelta] enabled = true`. + +For TLS tracing without disabling SSL, use `SUPABASE_SSL_DEBUG=true` alongside `PGDELTA_DEBUG=1`. diff --git a/apps/cli/docs/supabase/db/push.md b/apps/cli/docs/supabase/db/push.md new file mode 100644 index 0000000000..fe0893f90c --- /dev/null +++ b/apps/cli/docs/supabase/db/push.md @@ -0,0 +1,11 @@ +## supabase-db-push + +Pushes all local migrations to a remote database. + +Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. + +The first time this command is run, a migration history table will be created under `supabase_migrations.schema_migrations`. After successfully applying a migration, a new row will be inserted into the migration history table with timestamp as its unique id. Subsequent pushes will skip migrations that have already been applied. + +If you need to mutate the migration history table, such as deleting existing entries or inserting new entries without actually running the migration, use the `migration repair` command. + +Use the `--dry-run` flag to view the list of changes before applying. diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md new file mode 100644 index 0000000000..35d3bf6224 --- /dev/null +++ b/apps/cli/docs/supabase/db/query.md @@ -0,0 +1,8 @@ +## supabase-db-query + +Execute a SQL query against the local or linked database. + +When used by an AI coding agent (auto-detected or via --agent=yes), the default +output format is JSON with an untrusted data warning envelope. When used by a +human (--agent=no or no agent detected), the default output format is table +without the envelope. diff --git a/apps/cli/docs/supabase/db/reset.md b/apps/cli/docs/supabase/db/reset.md new file mode 100644 index 0000000000..9a60a67711 --- /dev/null +++ b/apps/cli/docs/supabase/db/reset.md @@ -0,0 +1,13 @@ +## supabase-db-reset + +Resets the local database to a clean state. + +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/docs/supabase/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md new file mode 100644 index 0000000000..6c39004e5e --- /dev/null +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -0,0 +1,7 @@ +## supabase-db-schema-declarative-generate + +Generate declarative schema files from a database. + +Exports the schema of a live database (local, linked, or custom URL) into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. + +Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli/docs/supabase/db/schema-declarative-sync.md b/apps/cli/docs/supabase/db/schema-declarative-sync.md new file mode 100644 index 0000000000..1932b16f11 --- /dev/null +++ b/apps/cli/docs/supabase/db/schema-declarative-sync.md @@ -0,0 +1,7 @@ +## supabase-db-schema-declarative-sync + +Generate a new migration by diffing your declarative schema files against the current migration state. + +When no declarative schema exists yet, the command offers to run `generate` first. After computing the diff, you can optionally name the migration and apply it to the local database. + +Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli/docs/supabase/domains/activate.md b/apps/cli/docs/supabase/domains/activate.md new file mode 100644 index 0000000000..6624839c45 --- /dev/null +++ b/apps/cli/docs/supabase/domains/activate.md @@ -0,0 +1,7 @@ +## supabase-domains-activate + +Activates the custom hostname configuration for a project. + +This reconfigures your Supabase project to respond to requests on your custom hostname. + +After the custom hostname is activated, your project's third-party auth providers will no longer function on the Supabase-provisioned subdomain. Please refer to [Prepare to activate your domain](/docs/guides/platform/custom-domains#prepare-to-activate-your-domain) section in our documentation to learn more about the steps you need to follow. diff --git a/apps/cli/docs/supabase/functions.md b/apps/cli/docs/supabase/functions.md new file mode 100644 index 0000000000..d0a08de06c --- /dev/null +++ b/apps/cli/docs/supabase/functions.md @@ -0,0 +1,9 @@ +## supabase-functions + +Manage Supabase Edge Functions. + +Supabase Edge Functions are server-less functions that run close to your users. + +Edge Functions allow you to execute custom server-side code without deploying or scaling a traditional server. They're ideal for handling webhooks, custom API endpoints, data validation, and serving personalized content. + +Edge Functions are written in TypeScript and run on Deno compatible edge runtime, which is a secure runtime with no package management needed, fast cold starts, and built-in security. diff --git a/apps/cli/docs/supabase/functions/new.md b/apps/cli/docs/supabase/functions/new.md new file mode 100644 index 0000000000..8d51536517 --- /dev/null +++ b/apps/cli/docs/supabase/functions/new.md @@ -0,0 +1,7 @@ +## supabase-functions-new + +Creates a new Edge Function with boilerplate code in the `supabase/functions` directory. + +This command generates a starter TypeScript file with the necessary Deno imports and a basic function structure. The function is created as a new directory with the name you specify, containing an `index.ts` file with the function code. + +After creating the function, you can edit it locally and then use `supabase functions serve` to test it before deploying with `supabase functions deploy`. diff --git a/apps/cli/docs/supabase/functions/serve.md b/apps/cli/docs/supabase/functions/serve.md new file mode 100644 index 0000000000..dd46fd35a8 --- /dev/null +++ b/apps/cli/docs/supabase/functions/serve.md @@ -0,0 +1,28 @@ +## supabase-functions-serve + +Serve all Functions locally. + +`supabase functions serve` command includes additional flags to assist developers in debugging Edge Functions via the v8 inspector protocol, allowing for debugging via Chrome DevTools, VS Code, and IntelliJ IDEA for example. Refer to the [docs guide](/docs/guides/functions/debugging-tools) for setup instructions. + +1. `--inspect` + - Alias of `--inspect-mode brk`. + +2. `--inspect-mode [ run | brk | wait ]` + - Activates the inspector capability. + - `run` mode simply allows a connection without additional behavior. It is not ideal for short scripts, but it can be useful for long-running scripts where you might occasionally want to set breakpoints. + - `brk` mode same as `run` mode, but additionally sets a breakpoint at the first line to pause script execution before any code runs. + - `wait` mode similar to `brk` mode, but instead of setting a breakpoint at the first line, it pauses script execution until an inspector session is connected. + +3. `--inspect-main` + - Can only be used when one of the above two flags is enabled. + - By default, creating an inspector session for the main worker is not allowed, but this flag allows it. + - Other behaviors follow the `inspect-mode` flag mentioned above. + +Additionally, the following properties can be customized via `supabase/config.toml` under `edge_runtime` section. + +1. `inspector_port` + - The port used to listen to the Inspector session, defaults to 8083. +2. `policy` + - A value that indicates how the edge-runtime should forward incoming HTTP requests to the worker. + - `per_worker` allows multiple HTTP requests to be forwarded to a worker that has already been created. + - `oneshot` will force the worker to process a single HTTP request and then exit. (Debugging purpose, This is especially useful if you want to reflect changes you've made immediately.) diff --git a/apps/cli/docs/supabase/gen.md b/apps/cli/docs/supabase/gen.md new file mode 100644 index 0000000000..f88cbf8c9e --- /dev/null +++ b/apps/cli/docs/supabase/gen.md @@ -0,0 +1,9 @@ +## supabase-gen + +Automatically generates type definitions based on your Postgres database schema. + +This command connects to your database (local or remote) and generates typed definitions that match your database tables, views, and stored procedures. By default, it generates TypeScript definitions, but also supports Go and Swift. + +Generated types give you type safety and autocompletion when working with your database in code, helping prevent runtime errors and improving developer experience. + +The types respect relationships, constraints, and custom types defined in your database schema. diff --git a/apps/cli/docs/supabase/init.md b/apps/cli/docs/supabase/init.md new file mode 100644 index 0000000000..1290561dba --- /dev/null +++ b/apps/cli/docs/supabase/init.md @@ -0,0 +1,9 @@ +## supabase-init + +Initialize configurations for Supabase local development. + +A `supabase/config.toml` file is created in your current working directory. This configuration is specific to each local project. + +> You may override the directory path by specifying the `SUPABASE_WORKDIR` environment variable or `--workdir` flag. + +In addition to `config.toml`, the `supabase` directory may also contain other Supabase objects, such as `migrations`, `functions`, `tests`, etc. diff --git a/apps/cli/docs/supabase/inspect/db-bloat.md b/apps/cli/docs/supabase/inspect/db-bloat.md new file mode 100644 index 0000000000..7c4f34e317 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-bloat.md @@ -0,0 +1,14 @@ +## db-bloat + +This command displays an estimation of table "bloat" - Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovaccum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will asynchronously clean the dead tuples. Sometimes the autovaccum is unable to work fast enough to reduce or prevent tables from becoming bloated. High bloat can slow down queries, cause excessive IOPS and waste space in your database. + +Tables with a high bloat ratio should be investigated to see if there are vacuuming is not quick enough or there are other issues. + +``` + TYPE │ SCHEMA NAME │ OBJECT NAME │ BLOAT │ WASTE + ────────┼─────────────┼────────────────────────────┼───────┼───────────── + table │ public │ very_bloated_table │ 41.0 │ 700 MB + table │ public │ my_table │ 4.0 │ 76 MB + table │ public │ happy_table │ 1.0 │ 1472 kB + index │ public │ happy_table::my_nice_index │ 0.7 │ 880 kB +``` diff --git a/apps/cli/docs/supabase/inspect/db-blocking.md b/apps/cli/docs/supabase/inspect/db-blocking.md new file mode 100644 index 0000000000..b8fb5a6010 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-blocking.md @@ -0,0 +1,9 @@ +## db-blocking + +This command shows you statements that are currently holding locks and blocking, as well as the statement that is being blocked. This can be used in conjunction with `inspect db locks` to determine which statements need to be terminated in order to resolve lock contention. + +``` + BLOCKED PID │ BLOCKING STATEMENT │ BLOCKING DURATION │ BLOCKING PID │ BLOCKED STATEMENT │ BLOCKED DURATION + ──────────────┼──────────────────────────────┼───────────────────┼──────────────┼────────────────────────────────────────────────────────────────────────────────────────┼─────────────────── + 253 │ select count(*) from mytable │ 00:00:03.838314 │ 13495 │ UPDATE "mytable" SET "updated_at" = '2023─08─03 14:07:04.746688' WHERE "id" = 83719341 │ 00:00:03.821826 +``` diff --git a/apps/cli/docs/supabase/inspect/db-cache-hit.md b/apps/cli/docs/supabase/inspect/db-cache-hit.md new file mode 100644 index 0000000000..da6c4acc07 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-cache-hit.md @@ -0,0 +1,14 @@ +# db-cache-hit + +This command provides information on the efficiency of the buffer cache and how often your queries have to go hit the disk rather than reading from memory. Information on both index reads (`index hit rate`) as well as table reads (`table hit rate`) are shown. In general, databases with low cache hit rates perform worse as it is slower to go to disk than retrieve data from memory. If your table hit rate is low, this can indicate that you do not have enough RAM and you may benefit from upgrading to a larger compute addon with more memory. If your index hit rate is low, this may indicate that there is scope to add more appropriate indexes. + +The hit rates are calculated as a ratio of number of table or index blocks fetched from the postgres buffer cache against the sum of cached blocks and uncached blocks read from disk. + +On smaller compute plans (free, small, medium), a ratio of below 99% can indicate a problem. On larger plans the hit rates may be lower but performance will remain constant as the data may use the OS cache rather than Postgres buffer cache. + +``` + NAME │ RATIO + ─────────────────┼─────────── + index hit rate │ 0.996621 + table hit rate │ 0.999341 +``` diff --git a/apps/cli/docs/supabase/inspect/db-calls.md b/apps/cli/docs/supabase/inspect/db-calls.md new file mode 100644 index 0000000000..5b76042981 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-calls.md @@ -0,0 +1,15 @@ +# db-calls + +This command is much like the `supabase inspect db outliers` command, but ordered by the number of times a statement has been called. + +You can use this information to see which queries are called most often, which can potentially be good candidates for optimisation. + +``` + + QUERY │ TOTAL EXECUTION TIME │ PROPORTION OF TOTAL EXEC TIME │ NUMBER CALLS │ SYNC IO TIME + ─────────────────────────────────────────────────┼──────────────────────┼───────────────────────────────┼──────────────┼────────────────── + SELECT * FROM users WHERE id = $1 │ 14:50:11.828939 │ 89.8% │ 183,389,757 │ 00:00:00.002018 + SELECT * FROM user_events │ 01:20:23.466633 │ 1.4% │ 78,325 │ 00:00:00 + INSERT INTO users (email, name) VALUES ($1, $2)│ 00:40:11.616882 │ 0.8% │ 54,003 │ 00:00:00.000322 + +``` diff --git a/apps/cli/docs/supabase/inspect/db-index-sizes.md b/apps/cli/docs/supabase/inspect/db-index-sizes.md new file mode 100644 index 0000000000..4375af32de --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-index-sizes.md @@ -0,0 +1,14 @@ +# db-index-sizes + +This command displays the size of each each index in the database. It is calculated by taking the number of pages (reported in `relpages`) and multiplying it by the page size (8192 bytes). + +``` + NAME │ SIZE + ──────────────────────────────┼───────────── + user_events_index │ 2082 MB + job_run_details_pkey │ 3856 kB + schema_migrations_pkey │ 16 kB + refresh_tokens_token_unique │ 8192 bytes + users_instance_id_idx │ 0 bytes + buckets_pkey │ 0 bytes +``` diff --git a/apps/cli/docs/supabase/inspect/db-index-usage.md b/apps/cli/docs/supabase/inspect/db-index-usage.md new file mode 100644 index 0000000000..328d1f2ec4 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-index-usage.md @@ -0,0 +1,14 @@ +# db-index-usage + +This command provides information on the efficiency of indexes, represented as what percentage of total scans were index scans. A low percentage can indicate under indexing, or wrong data being indexed. + +``` + TABLE NAME │ PERCENTAGE OF TIMES INDEX USED │ ROWS IN TABLE + ────────────────────┼────────────────────────────────┼──────────────── + user_events │ 99 │ 4225318 + user_feed │ 99 │ 3581573 + unindexed_table │ 0 │ 322911 + job │ 100 │ 33242 + schema_migrations │ 97 │ 0 + migrations │ Insufficient data │ 0 +``` diff --git a/apps/cli/docs/supabase/inspect/db-locks.md b/apps/cli/docs/supabase/inspect/db-locks.md new file mode 100644 index 0000000000..2e7f56b7fb --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-locks.md @@ -0,0 +1,11 @@ +# db-locks + +This command displays queries that have taken out an exclusive lock on a relation. Exclusive locks typically prevent other operations on that relation from taking place, and can be a cause of "hung" queries that are waiting for a lock to be granted. + +If you see a query that is hanging for a very long time or causing blocking issues you may consider killing the query by connecting to the database and running `SELECT pg_cancel_backend(PID);` to cancel the query. If the query still does not stop you can force a hard stop by running `SELECT pg_terminate_backend(PID);` + +``` + PID │ RELNAME │ TRANSACTION ID │ GRANTED │ QUERY │ AGE + ─────────┼─────────┼────────────────┼─────────┼─────────────────────────────────────────┼─────────── + 328112 │ null │ 0 │ t │ SELECT * FROM logs; │ 00:04:20 +``` diff --git a/apps/cli/docs/supabase/inspect/db-long-running-queries.md b/apps/cli/docs/supabase/inspect/db-long-running-queries.md new file mode 100644 index 0000000000..49118fbf1d --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-long-running-queries.md @@ -0,0 +1,11 @@ +# db-long-running-queries + +This command displays currently running queries, that have been running for longer than 5 minutes, descending by duration. Very long running queries can be a source of multiple issues, such as preventing DDL statements completing or vacuum being unable to update `relfrozenxid`. + +``` + PID │ DURATION │ QUERY +───────┼─────────────────┼─────────────────────────────────────────────────────────────────────────────────────── + 19578 | 02:29:11.200129 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1450645 LIMIT 1 + 19465 | 02:26:05.542653 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1889881 LIMIT 1 + 19632 | 02:24:46.962818 | EXPLAIN SELECT "students".* FROM "students" WHERE "students"."id" = 1581884 LIMIT 1 +``` diff --git a/apps/cli/docs/supabase/inspect/db-outliers.md b/apps/cli/docs/supabase/inspect/db-outliers.md new file mode 100644 index 0000000000..830a7a4043 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-outliers.md @@ -0,0 +1,16 @@ +# db-outliers + +This command displays statements, obtained from `pg_stat_statements`, ordered by the amount of time to execute in aggregate. This includes the statement itself, the total execution time for that statement, the proportion of total execution time for all statements that statement has taken up, the number of times that statement has been called, and the amount of time that statement spent on synchronous I/O (reading/writing from the file system). + +Typically, an efficient query will have an appropriate ratio of calls to total execution time, with as little time spent on I/O as possible. Queries that have a high total execution time but low call count should be investigated to improve their performance. Queries that have a high proportion of execution time being spent on synchronous I/O should also be investigated. + +``` + + QUERY │ EXECUTION TIME │ PROPORTION OF EXEC TIME │ NUMBER CALLS │ SYNC IO TIME +─────────────────────────────────────────┼──────────────────┼─────────────────────────┼──────────────┼─────────────── + SELECT * FROM archivable_usage_events.. │ 154:39:26.431466 │ 72.2% │ 34,211,877 │ 00:00:00 + COPY public.archivable_usage_events (.. │ 50:38:33.198418 │ 23.6% │ 13 │ 13:34:21.00108 + COPY public.usage_events (id, reporte.. │ 02:32:16.335233 │ 1.2% │ 13 │ 00:34:19.784318 + INSERT INTO usage_events (id, retaine.. │ 01:42:59.436532 │ 0.8% │ 12,328,187 │ 00:00:00 + SELECT * FROM usage_events WHERE (alp.. │ 01:18:10.754354 │ 0.6% │ 102,114,301 │ 00:00:00 +``` diff --git a/apps/cli/docs/supabase/inspect/db-replication-slots.md b/apps/cli/docs/supabase/inspect/db-replication-slots.md new file mode 100644 index 0000000000..3e5665639b --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-replication-slots.md @@ -0,0 +1,12 @@ +# db-replication-slots + +This command shows information about [logical replication slots](https://www.postgresql.org/docs/current/logical-replication.html) that are setup on the database. It shows if the slot is active, the state of the WAL sender process ('startup', 'catchup', 'streaming', 'backup', 'stopping') the replication client address and the replication lag in GB. + +This command is useful to check that the amount of replication lag is as low as possible, replication lag can occur due to network latency issues, slow disk I/O, long running transactions or lack of ability for the subscriber to consume WAL fast enough. + +``` + NAME │ ACTIVE │ STATE │ REPLICATION CLIENT ADDRESS │ REPLICATION LAG GB + ─────────────────────────────────────────────┼────────┼─────────┼────────────────────────────┼───────────────────── + supabase_realtime_replication_slot │ t │ N/A │ N/A │ 0 + datastream │ t │ catchup │ 24.201.24.106 │ 45 +``` diff --git a/apps/cli/docs/supabase/inspect/db-role-connections.md b/apps/cli/docs/supabase/inspect/db-role-connections.md new file mode 100644 index 0000000000..435393339e --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-role-connections.md @@ -0,0 +1,33 @@ +# db-role-connections + +This command shows the number of active connections for each database roles to see which specific role might be consuming more connections than expected. + +This is a Supabase specific command. You can see this breakdown on the dashboard as well: +https://app.supabase.com/project/_/database/roles + +The maximum number of active connections depends [on your instance size](https://supabase.com/docs/guides/platform/compute-add-ons). You can [manually overwrite](https://supabase.com/docs/guides/platform/performance#allowing-higher-number-of-connections) the allowed number of connection but it is not advised. + +``` + + + ROLE NAME │ ACTIVE CONNCTION + ────────────────────────────┼─────────────────── + authenticator │ 5 + postgres │ 5 + supabase_admin │ 1 + pgbouncer │ 1 + anon │ 0 + authenticated │ 0 + service_role │ 0 + dashboard_user │ 0 + supabase_auth_admin │ 0 + supabase_storage_admin │ 0 + supabase_functions_admin │ 0 + pgsodium_keyholder │ 0 + pg_read_all_data │ 0 + pg_write_all_data │ 0 + pg_monitor │ 0 + +Active connections 12/90 + +``` diff --git a/apps/cli/docs/supabase/inspect/db-seq-scans.md b/apps/cli/docs/supabase/inspect/db-seq-scans.md new file mode 100644 index 0000000000..3ab93e14a2 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-seq-scans.md @@ -0,0 +1,13 @@ +# db-seq-scans + +This command displays the number of sequential scans recorded against all tables, descending by count of sequential scans. Tables that have very high numbers of sequential scans may be underindexed, and it may be worth investigating queries that read from these tables. + +``` + NAME │ COUNT + ───────────────────────────────────┼───────── + emails │ 182435 + users │ 25063 + job_run_details │ 60 + schema_migrations │ 0 + migrations │ 0 +``` diff --git a/apps/cli/docs/supabase/inspect/db-table-index-sizes.md b/apps/cli/docs/supabase/inspect/db-table-index-sizes.md new file mode 100644 index 0000000000..c62d5df6dc --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-table-index-sizes.md @@ -0,0 +1,13 @@ +# db-table-index-sizes + +This command displays the total size of indexes for each table. It is calculated by using the system administration function `pg_indexes_size()`. + +``` + TABLE │ INDEX SIZE + ───────────────────────────────────┼───────────── + job_run_details │ 10104 kB + users │ 128 kB + job │ 32 kB + instances │ 8192 bytes + http_request_queue │ 0 bytes +``` diff --git a/apps/cli/docs/supabase/inspect/db-table-record-counts.md b/apps/cli/docs/supabase/inspect/db-table-record-counts.md new file mode 100644 index 0000000000..d391331180 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-table-record-counts.md @@ -0,0 +1,12 @@ +# db-table-record-counts + +This command displays an estimated count of rows per table, descending by estimated count. The estimated count is derived from `n_live_tup`, which is updated by vacuum operations. Due to the way `n_live_tup` is populated, sparse vs. dense pages can result in estimations that are significantly out from the real count of rows. + +``` + NAME │ ESTIMATED COUNT + ─────────────┼────────────────── + logs │ 322943 + emails │ 1103 + job │ 1 + migrations │ 0 +``` diff --git a/apps/cli/docs/supabase/inspect/db-table-sizes.md b/apps/cli/docs/supabase/inspect/db-table-sizes.md new file mode 100644 index 0000000000..0a2a649edd --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-table-sizes.md @@ -0,0 +1,13 @@ +# db-table-sizes + +This command displays the size of each table in the database. It is calculated by using the system administration function `pg_table_size()`, which includes the size of the main data fork, free space map, visibility map and TOAST data. It does not include the size of the table's indexes. + +``` + NAME │ SIZE + ───────────────────────────────────┼───────────── + job_run_details │ 385 MB + emails │ 584 kB + job │ 40 kB + sessions │ 0 bytes + prod_resource_notifications_meta │ 0 bytes +``` diff --git a/apps/cli/docs/supabase/inspect/db-total-index-size.md b/apps/cli/docs/supabase/inspect/db-total-index-size.md new file mode 100644 index 0000000000..00b89a850e --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-total-index-size.md @@ -0,0 +1,9 @@ +# db-total-index-size + +This command displays the total size of all indexes on the database. It is calculated by taking the number of pages (reported in `relpages`) and multiplying it by the page size (8192 bytes). + +``` + SIZE + ───────── + 12 MB +``` diff --git a/apps/cli/docs/supabase/inspect/db-total-table-sizes.md b/apps/cli/docs/supabase/inspect/db-total-table-sizes.md new file mode 100644 index 0000000000..b30be82334 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-total-table-sizes.md @@ -0,0 +1,11 @@ +# db-total-table-sizes + +This command displays the total size of each table in the database. It is the sum of the values that `pg_table_size()` and `pg_indexes_size()` gives for each table. System tables inside `pg_catalog` and `information_schema` are not included. + +``` + NAME │ SIZE +───────────────────────────────────┼───────────── + job_run_details │ 395 MB + slack_msgs │ 648 kB + emails │ 640 kB +``` diff --git a/apps/cli/docs/supabase/inspect/db-traffic-profile.md b/apps/cli/docs/supabase/inspect/db-traffic-profile.md new file mode 100644 index 0000000000..1036417614 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-traffic-profile.md @@ -0,0 +1,23 @@ +# db-traffic-profile + +This command analyzes table I/O patterns to show read/write activity ratios based on block-level operations. It combines data from PostgreSQL's `pg_stat_user_tables` (for tuple operations) and `pg_statio_user_tables` (for block I/O) to categorize each table's workload profile. + +The command classifies tables into categories: + +- **Read-Heavy** - Read operations are more than 5x write operations (e.g., 1:10, 1:50) +- **Write-Heavy** - Write operations are more than 20% of read operations (e.g., 1:2, 1:4, 2:1, 10:1) +- **Balanced** - Mixed workload where writes are between 20% and 500% of reads +- **Read-Only** - Only read operations detected +- **Write-Only** - Only write operations detected + +``` +SCHEMA │ TABLE │ BLOCKS READ │ WRITE TUPLES │ BLOCKS WRITE │ ACTIVITY RATIO +───────┼──────────────┼─────────────┼──────────────┼──────────────┼──────────────────── +public │ user_events │ 450,234 │ 9,004,680│ 23,450 │ 20:1 (Write-Heavy) +public │ users │ 89,203 │ 12,451│ 1,203 │ 7.2:1 (Read-Heavy) +public │ sessions │ 15,402 │ 14,823│ 2,341 │ ≈1:1 (Balanced) +public │ cache_data │ 123,456 │ 0│ 0 │ Read-Only +auth │ audit_logs │ 0 │ 98,234│ 12,341 │ Write-Only +``` + +**Note:** This command only displays tables that have had both read and write activity. Tables with no I/O operations are not shown. The classification ratio threshold (default: 5:1) determines when a table is considered "heavy" in one direction versus balanced. diff --git a/apps/cli/docs/supabase/inspect/db-unused-indexes.md b/apps/cli/docs/supabase/inspect/db-unused-indexes.md new file mode 100644 index 0000000000..d0b9acacf9 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-unused-indexes.md @@ -0,0 +1,9 @@ +# db-unused-indexes + +This command displays indexes that have < 50 scans recorded against them, and are greater than 5 pages in size, ordered by size relative to the number of index scans. This command is generally useful for discovering indexes that are unused. Indexes can impact write performance, as well as read performance should they occupy space in memory, its a good idea to remove indexes that are not needed or being used. + +``` + TABLE │ INDEX │ INDEX SIZE │ INDEX SCANS +─────────────────────┼────────────────────────────────────────────┼────────────┼────────────── + public.users │ user_id_created_at_idx │ 97 MB │ 0 +``` diff --git a/apps/cli/docs/supabase/inspect/db-vacuum-stats.md b/apps/cli/docs/supabase/inspect/db-vacuum-stats.md new file mode 100644 index 0000000000..02c4de3834 --- /dev/null +++ b/apps/cli/docs/supabase/inspect/db-vacuum-stats.md @@ -0,0 +1,17 @@ +# db-vacuum-stats + +This shows you stats about the vacuum activities for each table. Due to Postgres' [MVCC](https://www.postgresql.org/docs/current/mvcc.html) when data is updated or deleted new rows are created and old rows are made invisible and marked as "dead tuples". Usually the [autovaccum](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) process will aysnchronously clean the dead tuples. + +The command lists when the last vacuum and last auto vacuum took place, the row count on the table as well as the count of dead rows and whether autovacuum is expected to run or not. If the number of dead rows is much higher than the row count, or if an autovacuum is expected but has not been performed for some time, this can indicate that autovacuum is not able to keep up and that your vacuum settings need to be tweaked or that you require more compute or disk IOPS to allow autovaccum to complete. + +``` + SCHEMA │ TABLE │ LAST VACUUM │ LAST AUTO VACUUM │ ROW COUNT │ DEAD ROW COUNT │ EXPECT AUTOVACUUM? +──────────────────────┼──────────────────────────────────┼─────────────┼──────────────────┼──────────────────────┼────────────────┼───────────────────── + auth │ users │ │ 2023-06-26 12:34 │ 18,030 │ 0 │ no + public │ profiles │ │ 2023-06-26 23:45 │ 13,420 │ 28 │ no + public │ logs │ │ 2023-06-26 01:23 │ 1,313,033 │ 3,318,228 │ yes + storage │ objects │ │ │ No stats │ 0 │ no + storage │ buckets │ │ │ No stats │ 0 │ no + supabase_migrations │ schema_migrations │ │ │ No stats │ 0 │ no + +``` diff --git a/apps/cli/docs/supabase/link.md b/apps/cli/docs/supabase/link.md new file mode 100644 index 0000000000..bba1291ae7 --- /dev/null +++ b/apps/cli/docs/supabase/link.md @@ -0,0 +1,11 @@ +## supabase-link + +Link your local development project to a hosted Supabase project. + +PostgREST configurations are fetched from the Supabase platform and validated against your local configuration file. + +Optionally, database settings can be validated if you provide a password. Your database password is saved in native credentials storage if available. + +> If you do not want to be prompted for the database password, such as in a CI environment, you may specify it explicitly via the `SUPABASE_DB_PASSWORD` environment variable. + +Some commands like `db dump`, `db push`, and `db pull` require your project to be linked first. diff --git a/apps/cli/docs/supabase/login.md b/apps/cli/docs/supabase/login.md new file mode 100644 index 0000000000..0387dbd3f6 --- /dev/null +++ b/apps/cli/docs/supabase/login.md @@ -0,0 +1,9 @@ +## supabase-login + +Connect the Supabase CLI to your Supabase account by logging in with your [personal access token](https://supabase.com/dashboard/account/tokens). + +Your access token is stored securely in [native credentials storage](https://github.com/zalando/go-keyring#dependencies). If native credentials storage is unavailable, it will be written to a plain text file at `/access-token`. + +> If this behavior is not desired, such as in a CI environment, you may skip login by specifying the `SUPABASE_ACCESS_TOKEN` environment variable in other commands. + +The Supabase CLI uses the stored token to access Management APIs for projects, functions, secrets, etc. diff --git a/apps/cli/docs/supabase/migration/list.md b/apps/cli/docs/supabase/migration/list.md new file mode 100644 index 0000000000..343bbd4322 --- /dev/null +++ b/apps/cli/docs/supabase/migration/list.md @@ -0,0 +1,11 @@ +## supabase-migration-list + +Lists migration history in both local and remote databases. + +Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. + +> Note that URL strings must be escaped according to [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). + +Local migrations are stored in `supabase/migrations` directory while remote migrations are tracked in `supabase_migrations.schema_migrations` table. Only the timestamps are compared to identify any differences. + +In case of discrepancies between the local and remote migration history, you can resolve them using the `migration repair` command. diff --git a/apps/cli/docs/supabase/migration/new.md b/apps/cli/docs/supabase/migration/new.md new file mode 100644 index 0000000000..1348844bfd --- /dev/null +++ b/apps/cli/docs/supabase/migration/new.md @@ -0,0 +1,7 @@ +## supabase-migration-new + +Creates a new migration file locally. + +A `supabase/migrations` directory will be created if it does not already exists in your current `workdir`. All schema migration files must be created in this directory following the pattern `_.sql`. + +Outputs from other commands like `db diff` may be piped to `migration new ` via stdin. diff --git a/apps/cli/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md new file mode 100644 index 0000000000..981bb9cb8e --- /dev/null +++ b/apps/cli/docs/supabase/migration/repair.md @@ -0,0 +1,57 @@ +## supabase-migration-repair + +Repairs the remote migration history table. + +Requires your local project to be linked to a remote database by running `supabase link`. + +If your local and remote migration history goes out of sync, you can repair the remote history by marking specific migrations as `--status applied` or `--status reverted`. Marking as `reverted` will delete an existing record from the migration history table while marking as `applied` will insert a new record. + +For example, your migration history may look like the table below, with missing entries in either local or remote. + +```bash +$ supabase migration list + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── + │ 20230103054303 │ 2023-01-03 05:43:03 + 20230103054315 │ │ 2023-01-03 05:43:15 +``` + +To reset your migration history to a clean state, first delete your local migration file. + +```bash +$ rm supabase/migrations/20230103054315_remote_commit.sql + +$ supabase migration list + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── + │ 20230103054303 │ 2023-01-03 05:43:03 +``` + +Then mark the remote migration `20230103054303` as reverted. + +```bash +$ supabase migration repair 20230103054303 --status reverted +Connecting to remote database... +Repaired migration history: [20220810154537] => reverted +Finished supabase migration repair. + +$ supabase migration list + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── +``` + +Now you can run `db pull` again to dump the remote schema as a local migration file. + +```bash +$ supabase db pull +Connecting to remote database... +Schema written to supabase/migrations/20240414044403_remote_schema.sql +Update remote migration history table? [Y/n] +Repaired migration history: [20240414044403] => applied +Finished supabase db pull. + +$ supabase migration list + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── + 20240414044403 │ 20240414044403 │ 2024-04-14 04:44:03 +``` diff --git a/apps/cli/docs/supabase/migration/squash.md b/apps/cli/docs/supabase/migration/squash.md new file mode 100644 index 0000000000..150d510e5b --- /dev/null +++ b/apps/cli/docs/supabase/migration/squash.md @@ -0,0 +1,11 @@ +## supabase-migration-squash + +Squashes local schema migrations to a single migration file. + +The squashed migration is equivalent to a schema only dump of the local database after applying existing migration files. This is especially useful when you want to remove repeated modifications of the same schema from your migration history. + +However, one limitation is that data manipulation statements, such as insert, update, or delete, are omitted from the squashed migration. You will have to add them back manually in a new migration file. This includes cron jobs, storage buckets, and any encrypted secrets in vault. + +By default, the latest `_.sql` file will be updated to contain the squashed migration. You can override the target version using the `--version ` flag. + +If your `supabase/migrations` directory is empty, running `supabase squash` will do nothing. diff --git a/apps/cli/docs/supabase/network-bans.md b/apps/cli/docs/supabase/network-bans.md new file mode 100644 index 0000000000..7f39d21dbc --- /dev/null +++ b/apps/cli/docs/supabase/network-bans.md @@ -0,0 +1,5 @@ +## supabase-network-bans + +Network bans are IPs that get temporarily blocked if their traffic pattern looks abusive (e.g. multiple failed auth attempts). + +The subcommands help you view the current bans, and unblock IPs if desired. diff --git a/apps/cli/docs/supabase/postgres-config/update.md b/apps/cli/docs/supabase/postgres-config/update.md new file mode 100644 index 0000000000..c7fde38474 --- /dev/null +++ b/apps/cli/docs/supabase/postgres-config/update.md @@ -0,0 +1,4 @@ +## supabase-postgres-config-update + +Overriding the default Postgres config could result in unstable database behavior. +Custom configuration also overrides the optimizations generated based on the compute add-ons in use. diff --git a/apps/cli/docs/supabase/projects.md b/apps/cli/docs/supabase/projects.md new file mode 100644 index 0000000000..d01c49ed65 --- /dev/null +++ b/apps/cli/docs/supabase/projects.md @@ -0,0 +1,7 @@ +## supabase-projects + +Provides tools for creating and managing your Supabase projects. + +This command group allows you to list all projects in your organizations, create new projects, delete existing projects, and retrieve API keys. These operations help you manage your Supabase infrastructure programmatically without using the dashboard. + +Project management via CLI is especially useful for automation scripts and when you need to provision environments in a repeatable way. diff --git a/apps/cli/docs/supabase/secrets.md b/apps/cli/docs/supabase/secrets.md new file mode 100644 index 0000000000..975f3456cf --- /dev/null +++ b/apps/cli/docs/supabase/secrets.md @@ -0,0 +1,12 @@ +## supabase-secrets + +Provides tools for managing environment variables and secrets for your Supabase project. + +This command group allows you to set, unset, and list secrets that are securely stored and made available to Edge Functions as environment variables. + +Secrets management through the CLI is useful for: + +- Setting environment-specific configuration +- Managing sensitive credentials securely + +Secrets can be set individually or loaded from .env files for convenience. diff --git a/apps/cli/docs/supabase/start.md b/apps/cli/docs/supabase/start.md new file mode 100644 index 0000000000..7db99b8c49 --- /dev/null +++ b/apps/cli/docs/supabase/start.md @@ -0,0 +1,13 @@ +## supabase-start + +Starts the Supabase local development stack. + +Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. + +All service containers are started by default. You can exclude those not needed by passing in `-x` flag. To exclude multiple containers, either pass in a comma separated string, such as `-x gotrue,imgproxy`, or specify `-x` flag multiple times. + +> It is recommended to have at least 7GB of RAM to start all services. + +Health checks are automatically added to verify the started containers. Use `--ignore-health-check` flag to ignore these errors. + +> If the CLI is running inside a dev container with the Docker socket bind-mounted, set the `SUPABASE_SERVICES_HOSTNAME` environment variable to the hostname reachable from inside that container, such as `host.docker.internal`. diff --git a/apps/cli/docs/supabase/status.md b/apps/cli/docs/supabase/status.md new file mode 100644 index 0000000000..5db5c7ce83 --- /dev/null +++ b/apps/cli/docs/supabase/status.md @@ -0,0 +1,7 @@ +## supabase-status + +Shows status of the Supabase local development stack. + +Requires the local development stack to be started by running `supabase start` or `supabase db start`. + +You can export the connection parameters for [initializing supabase-js](https://supabase.com/docs/reference/javascript/initializing) locally by specifying the `-o env` flag. Supported parameters include `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY`. diff --git a/apps/cli/docs/supabase/stop.md b/apps/cli/docs/supabase/stop.md new file mode 100644 index 0000000000..d716c0f88e --- /dev/null +++ b/apps/cli/docs/supabase/stop.md @@ -0,0 +1,9 @@ +## supabase-stop + +Stops the Supabase local development stack. + +Requires `supabase/config.toml` to be created in your current working directory by running `supabase init`. + +All Docker resources are maintained across restarts. Use `--no-backup` flag to reset your local development data between restarts. + +Use the `--all` flag to stop all local Supabase projects instances on the machine. Use with caution with `--no-backup` as it will delete all supabase local projects data. diff --git a/apps/cli/docs/supabase/test/db.md b/apps/cli/docs/supabase/test/db.md new file mode 100644 index 0000000000..9978bb4535 --- /dev/null +++ b/apps/cli/docs/supabase/test/db.md @@ -0,0 +1,9 @@ +# supabase-test-db + +Executes pgTAP tests against the local database. + +Requires the local development stack to be started by running `supabase start`. + +Runs `pg_prove` in a container with unit test files volume mounted from `supabase/tests` directory. The test file can be suffixed by either `.sql` or `.pg` extension. + +Since each test is wrapped in its own transaction, it will be individually rolled back regardless of success or failure. diff --git a/apps/cli/docs/supabase/vanity-subdomains.md b/apps/cli/docs/supabase/vanity-subdomains.md new file mode 100644 index 0000000000..92212a0886 --- /dev/null +++ b/apps/cli/docs/supabase/vanity-subdomains.md @@ -0,0 +1,5 @@ +## supabase-vanity-subdomains + +Manage vanity subdomains for Supabase projects. + +Usage of vanity subdomains and custom domains is mutually exclusive. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml new file mode 100644 index 0000000000..464f9d30d5 --- /dev/null +++ b/apps/cli/docs/templates/examples.yaml @@ -0,0 +1,408 @@ +supabase-init: + - id: basic-usage + name: Basic usage + code: supabase init + response: Finished supabase init. + - id: from-workdir + name: Initialize from an existing directory + code: supabase init --workdir . + response: Finished supabase init. +supabase-login: + - id: basic-usage + name: Basic usage + code: supabase login + response: | + You can generate an access token from https://supabase.com/dashboard/account/tokens + Enter your access token: sbp_**************************************** + Finished supabase login. +supabase-link: + - id: basic-usage + name: Basic usage + code: supabase link --project-ref ******************** + response: | + Enter your database password (or leave blank to skip): ******** + Finished supabase link. + - id: without-password + name: Link without database password + code: supabase link --project-ref ******************** <<< "" + response: | + Enter your database password (or leave blank to skip): + Finished supabase link. + - id: using-alternate-dns + name: Link using DNS-over-HTTPS resolver + code: supabase link --project-ref ******************** --dns-resolver https + response: | + Enter your database password (or leave blank to skip): + Finished supabase link. +supabase-start: + - id: basic-usage + name: Basic usage + code: supabase start + response: | + Creating custom roles supabase/roles.sql... + Applying migration 20220810154536_employee.sql... + Seeding data supabase/seed.sql... + Started supabase local development setup. + - id: without-studio + name: Start containers without studio and imgproxy + code: supabase start -x studio,imgproxy + response: | + Excluding container: supabase/studio:20221214-4eecc99 + Excluding container: darthsim/imgproxy:v3.8.0 + Started supabase local development setup. + - id: ignore-health-check + name: Ignore service health checks + code: supabase start --ignore-health-check + response: | + service not healthy: [supabase_storage_cli] + Started supabase local development setup. +supabase-stop: + - id: basic-usage + name: Basic usage + code: supabase stop + response: | + Stopped supabase local development setup. + Local data are backed up to docker volume. + - id: clean-up + name: Clean up local data after stopping + code: supabase stop --no-backup + response: | + Stopped supabase local development setup. +supabase-status: + - id: basic-usage + name: Basic usage + code: supabase status + response: |2 + supabase local development setup is running. + + API URL: http://127.0.0.1:54321 + GraphQL URL: http://127.0.0.1:54321/graphql/v1 + DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres + Studio URL: http://127.0.0.1:54323 + Inbucket URL: http://127.0.0.1:54324 + JWT secret: super-secret-jwt-token-with-at-least-32-characters-long + anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + - id: output-env + name: Format status as environment variables + code: supabase status -o env + response: | + ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + API_URL="http://127.0.0.1:54321" + DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" + GRAPHQL_URL="http://127.0.0.1:54321/graphql/v1" + INBUCKET_URL="http://127.0.0.1:54324" + JWT_SECRET="super-secret-jwt-token-with-at-least-32-characters-long" + SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" + STUDIO_URL="http://127.0.0.1:54323" + - id: output-custom-name + name: Customize the names of exported variables + code: supabase status -o env --override-name auth.anon_key=SUPABASE_ANON_KEY --override-name auth.service_role_key=SUPABASE_SERVICE_KEY + response: | + Stopped services: [supabase_inbucket_cli supabase_rest_cli supabase_studio_cli] + SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" + GRAPHQL_URL="http://127.0.0.1:54321/graphql/v1" + JWT_SECRET="super-secret-jwt-token-with-at-least-32-characters-long" + SUPABASE_SERVICE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" +supabase-migration-list: + - id: basic-usage + name: Basic usage + code: supabase migration list + response: |2 + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── + │ 20230103054303 │ 2023-01-03 05:43:03 + │ 20230103093141 │ 2023-01-03 09:31:41 + 20230222032233 │ │ 2023-02-22 03:22:33 + - id: with-db-url + name: Connect to self-hosted database + code: supabase migration list --db-url 'postgres://postgres[:percent_encoded_password]@127.0.0.1[:port]/postgres' + response: |2 + LOCAL │ REMOTE │ TIME (UTC) + ─────────────────┼────────────────┼────────────────────── + 20230103054303 │ 20230103054303 │ 2023-01-03 05:43:03 + 20230103093141 │ 20230103093141 │ 2023-01-03 09:31:41 +supabase-migration-new: + - id: basic-usage + name: Basic usage + code: supabase migration new schema_test + response: | + Created new migration at supabase/migrations/20230306095710_schema_test.sql. + - id: pipe-stdin + name: With statements piped from stdin + code: echo "create schema if not exists test;" | supabase migration new schema_test + response: | + Created new migration at supabase/migrations/20230306095710_schema_test.sql. +supabase-migration-repair: + - id: basic-usage + name: Mark a migration as reverted + code: supabase migration repair 20230103054303 --status reverted + response: | + Repaired migration history: 20230103054303 => reverted + - id: mark-applied + name: Mark a migration as applied + code: supabase migration repair 20230222032233 --status applied + response: | + Repaired migration history: 20230222032233 => applied +supabase-db-diff: + - id: basic-usage + name: Basic usage + code: supabase db diff -f my_table + response: | + Connecting to local database... + Creating shadow database... + Applying migration 20230425064254_remote_commit.sql... + Diffing schemas: auth,extensions,public,storage + Finished supabase db diff on branch main. + + No schema changes found + - id: linked-project + name: Against linked project + code: supabase db diff -f my_table --linked + response: | + Connecting to local database... + Creating shadow database... + Diffing schemas: auth,extensions,public,storage + Finished supabase db diff on branch main. + + WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. + Run supabase db reset to verify that the new migration does not generate errors. + - id: specific-schema + name: For a specific schema + code: supabase db diff -f my_table --schema auth + response: | + Connecting to local database... + Creating shadow database... + Diffing schemas: auth + Finished supabase db diff on branch main. + + No schema changes found +supabase-db-dump: + - id: basic-usage + name: Basic usage + code: supabase db dump -f supabase/schema.sql + response: | + Dumping schemas from remote database... + Dumped schema to supabase/schema.sql. + - id: role-only + name: Role only + code: supabase db dump -f supabase/roles.sql --role-only + response: | + Dumping roles from remote database... + Dumped schema to supabase/roles.sql. + - id: data-only + name: Data only + code: supabase db dump -f supabase/seed.sql --data-only + response: | + Dumping data from remote database... + Dumped schema to supabase/seed.sql. +supabase-db-lint: + - id: basic-usage + name: Basic usage + code: supabase db lint + response: | + Linting schema: public + + No schema errors found + - id: schema-warnings + name: Warnings for a specific schema + code: supabase db lint --level warning --schema storage + response: | + Linting schema: storage + [ + { + "function": "storage.search", + "issues": [ + { + "level": "warning", + "message": "unused variable \"_bucketid\"", + "sqlState": "00000" + } + ] + } + ] +supabase-db-pull: + - id: basic-usage + name: Basic usage + code: supabase db pull + response: | + Connecting to remote database... + Schema written to supabase/migrations/20240414044403_remote_schema.sql + Update remote migration history table? [Y/n] + Repaired migration history: [20240414044403] => applied + Finished supabase db pull. + The auth and storage schemas are excluded. Run supabase db pull --schema auth,storage again to diff them. + - id: local-studio + name: Local studio + code: supabase db pull --local + response: | + Connecting to local database... + Setting up initial schema.... + Creating custom roles supabase/roles.sql... + Applying migration 20240414044403_remote_schema.sql... + No schema changes found + The auth and storage schemas are excluded. Run supabase db pull --schema auth,storage again to diff them. + exit status 1 + - id: custom-schemas + name: Custom schemas + code: supabase db pull --schema auth,storage + response: | + Connecting to remote database... + Setting up initial schema.... + Creating custom roles supabase/roles.sql... + Applying migration 20240414044403_remote_schema.sql... + No schema changes found + Try rerunning the command with --debug to troubleshoot the error. + exit status 1 +supabase-db-push: + - id: basic-usage + name: Basic usage + code: supabase db push + response: | + Linked project is up to date. + - id: self-hosted + name: Self hosted + code: supabase db push --db-url "postgres://user:pass@127.0.0.1:5432/postgres" + response: | + Pushing migration 20230410135622_create_employees_table.sql... + Finished supabase db push. + - id: dry-run + name: Dry run + code: supabase db push --dry-run + response: | + DRY RUN: migrations will *not* be pushed to the database. + Would push migration 20230410135622_create_employees_table.sql... + Would push migration 20230425064254_my_table.sql... + Finished supabase db push. +supabase-db-reset: + - id: basic-usage + name: Basic usage + code: supabase db reset + response: | + Resetting database... + Initializing schema... + Applying migration 20220810154537_create_employees_table.sql... + Seeding data supabase/seed.sql... + Finished supabase db reset on branch main. +supabase-db-schema-declarative-sync: + - id: with-pg-delta + name: Sync declarative schema with pg-delta + code: | + # After editing declarative schema files, generate a migration: + supabase db schema declarative sync --experimental + response: | + Creating shadow database... + Applying declarative schemas via pg-delta... + Applied 239 statements in 1 round(s). + Enter a name for this migration (press Enter to keep 'declarative_sync'): add_updated_at + Created new migration at supabase/migrations/20260317194051_add_updated_at.sql + Apply this migration to local database? [Y/n] + Connecting to local database... + Applying migration 20260317194051_add_updated_at.sql... + Migration applied successfully. + - id: generate-first + name: Generate declarative schema from migrations + code: | + supabase db schema declarative sync --experimental + response: | + No declarative schema found. Generate a new one ? [Y/n] + Reset local database to match migrations first? (local data will be lost) [y/N] y + Resetting database... + ... + Declarative schema written to supabase/database + Finished supabase db schema declarative generate. +supabase-test-db: + - id: basic-usage + name: Basic usage + code: supabase test db + response: | + /tmp/supabase/tests/nested/order_test.pg .. ok + /tmp/supabase/tests/pet_test.sql .......... ok + All tests successful. + Files=2, Tests=2, 6 wallclock secs ( 0.03 usr 0.01 sys + 0.05 cusr 0.02 csys = 0.11 CPU) + Result: PASS +# TODO: use actual cli response for sso commands +supabase-sso-show: + - id: basic-usage + name: Show information + code: |- + supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst + response: |- + Information about the identity provider in pretty output. + - id: metadata-output + name: Get raw SAML 2.0 Metadata XML + code: |- + supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --metadata + response: |- + Raw SAML 2.0 XML assigned to this identity provider. This is the + version used in the authentication project, and if using a SAML 2.0 + Metadata URL it may change depending on the caching information + contained within the metadata. +supabase-sso-update: + - id: basic-usage + name: Replace domains + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --domains new-company.com,new-company.net + response: |- + Information about the updated provider. + - id: add-domains + name: Add an additional domain + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --add-domains company.net + response: |- + Information about the updated provider. + - id: remove-domains + name: Remove a domain + code: |- + supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst \ + --remove-domains company.org + response: |- + Information about the updated provider. +supabase-sso-remove: + - id: basic-usage + name: Remove a provider + code: |- + supabase sso remove 6df4d73f-bf21-405f-a084-b11adf19fea5 \ + --project-ref abcdefghijklmnopqrst + response: |- + Information about the removed identity provider. It's a good idea to + save this in case you need it later on. +supabase-sso-add: + - id: basic-usage + name: Add with Metadata URL + code: |- + supabase sso add \ + --project-ref abcdefgijklmnopqrst \ + --type saml \ + --metadata-url 'https://...' \ + --domains company.com + response: |- + Information about the added identity provider. You can use + company.com as the domain name on the frontend side to initiate a SSO + request to the identity provider. + - id: with-xml + name: Add with Metadata File + code: |- + supabase sso add \ + --project-ref abcdefgijklmnopqrst \ + --type saml \ + --metadata-file /path/to/metadata/file.xml \ + --domains company.com + response: |- + Information about the added identity provider. You can use + company.com as the domain name on the frontend side to initiate a SSO + request to the identity provider. +supabase-sso-info: + - id: basic-usage + name: Show project information + code: supabase sso info --project-ref abcdefghijklmnopqrst + response: Information about your project's SAML 2.0 configuration. diff --git a/apps/cli/package.json b/apps/cli/package.json index a3c4aa09e6..0efd97bd4a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -82,6 +82,7 @@ "semantic-release": "^25.0.8", "smol-toml": "^1.7.1", "tldts": "catalog:", + "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "catalog:", "yaml": "^2.9.0" }, diff --git a/apps/cli/src/legacy/auth/legacy-access-token.ts b/apps/cli/src/legacy/auth/legacy-access-token.ts index 78a3865e02..604e7d7a6e 100644 --- a/apps/cli/src/legacy/auth/legacy-access-token.ts +++ b/apps/cli/src/legacy/auth/legacy-access-token.ts @@ -27,9 +27,13 @@ const LEGACY_INVALID_ACCESS_TOKEN_MESSAGE = */ export const validateLegacyAccessToken = ( token: string, + source?: "env" | "stored", ): Effect.Effect => LEGACY_ACCESS_TOKEN_PATTERN.test(token) ? Effect.succeed(token) : Effect.fail( - new LegacyInvalidAccessTokenError({ message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE }), + new LegacyInvalidAccessTokenError({ + message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE, + source, + }), ); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 96ad62e63f..fb5ccdb19e 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -483,14 +483,14 @@ const makeLegacyCredentials = Effect.gen(function* () { // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliConfig.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); - yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value)); + yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value), "env"); return Option.some(cliConfig.accessToken.value); } // Keyring (profile key, then legacy key). Skipped on WSL. const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { - yield* validateLegacyAccessToken(keyringValue.value); + yield* validateLegacyAccessToken(keyringValue.value, "stored"); return Option.some(Redacted.make(keyringValue.value)); } @@ -498,7 +498,7 @@ const makeLegacyCredentials = Effect.gen(function* () { const fileValue = yield* readFile; if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); - yield* validateLegacyAccessToken(fileValue.value); + yield* validateLegacyAccessToken(fileValue.value, "stored"); return Option.some(Redacted.make(fileValue.value)); } diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index 30314cb4ec..f99d230b40 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -1,16 +1,37 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; export class LegacyInvalidAccessTokenError extends Data.TaggedError( "LegacyInvalidAccessTokenError", )<{ readonly message: string; -}> {} + /** + * Where the malformed token was read from. An env-var token + * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env + * var. A stored (keyring/file) token, or an unknown source, is fixable by + * logging in again. + */ + readonly source?: "env" | "stored"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.source === "env" ? actionability.authToken : actionability.authLogin; + } +} export class LegacyPlatformAuthRequiredError extends Data.TaggedError( "LegacyPlatformAuthRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteProjectCredential` when removing a stored database-password @@ -21,7 +42,11 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( */ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Raised by `deleteAccessToken` when there is no access token to delete, i.e. @@ -33,7 +58,11 @@ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredent */ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteAccessToken` when removing the token fails for a real reason @@ -44,4 +73,8 @@ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInE */ export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 77eb406486..2093c73564 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -47,7 +47,7 @@ export const legacyMakePlatformApi = Effect.gen(function* () { // already validates the keyring/file paths; validate the env token here too so // a malformed SUPABASE_ACCESS_TOKEN fails with the invalid-token error rather // than being sent to the API. - yield* validateLegacyAccessToken(Redacted.value(configuredToken.value)); + yield* validateLegacyAccessToken(Redacted.value(configuredToken.value), "env"); return configuredToken; } return yield* credentials.getAccessToken; diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts index b439a7210c..d4061ce87e 100644 --- a/apps/cli/src/legacy/commands/backups/backups.errors.ts +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -1,8 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( "LegacyBackupListUnexpectedStatusError", @@ -10,13 +23,24 @@ export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBackupRestoreNetworkError extends Data.TaggedError( "LegacyBackupRestoreNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( "LegacyBackupRestoreUnexpectedStatusError", @@ -24,4 +48,8 @@ export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index 95896f9b6f..8a925ee7cc 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // Bootstrap-specific tagged errors. Each maps to a Go `errors.New` / failure @@ -12,21 +18,33 @@ export class LegacyBootstrapInvalidTemplateError extends Data.TaggedError( "LegacyBootstrapInvalidTemplateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** GitHub samples listing failure — Go's `failed to list samples` (`bootstrap.go:ListSamples`). */ export class LegacyBootstrapTemplateListError extends Data.TaggedError( "LegacyBootstrapTemplateListError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Reading the target workdir failed — Go's `failed to read workdir: %w` (`bootstrap.go:44`). */ export class LegacyBootstrapWorkdirReadError extends Data.TaggedError( "LegacyBootstrapWorkdirReadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * User declined the overwrite prompt — Go returns `errors.New(context.Canceled)` @@ -36,14 +54,22 @@ export class LegacyBootstrapOverwriteDeclinedError extends Data.TaggedError( "LegacyBootstrapOverwriteDeclinedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Template download failure — Go's `failed to download template: %w` (`bootstrap.go:downloadSample`). */ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( "LegacyBootstrapTemplateDownloadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * Project health probe failed — Go's `Error status %d: %s` (non-200) or @@ -51,4 +77,25 @@ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( */ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstrapHealthError")<{ readonly message: string; -}> {} + /** Set when the health poll itself failed with a non-200; absent when the + * service reported unhealthy. */ + readonly status?: number; + /** Set when the health poll's response came back with a 200 the generated + * client could not decode (`SchemaError`) — an API-response + * problem, not a transport failure. */ + readonly decode?: boolean; + /** Set when the health poll failed without any HTTP response (DNS, TLS, + * timeout) — a network failure, not an API status. */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.status !== undefined) return statusCodeActionability(this.status); + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index ca10bed19c..d08a8634d9 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -357,7 +357,6 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( cliConfig.poolerHost, dnsResolver, Option.some(created.dbPassword), - false, ).pipe( Effect.catchTag("LegacyDbConfigIpv6Error", (error) => output.raw(`${error.message}\n`, "stderr").pipe(Effect.as(dbConfig)), @@ -375,6 +374,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( includeAll: false, includeRoles: true, includeSeed: true, + includeVault: true, dnsResolver, projectId: cliConfig.projectId, toml, @@ -423,6 +423,16 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( ); }); +// Whether `cause` is the generated client's `SchemaError` — a 200 response the +// client could not decode, as opposed to a transport failure (DNS, TLS, +// timeout). +function isDecodeFailureCause(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) { + return false; + } + return cause._tag === "SchemaError"; +} + // Mirrors Go's `checkProjectHealth` non-200 branch: `Error status %d: %s`. const mapHealthError = (cause: unknown): Effect.Effect => { if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { @@ -431,9 +441,15 @@ const mapHealthError = (cause: unknown): Effect.Effect ""), Effect.map(sanitizeLegacyErrorBody), Effect.flatMap((body) => - Effect.fail(new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}` })), + Effect.fail( + new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}`, status }), + ), ), ); } - return Effect.fail(new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}` })); + return Effect.fail( + isDecodeFailureCause(cause) + ? new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true }) + : new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }), + ); }; diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts index 3a9cc4132e..8525db8c87 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -19,20 +19,20 @@ import { mockBrowser, mockOutput, mockProcessControl, - mockRuntimeInfo, mockStdin, mockTelemetryRuntime, mockTty, - processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_TOKEN, + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, mockLegacyLoginApi, mockLegacyLoginCrypto, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -53,6 +53,8 @@ import { LegacyTemplateService } from "./bootstrap.templates.ts"; import { legacyBootstrapRuntimeLayer } from "./bootstrap.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-bootstrap-layers-"); + /** * Stub layer satisfying every external service required by * `legacyBootstrapRuntimeLayer` from the root runtime. Services under test are @@ -102,7 +104,13 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. Bootstrap's + // legacyPlatformApiLayer additionally eagerly validates the access token at + // layer-construction time, so inject a valid token via the isolated env — + // the same mechanism the cli-e2e harness uses (SUPABASE_ACCESS_TOKEN env + // var, legacy CLAUDE.md item 4 dual-mode profile). + legacyIsolatedHomeLayer(tempRoot.current, { SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN }), mockTty(), mockProcessControl().layer, mockBrowser(), @@ -111,13 +119,6 @@ function ambientStubs() { mockTelemetryRuntime(), out.layer, flagLayers, - // Bootstrap's legacyPlatformApiLayer eagerly validates the access token at - // layer-construction time. Inject a valid token via the environment so the - // real legacyCliConfigLayer (built inside the bootstrap runtime) finds it — - // matching the same mechanism the cli-e2e harness uses (SUPABASE_ACCESS_TOKEN - // env var, legacy CLAUDE.md item 4 dual-mode profile). The processEnvLayer - // isolates the env mutation to this test's scope. - processEnvLayer({ SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN }), mockLegacyCliConfig({ workdir: "/tmp/bootstrap-layers-test" }), mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 0092742e88..80358beff7 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -1,4 +1,11 @@ import { Data } from "effect"; +import { + actionability, + CliSuggestionType, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +17,14 @@ export class LegacyBranchesListNetworkError extends Data.TaggedError( "LegacyBranchesListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesListUnexpectedStatusError", @@ -18,13 +32,24 @@ export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesCreateNetworkError extends Data.TaggedError( "LegacyBranchesCreateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesCreateUnexpectedStatusError", @@ -32,14 +57,37 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A non-gated 409 on `branches create` means the branch name already + // exists (the next shell maps this endpoint's 409 to + // BranchAlreadyExistsError) — user input, not a raw API status. The gate + // guard stays ahead so a confirmed plan-limited 409 still classifies as + // plan_limit via the shared policy. + if (this.upgradeSuggested !== true && this.status === 409) { + return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; + } + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: true, + }); + } +} // Lookup phase of `branches get` (only runs when input is not UUID / not ref). export class LegacyBranchesFindNetworkError extends Data.TaggedError( "LegacyBranchesFindNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesFindUnexpectedStatusError", @@ -47,7 +95,11 @@ export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // `branches get` detail phase + the resolver's UUID branch (both use // V1GetABranchConfig; Go shares the same error template). @@ -55,7 +107,14 @@ export class LegacyBranchesGetNetworkError extends Data.TaggedError( "LegacyBranchesGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesGetUnexpectedStatusError", @@ -63,13 +122,24 @@ export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesApiKeysNetworkError extends Data.TaggedError( "LegacyBranchesApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesApiKeysUnexpectedStatusError", @@ -77,13 +147,24 @@ export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesPoolerNetworkError extends Data.TaggedError( "LegacyBranchesPoolerNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPoolerUnexpectedStatusError", @@ -91,19 +172,36 @@ export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesPrimaryNotFoundError extends Data.TaggedError( "LegacyBranchesPrimaryNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A successful pooler-config response with no PRIMARY entry — an API + // response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} export class LegacyBranchesUpdateNetworkError extends Data.TaggedError( "LegacyBranchesUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUpdateUnexpectedStatusError", @@ -111,13 +209,28 @@ export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: true, + }); + } +} export class LegacyBranchesPauseNetworkError extends Data.TaggedError( "LegacyBranchesPauseNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPauseUnexpectedStatusError", @@ -125,13 +238,24 @@ export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesUnpauseNetworkError extends Data.TaggedError( "LegacyBranchesUnpauseNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUnpauseUnexpectedStatusError", @@ -139,13 +263,24 @@ export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesDeleteNetworkError extends Data.TaggedError( "LegacyBranchesDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDeleteUnexpectedStatusError", @@ -153,13 +288,24 @@ export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyBranchesDisableNetworkError extends Data.TaggedError( "LegacyBranchesDisableNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDisableUnexpectedStatusError", @@ -167,7 +313,11 @@ export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -177,19 +327,31 @@ export class LegacyBranchesEnvNotSupportedError extends Data.TaggedError( "LegacyBranchesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyBranchesCreateCancelledError extends Data.TaggedError( "LegacyBranchesCreateCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} export class LegacyBranchesBranchNameEmptyError extends Data.TaggedError( "LegacyBranchesBranchNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( "LegacyBranchesBranchingDisabledError", @@ -201,4 +363,13 @@ export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( * `normalizeCliError` and printed after the error message in text mode. */ readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.invalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase branches create", + }; + } +} diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts new file mode 100644 index 0000000000..2d915cd6d5 --- /dev/null +++ b/apps/cli/src/legacy/commands/branches/branches.errors.unit.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { + LegacyBranchesBranchingDisabledError, + LegacyBranchesCreateUnexpectedStatusError, + LegacyBranchesDeleteUnexpectedStatusError, + LegacyBranchesPauseUnexpectedStatusError, + LegacyBranchesUnpauseUnexpectedStatusError, + LegacyBranchesUpdateUnexpectedStatusError, +} from "./branches.errors.ts"; + +const body = { body: "not found", message: "boom" }; + +describe("branch operation 404s classify as invalid input", () => { + it("pause 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesPauseUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LegacyBranchesPauseUnexpectedStatusError:not_found"); + }); + + it("unpause 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUnpauseUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe( + "tag:LegacyBranchesUnpauseUnexpectedStatusError:not_found", + ); + }); + + it("delete 404 → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesDeleteUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe( + "tag:LegacyBranchesDeleteUnexpectedStatusError:not_found", + ); + }); + + it("a non-404 status stays on the status policy", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesPauseUnexpectedStatusError({ status: 500, ...body }), + ); + expect(result.error_category).toBe("api_status"); + }); +}); + +describe("gated branch operation 404s", () => { + it("update 404 without an upgrade gate → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUpdateUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("none"); + }); + + it("create 404 without an upgrade gate → invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ status: 404, ...body }), + ); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("none"); + }); + + it("update: a confirmed plan gate is not shadowed by the 404 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesUpdateUnexpectedStatusError({ + status: 404, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); + + it("create: a confirmed plan gate is not shadowed by the 404 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ + status: 404, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); +}); + +describe("branches create 409 = duplicate branch name", () => { + it("create 409 without an upgrade gate → invalid input (conflict)", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ status: 409, ...body }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LegacyBranchesCreateUnexpectedStatusError:conflict"); + }); + + it("create: a confirmed plan gate is not shadowed by the 409 branch", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesCreateUnexpectedStatusError({ + status: 409, + ...body, + upgradeSuggested: true, + }), + ); + expect(result.error_category).toBe("plan_limit"); + expect(result.suggestion_type).toBe("upgrade_plan"); + }); +}); + +it("classifies the branching-disabled remediation as running the suggested command", () => { + const result = classifyCliErrorActionability( + new LegacyBranchesBranchingDisabledError({ + message: "Preview branching is disabled.", + suggestion: "Create your first branch with: supabase branches create", + }), + ); + expect(result.suggestion_type).toBe("run_command"); + expect(result.suggested_command).toBe("supabase branches create"); +}); diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 1d6f185d12..1ad03a962d 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -20,6 +20,7 @@ import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LEGACY_GO_BRANCH_RESPONSE } from "../branches.go-payload.ts"; import { + LegacyBranchesBranchNameEmptyError, LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, LegacyBranchesCreateUnexpectedStatusError, @@ -84,6 +85,12 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function } } + if (branchName.length === 0) { + return yield* new LegacyBranchesBranchNameEmptyError({ + message: "branch name cannot be empty", + }); + } + const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { @@ -107,7 +114,24 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function // Mirror Go's `create.go:34-37`: on any non-201 status (including // gated 4xx), run the plan-gate check before mapping the error. Effect.catch( - legacyGateMapError({ projectRef: ref, featureKey: "branching_limit" }, mapCreateErrorRaw), + legacyGateMapError( + { projectRef: ref, featureKey: "branching_limit" }, + (cause, upgradeSuggested) => + Effect.gen(function* () { + const mapped = yield* Effect.flip(mapCreateErrorRaw(cause)); + if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesCreateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); + }), + ), ), ); yield* creating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index d9b7029288..8a3f16c087 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -22,6 +22,7 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { legacyBranchesCreateCommand, type LegacyBranchesCreateFlags } from "./create.command.ts"; import { legacyBranchesCreate } from "./create.handler.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; type CreatedBranch = typeof V1CreateABranchOutput.Type; @@ -219,6 +220,33 @@ describe("legacy branches create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("reports a missing name before contacting the API outside a git repository", () => { + const previousHead = process.env["GITHUB_HEAD_REF"]; + delete process.env["GITHUB_HEAD_REF"]; + const { layer, api } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyBranchesCreate(baseFlags).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesBranchNameEmptyError"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + suggestion_type: "provide_flags", + }); + } + expect(api.requests).toHaveLength(0); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousHead === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previousHead; + }), + ), + Effect.provide(layer), + ); + }); + // --------------------------------------------------------------------------- // Git-branch auto-name confirmation — Go `create.go:17-28` routes it through // `PromptYesNo(title, true)` (`console.go:64-82`). `GITHUB_HEAD_REF` drives diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 6e8dbc9190..75533496a4 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -74,7 +74,21 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function Effect.catch( legacyGateMapError( { projectRef: branchRef, featureKey: "branching_persistent" }, - mapUpdateError, + (cause, upgradeSuggested) => + Effect.gen(function* () { + const mapped = yield* Effect.flip(mapUpdateError(cause)); + if (mapped._tag === "LegacyBranchesUpdateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); + }), ), ), ); diff --git a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts index 8bdfbb4416..2b3c166837 100644 --- a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts @@ -72,6 +72,7 @@ export const getCostMatrix = Effect.fn("legacy.config.push.cost-matrix")(functio catch: (cause) => new LegacyConfigPushListAddonsNetworkError({ message: `failed to list addons: ${String(cause)}`, + decode: true, }), }); diff --git a/apps/cli/src/legacy/commands/config/push/push.errors.ts b/apps/cli/src/legacy/commands/config/push/push.errors.ts index f0921dfdda..20b704e69b 100644 --- a/apps/cli/src/legacy/commands/config/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/config/push/push.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `supabase config push`, one per Go error path @@ -20,6 +26,17 @@ interface NetworkErrorArgs { readonly message: string; } +/** + * A network-error shape that may instead represent a 200-response body decode + * failure (`SchemaError` folded in by `mapLegacyHttpError`). + * `decode: true` reclassifies the failure as an API-response problem rather + * than a transport/network problem. + */ +interface DecodableNetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + interface StatusErrorArgs { readonly status: number; readonly body: string; @@ -29,113 +46,258 @@ interface StatusErrorArgs { /** TOML parse failure (rewraps the packages/config parse error). Aborts before any network call. */ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( "LegacyConfigPushLoadConfigError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} // --- cost matrix (list addons) --------------------------------------------- export class LegacyConfigPushListAddonsNetworkError extends Data.TaggedError( "LegacyConfigPushListAddonsNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return actionability.externalNetwork; + } +} export class LegacyConfigPushListAddonsStatusError extends Data.TaggedError( "LegacyConfigPushListAddonsStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- api -------------------------------------------------------------------- export class LegacyConfigPushApiReadNetworkError extends Data.TaggedError( "LegacyConfigPushApiReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushApiReadStatusError extends Data.TaggedError( "LegacyConfigPushApiReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushApiUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushApiUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushApiUpdateStatusError extends Data.TaggedError( "LegacyConfigPushApiUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- db.settings ------------------------------------------------------------ export class LegacyConfigPushDbReadNetworkError extends Data.TaggedError( "LegacyConfigPushDbReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushDbReadStatusError extends Data.TaggedError( "LegacyConfigPushDbReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushDbUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushDbUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushDbUpdateStatusError extends Data.TaggedError( "LegacyConfigPushDbUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- db.network_restrictions ------------------------------------------------ export class LegacyConfigPushNetworkRestrictionsReadNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsReadStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsUpdateStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- db.ssl_enforcement ----------------------------------------------------- export class LegacyConfigPushSslEnforcementReadNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementReadStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushSslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementUpdateStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- auth ------------------------------------------------------------------- export class LegacyConfigPushAuthReadNetworkError extends Data.TaggedError( "LegacyConfigPushAuthReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushAuthReadStatusError extends Data.TaggedError( "LegacyConfigPushAuthReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushAuthUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushAuthUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushAuthUpdateStatusError extends Data.TaggedError( "LegacyConfigPushAuthUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- storage ---------------------------------------------------------------- export class LegacyConfigPushStorageReadNetworkError extends Data.TaggedError( "LegacyConfigPushStorageReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushStorageReadStatusError extends Data.TaggedError( "LegacyConfigPushStorageReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyConfigPushStorageUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushStorageUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushStorageUpdateStatusError extends Data.TaggedError( "LegacyConfigPushStorageUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --- experimental.webhooks -------------------------------------------------- export class LegacyConfigPushEnableWebhookNetworkError extends Data.TaggedError( "LegacyConfigPushEnableWebhookNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushEnableWebhookStatusError extends Data.TaggedError( "LegacyConfigPushEnableWebhookStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/advisors/SIDE_EFFECTS.md index 2a4fad7e05..41e7884557 100644 --- a/apps/cli/src/legacy/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/advisors/SIDE_EFFECTS.md @@ -6,11 +6,11 @@ database directly; `--linked` fetches from the Management API. ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | -------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config | -| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | -| `/supabase/.temp/project-ref` | plain text | `--linked` only — to resolve the project ref | +| Path | Format | When | +| -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config | +| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `/supabase/.temp/project-ref` | plain text | `--linked` only — to resolve the project ref; skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -41,26 +41,27 @@ One connection. Within one transaction: `BEGIN` → `set local search_path = ''` ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ----------------------------------------- | ----------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked project ref override | no | -| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | -| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------- | ----------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no | +| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | +| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | The API base URL is derived from `SUPABASE_PROFILE`; `SUPABASE_API_URL` is **not** honored (Go parity — see `legacy-cli-config.layer.unit.test.ts`). ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------- | -| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | -| `1` | mutually-exclusive `--db-url` / `--linked` / `--local` | -| `1` | `--linked` with no access token (suggests `supabase login`) | -| `1` | connection / `BEGIN` / setup / query failure (local) | -| `1` | advisors API non-200 (linked) | -| `1` | a lint's level is at or above `--fail-on` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | +| `1` | mutually-exclusive `--db-url` / `--linked` / `--local` | +| `1` | `--linked` with no access token (suggests `supabase login`) | +| `1` | connection / `BEGIN` / setup / query failure (local) | +| `1` | advisors API non-200 (linked) | +| `1` | a lint's level is at or above `--fail-on` | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -89,5 +90,12 @@ the process exits non-zero (no error envelope is written over the payload). - `--level` (`warn` default) sets the minimum issue level to display. - `--fail-on` (`none` default) sets the level that forces a non-zero exit. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution used for the connection and + the linked-project cache (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`). It never implies `--linked`: passing it with a + resolved `--local`/`--db-url` target is a hard error rather than a silently + discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's + equivalent env var simply leaves unused on a non-linked target). - Not-logged-in suggestion: `Run supabase login first.` - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts index 2a9e55fc24..5dfb4ed926 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts @@ -16,6 +16,11 @@ const config = { Flag.withDescription("Checks the linked project for issues."), ), local: Flag.boolean("local").pipe(Flag.withDescription("Checks the local database for issues.")), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), type: Flag.choice("type", ["all", "security", "performance"] as const).pipe( Flag.withDescription("Type of advisors to check: all, security, performance."), Flag.optional, @@ -42,13 +47,17 @@ export const legacyDbAdvisorsCommand = Command.make("advisors", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, type: flags.type, level: flags.level, "fail-on": flags.failOn, }, // type/level/fail-on are Flag.choice and are auto-detected as safe via // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116); - // --db-url stays redacted (plain string, may carry secrets). + // --db-url stays redacted (plain string, may carry secrets). --project-ref + // is a TS-only flag with no Go telemetry-safety baseline either; Go's + // nearest --project-ref registrations (cmd/pgdelta_catalog.go:44 and + // most others) are unmarked, so it stays redacted too. config, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index bd073206d8..26edf24b19 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `db advisors`, one per Go failure path @@ -13,7 +19,11 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbAdvisorsMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` PreRunE: no access token. Message is Go's `utils.ErrMissingToken`; @@ -22,7 +32,11 @@ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedErro */ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( "LegacyDbAdvisorsNotLoggedInError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ readonly message: string; readonly suggestion: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * `--linked` PreRunE: the resolved access token is malformed. Message is Go's @@ -33,44 +47,105 @@ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( */ export class LegacyDbAdvisorsInvalidTokenError extends Data.TaggedError( "LegacyDbAdvisorsInvalidTokenError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ + readonly message: string; + readonly suggestion: string; + /** + * Copied from the wrapped `LegacyInvalidAccessTokenError`: an env-var token + * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env + * var. A stored (keyring/file) token, or an unknown source, is fixable by + * logging in again. + */ + readonly source?: "env" | "stored"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.source === "env" ? actionability.authToken : actionability.authLogin; + } +} /** `failed to begin transaction: %w` (`advisors.go:105`). */ export class LegacyDbAdvisorsBeginTxError extends Data.TaggedError("LegacyDbAdvisorsBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to prepare lint session: %w` (`advisors.go:115`). */ export class LegacyDbAdvisorsSetupError extends Data.TaggedError("LegacyDbAdvisorsSetupError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to query lints: %w` (`advisors.go:120`). */ export class LegacyDbAdvisorsQueryError extends Data.TaggedError("LegacyDbAdvisorsQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} -/** `failed to fetch security advisors: %w` (`advisors.go:165`). */ +/** + * `failed to fetch security advisors: %w` (`advisors.go:165`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsSecurityNetworkError extends Data.TaggedError( "LegacyDbAdvisorsSecurityNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** `unexpected security advisors status %d: %s` (`advisors.go:168`). */ export class LegacyDbAdvisorsSecurityStatusError extends Data.TaggedError( "LegacyDbAdvisorsSecurityStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} -/** `failed to fetch performance advisors: %w` (`advisors.go:176`). */ +/** + * `failed to fetch performance advisors: %w` (`advisors.go:176`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsPerformanceNetworkError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** `unexpected performance advisors status %d: %s` (`advisors.go:179`). */ export class LegacyDbAdvisorsPerformanceStatusError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** `fail-on is set to %s, non-zero exit` (`advisors.go:257`). */ export class LegacyDbAdvisorsFailOnError extends Data.TaggedError("LegacyDbAdvisorsFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts new file mode 100644 index 0000000000..862549890b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyDbAdvisorsInvalidTokenError } from "./advisors.errors.ts"; + +describe("LegacyDbAdvisorsInvalidTokenError actionability", () => { + const build = (source?: "env" | "stored") => + new LegacyDbAdvisorsInvalidTokenError({ + message: "Invalid access token format. Must be like `sbp_0102...1920`.", + suggestion: "Run supabase login first.", + source, + }); + + it("classifies an env-provided malformed token as a set-env-var remediation", () => { + const result = classifyCliErrorActionability(build("env")); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + expect(result.error_fingerprint).toBe("tag:LegacyDbAdvisorsInvalidTokenError"); + }); + + it("classifies a stored malformed token as a re-login remediation", () => { + const result = classifyCliErrorActionability(build("stored")); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("login"); + expect(result.suggested_command).toBe("supabase login"); + }); + + it("defaults to a re-login remediation when the source is unknown", () => { + const result = classifyCliErrorActionability(build()); + expect(result.suggestion_type).toBe("login"); + expect(result.suggested_command).toBe("supabase login"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts index f3cf7b40c9..95e09e3cee 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts @@ -111,6 +111,7 @@ const runLocal = Effect.fnUntraced(function* ( /** Go's root `PersistentPreRunE` (`cmd/root.go:118`) + advisors `PreRunE` + * `RunLinked` (`cmd/db.go:355-371`, `advisors.go:79-100`). */ const runLinked = Effect.fnUntraced(function* ( + flags: LegacyDbAdvisorsFlags, dnsResolver: "native" | "https", advisorType: string, level: string, @@ -133,7 +134,7 @@ const runLinked = Effect.fnUntraced(function* ( // when the DB-config resolve below fails (e.g. the IPv6 error). Load the ref // first (non-prompting `LoadProjectRef`; ErrNotLinked → empty ref → nothing to // cache, matching Go) and wrap everything after it in the cache finalizer. - const ref = yield* projectRefResolver.loadProjectRef(Option.none()); + const ref = yield* projectRefResolver.loadProjectRef(flags.projectRef); return yield* Effect.gen(function* () { // Root PersistentPreRunE's `ParseDatabaseConfig` host probe / login-role mint @@ -141,7 +142,12 @@ const runLinked = Effect.fnUntraced(function* ( // the resolved config (`advisors.go:79-100`), so resolve-and-discard — purely // for the side effects and early-failure ordering (before the token gate, // matching root PersistentPreRunE → advisors PreRunE). - yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", dnsResolver }); + yield* resolver.resolve({ + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + linkedProjectRef: flags.projectRef, + }); // PreRunE: Go calls `utils.LoadAccessTokenFS` (`cmd/db.go:358`), which VALIDATES // the token (env/keyring/file) against the `sbp_` pattern and fails with @@ -154,6 +160,9 @@ const runLinked = Effect.fnUntraced(function* ( new LegacyDbAdvisorsInvalidTokenError({ message: cause.message, suggestion: loginSuggestion(), + // Preserve the token source so an env-provided malformed token keeps + // its `set_env_var` remediation instead of degrading to `supabase login`. + source: cause.source, }), ), ), @@ -230,6 +239,19 @@ const runAdvisors = Effect.fnUntraced(function* ( ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. advisors defaults to the local/db-url path + // (`runLocal`) whenever `--linked` isn't the resolved target selector. + if (Option.isSome(flags.projectRef) && target.connType !== "linked") { + return yield* Effect.fail( + new LegacyDbAdvisorsMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const advisorType = Option.getOrElse(flags.type, () => "all"); const level = Option.getOrElse(flags.level, () => "warn"); const failOn = Option.getOrElse(flags.failOn, () => "none"); @@ -238,7 +260,7 @@ const runAdvisors = Effect.fnUntraced(function* ( // linked → Management API; otherwise local / `--db-url`. const filtered = target.connType === "linked" - ? yield* runLinked(dnsResolver, advisorType, level) + ? yield* runLinked(flags, dnsResolver, advisorType, level) : yield* runLocal(flags, dnsResolver, advisorType, level, target); yield* outputAndCheck(filtered, failOn); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts index bc615a0b5c..62b7ce051c 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts @@ -143,10 +143,15 @@ function mockProjectRef() { }), resolveForLink: () => Effect.succeed(LEGACY_VALID_REF), resolveOptional: () => Effect.succeed(Option.some(LEGACY_VALID_REF)), - loadProjectRef: () => + // Gives an explicit `--project-ref` flag top precedence, same as Go's + // `flags.LoadProjectRef` — mirrors the real resolver so a test can prove the + // flag (not just the hardcoded fallback) drives the linked ref. + loadProjectRef: (flagValue: Option.Option) => Effect.sync(() => { calls.push("loadProjectRef"); - return LEGACY_VALID_REF; + return Option.isSome(flagValue) && flagValue.value.length > 0 + ? flagValue.value + : LEGACY_VALID_REF; }), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -306,6 +311,7 @@ const flags = (over: Partial = {}): LegacyDbAdvisorsFlags dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), type: over.type ?? Option.none<"all" | "security" | "performance">(), level: over.level ?? Option.none<"info" | "warn" | "error">(), failOn: over.failOn ?? Option.none<"none" | "info" | "warn" | "error">(), @@ -546,6 +552,56 @@ describe("legacy db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "fetches advisors for the project given via --project-ref, overriding the workdir's own ref", + () => { + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag (e.g. .temp/project-ref) — + // the flag must win over it and drive both the API path and the cache. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, api, cache } = setup({ + securityLints: [securityLint], + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* legacyDbAdvisors( + flags({ type: Option.some("security"), projectRef: Option.some(FLAG_REF) }), + ); + // The request path itself must be scoped to the FLAG ref, not merely + // any /advisors/security hit — proving the flag (not the fallback) + // drove the API call the same way it drove the cache below. + expect( + api.requests.some((r) => r.url.includes(`/v1/projects/${FLAG_REF}/advisors/security`)), + ).toBe(true); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects --project-ref on the default local target", () => { + // advisors defaults to the local path when --linked isn't set — the guard + // must fire from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, connection, api, cache } = setup({ rows: [] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbAdvisors(flags({ projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection, API call, or cache write. + expect(connection.execs).toEqual([]); + expect(api.requests).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live( "resolves the linked DB config before fetching advisors (Go root PersistentPreRunE)", () => { diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.layers.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.layers.ts index fabbfd116d..d6935f5e11 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.layers.ts @@ -27,9 +27,10 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * smoke test — legacy CLAUDE.md item 5 / 7). * * Instead the project-ref resolver is given the **lazy** `legacyPlatformApiFactoryLayer`, - * whose `make` is only forced by an interactive project-ref prompt. advisors only - * ever calls `resolve(Option.none())` (Go's soft `LoadProjectRef`), so no token is - * resolved on the local path; the linked path resolves it explicitly in the handler. + * whose `make` is only forced by an interactive project-ref prompt. The linked path + * resolves the ref via the non-prompting `loadProjectRef`, which never forces the + * factory; the local path never resolves a project ref at all, so no token is + * resolved there either. * * `legacyCliConfigLayer` is provided to each consumer that needs it (item 5: * `Layer.provide` does not share to merge siblings); layers are memoised by diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts index 6ef5d5aa35..0425245942 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts @@ -18,8 +18,15 @@ import { apiResponseToLegacyAdvisorLints } from "./advisors.format.ts"; interface AdvisorEndpoint { readonly path: "security" | "performance"; - /** Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). */ - readonly network: (message: string) => LegacyAdvisorNetworkError; + /** + * Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). + * `decode: true` marks a 200-response body decode failure rather than a + * transport failure, even though Go folds both into the same message path. + */ + readonly network: ( + message: string, + opts?: { readonly decode?: boolean }, + ) => LegacyAdvisorNetworkError; /** Builds the non-200 failure (Go's `unexpected … advisors status %d: %s`). */ readonly status: (status: number, body: string) => LegacyAdvisorStatusError; } @@ -92,7 +99,7 @@ const fetchAdvisors = Effect.fnUntraced(function* ( // `apiResponseToLegacyAdvisorLints`) to the endpoint's network error. return yield* Effect.try({ try: () => apiResponseToLegacyAdvisorLints(JSON.parse(rawBody) as unknown), - catch: (cause) => endpoint.network(String(cause)), + catch: (cause) => endpoint.network(String(cause), { decode: true }), }); }); @@ -101,9 +108,10 @@ export const legacyFetchSecurityAdvisors = (ref: string, stitch: LegacyStitchFn) ref, { path: "security", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsSecurityNetworkError({ message: `failed to fetch security advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsSecurityStatusError({ @@ -120,9 +128,10 @@ export const legacyFetchPerformanceAdvisors = (ref: string, stitch: LegacyStitch ref, { path: "performance", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsPerformanceNetworkError({ message: `failed to fetch performance advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsPerformanceStatusError({ diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index 814e5c4a47..9233e1d338 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -1,40 +1,63 @@ # `supabase db diff` Native Effect port. Diffs the local project's expected schema (a throwaway shadow -database) against a target database (local / linked / `--db-url`), using either -the native pg-delta or migra engine (both run inside Docker via edge-runtime). The -`--use-pgadmin` / `--use-pg-schema` engines delegate to the bundled Go binary. +database) against a target database (local / linked / `--db-url`), using one of +three native engines: pg-delta or migra (both run inside Docker via edge-runtime), +or pgAdmin (CLI-1968 — a native `docker run` of the differ container, no +edge-runtime involved). `--use-pg-schema` is the CLI's sole remaining Go +delegation on this command — a documented keep-in-Go exception (CLI-1960), not a +pending port. ## Files Read -| Path | Format | When | -| -------------------------------------------------- | ---------- | ----------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | -| `/supabase/database/**` (declarative dir) | SQL | local target when declarative schemas exist | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | +| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); missing file tolerated | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | local target: 3-source declarative-schema fallback ladder, first non-empty source wins — `--use-pgadmin` never reads this ladder (Go's `pgadmin.go` calls `MigrateShadowDatabase` directly, never `PrepareShadowSource`) | +| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ----------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty (also `--use-pgadmin --file`: always exactly one file — pgAdmin never produces a multi-unit plan) | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | +| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (pg-delta / migra diff scripts; also runs the pg-delta +- Edge-runtime container (pg-delta / migra diff scripts; also the declarative + pg-delta apply script for the local-target branch, and runs the pg-delta catalog-export script for explicit `--from/--to migrations` on a cache miss — CLI-1959, native, no longer the hidden Go `__catalog` seam). -- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam; - explicit `--from/--to migrations` reuses this same seam call — `mode: "diff"` — - on a cache miss, rather than a second, `__catalog`-specific shadow). +- Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` + in `legacy/commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in + `legacy/shared/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit + `--from/--to migrations` reuses the SAME native primitives on a cache miss + (`legacyResolveMigrationsCatalogRef` -> `exportViaShadowCatalog`, `legacy-pgdelta.cache.ts`), + called with `targetLocal: false`/`usePgDelta: false` to skip the declarative-schema-override + branch — not a second, `__catalog`-specific shadow, and not a shared `mode: "diff"` parameter + (that seam-era concept no longer exists). `--use-pgadmin` provisions its OWN shadow via a + narrower composition — `legacyCreateShadowDatabase` -> health-wait -> `legacyMigrateShadowDatabase` + directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and + no `targetUrlOverride`, matching Go's `pgadmin.go` calling `MigrateShadowDatabase` directly + rather than `PrepareShadowSource`. - `supabase/migra` container — the migra OOM bash fallback only. +- **Differ container** (`--use-pgadmin`, CLI-1968) — `supabase/pgadmin-schema-diff:cli-0.0.5` + (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one + run per `--schema` value, in flag order. Runs on the project's Docker network (`--network-id` + or the generated `supabase_network_` — never the host network, unlike the migra + bash fallback), with `--add-host host.docker.internal:host-gateway` on Linux only, and both + `com.supabase.cli.project`/`com.docker.compose.project` labels — no env vars, bind mounts, or + working-directory override. ## API Routes (linked path, via the db-config resolver) @@ -45,23 +68,56 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T | GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | | GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +`--use-pgadmin --linked` performs every one of these calls in TS now (CLI-1968): Go's +`RunPgAdmin` used to run entirely inside the delegated Go binary, so the temp-role +mint / pooler fallback / network-ban retry happened in the Go child; they now run +natively as part of this command's own target resolve, ahead of the differ container. + ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | ------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | -| `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | -| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | +| `PGDELTA_DEBUG` | pg-delta debug capture | no | +| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | +| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | + +`SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ +`SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned +through the same primitives. + +`SUPABASE_EXPERIMENTAL_PG_DELTA` is **read, no effect** on the pgadmin path: the pg-delta +engine-selection lookup (`legacyShouldUsePgDelta`) runs unconditionally, before the +`--use-pgadmin` branch, but the pgadmin branch is chosen first and never consults the +resulting `useDelta` value. + +`SUPABASE_INTERNAL_IMAGE_REGISTRY` applies to the differ's own image resolution too. The +docker-run layer's resolver (`legacy-docker-run.layer.ts`) is built once, statically, with +no `projectEnvValues` in scope, so it falls back to reading `process.env` directly at +`runCapture` call time — the handler's own `legacyApplyProjectEnv(cfg.projectEnv)` call +(right after the config load) is what makes a registry override set only in +`supabase/.env`/project-root dotenv (not the ambient shell) visible to it by then, mirroring +Go's `loadNestedEnv` `os.Setenv`ing the project `.env` during config load +(`pkg/config/config.go:788-791`) before `GetRegistry()` +(`internal/utils/docker.go:221-231,244-246`) ever reads it. + +Explicitly **not** read by `--use-pgadmin`: `PGDELTA_*`, `SUPABASE_SSL_DEBUG` (both +migra/pg-delta-engine-specific). ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success; empty diff ("No schema changes found") | -| `1` | `--from` without `--to`; engine-flag mutex; target mutex; unknown explicit target; connection/shadow/engine failure; file IO error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success; empty diff ("No schema changes found") | +| `1` | `--from` without `--to`; engine-flag mutex; target mutex; unknown explicit target; connection/shadow/engine failure; file IO error; local db not running (`--use-pgadmin`); differ container non-zero exit; unparseable `--json-diff` output | +| `1` | `--project-ref` set with a resolved target other than linked; (in explicit mode) `--project-ref` with `--linked` unchanged and neither `--from` nor `--to` being `linked`; `--project-ref` combined with `--use-pg-schema` (see Notes) | ## Output @@ -75,24 +131,146 @@ explicit `--output` is set. ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope -`{ diff, file, schemas, engine, dropStatements }` instead of the raw SQL. +`{ diff, file, files, schemas, engine, dropStatements }` instead of the raw SQL. + +### `--use-pgadmin` (CLI-1968) + +- **Status lines go to STDOUT in text mode, not stderr** — Go's NON-TTY `fakeProgram` prints + `StatusMsg` via `fmt.Println` (`tea.go:57-70`), unlike the migra/pg-delta path's + `fmt.Fprintln(os.Stderr, …)` diagnostics. So `db diff --use-pgadmin > out.sql` captures them, + exactly as Go's non-TTY invocation does — **this claim holds for non-TTY invocations only**; + on a real terminal Go instead runs the `bubbletea` renderer, repainting ephemeral frames + rather than appending printed lines, which this port has no equivalent for and does not + target. In `json`/`stream-json` mode these are diagnostics, not payload, so they redirect to + STDERR instead — see below. +- **Progress-streaming UX delta**: Go live-streams progress as the differ emits it — + `NewDiffStream` pipes the container's stderr through an `io.Pipe`, scanned by a goroutine + WHILE the container is still running, so a status line prints the instant its underlying + stderr line arrives. This port batches instead: `LegacyDockerRun.runStream` only exposes an + `onStdout` hook (no `onStderr` equivalent), so this port buffers each run's stderr via + `runCapture` and only filters/emits its status lines once that run's container has already + exited — one status BATCH per `--schema` run, not a continuous stream. That batch is + processed and emitted BEFORE this port's own exit-code check, matching Go's stderr goroutine + running concurrently with (i.e. ahead of) the container's own exit — so a run that goes on to + exit non-zero still has its own captured statuses printed first, not dropped. See + `legacy-pgadmin-diff.ts`'s own doc comment on `legacyDiffSchemaPgAdmin` for the full + rationale and the possible follow-up (adding an `onStderr` hook to `runStream`). +- Order: `Creating shadow database...` → shadow setup diagnostics (stderr, shared + with the migra/pg-delta path) → `Diffing local database with current migrations...` + → per-schema `Diffing schema: ` + filtered progress statuses → the SQL / + `No schema changes found` / the `--file` write warning. +- **No** `Finished supabase db diff on branch .` line and **no** drop-statement + warning — both live in Go's `diff.Run` (`diff.go:38-47`), which the pgadmin path + bypasses entirely. +- `json`/`stream-json`: status lines redirect to STDERR instead of STDOUT (stdout stays + payload-only, CLI-1546); envelope + `{ diff, file, files, schemas, engine: "pgadmin", dropStatements: [] }` — + `dropStatements` is always empty because Go performs no drop scan on this engine. ## Notes / Delegation - `--use-migra` (default), `--use-pgadmin`, `--use-pg-schema`, `--use-pg-delta` are a mutually-exclusive engine group; `--db-url` / `--linked` / `--local` are a mutually-exclusive target group (default `--local`). -- `--use-pgadmin` and `--use-pg-schema` rebuild the argv and exec the bundled Go - binary (their side effects are Go's); the Go child's telemetry is disabled so the - single `cli_command_executed` event comes from this TS command. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` + performs (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) — unlike + `SUPABASE_PROJECT_ID`, it does not affect the shadow container's project + id/labels. It never implies `--linked`: passing it with a resolved + `--local`/`--db-url` target (native mode) is a hard error, as is a plain + `--project-ref` with no `--from`/`--to` (defaults to `--local`). Two + exceptions apply in explicit mode: `--from linked` / `--to linked` resolves a + linked ref without any target flag at all, so the guard does not fire there; + and a changed `--linked` (even `--linked=false`) genuinely consumes + `--project-ref` via the preflight, so the guard does not fire whenever + `--linked` was explicitly set either. It still fires for e.g. `--from local +--to migrations --project-ref X` (explicit mode, `--linked` unchanged, and + neither side `linked`), where the flag would otherwise go silently unused + (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent env + var simply leaves unused on a non-linked target). `--use-pgadmin --linked` + honors the flag like every other native engine (CLI-1968 — same target + resolve); `--use-pg-schema` rejects it up front, since the delegated Go child + never registered `--project-ref` and the flag would otherwise be silently + dropped. +- `--use-pg-schema` rebuilds the argv and exec's the bundled Go binary (its side + effects are Go's); the Go child's telemetry is disabled so the single + `cli_command_executed` event comes from this TS command. - Explicit `--from`/`--to` mode always uses pg-delta and writes to `--output` (or stdout). - The explicit `migrations` target resolves natively (CLI-1959): a bare migrations-content hash cache lookup (`/supabase/.temp/pgdelta/catalog-local-migrations--.json`, - shared with `db push`'s post-apply cache write), and on a miss, the existing - `db __shadow --mode diff` seam call (unchanged — still Go, out of scope for - CLI-1959) plus a native pg-delta catalog export. No hidden Go + shared with `db push`'s post-apply cache write), and on a miss, a natively-provisioned + shadow database (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, + no longer the `db __shadow` seam) plus a native pg-delta catalog export. No hidden Go `db schema declarative __catalog` subprocess runs for this path any more. +### `--use-pgadmin` parity quirks and deliberate divergence (CLI-1968) + +- `source`/`target` are INVERTED relative to the migra/pg-delta path: `source` is the + USER'S db, `target` is the SHADOW (Go's `pgadmin.go:85-86`). +- The shadow `target` URL is a raw `Sprintf`, not `legacyToPostgresURL`: hardcoded + `127.0.0.1` and `postgres:postgres`, ignoring `SUPABASE_SERVICES_HOSTNAME`/`[db] password`. +- `AssertSupabaseDbIsRunning` runs for `--linked`/`--db-url` too, and AFTER config load + + target resolution — every other engine on this command never runs this check at all. +- The `NOTE: …DESKTOP mode.` prefix (`supabase/pgadmin4#24`) is trimmed from the front of + EACH run's own stdout independently (each run is parsed on its own — see the "Deliberate + divergence" entry below), not just the front of a single, first run's buffer. +- The differ's stderr is filtered by the progress-line regex and non-matching lines are + dropped, so a differ failure surfaces only `error running container: exit ` — even + under `--debug`. +- `(.*)([0-9]{2,3})%` greedy-submatch quirk (e.g. `Diffing 100%` → status `Diffing 1`, + progress silently dropped). +- Internal-schema filtering is exact string membership, not glob expansion — a + `group_name`/`source_schema_name` of literal `pg_catalog` is KEPT, since only the + literal string `"pg_*"` (not a real glob) is in the list. +- JSON-parse error text cannot byte-match Go's `encoding/json` message; this port + prefixes it with the stable string `failed to parse schema diff output:`. + +**Deliberate divergence, not bug-for-bug parity:** Go's `DiffStream` (`container_output.go:79,87`) +declares `Stdout()`/`Collect()` on a VALUE receiver, so every call operates on its OWN copy of +the struct — the differ's stdout, written via one call's `Stdout()`, is never visible to a +LATER `Collect()` call's own (separate, always-empty) copy. The practical effect: the real Go +CLI's `--use-pgadmin` ALWAYS reports "No schema changes found" (exit 0) — it never writes a +migration file and never hits a JSON-parse error, regardless of the differ's actual output or +`--schema` count. (`Stderr()`/progress is unaffected — `c.w` is a `*io.PipeWriter`, a reference +type shared across copies.) This port implements the INTENDED algorithm instead: every run's +stdout is genuinely parsed and its kept DDL entries are aggregated into one final diff, which +is what `NewDiffStream`'s own design clearly intends — so wherever the real Go binary silently +discards a genuine diff, this port produces it (or a real per-run parse failure). + +Getting there took two rounds. The first, literal-minded reading of "one shared buffer" glued +every run's raw stdout BYTES together before parsing once — which is neither Go-as-shipped +(always an empty, successful diff, since `Collect()` never sees real bytes at all) nor +Go-as-written-but-unreachable (which, had `Collect()` ever actually run against accumulated +bytes, would itself have failed to parse `>=2` concatenated JSON arrays the exact same way). +Both of those are nonsensical outcomes nobody would design for, so round two completes the +INTENDED algorithm instead of literally reproducing either one: each run's OWN stdout is +parsed independently (`legacyParsePgAdminDiffEntries`, trimming that run's own DESKTOP-mode +NOTE prefix off its own buffer), and every run's filtered DDLs are aggregated into a single +list before the header is rendered once (`legacyRenderPgAdminDiff`). A multi-`--schema` diff +where every run's own `--json-diff` output is independently well-formed now succeeds — Go's +own `[]DiffEntry`-per-run JSON shape was never designed to be concatenated and parsed as one +document, so a per-run parse is the evident intent, not literal buffer-sharing. A genuinely +malformed run (or a Go-parity-preserving concatenation WITHIN a single run's own buffer — see +`legacyProcessPgAdminDiffOutput`'s own doc comment, still exercised by this file's unit tests) +still fails with `invalid_output`, same as before. + +**Network reachability (settled, static ruling):** with the differ container on the project's +default Docker network (the compose bridge `supabase_network_`), `127.0.0.1` inside +it resolves to the differ's OWN loopback — so both the hardcoded shadow `target` and a local +`source` (`GetHostname()` → `127.0.0.1`) are unreachable from inside the differ container, in +BOTH implementations: identical argv, identical network, and identical hosts produce an +identical (unreachable) outcome on either binary, so this needs no live spot-check to settle. +`--network-id host` alone does NOT rescue the golden path either (see +`diff.live.test.ts`): the network override applies to every container the command starts, +including the shadow, whose `54320→5432` port publication is discarded under host +networking — so the hardcoded `target` at `127.0.0.1:` stays unreachable +while only the host-published `source` becomes reachable. Reaching both databases requires +`--network-id host` **plus** a `[db] shadow_port = 5432` config override — a contrived +setup no default user runs (identically contrived on the Go binary). That configuration is +where the `DiffStream` divergence above becomes user-visible: the real Go CLI still +reports "No schema changes found" no matter what the (now reachable) differ actually finds, +while this port reports the real diff. + ### `--use-pg-schema` is deprecated (CLI-1960) — keep-in-Go exception `--use-pg-schema` wraps the in-process Go library `stripe/pg-schema-diff` @@ -100,8 +278,8 @@ Progress strings still go to stderr; stdout carries a single structured envelope than a pending port because: - it runs **in-process** inside the Go binary, with no container/binary boundary - to re-invoke from TS — unlike `--use-pgadmin`, which shells out to a - container/binary path that could in principle be called from TS; + to re-invoke from TS — unlike `--use-pgadmin` (now native, CLI-1968), which shelled + out to a container/binary path that could in principle be called from TS; - no TS binding and no WASM build of the library exists, or is reasonably buildable, within the M9 "Final Cleanup — Go Removal" milestone's scope; - this specific exception (`db diff --use-pg-schema`) was pre-named when the M9 @@ -109,10 +287,10 @@ than a pending port because: The decision record is Linear issue CLI-1960 and the pull request that introduced this deprecation notice; re-open only if a TS/WASM binding for -`stripe/pg-schema-diff` ships. It will become the CLI's sole remaining Go delegation -once `--use-pgadmin`'s delegation, the `db __shadow` seam (the sibling `db -__db-bootstrap` seam was already removed outright by CLI-1955), and the rest of -the M9 milestone's in-flight issues are done — it is not there yet. +`stripe/pg-schema-diff` ships. It **is** the CLI's sole remaining Go delegation on +`db diff` now that `--use-pgadmin`'s delegation is gone (CLI-1968) — the sibling +`db __db-bootstrap` seam was already removed outright by CLI-1955, and the +`db __shadow` seam by CLI-1956. Given that, the flag is now deprecated rather than ported: diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts index 0aa0d9b1ff..d89dd8963e 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -67,6 +67,11 @@ const config = { Flag.withDescription("Diffs local migration files against the local database."), Flag.optional, ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), file: Flag.string("file").pipe( Flag.withAlias("f"), Flag.withDescription("Saves schema diff to a new migration file."), @@ -105,9 +110,13 @@ export const legacyDbDiffCommand = Command.make("diff", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, file: flags.file, schema: flags.schema, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. aliases: { o: "output", f: "file", s: "schema" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts index b7c6dff954..fb6cab412d 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbDiffTargetFlagsError extends Data.TaggedError("LegacyDbDiffTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Conflicting diff-engine flags. Reproduces cobra's @@ -18,7 +27,11 @@ export class LegacyDbDiffEngineConflictError extends Data.TaggedError( "LegacyDbDiffEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Only one of `--from` / `--to` was set in explicit diff mode. Byte-matches Go's @@ -29,7 +42,11 @@ export class LegacyDbDiffExplicitFlagsError extends Data.TaggedError( "LegacyDbDiffExplicitFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * An explicit `--from`/`--to` ref was neither `local`/`linked`/`migrations` nor a @@ -41,7 +58,11 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( "LegacyDbDiffUnknownTargetError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the diff output failed — a `--file` migration, or an explicit-mode @@ -49,4 +70,88 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( */ export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * The local database container is not running, or inspecting it failed — + * Go's `utils.ErrNotRunning` / `"failed to inspect service: %w"` via + * `AssertSupabaseDbIsRunning` (`apps/cli-go/internal/db/diff/pgadmin.go:51`, + * `internal/utils/misc.go:151-166`). Unlike every other engine on this command, + * `--use-pgadmin` runs this check even for `--linked`/`--db-url` — see + * `diff.handler.ts`'s pgadmin branch. + */ +export class LegacyDbDiffDbNotRunningError extends Data.TaggedError( + "LegacyDbDiffDbNotRunningError", +)<{ + readonly message: string; + readonly daemonDown?: boolean; + readonly suggestion?: string; +}> { + // Must stay character-identical to `LegacyLocalDbRunningError`'s classification + // (`legacy-db-bootstrap`'s equivalent local-db-not-running check) — the two are + // deliberately duplicated for this command's own `AssertSupabaseDbIsRunning` + // parity target, not shared, so keep them in sync by hand. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.daemonDown === true + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : actionability.startStack; // same preset `reset-local-database.ts` uses + } +} + +/** + * Classic "assertNever" exhaustiveness helper: with every literal of + * `LegacyDbDiffPgAdminError["reason"]` handled by its own `case` below, `reason` + * narrows to `never` by the time it reaches this call — so a FUTURE reason added + * to the union without a matching `case` is a compile error here (its residual + * type inside `default:` would no longer be `never`), not a silently-absorbed + * classification. The parameter is intentionally unused at runtime: the drift + * guard (`error-actionability-coverage.unit.test.ts`) evaluates every getter + * against a field-less probe (`Object.create(prototype)`, no constructor args), + * so `this.reason` is genuinely runtime-`undefined` there, bypassing the type + * system entirely — this must still degrade to a valid declaration rather than + * `undefined`/a crash, so it returns the SAME fallback as the "differ" case. + */ +function legacyPgAdminUnreachableReason(_reason: never): CliErrorActionabilityDeclaration { + return actionability.dbFinding; +} + +/** + * The pgAdmin differ container failed to run, or its `--json-diff` output could + * not be parsed. `reason` is a closed union set at the docker/parse boundary — + * never inferred from `message` text. + */ +export class LegacyDbDiffPgAdminError extends Data.TaggedError("LegacyDbDiffPgAdminError")<{ + readonly message: string; + readonly reason: + | "differ" + | "invalid_output" + | "docker_daemon" + | "registry_pull" + | "image_inspect"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "docker_daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "registry_pull": + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + // Malformed pinned-differ wire output is an internal contract violation, not a + // user input mistake — same precedent as pg-delta's own malformed-subprocess- + // output branch (`legacy-pgdelta.apply.ts`'s `"output_parse"` case). + case "invalid_output": + return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; + case "image_inspect": + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + // "differ": a failing container is the user's own schema/connection, matching + // `LegacyMigraDiffError`'s default classification for the equivalent engine failure. + case "differ": + return actionability.dbFinding; + default: + return legacyPgAdminUnreachableReason(this.reason); + } + } +} 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 47689ac6d4..bbde7fbd41 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -1,19 +1,39 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyApplyProjectEnv, + legacyReadDbToml, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.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 type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.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"; +import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { + legacyCreateShadowDatabase, + legacyMigrateShadowDatabase, + legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, +} from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -28,17 +48,25 @@ import { import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { legacyResolveMigrationsCatalogRef } from "../../../shared/legacy-pgdelta.cache.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; -import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../../../shared/legacy-pgdelta.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; +import { + type LegacyPgDeltaContext, + legacyDiffPgDelta, + legacyExportCatalogPgDelta, + legacyIsPgDeltaDebugEnabled, + legacyResolvePgDeltaProjectId, +} from "../../../shared/legacy-pgdelta.ts"; +import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; import { + LegacyDbDiffDbNotRunningError, LegacyDbDiffEngineConflictError, LegacyDbDiffExplicitFlagsError, LegacyDbDiffTargetFlagsError, LegacyDbDiffUnknownTargetError, LegacyDbDiffWriteError, } from "./diff.errors.ts"; +import { legacyDiffSchemaPgAdmin } from "./legacy-pgadmin-diff.ts"; // Go's `warnDiff` (`apps/cli-go/internal/db/diff/pgadmin.go:17`), shown after a // `--file` migration is written. @@ -56,18 +84,17 @@ Run ${legacyAqua("supabase db reset")} to verify that the new migration does not const warnPgSchemaDeprecated = `${legacyYellow("WARNING:")} "--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.`; /** - * Rebuilds the `db diff` argv for the pgAdmin / pg-schema delegate path. Flags - * stay flags (the Go-proxy channel-parity rule). The explicit `--from`/`--to` and - * engine mutex are already handled before this runs, so it just forwards the - * engine flag that won plus the target / schema / file flags the user passed. + * Rebuilds the `db diff` argv for the `--use-pg-schema` delegate path — the CLI's + * sole remaining Go delegation on this command (CLI-1960's keep-in-Go exception: + * the in-process `stripe/pg-schema-diff` library has no TS/container equivalent; + * `--use-pgadmin` is native as of CLI-1968). Flags stay flags (the Go-proxy + * channel-parity rule). The explicit `--from`/`--to` and engine mutex are already + * handled before this runs, and the mutex guarantees `--use-migra`/`--use-pgadmin`/ + * `--use-pg-delta` are all unset whenever this is reached, so it just forwards + * `--use-pg-schema` plus the target / schema / file flags the user passed. */ -const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { - const args = ["db", "diff"]; - const pushBool = (name: string, value: Option.Option) => { - // Engine flags act on their value, so only an explicitly-true one is - // meaningful; `Some(false)` equals the cobra default. - if (Option.isSome(value) && value.value) args.push(`--${name}`); - }; +const rebuildPgSchemaDelegateArgs = (flags: LegacyDbDiffFlags): Array => { + const args = ["db", "diff", "--use-pg-schema"]; const pushTarget = (name: string, value: Option.Option) => { // Target flags (linked/local) are *selectors*: Go's ParseDatabaseConfig keys // off `flag.Changed` before the value (`internal/utils/flags/db_url.go`), so a @@ -77,10 +104,6 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { // different default target than the one the native path resolved. if (Option.isSome(value)) args.push(value.value ? `--${name}` : `--${name}=false`); }; - pushBool("use-migra", flags.useMigra); - pushBool("use-pgadmin", flags.usePgAdmin); - pushBool("use-pg-schema", flags.usePgSchema); - pushBool("use-pg-delta", flags.usePgDelta); if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); pushTarget("linked", flags.linked); pushTarget("local", flags.local); @@ -95,7 +118,6 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: LegacyDbDiffFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; - const seam = yield* LegacyDeclarativeSeam; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -103,6 +125,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}) — Go's `ensureProjectGroupsCached` (cmd/root.go:214). @@ -160,6 +183,31 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }), ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded — see push.handler.ts's identical guard for the full TS-only + // rationale. Two exceptions in explicit mode: (1) `--from linked` / + // `--to linked` resolves a linked ref (via `resolveRef`'s "linked" case + // below) without any `--linked`/target flag at all, so the guard must + // NOT fire there — only when NEITHER side is the literal ref "linked" + // (e.g. plain `--project-ref X`, or `--from local --to migrations + // --project-ref X`, where the flag genuinely goes unused). (2) a changed + // `--linked` (even `--linked=false`) genuinely consumes `--project-ref` + // via the preflight below, whose `preflightConnType` keys off + // `Option.isSome(flags.linked)` regardless of the boolean's value — so + // the guard must not fire whenever `--linked` was explicitly set. + if ( + Option.isSome(flags.projectRef) && + Option.isNone(flags.linked) && + legacyClassifyExplicitRef(from) !== "linked" && + legacyClassifyExplicitRef(to) !== "linked" + ) { + return yield* Effect.fail( + new LegacyDbDiffTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", + }), + ); + } // `mergedLinkedRef` tracks the linked ref resolved so far (preflight or // cascade) so the config read below + a later `migrations` catalog export // merge the matching `[remotes.]` override. Undefined until a linked ref @@ -184,6 +232,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy connType: preflightConnType, dnsResolver, password: Option.none(), + linkedProjectRef: flags.projectRef, }); if (preflightConnType === "linked") { const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); @@ -221,6 +270,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy connType: "linked", dnsResolver, password: Option.none(), + linkedProjectRef: flags.projectRef, }); const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); if (ref2 !== undefined) { @@ -231,16 +281,24 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return legacyToPostgresURL(resolved.conn); } case "migrations": { - // Native (CLI-1959): mirrors Go's `resolveMigrationsCatalogRef` - // (`explicit.go:88-126`) exactly — see `legacyResolveMigrationsCatalogRef`'s - // doc comment. The pg-delta context is built from whatever `cfg` is - // current at this point in the cascade (possibly re-merged by an - // earlier "linked" ref above), matching Go's stateful pre-run. + // Native (CLI-1959 cache mechanics; CLI-1956 native shadow provisioning + // — see `legacyResolveMigrationsCatalogRef`'s doc comment): mirrors Go's + // `resolveMigrationsCatalogRef` (`explicit.go:88-126`) exactly. The + // pg-delta context AND the shadow's own container spec (`cfg` below, + // passed through to `legacyResolveMigrationsCatalogRef`'s `toml` + // parameter) are built from whatever `cfg` is current at this point in + // the cascade (possibly re-merged by an earlier "linked" ref above), + // matching Go's stateful pre-run. const migrationsCtx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + projectId: legacyResolvePgDeltaProjectId( + cliConfig.projectId, + cfg, + cliConfig.workdir, + ), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; // Pass the linked ref only if one resolved earlier in the cascade, so // the shadow merges the same remote override Go's in-process @@ -250,6 +308,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy fs, path, migrationsCtx, + cfg, mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}, ); } @@ -264,10 +323,11 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const sourceRef = yield* resolveRef(from); const targetRef = yield* resolveRef(to); const explicitCtx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, cfg, cliConfig.workdir), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; const result = yield* legacyDiffPgDelta(explicitCtx, { sourceRef, @@ -314,46 +374,59 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return; } - // pgAdmin / pg-schema delegate to the bundled Go binary (Go's `RunPgAdmin` / - // `DiffPgSchema` are not ported). They are explicit engine selections that do - // not depend on config, so they short-circuit before the target resolve. - // Disable the child's telemetry so the single `cli_command_executed` event - // comes from this TS command's instrumentation. + // `--use-pg-schema` delegates to the bundled Go binary (Go's `DiffPgSchema` is not + // ported — CLI-1960 keep-in-Go exception). It is an explicit engine selection that + // does not depend on config, so it short-circuits before the target resolve. + // Disable the child's telemetry so the single `cli_command_executed` event comes + // from this TS command's instrumentation. `--use-pgadmin` no longer short-circuits + // here (CLI-1968): unlike `--use-pg-schema`, Go resolves the target in the root + // `PersistentPreRunE` *before* `RunPgAdmin` ever runs (`cmd/db.go:110` → + // `cmd/db.go:115`), so config validation, the `[remotes.]` merge print, and + // the linked temp-role mint all still happen for `--use-pgadmin` — see the native + // pgadmin branch further down, which reuses this function's own target resolve. const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); const usePgSchema = Option.getOrElse(flags.usePgSchema, () => false); - // Runs the delegated engine via the Go binary. In machine-output mode the - // child's stdout is captured and re-emitted as a structured envelope, so - // scripted callers get valid JSON instead of the Go child's raw SQL on stdout - // (CLI-1546: stdout is payload-only in machine mode). The delegated child owns - // any `--file` write, so the written migration path isn't introspectable here - // (reported as `file: null`). - const delegateDiff = (engine: "pgadmin" | "pg-schema") => - Effect.gen(function* () { - const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - if (output.format !== "text") { - const captured = yield* proxy.execCapture(rebuildDelegateArgs(flags), { env }); - yield* output.success("Diff complete.", { - diff: captured, - file: null, - schemas: flags.schema, - engine, - }); - return; - } - yield* proxy.exec(rebuildDelegateArgs(flags), { env }); - }); - if (usePgAdmin) { - yield* delegateDiff("pgadmin"); - return; + // The pg-schema engine delegates to the bundled Go binary, whose `db diff` + // never registered `--project-ref` — `rebuildPgSchemaDelegateArgs` cannot + // forward it, so the flag would be silently dropped and the child would diff + // the workdir's own linked ref: the exact wrong-project hazard the guards + // below exist to prevent. Fail up front instead. (`--use-pgadmin` is native + // as of CLI-1968 and honors `--project-ref` through this function's own + // target resolve, like every other native engine.) + if (usePgSchema && Option.isSome(flags.projectRef)) { + return yield* Effect.fail( + new LegacyDbDiffTargetFlagsError({ + message: "--project-ref is not supported with --use-pg-schema", + }), + ); } if (usePgSchema) { // CLI-1960: TS-only deprecation notice, printed before delegating (in both // text and machine output modes — diagnostics stay stderr-only per CLI-1546). // The delegated Go `db diff --use-pg-schema` still prints its own experimental // warning itself in its RunE (`cmd/db.go`); this is additive, not a - // replacement, so don't drop it. Mirror the --use-pgadmin branch above. + // replacement, so don't drop it. yield* output.raw(`${warnPgSchemaDeprecated}\n`, "stderr"); - yield* delegateDiff("pg-schema"); + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + // In machine-output mode the child's stdout is captured and re-emitted as a + // structured envelope, so scripted callers get valid JSON instead of the Go + // child's raw SQL on stdout (CLI-1546: stdout is payload-only in machine mode). + // The delegated child owns any `--file` write, so the written migration path + // isn't introspectable here (reported as `file: null`). + if (output.format !== "text") { + const captured = yield* proxy.execCapture(rebuildPgSchemaDelegateArgs(flags), { + env, + suppressChildTelemetry: true, + }); + yield* output.success("Diff complete.", { + diff: captured, + file: null, + schemas: flags.schema, + engine: "pg-schema", + }); + return; + } + yield* proxy.exec(rebuildPgSchemaDelegateArgs(flags), { env, suppressChildTelemetry: true }); return; } @@ -363,30 +436,125 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy : Option.isSome(flags.linked) ? "linked" : "local"; + + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. (Explicit `--from`/`--to` mode has its own + // earlier guard, with the `--from/--to linked` exception; this native path + // never reaches here when explicit mode ran.) + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyDbDiffTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + + // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN + // reads the `[remotes.]`-merged config (`LoadConfig`, which prints "Loading config + // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — + // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection + // work (TCP probe / temp-role mint over the Management API, `flags/db_url.go:87-97`). + // Pre-load the ref and read config here, before `resolver.resolve()` below, so the + // override print (and the merged-config validation) happen in that same order. + // Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` failure + // (bad password, unreachable host, network-ban lookup, …) left the user never knowing + // which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl, pull.handler.ts's + // identical fix). The default `db diff` target is local/db-url, which never merges a + // remote block, so only the linked path pre-resolves a ref. + let linkedRef: string | undefined; + if (connType === "linked") { + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); + // Cache the ref the moment it's known, not after `cfg`/`localInputs` below (both + // fallible) resolve — Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the + // GLOBAL `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored (`cmd/root.go:169-175` never checks `err` before calling it) — so Go caches a + // resolved ref even when a LATER step (config validation, connection, the diff itself) + // fails. Setting `linkedRefForCache` here, right after the ref resolves, reproduces that + // instead of only doing so after `cfg`/`localInputs`/`resolver.resolve()` all succeed. + linkedRefForCache = linkedRef; + } + const cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader the pgAdmin differ's (and the migra/pg-delta + // shadow's) own image resolver falls back to, reverted when this scope closes. + // Go's `loadNestedEnv` `os.Setenv`s the project `.env` during config load + // (`pkg/config/config.go:788-791`), before `GetRegistry()` + // (`internal/utils/docker.go:221-231,244-246`) ever reads it — unlike every + // other native engine on this command, `db diff` never applied project env + // until now. + yield* legacyApplyProjectEnv(cfg.projectEnv); + if (cfg.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${cfg.appliedRemote}]\n`, "stderr"); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Built BEFORE `resolver.resolve()` below, not just before the "Creating shadow + // database..." banner: this call performs a SECOND config load + // (`legacyLoadLocalProjectContext`'s `@supabase/config` read, distinct from `cfg` + // above) and its own validation (e.g. enabled API TLS's cert/key files, read here — + // `cfg` above only tracks their dotted keys for remote-override gating, it never reads + // the files), which can print a warning (e.g. deprecated `[inbucket]`) or fail + // outright. Go's `flags.LoadConfig` does ALL config loading (including any warnings) + // once, in the root `PersistentPreRunE`, strictly before `NewDbConfigWithPassword` — + // `resolver.resolve()`'s own parity target, see that call's doc comment above — or + // `DiffDatabase` ever prints "Creating shadow database..." (`internal/db/diff/ + // diff.go:212`) run. Previously this validation ran AFTER `resolver.resolve()` (a + // linked target's temp-role mint over the Management API), so a config broken only in + // a field this build reads surfaced after that network side effect instead of before + // it, unlike Go (review: PRRT_kwDOErm0O86XIUK1, pull.handler.ts's identical fix). Only + // the actual Docker-image resolution below (`resolvePostgresImage`, lazy until this + // point) is the provisioning work the banner itself announces. + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + // So the shadow's own container spec (image/JWT secret/root key/db.settings/service + // enabled-for-setup flags) reflects the matching `[remotes.]` override too, same + // as `cfg` above (`legacyReadDbToml(..., linkedRef)`) — Go remote-merges the WHOLE + // config uniformly on the linked path (`LoadConfig` seeds `flags.ProjectRef` before + // every field read). + connType === "linked" ? linkedRef : undefined, + // `cfg`'s OWN remote-override-key tracking (same matched block) — so a remote-set + // bootstrap field (e.g. `db.major_version`) isn't re-overridden by a conflicting + // `SUPABASE_*` env var when deriving the shadow's container spec. + cfg.remoteOverrideKeys, + ); + const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, password: Option.none(), + linkedProjectRef: flags.projectRef, }); - const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + } if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = legacyToPostgresURL(resolved.conn); - - // Read config with the resolved linked ref so a matching `[remotes.]` - // block merges before the engine/format/runtime are read — Go loads config - // after `LoadProjectRef` on the linked path (`flags/db_url.go:87-97`). The - // default `db diff` target is local/db-url, which never merges a remote block, - // so it reads the base config here (Go's local/direct `LoadConfig`, no ref). - const cfg = - connType === "linked" && linkedRef !== undefined - ? yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef) - : yield* legacyReadDbToml(fs, path, cliConfig.workdir); const ctx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // `legacyResolvePgDeltaProjectId` mirrors Go's `UpdateDockerIds`, which derives + // `EdgeRuntimeId` from the ALREADY-sanitized `Config.ProjectId` singleton + // (`internal/utils/config.go:57-76`, sanitized once by `Config.Validate` at + // config-load time): `SUPABASE_PROJECT_ID` env override wins, then config.toml's + // `project_id`, then the workdir basename fallback (`pkg/config/config.go:563-570`), + // with the matched `[remotes.]` block's own `project_id` (`cfg.projectId`, + // already gated on `remoteOverrideKeys` by `legacyReadDbToml`) suppressing the raw env + // argument on the linked path — see that helper's own doc comment (review: + // PRRT_kwDOErm0O86XAlIw, PRRT_kwDOErm0O86XI1w8). + projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, cfg, cliConfig.workdir), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); @@ -404,63 +572,233 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy pgDeltaDefault, }); - yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", - targetLocal: resolved.isLocal, - usePgDelta: useDelta, - schema: flags.schema, - // Linked path only: the shadow merges the same `[remotes.]` override - // the engine/format read above (Go builds the shadow from the remote-merged - // config). Default `db diff` is local, which never merges a remote block. - projectRef: connType === "linked" ? linkedRef : undefined, + // pgAdmin's own text-mode status lines go to STDOUT, not stderr: only Go's NON-TTY + // `fakeProgram` prints StatusMsg via `fmt.Println` (`tea.go:57-70`) — on a TTY Go instead + // runs the real `bubbletea` renderer (ephemeral repainted frames, with no TS equivalent + // and not a parity target; non-TTY is) — unlike the migra/pg-delta path's + // `fmt.Fprintln(os.Stderr, …)` diagnostics below. In machine output modes (json/stream-json) + // these are diagnostics, not payload, so they redirect to STDERR instead of being dropped — + // the repo's stdout-payload-only invariant (CLI-1546), matching the sibling migra/pg-delta + // banner below, which keeps its own banner on stderr in every mode. + const emitStatus = (line: string) => + output.raw(`${line}\n`, output.format === "text" ? "stdout" : "stderr"); + + // Shared by both branches below (pgAdmin's `shadowBase` and the migra/pg-delta + // `shadowInput`'s own spread) — resolving the image is the actual provisioning work each + // branch's own "Creating shadow database..." banner announces, so every call site still + // emits its banner FIRST and only then invokes this (preserved, verified-parity ordering). + const resolveShadowRunInput = Effect.fnUntraced(function* () { + const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + return legacyShadowRunInputFromLocalContainerInputs( + localInputs, + resolvedShadowImage, + cfg, + fs, + path, + ); }); - const diffResult = yield* Effect.gen(function* () { - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", + let diffResult: { + readonly sql: string; + readonly files: ReadonlyArray<{ readonly name: string; readonly sql: string }> | undefined; + }; + if (usePgAdmin) { + // Go's `RunPgAdmin` (`pgadmin.go:49-63`): `AssertSupabaseDbIsRunning` runs AFTER the + // config load + target resolve above, and — unlike every other engine on this command — + // runs for `--linked`/`--db-url` too, not just the local target. `ctx.projectId` + // (already remote-merge-resolved, see its own doc comment above), not the raw + // `cliConfig.projectId` env reader: Go's `UpdateDockerIds` runs AFTER the linked + // remote merge, so `DbId` derives from the resolved `Config.ProjectId` singleton, + // not the ungated `SUPABASE_PROJECT_ID` env var (`config_path.go:10-15`, + // `pkg/config/config.go:604-610`, `internal/utils/config.go:57-65`). + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + cliConfig.workdir, + ctx.projectId, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDbDiffDbNotRunningError({ + message: cause.message, + daemonDown: cause.daemonDown, + suggestion: cause.suggestion, + }), + ), ); - if (useDelta) { - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, - schema: flags.schema, - formatOptions, - }); - // Keep the per-unit plan files so a multi-unit plan can be written as one - // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened - // join for stdout review + machine payloads. - return { sql: result.sql, files: result.files }; + if (!running) { + return yield* Effect.fail( + new LegacyDbDiffDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, - schema: flags.schema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - // The migra engine has no execution-aware plan units, so it always writes a - // single migration file (Go's `SaveDiff` single-file path). - return { sql, files: undefined }; - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + yield* emitStatus("Creating shadow database..."); + const shadowBase = yield* resolveShadowRunInput(); + const shadowConnConfig: LegacyPgConnInput = { + host: shadowBase.hostname, + port: shadowBase.shadowPort, + user: "postgres", + password: shadowBase.password, + database: "postgres", + }; + // Same `acquireUseRelease` rationale as the migra/pg-delta branch below: `acquire` is + // ONLY container creation (uninterruptible, matching Go's `defer DockerRemove` + // immediately after a successful `DockerStart`); the health-wait + migrate + diff run + // inside the interruptible `use` phase, mirroring Go's own single cancellable `ctx` + // (review: PRRT_kwDOErm0O86XMrID). `acquire` here is ONLY `legacyCreateShadowDatabase` — + // NOT `legacyPrepareShadowSource` (no `--target-local` declarative-schema branch, no + // `targetUrlOverride`, no pg-delta apply: Go's `pgadmin.go` calls `MigrateShadowDatabase` + // directly, never `PrepareShadowSource`). + const sql = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowBase), + (handle) => + Effect.gen(function* () { + yield* legacyWaitForHealthyServices(spawner, [handle.containerId], { + timeoutSeconds: shadowBase.healthTimeoutSeconds, + }); + yield* legacyMigrateShadowDatabase(spawner, { + fs, + path, + workdir: cliConfig.workdir, + projectId: shadowBase.projectId, + container: handle.containerId, + networkId: shadowBase.networkId, + connConfig: shadowConnConfig, + setup: shadowBase.setup, + }); + yield* emitStatus("Diffing local database with current migrations..."); + return yield* legacyDiffSchemaPgAdmin({ + // Go's `source`/`target` are INVERTED relative to the migra/pg-delta path + // below: `source` is the USER'S db, `target` is the SHADOW (`pgadmin.go:85-86`). + source: targetUrl, + // A raw `Sprintf`, not `legacyToPostgresURL` — Go hardcodes `127.0.0.1` and + // `postgres:postgres`, ignoring `SUPABASE_SERVICES_HOSTNAME`/`[db] password` + // (`pgadmin.go:86`, deliberate Go parity, not a bug to fix). + target: `postgresql://postgres:postgres@127.0.0.1:${shadowBase.shadowPort}/postgres`, + schema: flags.schema, + projectId: shadowBase.projectId, + networkId: shadowBase.networkId, + extraHosts: shadowBase.extraHosts, + emitStatus, + }); + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); + diffResult = { sql, files: undefined }; + } else { + yield* output.raw("Creating shadow database...\n", "stderr"); + const shadowInput = { + ...(yield* resolveShadowRunInput()), + targetLocal: resolved.isLocal, + usePgDelta: useDelta, + // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: + // the latter is the raw `@supabase/config` field, which never applies + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no viper-`AutomaticEnv` + // equivalent) — `cfg` above (`legacyReadDbToml`) already resolves that env override the + // same way Go's `utils.Config.Db.Migrations.SchemaPaths` does (review: PRRT_kwDOErm0O86XDr4S). + schemaPaths: cfg.schemaPathPatterns, + pgDelta: cfg.pgDelta, + ctx, + }; + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))`: the latter shape leaves a real gap + // between the shadow's successful creation and the `Effect.ensuring` finalizer actually + // being attached — a fiber interrupt landing in that gap (between the two `yield*` + // statements) would skip `legacyRemoveShadowDatabase` entirely, leaking the live shadow + // container and leaving the shadow port occupied. `acquireUseRelease` closes that: + // `acquire` runs inside an `uninterruptibleMask`, and the release finalizer is registered + // in the SAME uninterruptible continuation `acquire` resolves into, matching Go's `defer + // DockerRemove` immediately after successful creation (review: PRRT_kwDOErm0O86XDr4Y). + // This does NOT make removal unconditional, though — see `legacyCreateShadowDatabase`'s + // own doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity leak + // window when `acquire` itself fails partway through (a `docker create` success followed + // by a `docker cp`/`docker start` failure). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT the + // health-wait/migrate/declarative-apply `legacyPrepareShadowSource` performs. Those run + // inside the `use` phase below instead, where a SIGINT can still interrupt them (matching + // Go's single cancellable `ctx` threaded through the equivalent calls); passing all of + // `legacyPrepareShadowSource` as `acquire` made that whole sequence uninterruptible too, + // since `acquireUseRelease`'s `uninterruptibleMask` has no `restore` around `acquire` — + // see `legacy-shadow-source.ts`'s own doc comment on `legacyPrepareShadowSource` for the + // full rationale (review: PRRT_kwDOErm0O86XMrID). + diffResult = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const target = shadow.targetUrlOverride ?? targetUrl; + yield* output.raw( + flags.schema.length > 0 + ? `Diffing schemas: ${flags.schema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (useDelta) { + // With PGDELTA_DEBUG set, export the shadow's baseline catalog before diffing + // (Go's `DiffDatabase`, `internal/db/diff/diff.go:228-244`, shared by `db diff` + // AND `db pull`) — the snapshot itself is unused here (unlike `db pull`'s + // `legacySaveEmptyPgDeltaPullDebug`, `db diff` has no debug-bundle consumer for + // it); a failed export only warns and the diff continues. + if (legacyIsPgDeltaDebugEnabled()) { + yield* legacyExportCatalogPgDelta(ctx, { + targetRef: shadow.sourceUrl, + role: "postgres", + }).pipe( + Effect.catch((error) => + output.raw( + `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, + "stderr", + ), + ), + ); + } + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: target, + schema: flags.schema, + formatOptions, + }); + // Keep the per-unit plan files so a multi-unit plan can be written as one + // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened + // join for stdout review + machine payloads. + return { sql: result.sql, files: result.files }; + } + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: flags.schema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + // The migra engine has no execution-aware plan units, so it always writes a + // single migration file (Go's `SaveDiff` single-file path). + return { sql, files: undefined }; + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); + } const out = diffResult.sql; - // Detect the branch from the resolved workdir, not the caller's CWD: Go - // chdirs into --workdir in PersistentPreRunE before GetGitBranch - // (`cmd/root.go`), so `supabase --workdir … db diff` must report the - // project's branch, not the directory the command was invoked from. - const branch = Option.getOrElse(yield* detectGitBranch(cliConfig.workdir), () => "main"); - yield* output.raw( - `Finished ${legacyAqua("supabase db diff")} on branch ${legacyAqua(branch)}.\n\n`, - "stderr", - ); + // Go's `RunPgAdmin` returns straight to `SaveDiff` — no branch banner, no drop scan (both + // live in `diff.Run`, `diff.go:38-47`, which the pgadmin path bypasses entirely). + if (!usePgAdmin) { + // Detect the branch from the resolved workdir, not the caller's CWD: Go + // chdirs into --workdir in PersistentPreRunE before GetGitBranch + // (`cmd/root.go`), so `supabase --workdir … db diff` must report the + // project's branch, not the directory the command was invoked from. + const branch = Option.getOrElse(yield* detectGitBranch(cliConfig.workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db diff")} on branch ${legacyAqua(branch)}.\n\n`, + "stderr", + ); + } - // Go's `SaveDiff` (`pgadmin.go:20`) + the drop-statement warning (`diff.go:44`). - const engine = useDelta ? "pg-delta" : "migra"; - const drops = legacyFindDropStatements(out); + // Go's `SaveDiff` (`pgadmin.go:20`) + the drop-statement warning (`diff.go:44`, bypassed + // by the pgadmin path). + const engine = usePgAdmin ? "pgadmin" : useDelta ? "pg-delta" : "migra"; + const drops: ReadonlyArray = usePgAdmin ? [] : legacyFindDropStatements(out); const writtenFiles: Array = []; if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); @@ -533,5 +871,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ), ), Effect.ensuring(telemetryState.flush), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply above to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, ); }); 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 b73557e9b0..a7b2fd236a 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 @@ -1,37 +1,62 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Fiber, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { + LEGACY_FAKE_SHADOW_CONTAINER_ID, + LEGACY_VALID_REF, legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, + LegacyExperimentalFlag, LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, +} from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyDbDiff } from "./diff.handler.ts"; +import { + LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX, + LEGACY_PGADMIN_DIFF_HEADER, +} from "./legacy-pgadmin-diff.ts"; interface SetupOpts { readonly format?: OutputFormat; @@ -41,12 +66,87 @@ interface SetupOpts { // When set, the pg-delta edge mock emits a multi-unit plan envelope (one file // per entry) instead of the single-unit wrap of `diffSql`. readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; - readonly targetOverride?: string; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run + // When set, the PGDELTA_DEBUG shadow-catalog export (Go's `DiffDatabase`, + // `internal/db/diff/diff.go:228-244`) fails with this message instead of succeeding. + readonly catalogExportFailWith?: string; + // When set, the shadow's own PG15+ one-shot platform-baseline job(s) exit + // non-zero, exercising cleanup-on-partial-failure (the shadow is still removed). + readonly failShadowSetupJob?: boolean; readonly networkId?: string; // --network-id value forwarded to docker runs // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. readonly failWriteOnCall?: number; + // When set, the shadow container never reports healthy — for the interrupt-during- + // health-wait regression coverage (review: PRRT_kwDOErm0O86XMrID). See + // `mockLegacyShadowContainerCliSpawner`'s own doc comment for why this is required + // (not `Effect.never`) to observe a genuinely suspended retry loop. + readonly neverHealthyShadow?: boolean; + // `LegacyCliConfig.projectId` (Go's `SUPABASE_PROJECT_ID` env-only reader). Defaults to + // `Option.some("test")`; pass `Option.none()` to exercise the config.toml/workdir-basename + // fallback `legacyResolveLocalProjectId` provides for the pg-delta edge-runtime cache bind. + readonly projectId?: Option.Option; + // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with + // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, + // instead of silently falling back to `opts.linkedRef ?? LEGACY_VALID_REF`. + readonly linkedFails?: boolean; + // --- CLI-1968 (native --use-pgadmin) --- + // Per-differ-run `--json-diff` stdout, one entry per `runCapture` call to the differ + // image (index 0 = the no-`--schema` run, or the 1st `--schema` run; index 1 = the + // 2nd `--schema` run; …). Falls back to `""` (an empty/"No schema changes" diff) once + // exhausted, so a single-run test only needs a one-element array. + readonly pgadminStdout?: ReadonlyArray; + // Per-differ-run stderr (the raw text `legacyProcessPgAdminDiffProgress` filters). + // Falls back to `""` once exhausted. + readonly pgadminStderr?: ReadonlyArray; + // Applied to every differ `runCapture` call (the failure tests below only ever drive + // a single, no-`--schema` run, so one number covers them). + readonly pgadminExitCode?: number; + // Makes every differ `runCapture` call fail at the docker boundary instead of + // returning a result — `"spawn"` (daemon unreachable) or `"pull"` (registry failure). + readonly pgadminDockerFail?: "spawn" | "pull"; + // Makes the pre-flight `docker container inspect supabase_db_` probe + // (`legacyIsLocalDbRunning`, run before `--use-pgadmin` provisions anything) report + // "container not found" — Go's `supabase start is not running.`. + readonly dbNotRunning?: boolean; + // Makes that SAME probe fail with a daemon-unreachable stderr instead — the + // `daemonDown: true` classification branch. Mutually exclusive with `dbNotRunning`. + readonly dbInspectFailsWith?: string; + // `RuntimeInfo.platform` — drives the differ's `--add-host host.docker.internal: + // host-gateway` (Linux-only). Defaults to `"linux"` (every other test's implicit + // baseline); pass `"darwin"`/`"win32"` to exercise the no-add-host branch. + readonly platform?: NodeJS.Platform; +} + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +/** Records every `LegacyDbConnection.connect` target's database name, and every `exec`/`query` SQL run against it. */ +function fakeShadowDbConnection() { + const connectedDatabases: Array = []; + const execCalls: Array = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + execCalls.push(sql); + }), + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; + }), + }); + return { layer, connectedDatabases, execCalls }; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -54,36 +154,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ - mode: string; - targetLocal: boolean; - usePgDelta: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; - const exportCalls: string[] = []; - const exportCatalogCalls: Array<{ mode: string; projectRef?: string }> = []; - const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, projectRef }) => { - exportCalls.push(mode); - exportCatalogCalls.push({ mode, projectRef }); - return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); - }, - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.targetOverride, - }); - }, - removeShadowContainer: (container) => - Effect.sync(() => { - removedContainers.push(container); - }), + // Shadow provisioning is native (CLI-1956): a real docker-spawner fake backs + // container create/start/health-inspect/cleanup, and a real (fake) Postgres + // session backs the shadow's own platform-baseline/migration/declarative setup. + const shadowSpawner = mockLegacyShadowContainerCliSpawner({ + neverHealthy: opts.neverHealthyShadow ?? false, + dbNotRunning: opts.dbNotRunning ?? false, + dbInspectFailsWith: opts.dbInspectFailsWith, }); + const shadowDbConnection = fakeShadowDbConnection(); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { @@ -94,6 +173,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { new LegacyEdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), ); } + // The PGDELTA_DEBUG shadow-catalog export uses a distinct errPrefix (`legacy- + // pgdelta.ts`'s `legacyExportCatalogPgDelta`), same as `db pull`'s own mock. + if (runOpts.errPrefix.includes("catalog")) { + if (opts.catalogExportFailWith !== undefined) { + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), + ); + } + return Effect.succeed({ stdout: '{"tables":[]}', stderr: "" }); + } const diffSql = opts.diffSql ?? ""; // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a // JSON envelope with one file per plan unit; wrap the test's raw SQL into a @@ -119,11 +208,51 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - // Exercised only by the migra OOM bash fallback. + // `dockerCalls` tracks the migra OOM bash fallback's own `runCapture` calls — the + // native shadow's PG15+ one-shot setup jobs (`legacyRunStartMigrateJob`) go through + // `runStream` instead (constant-memory stdout discard, matching Go's `io.Discard` + // writer for these jobs), so they're tracked separately in `shadowSetupJobCalls` + // (their `env`, notably `DB_HOST`, is the one shadow-specific parameterization + // CLI-1956 exists to get right). const dockerCalls: unknown[] = []; + // The pgAdmin differ's own `runCapture` calls (CLI-1968), tracked separately from + // `dockerCalls` (the migra OOM bash fallback's image) so pgadmin tests never + // conflate the two — both go through the SAME `LegacyDockerRun.runCapture` seam, + // distinguished only by `image`. + const differCalls: Array = []; + // The `runCapture` SECOND (options) argument for every differ call, parallel to + // `differCalls` — pinned `undefined` below, since Go never tees the differ's raw + // stderr to the parent terminal (see `legacy-pgadmin-diff.ts`'s own doc comment). + const differCaptureOpts: Array<{ readonly teeStderr?: boolean } | undefined> = []; + // Snapshots `process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]` at the moment each + // differ `runCapture` call is made — the real `legacyDockerRunLayer`'s own image + // resolver reads that key straight off `process.env` at call time (no + // `projectEnvValues` threaded through), so this stands in for it here. + const differRegistryEnvAtCall: Array = []; + const shadowSetupJobCalls: Array<{ readonly env: Readonly> }> = []; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), - runCapture: (dockerOpts) => { + runCapture: (dockerOpts, captureOpts) => { + if (dockerOpts.image.includes("pgadmin-schema-diff")) { + differCalls.push(dockerOpts); + differCaptureOpts.push(captureOpts); + differRegistryEnvAtCall.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + if (opts.pgadminDockerFail !== undefined) { + return Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: opts.pgadminDockerFail, + daemonDown: opts.pgadminDockerFail === "spawn", + }), + ); + } + const i = differCalls.length - 1; + return Effect.succeed({ + exitCode: opts.pgadminExitCode ?? 0, + stdout: new TextEncoder().encode(opts.pgadminStdout?.[i] ?? ""), + stderr: opts.pgadminStderr?.[i] ?? "", + }); + } dockerCalls.push(dockerOpts); return Effect.succeed({ exitCode: 0, @@ -131,17 +260,27 @@ function setup(workdir: string, opts: SetupOpts = {}) { stderr: "", }); }, - runStream: () => Effect.die("runStream unused"), - }); - - const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: () => Effect.die("connect unused"), + // The shadow's own PG15+ one-shot platform-baseline job(s). + runStream: (dockerOpts) => { + shadowSetupJobCalls.push(dockerOpts); + return Effect.succeed({ + exitCode: opts.failShadowSetupJob === true ? 1 : 0, + stderr: "", + }); + }, }); const resolverCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { resolve: (resolveFlags) => { resolverCalls.push(resolveFlags); + // A threaded `--project-ref` flag wins over the fixed `opts.linkedRef` test + // fixture, same top precedence a real resolver would give it — lets a test + // prove the flag (not just `opts.linkedRef`) drives the resolved ref (read + // by both the native path and explicit mode's "linked" case). + const flagRef = resolveFlags.linkedProjectRef ?? Option.none(); + const ref = + Option.isSome(flagRef) && flagRef.value.length > 0 ? flagRef.value : opts.linkedRef; return Effect.succeed({ conn: { host: "127.0.0.1", @@ -151,12 +290,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { database: "postgres", }, isLocal: opts.isLocal ?? true, - ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none(), + ref: ref !== undefined ? Option.some(ref) : Option.none(), }); }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); + // The linked ref is now pre-loaded (for the config-override print, ahead of + // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via + // `LegacyProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in + // its resolved `ref` above, so both stay consistent regardless of whether a test sets + // `opts.linkedRef` (mirrors `reset.integration.test.ts`'s identical mock). + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just `opts.linkedRef`) drives the linked ref. + const projectRefResolver = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? LEGACY_VALID_REF)), + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) + : Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + }); + const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; const proxyCaptureCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; @@ -170,16 +330,23 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( + // `BunServices.layer` is listed FIRST so every fake service layer below (most + // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its + // real implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `start.integration.test.ts`'s own established ordering. + BunServices.layer, out.layer, telemetry.layer, cache.layer, - seam, edge, docker, - dbConnection, + shadowDbConnection.layer, + shadowSpawner.layer, + alwaysReadyHttpClientLayer, resolver, + projectRefResolver, proxy, - mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed( LegacyNetworkIdFlag, @@ -189,11 +356,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), - mockRuntimeInfo(), - BunServices.layer, + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + mockRuntimeInfo({ platform: opts.platform ?? "linux" }), ); - // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` - // still resolves from `BunServices`. + // Merged last so its `FileSystem` overrides everything above (last-wins). const layer = opts.failWriteOnCall === undefined ? baseLayer @@ -204,15 +372,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { out, cache, telemetry, - provisionCalls, - removedContainers, - exportCalls, - exportCatalogCalls, edgeCalls, resolverCalls, proxyCalls, proxyCaptureCalls, dockerCalls, + differCalls, + differCaptureOpts, + differRegistryEnvAtCall, + shadowSetupJobCalls, + shadowSpawned: shadowSpawner.spawned, + shadowConnectedDatabases: shadowDbConnection.connectedDatabases, + shadowExecCalls: shadowDbConnection.execCalls, }; } @@ -227,6 +398,7 @@ const flags = (over: Partial = {}): LegacyDbDiffFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), local: over.local ?? Option.none(), + projectRef: over.projectRef ?? Option.none(), file: over.file ?? Option.none(), schema: over.schema ?? [], }); @@ -248,18 +420,68 @@ const stderr = (out: ReturnType) => const tmp = useLegacyTempWorkdir(); +// --- CLI-1968 (native --use-pgadmin) fixtures --- + +/** Go's `DiffEntry` (`container_output.go:127-134`) shape, defaulting to a kept entry. */ +function pgadminEntry(overrides: Record = {}) { + return { + type: "table", + status: "Different", + diff_ddl: "ALTER TABLE test;", + group_name: "public", + ...overrides, + }; +} + +/** `legacyProcessPgAdminDiffOutput`'s exact output for a single default `pgadminEntry()`. */ +const PGADMIN_DIFF_SQL = `${LEGACY_PGADMIN_DIFF_HEADER}\n\nALTER TABLE test;\n`; + +// The default `resolver`/shadow-port fixtures in `setup()` below (conn +// 127.0.0.1:54322, shadow port 54320) — Go's `source` (the user's db, via +// `legacyToPostgresURL`) and `target` (the shadow, a raw, hardcoded `Sprintf`). +const PGADMIN_SOURCE_URL = + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10"; +const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; + describe("legacy db diff", () => { it.effect("diffs local with the default migra engine and prints SQL to stdout", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: false }]); + // The native shadow was created once (one `docker create`) and removed once + // (one `docker rm -f -v`) — see `mockLegacyShadowContainerCliSpawner`. + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(stdout(s.out)).toBe("create table players ();\n\n"); expect(stderr(s.out)).toContain("Creating shadow database..."); expect(stderr(s.out)).toContain("Diffing schemas..."); expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); - expect(s.removedContainers).toEqual(["shadow-1"]); expect(s.telemetry.flushed).toBe(true); + // The shadow's PG15+ one-shot platform-baseline job(s) connect to the shadow over + // Docker's embedded DNS using the shadow container's OWN 12-char short id as `DB_HOST` + // (Go's `container[:12]`, `diff.go:172`) — NOT the real `db` container's name, and not + // some other slice length (a mutation from `.slice(0, 12)` to `.slice(0, 8)` must fail + // this). This is the one shadow-specific parameterization this port exists to get right + // (`legacyBuildShadowSetupDatabaseInput`'s `dbHost`). The default config enables realtime + // (and PG >= 15 by default), so this always exercises at least one one-shot job — + // Realtime's own env sets `DB_HOST` directly; Storage/Auth embed the same host inside a + // `DATABASE_URL`-style connection string instead. + const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); + expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); + let sawHost = false; + for (const call of s.shadowSetupJobCalls) { + if (call.env["DB_HOST"] !== undefined) { + expect(call.env["DB_HOST"]).toBe(expectedHost); + sawHost = true; + } + for (const value of Object.values(call.env)) { + if (value.includes("@") && value.includes(":")) { + expect(value).toContain(`@${expectedHost}:`); + sawHost = true; + } + } + } + expect(sawHost).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -267,12 +489,144 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), schema: ["public"] })); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: true }]); + // pg-delta selection is observable via the edge-runtime script it runs. + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); expect(stderr(s.out)).toContain("Diffing schemas: public"); expect(stdout(s.out)).toBe("create table p ();\n\n"); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "PGDELTA_DEBUG exports the shadow's baseline catalog before diffing (Go's DiffDatabase)", + () => { + const s = setup(tmp.current, { diffSql: "create table p ();\n" }); + return Effect.gen(function* () { + const prev = process.env["PGDELTA_DEBUG"]; + process.env["PGDELTA_DEBUG"] = "1"; + try { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + } finally { + if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; + else process.env["PGDELTA_DEBUG"] = prev; + } + expect(s.edgeCalls.some((c) => c.errPrefix.includes("catalog"))).toBe(true); + expect(stdout(s.out)).toBe("create table p ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a failed PGDELTA_DEBUG shadow-catalog export only warns; the diff still succeeds", + () => { + const s = setup(tmp.current, { + diffSql: "create table p ();\n", + catalogExportFailWith: "boom", + }); + return Effect.gen(function* () { + const prev = process.env["PGDELTA_DEBUG"]; + process.env["PGDELTA_DEBUG"] = "1"; + try { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + } finally { + if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; + else process.env["PGDELTA_DEBUG"] = prev; + } + expect(stderr(s.out)).toContain("Warning: failed to export shadow pg-delta catalog: boom"); + expect(stdout(s.out)).toBe("create table p ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", + () => { + // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's + // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) + // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value + // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from + // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting + // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project. + const s = setup(tmp.current, { + diffSql: "create table p ();\n", + projectId: Option.none(), + }); + const expectedProjectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", + () => { + // `legacyReadDbToml` already gates `cfg.projectId` behind `remoteOverrideKeys` so it + // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but + // `legacyResolveLocalProjectId` tries `cliConfig.projectId` (raw, ungated env) FIRST, so + // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be + // suppressed here too, or it silently wins back over the already-gated `cfg.projectId`. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table remote ();\n", + // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) + // project — must NOT win over the matched remote's own `project_id`. + projectId: Option.some("unrelated-env-project"), + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ linked: Option.some(true), usePgDelta: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { + // Go's own shadow test coverage hardcodes PG14 (`diff_test.go`); the PG15+ short-id + // DNS resolution path was verified separately (empirical Docker probe, see the + // task's own header) — this covers the OTHER major-version branch of the SAME + // `legacySetupDatabase` pipeline, which execs SQL directly via the session + // instead of the three one-shot `LegacyDockerRun` jobs. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 14\n"); + const s = setup(tmp.current, { diffSql: "create table pg14 ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stdout(s.out)).toBe("create table pg14 ();\n\n"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // PG14's `legacyStartInitSchemaPre15` execs SQL over the session directly — + // no one-shot `LegacyDockerRun` jobs (Go's `initSchema15` never runs). + expect(s.dockerCalls).toEqual([]); + expect(s.shadowExecCalls.length).toBeGreaterThan(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "removes the shadow even when its own platform-baseline setup fails midway (ok-sentinel cleanup)", + () => { + // Mirrors Go's `ok`-sentinel + `defer` pattern (`shadow.go:42-47`): once the + // shadow container is created, ANY later failure (here, a PG15+ one-shot + // platform-baseline job exiting non-zero) still removes it. + const s = setup(tmp.current, { diffSql: "create table x ();\n", failShadowSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("a linked [remotes.] block enabling pg-delta selects the pg-delta engine", () => { // Go loads the project ref before LoadConfig on the linked path, merging the // matching [remotes.] block before experimental.pgdelta.enabled is read @@ -301,13 +655,54 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The shadow is provisioned with the resolved ref so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + // pg-delta selection (ref-aware: read from the remote-merged `cfg.pgDelta`) is + // observable via the edge-runtime script the diff runs. + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just cfg", + () => { + // Go remote-merges the WHOLE config uniformly on the linked path (`LoadConfig` seeds + // `flags.ProjectRef` before every field read) — the shadow's container spec (image, + // JWT secret, root key, db.settings, service enabled-for-setup flags) must reflect the + // matched `[remotes.]` override too, not just the `cfg`/`toml` read used for + // pg-delta/schema_paths. `major_version` is a clean, directly-observable probe: PG <= 14 + // is the ONLY branch that emits a `--tmpfs` flag on the shadow's `docker create` argv + // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) + // overridden by a remote block's `major_version = 14` must flip that flag on. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "alter table x;\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ linked: Option.some(true) })); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + // The PG15+ one-shot platform-baseline jobs (`initSchema15`) never run for PG14 — + // it execs SQL directly over the session instead — corroborating the same override. + expect(s.dockerCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("the base config (default local target) does not merge a remote block", () => { // The default db diff target is local; Go never calls LoadProjectRef for local, // so a [remotes.] override must be ignored and the base engine (migra) wins. @@ -329,9 +724,8 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); - // The local default never passes a ref, so the shadow uses base config. - expect(s.provisionCalls[0]?.projectRef).toBeUndefined(); + // The local default never merges a remote block, so the base (migra) engine wins. + expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); }).pipe(Effect.provide(s.layer)); }); @@ -343,49 +737,375 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(false); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); }); - it.effect("uses the seam's target override for the local declarative branch", () => { + it.effect("diffs the project given via --project-ref without a linked workdir", () => { + // The fake resolver fails as "unlinked" (`LegacyProjectNotLinkedError`) + // absent the flag — only the flag can resolve a ref here. + const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { - targetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", - diffSql: "create table o ();\n", + isLocal: false, + diffSql: "alter table x;\n", + linkedFails: true, }); return Effect.gen(function* () { - yield* legacyDbDiff(flags()); - expect(stdout(s.out)).toBe("create table o ();\n\n"); - expect(s.removedContainers).toEqual(["shadow-1"]); + yield* legacyDbDiff(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); }).pipe(Effect.provide(s.layer)); }); - it.effect("delegates --use-pgadmin to the Go binary (telemetry disabled on the child)", () => { + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + const FLAG_REF = "flagflagflagflagflag"; + // The workdir already resolves to LEGACY_VALID_REF (e.g. via + // .temp/project-ref) — the flag must win over it. + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "alter table x;\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); + expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); - expect(s.proxyCalls).toHaveLength(1); - expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pgadmin"]); - expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - expect(s.provisionCalls).toEqual([]); + const exit = yield* Effect.exit( + legacyDbDiff(flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + // The guard fires before any connection resolution or cache write. + expect(s.resolverCalls).toEqual([]); + expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); }); - it.effect("a delegated --use-pgadmin does not validate the base config first", () => { - // The delegate forwards the whole command to the Go child, which loads config - // itself (with the linked ref). So the TS path must NOT read/validate the base - // config up front — otherwise a project that's only valid after a [remotes.] - // merge (here: base db.major_version=16 is invalid) fails before delegating, - // even though Go validates the remote-merged config and succeeds. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); - const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst" }); + it.effect( + "explicit --from linked --to migrations --project-ref proceeds and uses the flag ref", + () => { + // The `[remotes.staging]` block's `project_id` matches the FLAG ref, not the + // resolver's own `opts.linkedRef` fallback (left unset) — the shadow only + // gets the remote's `db.major_version = 14` override (`--tmpfs` on PG<=14) + // if the flag (not a fallback) actually resolved the "linked" ref. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + `project_id = "flagflagflagflagflag"`, + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { isLocal: false, diffSql: "create table m ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("linked"), + to: Option.some("migrations"), + projectRef: Option.some("flagflagflagflagflag"), + }), + ); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + expect(s.cache.cachedRef).toBe("flagflagflagflagflag"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "explicit --from local --to migrations --linked --project-ref proceeds and applies the flag ref's remote override", + () => { + // Same `[remotes.staging]` fixture as the `--from linked` case above, but here + // it's a changed `--linked` (not a "linked" ref on either side) that resolves the + // flag ref via the preflight — `preflightConnType` keys off + // `Option.isSome(flags.linked)`, so the guard must not fire and the preflight's + // resolved ref must still drive the `[remotes.]` merge below. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + `project_id = "flagflagflagflagflag"`, + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { isLocal: false, diffSql: "create table m ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("migrations"), + linked: Option.some(true), + projectRef: Option.some("flagflagflagflagflag"), + }), + ); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + expect(s.cache.cachedRef).toBe("flagflagflagflagflag"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "explicit --from local --to migrations --project-ref errors (neither side is linked)", + () => { + // Neither side of the explicit cascade is the literal ref "linked", so the + // flag would go unused — the guard fires instead of silently discarding it. + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("migrations"), + projectRef: Option.some(FLAG_REF), + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", + ); + expect(s.resolverCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", + () => { + // Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the GLOBAL + // `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored — so a ref resolved via `LoadProjectRef` gets cached even when a LATER step + // (here, `legacyReadDbToml`'s own config-load) fails. `db.migrations.enabled = "notabool"` + // fails `legacyReadDbToml`'s own bool parse AFTER the ref is already known, exercising + // exactly that gap. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "alter table x;\n", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ linked: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "provisions a local-target declarative shadow and diffs against the override database", + () => { + // A declarative schema file under supabase/schemas makes `loadDeclaredSchemas` + // non-empty, so the native `--target-local` branch redirects the diff target to + // a second (contrib_regression) database on the SAME shadow container. + mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); + const s = setup(tmp.current, { diffSql: "create table o ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stdout(s.out)).toBe("create table o ();\n\n"); + // The declarative-schema file was migrated into the contrib_regression override. + expect(s.shadowConnectedDatabases).toContain("contrib_regression"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "diffs with the native pgAdmin engine: shadow create/rm, one differ run, no Go proxy call", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + // CLI-1968: --use-pgadmin no longer delegates to the bundled Go binary. + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.differCalls).toHaveLength(1); + // Status lines go to STDOUT (Go's fakeProgram fmt.Println), not stderr. + expect(stdout(s.out)).toBe( + `Creating shadow database...\nDiffing local database with current migrations...\n${PGADMIN_DIFF_SQL}\n`, + ); + // Stderr still carries the SHARED shadow-setup diagnostics (revoke-api-privileges, + // roles.sql seeding — identical on every diff engine), but none of pgAdmin's own + // status lines, which are on stdout instead, and none of the migra/pg-delta-only + // "Diffing schemas..."/"Finished ... on branch" lines (`diff.Run`-only, bypassed). + const err = stderr(s.out); + expect(err).not.toContain("Creating shadow database..."); + expect(err).not.toContain("Diffing local database with current migrations..."); + expect(err).not.toContain("Diffing schemas"); + expect(err).not.toContain("Finished"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("rejects --project-ref combined with --use-pg-schema before delegating", () => { + // The bundled Go binary's own `db diff` never registered `--project-ref`, so + // the flag can't be forwarded — fail up front instead of silently dropping it. + // (`--use-pgadmin` is native as of CLI-1968 and honors the flag — see the + // positive test below.) + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); - expect(s.proxyCalls).toHaveLength(1); + const exit = yield* Effect.exit( + legacyDbDiff(flags({ usePgSchema: Option.some(true), projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("--project-ref is not supported with --use-pg-schema"); + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); + it.effect("--use-pgadmin --linked honors --project-ref like the other native engines", () => { + // CLI-1968 made pgadmin share the same target resolve as migra/pg-delta, so + // the flag ref must win over the workdir's own linked ref here too. + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + usePgAdmin: Option.some(true), + linked: Option.some(true), + projectRef: Option.some(FLAG_REF), + }), + ); + expect(s.proxyCalls).toEqual([]); + expect(s.differCalls).toHaveLength(1); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--use-pgadmin --linked succeeds when only the [remotes.] override fixes an invalid base config", + () => { + // CLI-1968: pgadmin now shares the SAME target resolve as migra/pg-delta (Go + // resolves the target in the root PersistentPreRunE, strictly before + // RunPgAdmin), so it validates the remote-merged config, prints the override + // line, and succeeds — unlike the old Go-delegate era, where the whole + // command (config load included) ran inside the delegated child. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 16", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 15", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + expect(stderr(s.out)).toContain("Loading config override: [remotes.staging]"); + expect(s.proxyCalls).toEqual([]); + expect(s.differCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "--use-pgadmin --linked's preflight probe targets the resolved LINKED project id, not the base config's", + () => { + // Go's `UpdateDockerIds` runs AFTER the linked remote merge, so `DbId` derives + // from the resolved `Config.ProjectId` singleton (`config_path.go:10-15`, + // `pkg/config/config.go:604-610`, `internal/utils/config.go:57-65`), NOT the + // base config's own `project_id` — the matched `[remotes.]` block's own + // `project_id` must suppress it. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + 'project_id = "test"', + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + // `mockLegacyShadowContainerCliSpawner` distinguishes this SEPARATE + // `legacyIsLocalDbRunning` preflight probe from the shadow's own (64-hex-id) + // health-check inspect by the `supabase_db_` container-name prefix. + const inspectTargets = s.shadowSpawned + .filter((c) => c.args[0] === "container" && c.args[1] === "inspect") + .map((c) => c.args[2]); + expect(inspectTargets).toContain("supabase_db_abcdefghijklmnopqrst"); + expect(inspectTargets).not.toContain("supabase_db_test"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "--use-pgadmin fails on an invalid base config when no [remotes.] override exists (parity with the native local path)", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.resolverCalls).toHaveLength(0); + expect(s.differCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("a native local diff still validates the base config", () => { // Control for the delegate case: the local/db-url native path reads the base // config (Go's local LoadConfig, no remote merge), so an invalid base value @@ -399,19 +1119,70 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("re-quotes a comma-containing schema when delegating the diff", () => { + it.effect( + "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", + () => { + // `db.major_version` above is caught by `cfg` (`legacyReadDbToml`'s "D" pipeline), + // which already runs ahead of `resolver.resolve()`. `api.tls` is "L only" — `cfg` + // only tracks its dotted keys for remote-override gating, it never reads the cert/key + // files (see `legacyBuildLocalDbContainerInputs`'s doc comment) — so this is the ONE + // config error only `legacyBuildLocalDbContainerInputs`'s own validation catches. Go + // validates it as part of `LoadConfig`, in the root `PersistentPreRunE`, strictly + // before `NewDbConfigWithPassword` (`resolver.resolve()`'s parity target) ever runs + // (review: PRRT_kwDOErm0O86XIUK1) — so `resolverCalls` must stay empty here, proving + // the shadow's config validation ran first, not just that the command failed. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { diffSql: "create table x ();\n" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags()).pipe(Effect.flip); + expect(error.message).toContain("failed to read TLS cert"); + expect(s.resolverCalls).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("re-quotes a comma-containing schema when delegating --use-pg-schema", () => { // flags.schema holds the single parsed value `tenant,one`; forwarding it raw // would let the Go child's pflag StringSlice CSV-split it into two schemas, so - // it must be re-encoded as a quoted CSV field. + // it must be re-encoded as a quoted CSV field. `--use-pg-schema` is the only + // remaining delegate path (CLI-1968 cut pgadmin's own delegation). const s = setup(tmp.current); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["tenant,one"] })); + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true), schema: ["tenant,one"] })); const args = s.proxyCalls[0]?.args ?? []; const idx = args.indexOf("--schema"); expect(args[idx + 1]).toBe('"tenant,one"'); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "forwards a comma-containing --schema value to the differ raw, with no CSV re-quoting (native path)", + () => { + // Unlike the --use-pg-schema delegate above, the native differ argv is never + // re-parsed by a pflag StringSlice, so the single parsed value reaches the + // container unchanged. + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["tenant,one"] })); + const call = s.differCalls[0]; + const idx = call?.cmd.indexOf("--schema") ?? -1; + expect(call?.cmd[idx + 1]).toBe("tenant,one"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect( "delegates --use-pg-schema to the Go binary, printing a deprecation warning without duplicating Go's own warning", () => { @@ -429,6 +1200,9 @@ describe("legacy db diff", () => { expect(stderr(s.out)).not.toContain("--use-pg-schema flag is experimental"); // Delegation to Go is unchanged besides the new warning. expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema"]); + // The child's own telemetry is disabled so the single `cli_command_executed` + // event comes from this TS command's instrumentation, not the delegated child. + expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); }).pipe(Effect.provide(s.layer)); }, ); @@ -442,9 +1216,9 @@ describe("legacy db diff", () => { }); it.effect( - "does not print the --use-pg-schema deprecation warning when delegating --use-pgadmin", + "does not print the --use-pg-schema deprecation warning on the native --use-pgadmin path", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(stderr(s.out)).not.toContain('"--use-pg-schema" is deprecated'); @@ -452,24 +1226,67 @@ describe("legacy db diff", () => { }, ); - it.effect("--use-pgadmin in json mode wraps the captured SQL in a structured envelope", () => { - // Regression: the delegated child inherited stdout and returned without - // output.success, so machine-mode stdout carried the Go child's raw SQL - // instead of a JSON envelope (CLI-1546). Now the child's stdout is captured - // and re-emitted as the structured payload. - const s = setup(tmp.current, { format: "json", delegateStdout: "create table d ();\n" }); + it.effect( + "emits a json envelope for --use-pgadmin with status lines redirected to stderr (payload-only stdout)", + () => { + const s = setup(tmp.current, { + format: "json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + // stdout stays payload-only in machine mode — no status lines leak into it. + expect(stdout(s.out)).toBe(""); + // The status lines are diagnostics, not payload, so machine mode redirects + // them to stderr instead of dropping them (repo invariant: stdout is + // payload-only, diagnostics go to stderr — CLI-1546). + const err = stderr(s.out); + expect(err).toContain("Creating shadow database..."); + expect(err).toContain("Diffing local database with current migrations..."); + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + diff: PGADMIN_DIFF_SQL, + file: null, + files: [], + schemas: [], + engine: "pgadmin", + dropStatements: [], + }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a json-mode --use-pgadmin --file reports the written migration path instead of null (regression vs the old delegate)", + () => { + const s = setup(tmp.current, { + format: "json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { file: string; files: ReadonlyArray }; + expect(data.file).toMatch(/\d{14}_pgadmin_diff\.sql$/); + expect(data.files).toEqual([data.file]); + expect(existsSync(data.file)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("delivers the pgadmin payload as a stream-json result event too", () => { + const s = setup(tmp.current, { + format: "stream-json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); - // stdout stays payload-only; the child's SQL was captured, not inherited. - expect(stdout(s.out)).toBe(""); - expect(s.proxyCalls).toHaveLength(0); - expect(s.proxyCaptureCalls).toHaveLength(1); const success = s.out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ - diff: "create table d ();\n", - file: null, - engine: "pgadmin", - }); + expect(success?.data).toMatchObject({ diff: PGADMIN_DIFF_SQL, engine: "pgadmin" }); }).pipe(Effect.provide(s.layer)); }); @@ -485,6 +1302,8 @@ describe("legacy db diff", () => { // stderr in machine output mode (CLI-1546) rather than being dropped or // leaking into the stdout payload. expect(stderr(s.out)).toContain('"--use-pg-schema" is deprecated'); + // The child's own telemetry is disabled here too, same as the text-mode delegate. + expect(s.proxyCaptureCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); }).pipe(Effect.provide(s.layer)); }); @@ -622,11 +1441,47 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); // Explicit mode is pg-delta and never provisions a shadow. - expect(s.provisionCalls).toEqual([]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(stdout(s.out)).toBe("create table e ();\n"); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "explicit mode mounts the pg-delta Deno-cache volume by the config.toml-resolved project id", + () => { + // `explicitCtx` (built for the actual `--from`/`--to` diff) and `migrationsCtx` + // (built when a `migrations` ref is in the cascade) both used to pass the raw, + // env-only `cliConfig.projectId` straight through — resolving to `""` whenever a + // project relies on config.toml's `project_id` (or the workdir-basename default) + // instead of `SUPABASE_PROJECT_ID`, mounting `supabase_edge_runtime_:...` instead + // of the real project's volume. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "demo"\n'); + const s = setup(tmp.current, { diffSql: "create table e ();\n", projectId: Option.none() }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("local") })); + const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); + expect(diffCall?.binds).toContain("supabase_edge_runtime_demo:/root/.cache/deno:rw"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "the migrations-catalog shadow export mounts the same config.toml-resolved project id", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "demo"\n'); + const s = setup(tmp.current, { diffSql: "create table m ();\n", projectId: Option.none() }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); + const catalogExportCall = s.edgeCalls.find((c) => !c.script.includes("renderPlanFiles")); + expect(catalogExportCall?.binds).toContain( + "supabase_edge_runtime_demo:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("explicit --output writes raw SQL to the given path", () => { const s = setup(tmp.current, { diffSql: "create table w ();\n" }); return Effect.gen(function* () { @@ -642,16 +1497,20 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("forwards an explicit --linked=false target flag to the delegated child", () => { - // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) - // would make the child default to local instead of the linked target the - // native path selected. - const s = setup(tmp.current); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(false) })); - expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pgadmin", "--linked=false"]); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "forwards an explicit --linked=false target flag to the delegated pg-schema child", + () => { + // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) + // would make the child default to local instead of the linked target the + // native path selected. `--use-pg-schema` is the only remaining delegate path + // (CLI-1968 cut pgadmin's own delegation). + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true), linked: Option.some(false) })); + expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema", "--linked=false"]); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect( "an empty --file value prints to stdout instead of writing a nameless migration", @@ -686,16 +1545,16 @@ describe("legacy db diff", () => { ); it.effect("explicit --from migrations resolves a shadow catalog natively", () => { - // CLI-1959: the migrations ref now resolves via `provisionShadow` (Go's - // unchanged `db __shadow --mode diff`) + a native pg-delta catalog export, - // instead of the retired `exportCatalog({mode:"migrations"})` seam call. + // CLI-1959 (cache mechanics) + CLI-1956 (shadow provisioning): the migrations + // ref now resolves via the SAME native `legacyCreateShadowDatabase`/ + // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db + // diff`'s own shadow uses, not the retired `db __shadow` seam — a shadow is + // created and torn down (`s.shadowSpawned`). const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.exportCalls).toEqual([]); - expect(s.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // `resolveMigrationsCatalogRef` (Go's `explicit.go:88-126`) calls the shadow // primitives directly, without `DiffDatabase`'s own progress line — unlike // `db schema declarative sync`'s `getMigrationsCatalogRef`, which DOES print @@ -722,8 +1581,7 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.provisionCalls).toEqual([]); - expect(s.exportCalls).toEqual([]); + expect(s.shadowSpawned).toEqual([]); const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); expect(diffCall?.env["SOURCE"]).toBe( `/workspace/${join("supabase", ".temp", "pgdelta", `catalog-local-migrations-${noMigrationsHash}-1000.json`)}`, @@ -736,7 +1594,26 @@ describe("legacy db diff", () => { "explicit --from linked --to migrations provisions the shadow with the linked ref", () => { // Go resolves linked first (LoadConfig merges [remotes.]), so the later - // migrations catalog is built from the remote-merged config (explicit.go). + // migrations catalog is built from the remote-merged config (explicit.go) — + // and the migrations shadow's OWN container spec must reflect it too, not + // just the pg-delta ref (same probe as "a linked [remotes.] + // db.major_version override reaches the shadow's OWN container spec" above: + // PG <= 14 is the only branch that emits `--tmpfs` on `docker create` argv). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -744,15 +1621,31 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }, ); it.effect("explicit --from migrations --to linked provisions the shadow with base config", () => { // Migrations is resolved BEFORE linked here, so Go's LoadConfig(ref) hasn't run - // yet — the catalog must use base config (no ref forwarded), matching order. + // yet — the catalog (and its shadow's own container spec) must use base config + // (no ref forwarded), matching order. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -760,16 +1653,31 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBeUndefined(); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).not.toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }); it.effect("explicit --from local --to migrations --linked seeds the merged config", () => { // Go's root ParseDatabaseConfig runs LoadProjectRef+LoadConfig for a changed // --linked before RunExplicit, leaving the config remote-merged — so the - // migrations catalog (and local refs/format options) use the linked override - // even though neither explicit ref is itself `linked`. + // migrations catalog's shadow (and local refs/format options) use the linked + // override even though neither explicit ref is itself `linked`. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -783,8 +1691,8 @@ describe("legacy db diff", () => { linked: Option.some(true), }), ); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }); @@ -832,7 +1740,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some(""), to: Option.some("") })); // Reaching the native path proves it didn't enter explicit mode and error. - expect(s.provisionCalls).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(stdout(s.out)).toBe("create table e ();\n\n"); }).pipe(Effect.provide(s.layer)); }); @@ -954,4 +1862,624 @@ describe("legacy db diff", () => { }); }).pipe(Effect.provide(s.layer)); }); + + it.live( + "removes the shadow container on a SIGINT-style interruption during the health wait, without waiting for the health-check timeout", + () => { + // Regression test for the acquireUseRelease restructuring (review: + // PRRT_kwDOErm0O86XMrID): an earlier shape passed the ENTIRE + // `legacyPrepareShadowSource` (create -> health-wait -> migrate -> + // declarative-apply) as `acquireUseRelease`'s `acquire`, which Effect's + // `uninterruptibleMask` (no `restore` around `acquire`) made completely + // uninterruptible — a SIGINT landing during the health wait (which can run for + // up to 30 real seconds, `LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS`) was silently + // swallowed until the health check gave up on its own, unlike Go's single + // cancellable `ctx`. `acquire` is now ONLY `legacyCreateShadowDatabase` + // (container creation); the health wait runs inside the interruptible `use` + // phase instead, so a `Fiber.interrupt` here must land promptly. + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const fiber = yield* legacyDbDiff(flags()).pipe( + Effect.provide(s.layer), + Effect.forkChild({ startImmediately: true }), + ); + // Wait until the shadow's own health check has actually probed the + // never-healthy container at least once — proving the fiber is genuinely + // suspended inside `legacyWaitForHealthyServices`'s retry loop, not merely + // past the `create` call. + while (!s.shadowSpawned.some((c) => c.args[0] === "container" && c.args[1] === "inspect")) { + yield* Effect.sleep("5 millis"); + } + // `Fiber.interrupt` only resolves once the target fiber (and its finalizers, + // including `legacyRemoveShadowDatabase`) has fully completed — if `acquire` + // still covered the health wait, this call would hang for up to 30 real + // seconds (or until this test's own timeout), instead of resolving as soon + // as the in-flight probe's own subprocess call returns. + yield* Fiber.interrupt(fiber); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // The diff step (past the health wait) was never reached. + expect(s.edgeCalls).toHaveLength(0); + }); + }, + ); + + describe("--use-pgadmin (native differ, CLI-1968)", () => { + it.effect( + "prints 'No schema changes found' and writes nothing when the differ output is empty", + () => { + const s = setup(tmp.current, { pgadminStdout: [""] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).toContain("No schema changes found"); + expect(stdout(s.out)).toBe( + "Creating shadow database...\nDiffing local database with current migrations...\n", + ); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "prints 'No schema changes found' when every diff entry is filtered out (all Identical)", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ status: "Identical" })])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).toContain("No schema changes found"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("writes a timestamped migration for --use-pgadmin --file instead of printing", () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ); + expect(stdout(s.out)).not.toContain("ALTER TABLE"); + expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); + const dir = join(tmp.current, "supabase", "migrations"); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^\d{14}_pgadmin_diff\.sql$/); + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe(PGADMIN_DIFF_SQL); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("creates nested parent directories for a nested --use-pgadmin --file name", () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("snapshots/remote") }), + ); + const migrationsRoot = join(tmp.current, "supabase", "migrations"); + const dirs = readdirSync(migrationsRoot); + expect(dirs).toHaveLength(1); + expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); + expect(readdirSync(join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an empty --use-pgadmin --file value falls through to stdout instead of writing", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), file: Option.some("") })); + expect(stdout(s.out)).toContain("ALTER TABLE test;"); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "never prints the 'Finished ... on branch' banner or a drop-statement warning, even with a DROP in the SQL", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ diff_ddl: "drop table gone;" })])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).not.toContain("Finished"); + expect(stderr(s.out)).not.toContain("Found drop statements"); + expect(stdout(s.out)).toContain("drop table gone;"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "invokes the differ with the exact argv, image, network, labels, and empty env/binds (no --schema)", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + // `LegacyCliConfig.projectId` only feeds pg-delta's own project id (a + // SEPARATE mechanism); the shadow/differ's docker network+labels come from + // `legacyLoadLocalProjectContext`'s REAL resolution (no config.toml + // `project_id`/`SUPABASE_PROJECT_ID` here), which falls back to the workdir + // basename — same as the pg-delta Deno-cache-volume tests above. + const projectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(s.differCalls).toHaveLength(1); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.image).toBe(dockerfileServiceImage("differ")); + expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); + expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); + expect(call.env).toEqual({}); + expect(call.binds).toEqual([]); + expect(call.securityOpt).toEqual([]); + expect(call.workingDir).toEqual(Option.none()); + expect(call.entrypoint).toBeUndefined(); + expect(call.network).toEqual({ _tag: "named", name: `supabase_network_${projectId}` }); + expect(call.labels).toEqual({ + "com.supabase.cli.project": projectId, + "com.docker.compose.project": projectId, + }); + expect(call.extraHosts).toEqual(["host.docker.internal:host-gateway"]); + // Go never tees the differ's raw stderr to the parent terminal — the + // `runCapture` options argument must stay unset. + expect(s.differCaptureOpts[0]).toBeUndefined(); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--network-id forwards to the differ's --network, same as the shadow", () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + networkId: "custom-net", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "omits --add-host on a non-Linux host (Go's docker_darwin.go/docker_windows.go)", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + platform: "darwin", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.extraHosts).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "hardcodes the shadow target's postgres:postgres credentials, ignoring a configured [db] password (Go pgadmin.go quirk)", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + '[db]\npassword = "distinctive-pw"\n', + ); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.cmd.at(-1)).toBe(PGADMIN_TARGET_URL); + expect(call.cmd.join(" ")).not.toContain("distinctive-pw"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a supabase/.env-only SUPABASE_INTERNAL_IMAGE_REGISTRY reaches the differ's image resolver during the run, and reverts after", + () => { + // TS `db diff` never applied project env at all before this fix (unlike `db + // push`/`pull`/`dump`/`reset`/`bootstrap`) — Go's `loadNestedEnv` `os.Setenv`s + // the project `.env` during config load (`pkg/config/config.go:788-791`), + // before `GetRegistry()` (`internal/utils/docker.go:221-231,244-246`) ever + // reads it. `legacyDockerRunLayer`'s own image resolver has no + // `projectEnvValues` in scope, so it falls back to reading `process.env` + // directly at `runCapture` call time; this mock docker layer records that + // same read (`differRegistryEnvAtCall`) since it replaces the real resolver + // wholesale and can't observe an already-rewritten image. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", + ); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(s.differRegistryEnvAtCall).toEqual(["registry.example.com"]); + // Reverted once the handler's scope closes — no leak into a later command + // (or a later test) sharing this process. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect( + "filters differ stderr through ProcessDiffProgress, printing only the matched status text to stdout", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + pgadminStderr: [ + "Starting schema diff...\nComparing Tables 45%\nnoise line\nDiffing 100%\n", + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const text = stdout(s.out); + expect(text).toContain("Comparing Tables \n"); + expect(text).toContain("Diffing 1\n"); + expect(text).not.toContain("Starting schema diff..."); + expect(text).not.toContain("noise line"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("still parses --json-diff output prefixed with the DESKTOP-mode NOTE line", () => { + const s = setup(tmp.current, { + pgadminStdout: [`${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry()])}`], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stdout(s.out)).toContain("ALTER TABLE test;"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "loops one differ run per --schema, in flag order, with per-run 'Diffing schema:' status lines", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ diff_ddl: "create table pub ();" })]), ""], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); + expect(s.differCalls).toHaveLength(2); + expect((s.differCalls[0] as LegacyDockerRunOpts).cmd).toEqual([ + "--schema", + "public", + "--json-diff", + PGADMIN_SOURCE_URL, + PGADMIN_TARGET_URL, + ]); + expect((s.differCalls[1] as LegacyDockerRunOpts).cmd).toEqual([ + "--schema", + "app", + "--json-diff", + PGADMIN_SOURCE_URL, + PGADMIN_TARGET_URL, + ]); + const text = stdout(s.out); + const idxPublic = text.indexOf("Diffing schema: public"); + const idxApp = text.indexOf("Diffing schema: app"); + expect(idxPublic).toBeGreaterThanOrEqual(0); + expect(idxApp).toBeGreaterThan(idxPublic); + expect(text).toContain("create table pub ();"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + ">=2 --schema runs each emitting a diff array succeed, aggregating every run's DDL under ONE header (CLI-1968 round 2: parsed per run, not concatenated then parsed once)", + () => { + // Completes the intended shared-buffer algorithm's own purpose (see + // `legacy-pgadmin-diff.ts`'s own header comment): each run's stdout is + // parsed on its own, so >=2 `--schema` runs that each emit a full JSON + // array no longer concatenate into one buffer and fail a single + // `JSON.parse` — every run's own DESKTOP-mode NOTE prefix (`pgadmin4#24`) + // is trimmed from that run's own buffer too, not just the very first run's. + const s = setup(tmp.current, { + pgadminStdout: [ + `${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry({ diff_ddl: "create table pub ();" })])}`, + `${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry({ diff_ddl: "create table app ();" })])}`, + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); + const text = stdout(s.out); + // A single header, not one per run. + expect(text.split(LEGACY_PGADMIN_DIFF_HEADER)).toHaveLength(2); + expect(text).toContain( + `${LEGACY_PGADMIN_DIFF_HEADER}\n\ncreate table pub ();\n\ncreate table app ();\n`, + ); + // Per-run "Diffing schema:" ordering is preserved. + const idxPublic = text.indexOf("Diffing schema: public"); + const idxApp = text.indexOf("Diffing schema: app"); + expect(idxPublic).toBeGreaterThanOrEqual(0); + expect(idxApp).toBeGreaterThan(idxPublic); + // Neither run's raw NOTE prefix leaked into the rendered diff. + expect(text).not.toContain("NOTE: Configuring authentication for DESKTOP mode."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails with invalid_output when a run's own --json-diff stdout doesn't parse", () => { + const s = setup(tmp.current, { pgadminStdout: ["not valid json"] }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "invalid_output", + }); + expect((error as { message: string }).message).toContain( + "failed to parse schema diff output:", + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "emits a failed run's captured progress statuses before the container-error surfaces", + () => { + // Go's stderr goroutine (`NewDiffStream`'s `io.Pipe`) scans progress + // concurrently WHILE the container runs, so a run that later exits non-zero + // still had its status lines printed already. This port batches stderr via + // `runCapture` instead of streaming it, so parity requires processing/ + // emitting that batch BEFORE the exit-code check, not after returning early. + const s = setup(tmp.current, { + pgadminExitCode: 1, + pgadminStderr: ["Comparing Tables 45%\nDiffing 100%\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 1", + }); + const text = stdout(s.out); + expect(text).toContain("Comparing Tables \n"); + expect(text).toContain("Diffing 1\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "in stream-json mode, a failed run's captured progress statuses redirect to stderr (CLI-1546) but are still emitted before the container-error result", + () => { + const s = setup(tmp.current, { + format: "stream-json", + pgadminExitCode: 1, + pgadminStderr: ["Comparing Tables 45%\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "differ" }); + expect(stderr(s.out)).toContain("Comparing Tables \n"); + expect(stdout(s.out)).toBe(""); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'error running container: exit 1' when the differ exits non-zero, and still removes the shadow", + () => { + const s = setup(tmp.current, { + pgadminExitCode: 1, + pgadminStderr: ["some differ crash text\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 1", + }); + // The differ's own stderr never reaches the error message (Go quirk — it + // only ever fed the progress-line filter). + expect((error as { message: string }).message).not.toContain("some differ crash text"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails with 'error running container: exit 137' on an OOM-killed differ", () => { + const s = setup(tmp.current, { pgadminExitCode: 137 }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 137", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("classifies a differ spawn failure as docker_daemon", () => { + const s = setup(tmp.current, { pgadminDockerFail: "spawn" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "docker_daemon" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("classifies a differ image-pull failure as registry_pull", () => { + const s = setup(tmp.current, { pgadminDockerFail: "pull" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "registry_pull" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'supabase start is not running.' before ever creating a shadow, but after the target resolve", + () => { + const s = setup(tmp.current, { dbNotRunning: true }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffDbNotRunningError" }); + expect(stripAnsi((error as { message: string }).message)).toBe( + "supabase start is not running.", + ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); + expect(s.differCalls).toEqual([]); + // The target was still resolved BEFORE the running-check failed — Go + // resolves the target in the root PersistentPreRunE, strictly before + // RunPgAdmin's AssertSupabaseDbIsRunning. + expect(s.resolverCalls.length).toBeGreaterThan(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "classifies a daemon-unreachable local-db inspect as daemonDown with the Docker install suggestion", + () => { + const s = setup(tmp.current, { + dbInspectFailsWith: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffDbNotRunningError", daemonDown: true }); + expect((error as { suggestion?: string }).suggestion).toContain("Docker Desktop"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "propagates a failed shadow platform-baseline job and still removes the shadow (pgAdmin path)", + () => { + const s = setup(tmp.current, { failShadowSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with LegacyDbDiffWriteError when writing the pgAdmin --file migration fails", + () => { + // Call #1 is the shadow's own `revoke-api-privileges.sql` write + // (`legacyApplyApiPrivileges`, shared by every diff engine); call #2 is the + // pgAdmin diff-file write itself. + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + failWriteOnCall: 2, + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "LegacyDbDiffWriteError" }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails on engine-flag conflict (--use-pgadmin with --use-pg-delta), byte-exact cobra message", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), usePgDelta: Option.some(true) }), + ).pipe(Effect.flip); + expect((error as { message: string }).message).toBe( + "if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [use-pg-delta use-pgadmin] were all set", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails on target mutex when --use-pgadmin is combined with --linked and --local", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ + usePgAdmin: Option.some(true), + linked: Option.some(true), + local: Option.some(true), + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "explicit --from/--to wins over --use-pgadmin (pgadmin is ignored, pg-delta runs)", + () => { + const s = setup(tmp.current, { isLocal: false, diffSql: "create table explicit ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + usePgAdmin: Option.some(true), + from: Option.some("local"), + to: Option.some("linked"), + }), + ); + expect(s.differCalls).toEqual([]); + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); + expect(stdout(s.out)).toBe("create table explicit ();\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.live( + "removes the shadow container on interruption during the health wait for --use-pgadmin too", + () => { + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const fiber = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.provide(s.layer), + Effect.forkChild({ startImmediately: true }), + ); + // Wait for the SHADOW's own health probe specifically (its 64-hex id) — + // the pgadmin path's separate `supabase_db_test` "is running" probe fires + // first and would otherwise satisfy a looser check immediately. + while ( + !s.shadowSpawned.some( + (c) => + c.args[0] === "container" && + c.args[1] === "inspect" && + c.args[2] === LEGACY_FAKE_SHADOW_CONTAINER_ID, + ) + ) { + yield* Effect.sleep("5 millis"); + } + yield* Fiber.interrupt(fiber); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.differCalls).toEqual([]); + }); + }, + ); + }); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts index 8c2ab09380..b47fe46396 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; @@ -11,16 +12,24 @@ import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitc import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; -import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; /** * Runtime layer for `supabase db diff`. * * Mirrors `db schema declarative generate` (`generate.layers.ts`): the db-config - * resolver plus the native pg-delta / migra stack — the edge-runtime runner, the - * SSL probe, and the Go shadow-database seam (`provisionShadow`). `LegacyDockerRun` - * is exposed in the merge (not just provided to the edge-runtime layer) because the - * migra OOM bash fallback runs the `supabase/migra` container directly. + * resolver plus the native pg-delta / migra / pgAdmin stack — the edge-runtime + * runner, the SSL probe, and `HttpClient` (the native shadow's health-check wait). + * Shadow provisioning (`db diff`'s own — migra/pg-delta AND pgadmin alike — plus + * the explicit `--from migrations`/`--to migrations` catalog shadow) is fully + * native (CLI-1956/CLI-1959/CLI-1968) — see `commands/db/shared/ + * legacy-shadow-source.ts` and `shared/legacy-pgdelta.cache.ts` — so no + * `LegacyDeclarativeSeam` layer is needed here. `--use-pg-schema` is now the + * only engine that delegates through `LegacyGoProxy` (CLI-1960's keep-in-Go + * exception); `--use-pgadmin` uses `LegacyDockerRun` natively instead, the same + * service the migra OOM bash fallback already needed. + * `LegacyDockerRun` is exposed in the merge (not just provided to the + * edge-runtime layer) because both the migra OOM bash fallback and the pgadmin + * differ container run their own container directly. * Per the "provide doesn't share to siblings" rule, `LegacyCliConfig` is provided * to every layer that needs it. */ @@ -41,7 +50,7 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); -const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); export const legacyDbDiffRuntimeLayer = Layer.mergeAll( dbConfig, @@ -49,7 +58,7 @@ export const legacyDbDiffRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, - seam, + httpClient, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts index 35adf5826b..8d9c0cf9b2 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts @@ -1,12 +1,21 @@ +import { execFile } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +const execFileAsync = promisify(execFile); + const START_TIMEOUT_MS = 280_000; +// Lifecycle allowance for scenarios that run TWO full-budget subprocesses (`start` +// then the command under test) plus init/inspection overhead — same shape as +// `start.live.test.ts`. A single shared `START_TIMEOUT_MS` test budget would let a +// slow-but-valid `start` starve the command under test before it ever runs. +const LIFECYCLE_OVERHEAD_MS = 90_000; // CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally // treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, @@ -101,3 +110,115 @@ revoke execute on function public.probe_fn() from public; }, ); }); + +// CLI-1968: `--use-pgadmin` is a native `docker run` of the differ container, no +// edge-runtime and no Go delegation involved. Golden-path smoke coverage only — the +// pure filtering/progress logic and the docker-run argv are covered exhaustively by +// `legacy-pgadmin-diff.unit.test.ts` and `diff.integration.test.ts`; this just proves +// the real container actually runs against a real local stack and cleans up after +// itself either way. +// +// The real, reachable outcome here is a FAILURE, not a golden diff, and by design in +// BOTH CLIs: the differ container joins the project's own bridge network +// (`supabase_network_`, `docker.go:378-382`), and Go hardcodes both diff +// endpoints as loopback URLs from that container's own point of view — `source` +// (`utils.ToPostgresURL`, resolving `GetHostname()` to `127.0.0.1` for a local target) +// and `target` (`postgresql://postgres:postgres@127.0.0.1:/postgres`, +// `pgadmin.go:85-86`). Inside a bridge-attached container, `127.0.0.1` is the +// container's OWN loopback, not the host's — so neither the local db nor the shadow is +// reachable from inside the differ, and the container exits non-zero. This holds +// identically for the real Go CLI (identical argv, identical network, identical +// hardcoded hosts), so there is no live A/B needed to establish it — see +// `SIDE_EFFECTS.md`'s "Network reachability" entry for the full static ruling. (The +// `DiffStream` value-receiver divergence documented there — the real Go CLI always +// reporting "No schema changes found" regardless of the differ's actual output — only +// ever engages when the differ container exits 0; it plays no role in this failure +// path.) Note that a plain `--network-id host` does NOT rescue a golden run here: it +// also rewires the SHADOW container onto host networking, discarding its own +// `54320->5432` port publish that `target` depends on — so `source` would become +// reachable but `target` would not, still failing the diff. This suite therefore +// verifies the real, always-reachable failure mode end-to-end, plus that both the +// differ AND the shadow container it provisions are still cleaned up. +describeLive("supabase db diff (live, --use-pgadmin native differ container)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "runs the native differ container against the real stack, surfaces Go's error running container failure, and leaves no differ container behind", + { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-pgadmin-live-")); + // No `project_id` override, so the cli resolves it from the workdir basename — + // matching Go's precedence exactly (see legacy-docker-ids.ts), same as + // `stop.live.test.ts`. + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services — `db diff --use-pgadmin` only + // needs the local Postgres container reachable, same rationale as stop/status. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const diff = await runSupabaseLive(["db", "diff", "--use-pgadmin"], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + // Both hardcoded loopback endpoints are unreachable from inside the + // bridge-attached differ container (see this suite's own header comment for the + // full, static ruling) — the differ exits non-zero and the CLI surfaces Go's own + // wrapper message. The differ's own exit code isn't pinned: only that the differ + // ran and failed, not the shadow/connection machinery around it. + expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(1); + expect(diff.stderr).toContain("error running container: exit "); + + // The differ is a one-shot `docker run --rm` — real Docker must agree that no + // container survives it, the same "the daemon must agree" check + // `stop.live.test.ts` runs against `com.supabase.cli.project`. + const { stdout: remainingDiffer } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + "ancestor=supabase/pgadmin-schema-diff:cli-0.0.5", + "--format", + "{{.ID}}", + ]); + expect(remainingDiffer.trim()).toBe(""); + + // This failure path exercises the shadow's `acquireUseRelease` teardown for + // real (the differ error propagates out of the `use` phase after the shadow was + // already created) — the shadow itself is created with no `--name` (Docker + // auto-generates one), unlike every real stack container, which is always named + // `supabase__`. So a leaked shadow shows up as a + // project-labeled container whose name does NOT carry that fixed prefix. + const { stdout: projectContainers } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.Names}}", + ]); + const names = projectContainers + .trim() + .split("\n") + .filter((name) => name.length > 0); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name.startsWith("supabase_"))).toBe(true); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts new file mode 100644 index 0000000000..17e8d3a886 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts @@ -0,0 +1,443 @@ +/** + * Native port of Go's pgAdmin schema-diff engine + * (`apps/cli-go/internal/db/diff/pgadmin.go`, `apps/cli-go/internal/utils/container_output.go`) + * — CLI-1968. `db diff` is the only caller in Go (`cmd/db.go:115` is the sole `RunPgAdmin` call + * site), so this stays colocated with the command rather than under `commands/db/shared/`; move + * it there (and split the error into its own `legacy-pgadmin-diff.errors.ts`, mirroring + * `legacy-migra.ts`/`legacy-migra.errors.ts`) if a second command ever needs it. + * + * Covers the two pure halves — `ProcessDiffProgress`/`ProcessDiffOutput` + * (`container_output.go:94-201`, split here into `legacyParsePgAdminDiffEntries` + + * `legacyRenderPgAdminDiff`, recomposed as `legacyProcessPgAdminDiffOutput` for a single + * whole buffer) — and the container-invocation loop, `DiffSchemaPgAdmin` (`pgadmin.go:91-121`). + * Shadow provisioning, the `Creating shadow database...`/`Diffing local database with + * current migrations...` status lines, and `SaveDiff` all stay in `diff.handler.ts`, + * matching Go's own module boundary. + * + * **Deliberate divergence, not bug-for-bug parity (`container_output.go:79,87`):** Go's + * `DiffStream` declares `Stdout()`/`Collect()` on a VALUE receiver (`func (c DiffStream) + * ...`, not `*DiffStream`), so every call runs against its OWN COPY of the struct's `o + * bytes.Buffer` field. `Stdout()` returns `&c.o` of the copy made for THAT call, and the + * differ's stdout is written into it — a buffer `Collect()` (called later, on a DIFFERENT + * copy) never sees. `Collect()`'s own `c.o` is therefore always the zero-value empty buffer, + * so `ProcessDiffOutput` always receives zero bytes — the real Go CLI's `--use-pgadmin` + * ALWAYS reports "No schema changes found" (exit 0), regardless of the differ's actual + * output: it never writes a migration file and never hits a JSON-parse error, on ANY schema + * count. (`Stderr()`/progress DOES work: `c.w` is a `*io.PipeWriter`, a reference type, so + * every copy shares the same pipe.) This port implements the INTENDED algorithm — the one + * `NewDiffStream`'s own comments and `Collect`'s call to `ProcessDiffOutput` clearly intend + * — but completes it by parsing EACH run's own real stdout separately and aggregating the + * kept DDLs across runs, rather than gluing every run's raw bytes into one buffer and parsing + * that once: a shared *byte* buffer was never the intent behind `NewDiffStream`'s design, only + * a means to see every run's output at all, and concatenating raw JSON arrays before parsing + * turns a multi-`--schema` diff whose every run individually parses fine into a spurious + * `JSON.parse` "trailing data" failure — the worst of both worlds, matching neither Go-as- + * shipped (always an empty, successful diff) nor this algorithm's own evident purpose. So + * where the real Go binary silently reports an empty diff no matter what the differ produced, + * this port produces the actual, aggregated diff across every run (or a real per-run + * JSON-parse error — see `legacyParsePgAdminDiffEntries`'s own doc comment). Ruling: keep this + * port's (correct) implementation rather than reproducing the empty-buffer bug; see + * `SIDE_EFFECTS.md`'s "Deliberate divergence" entry for the user-facing framing. + */ + +import { Effect, Option, Result } from "effect"; + +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { LEGACY_COMPOSE_PROJECT_LABEL } from "../../../shared/db-bootstrap/container-lifecycle.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../../../shared/legacy-docker-ids.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { legacyTrimGoSpace } from "../shared/legacy-go-string.ts"; +import { LEGACY_INTERNAL_SCHEMAS } from "../../../shared/legacy-pg-dump.env.ts"; +import { LegacyDbDiffPgAdminError } from "./diff.errors.ts"; + +/** Go's `config.Images.Differ` (`pkg/config/templates/Dockerfile:18`, `FROM … AS differ`). */ +const LEGACY_DIFFER_IMAGE = dockerfileServiceImage("differ"); + +/** + * Go's `ProcessDiffOutput` (`container_output.go:142-143`) trims this front-anchored only + * (`bytes.TrimPrefix`, not a global strip). `legacyParsePgAdminDiffEntries` runs once per + * differ run now, so each run's OWN copy of this note (a real pgAdmin4 quirk, + * `supabase/pgadmin4#24`) is trimmed off the front of that run's own buffer — + * `legacyProcessPgAdminDiffOutput`, applied to a single whole buffer, still only strips the + * very front of whatever string it's given. + */ +export const LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX = + "NOTE: Configuring authentication for DESKTOP mode.\n"; + +/** Go's `diffHeader` (`container_output.go:136-139`), verbatim. */ +export const LEGACY_PGADMIN_DIFF_HEADER = `-- This script was generated by the Schema Diff utility in pgAdmin 4 +-- For the circular dependencies, the order in which Schema Diff writes the objects is not very sophisticated +-- and may require manual changes to the script to ensure changes are applied in the correct order. +-- Please report an issue for any failure with the reproduction steps.`; + +/** Go's `switch diffEntry.Type` allow-list (`container_output.go:160-165`). */ +const LEGACY_PGADMIN_DIFF_TYPES = new Set([ + "extension", + "function", + "mview", + "table", + "trigger_function", + "type", + "view", +]); + +/** + * Go's `(.*)([[:digit:]]{2,3})%` (`container_output.go:96`), compiled with the `s` + * (dotAll) flag: Go's RE2 `.` matches every character except `\n` — INCLUDING `\r` + * — when no `(?s)` flag is set, but JS's `.` excludes every line-terminator code + * point (`\r`, `\n`, U+2028, U+2029) unless `s` is set. `legacyScanLines` only splits + * on `\n` (matching `bufio.ScanLines`), so a line can still carry embedded `\r`s from + * a `\r`-driven progress bar (multiple updates overwriting the same terminal line); + * without `s`, this pattern would stop matching at the first embedded `\r` in JS but + * not in Go. No alternation, so RE2's leftmost-longest overall match still coincides + * with JS's leftmost-first greedy backtracking for the digit/percent suffix — + * verified empirically against the real Go binary (`go run` with this exact + * pattern): both engines pick the SAME (surprising, greedy) submatch, e.g. + * `"Diffing 100%"` → group 1 `"Diffing 1"`, group 2 `"00"`. + */ +const LEGACY_PGADMIN_PROGRESS_RE = /(.*)([0-9]{2,3})%/s; + +/** + * Splits `stderr` the way Go's `bufio.NewScanner(out).Scan()` does with the default + * `ScanLines` split function: `\r\n`/`\n`-terminated lines with the trailing `\r` (if any) + * stripped, and a final, non-newline-terminated fragment still emitted as its own line. An + * empty input yields zero lines (Go's scanner returns `false` on the very first `Scan()`). + * One known divergence: Go's scanner aborts (`bufio.Scanner: token too long`) on any line + * exceeding `bufio.MaxScanTokenSize` (64KiB) — this function has no such limit, so an + * abnormally long differ progress line is still scanned here where Go would give up. + */ +function legacyScanLines(text: string): ReadonlyArray { + if (text.length === 0) return []; + const lines = text.split("\n"); + // A trailing `\n` produces one trailing empty element from `split` that Go's scanner never + // emits as a token of its own — the `\n` itself already terminated the prior line. + const withoutTrailingNewline = text.endsWith("\n") ? lines.slice(0, -1) : lines; + return withoutTrailingNewline.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); +} + +/** + * Port of Go's `ProcessDiffProgress` (`container_output.go:94-124`) — the StatusMsg lines + * `fakeProgram`/`tea.Program` would print (progress percentages themselves are dropped: + * `ProgressMsg` never prints in either program mode). `"Starting schema diff..."` and any + * non-matching line (Go's `// TODO: emit actual error statements`) produce nothing, matching + * Go's `continue`. Only Go's NON-TTY `fakeProgram` actually prints a StatusMsg via + * `fmt.Println` (`tea.go:57-70`) — on a TTY Go instead runs the real `bubbletea` renderer, + * which repaints ephemeral frames rather than appending printed lines. This port's stdout + * emission (`diff.handler.ts`'s `emitStatus`) targets the non-TTY `fakeProgram` behavior; a + * TTY session's frame-by-frame rendering has no TS equivalent and isn't a parity target. + */ +export function legacyProcessPgAdminDiffProgress(stderr: string): ReadonlyArray { + const statuses: Array = []; + for (const line of legacyScanLines(stderr)) { + const match = LEGACY_PGADMIN_PROGRESS_RE.exec(line); + if (match === null) continue; + statuses.push(match[1] ?? ""); + } + return statuses; +} + +/** + * Go's `DiffDependencies` (`container_output.go:123-125`). Field kept snake_case (the literal + * wire key), not camelCased, so the guard below reads the parsed JSON 1:1. + */ +interface LegacyPgAdminDiffDependency { + readonly type?: string | null; +} + +/** Go's `DiffEntry` (`container_output.go:127-134`) — one `--json-diff` array element. */ +interface LegacyPgAdminDiffEntry { + readonly type?: string | null; + readonly status?: string | null; + readonly diff_ddl?: string | null; + readonly group_name?: string | null; + readonly dependencies?: ReadonlyArray | null; + readonly source_schema_name?: string | null; +} + +/** + * Go's `DiffDependencies` has no custom unmarshaler, so a present field's type is checked + * exactly like every other `DiffEntry` scalar below — see {@link legacyIsPgAdminDiffEntryElement}'s + * own doc comment for the shared "null tolerated per field" rule and its empirical verification. + */ +function legacyIsPgAdminDiffDependencyElement( + value: unknown, +): value is LegacyPgAdminDiffDependency | null { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("type" in value && value.type !== null && typeof value.type !== "string") return false; + return true; +} + +/** + * Structural guard for Go's `DiffEntry` JSON shape, applied to an untrusted `JSON.parse` of + * the differ's stdout. Verified empirically against the real Go struct (`encoding/json`), + * one throwaway `go run` per row: + * - a bare `null` array element unmarshals into the zero-valued struct (every field absent/""), + * so it is accepted here too — the caller normalizes it away before this guard ever sees it; + * - a non-null, non-object element (`{}`/`"x"`/`1`/`true`/an array) always fails Go's whole + * `[]DiffEntry` unmarshal, not just that one entry — rejected here the same way; + * - `null` for an individual DECLARED scalar field (`type`/`status`/`diff_ddl`/`group_name`/ + * `source_schema_name`, all plain `string`/`*string`, no custom unmarshaler) is tolerated + * with no error, leaving the zero value — so `{"status":null}` is accepted, not rejected; + * - a MISTYPED declared field (`{"type":123}`, `{"dependencies":{}}`, `{"dependencies":[1]}`, + * a `dependencies[].type` that isn't a string) fails the whole unmarshal, so every array + * field's own elements are validated too, not just its own top-level shape. + */ +function legacyIsPgAdminDiffEntryElement(value: unknown): value is LegacyPgAdminDiffEntry | null { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("type" in value && value.type !== null && typeof value.type !== "string") return false; + if ("status" in value && value.status !== null && typeof value.status !== "string") return false; + if ("diff_ddl" in value && value.diff_ddl !== null && typeof value.diff_ddl !== "string") { + return false; + } + if ("group_name" in value && value.group_name !== null && typeof value.group_name !== "string") { + return false; + } + if ( + "source_schema_name" in value && + value.source_schema_name !== null && + typeof value.source_schema_name !== "string" + ) { + return false; + } + if ("dependencies" in value && value.dependencies !== null) { + if ( + !Array.isArray(value.dependencies) || + !value.dependencies.every(legacyIsPgAdminDiffDependencyElement) + ) { + return false; + } + } + return true; +} + +/** + * Port of the parse/filter half of Go's `ProcessDiffOutput` (`container_output.go:141-201`) + * — pure, no Effect. Trims the DESKTOP-mode NOTE prefix off the FRONT of `stdout` (a real + * pgAdmin4 quirk, `supabase/pgadmin4#24`), then parses and filters it into the ordered list + * of kept, trimmed DDL strings (Go's `[]DiffEntry` unmarshal + the `switch diffEntry.Type` + * allow-list + internal-schema/extension-dependency filtering). Rendering the header and + * joining is `legacyRenderPgAdminDiff`'s job, kept separate so `legacyDiffSchemaPgAdmin`'s + * run loop can parse EACH run's own buffer (trimming that run's own DESKTOP-mode note, if + * any) and aggregate every run's DDLs before rendering once — completing the intended + * shared-buffer algorithm's purpose (see this module's own header comment) without the + * round-1 regression of gluing raw bytes together first, which turned a multi-`--schema` + * diff where every run individually parsed fine into one spurious `JSON.parse` "trailing + * data" failure. + */ +export function legacyParsePgAdminDiffEntries( + stdout: string, +): Result.Result, { readonly message: string }> { + const trimmed = stdout.startsWith(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX) + ? stdout.slice(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX.length) + : stdout; + if (trimmed.length === 0) return Result.succeed([]); + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (cause) { + return Result.fail({ + message: `failed to parse schema diff output: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } + // `json.Unmarshal` into a non-pointer `[]DiffEntry` accepts a top-level JSON `null` as a + // no-op (nil slice) — normalize it to `[]` before the array guard below, matching + // `legacyIsPgDeltaApplyResult`'s identical `null` handling. + const entries: unknown = parsed === null ? [] : parsed; + if (!Array.isArray(entries) || !entries.every(legacyIsPgAdminDiffEntryElement)) { + return Result.fail({ + message: "failed to parse schema diff output: not a valid schema-diff entry array", + }); + } + + const filteredDdls: Array = []; + for (const rawEntry of entries) { + const entry = rawEntry ?? {}; + const status = entry.status ?? ""; + const diffDdl = entry.diff_ddl ?? ""; + if (status === "Identical" || diffDdl === "") continue; + if (!LEGACY_PGADMIN_DIFF_TYPES.has(entry.type ?? "")) continue; + const dependencies = entry.dependencies ?? []; + if (dependencies.some((dep) => (dep?.type ?? "") === "extension")) continue; + const groupName = entry.group_name ?? ""; + const sourceSchemaName = entry.source_schema_name ?? undefined; + if ( + LEGACY_INTERNAL_SCHEMAS.includes(groupName) || + (sourceSchemaName !== undefined && LEGACY_INTERNAL_SCHEMAS.includes(sourceSchemaName)) + ) { + continue; + } + const trimmedDdl = legacyTrimGoSpace(diffDdl); + if (trimmedDdl.length > 0) filteredDdls.push(trimmedDdl); + } + + return Result.succeed(filteredDdls); +} + +/** Go's `diffHeader`-plus-join half of `ProcessDiffOutput` (`container_output.go:196-200`). */ +export function legacyRenderPgAdminDiff(ddls: ReadonlyArray): string { + if (ddls.length === 0) return ""; + return `${LEGACY_PGADMIN_DIFF_HEADER}\n\n${ddls.join("\n\n")}\n`; +} + +/** + * Parse-then-render composition of the two halves above, applied to a SINGLE, whole buffer + * — kept for callers (and this file's own unit tests) that want Go's `ProcessDiffOutput` as + * one function over one buffer. `legacyDiffSchemaPgAdmin`'s run loop calls + * `legacyParsePgAdminDiffEntries`/`legacyRenderPgAdminDiff` directly instead, once per run, + * so this function's own single-buffer semantics (including the multi-JSON-array + * "trailing data" failure on a buffer that concatenates >=1 complete arrays) are unchanged + * but no longer reachable from a multi-`--schema` diff. + */ +export function legacyProcessPgAdminDiffOutput( + stdout: string, +): Result.Result { + return Result.map(legacyParsePgAdminDiffEntries(stdout), legacyRenderPgAdminDiff); +} + +/** + * Maps `LegacyDockerRunError`'s own three-way docker-boundary discriminant onto this + * command's `reason` union — mirrors `legacyDbSetupDockerReason` (`db-setup.ts`): a spawn + * failure or a detected daemon-down message means the daemon itself is unreachable, a failed + * image inspect is a config/registry-availability issue distinct from a pull failure, and + * everything else at this boundary is a registry-pull failure. `diffMigraBash` + * (`legacy-migra.ts`) keeps its own pre-existing two-way collapse (`inspect` folded into + * `pull`) — out of scope for this port. + */ +function legacyPgAdminDockerReason( + reason: "spawn" | "inspect" | "pull", + daemonDown: boolean, +): "docker_daemon" | "image_inspect" | "registry_pull" { + if (reason === "spawn" || daemonDown) return "docker_daemon"; + if (reason === "pull") return "registry_pull"; + return "image_inspect"; +} + +export interface LegacyDiffSchemaPgAdminParams { + /** Go's `source` — the USER'S db (`ToPostgresURL(flags.DbConfig)`, `pgadmin.go:85`). */ + readonly source: string; + /** Go's `target` — the SHADOW, a raw `Sprintf` (`pgadmin.go:86`), not `ToPostgresURL`. */ + readonly target: string; + readonly schema: ReadonlyArray; + /** Merged onto both docker labels, matching every other container this codebase creates. */ + readonly projectId: string; + /** + * Already `--network-id`/`SUPABASE_NETWORK_ID`/`supabase_network_`-resolved by + * the caller (`legacyResolveNetworkId`, via `legacyBuildLocalDbContainerInputs`'s + * `localInputs.networkId`) — never empty, so this function does no second resolution and, + * unlike `legacy-migra.ts`'s `diffMigraBash`, never falls back to a host network: Go's + * differ always joins a user-defined bridge (`docker.go:379-383`). + */ + readonly networkId: string; + /** Linux-only `host.docker.internal:host-gateway` (`docker_linux.go`); empty elsewhere. */ + readonly extraHosts: ReadonlyArray; + /** Text-mode stdout sink for the `Diffing schema: ` / progress status lines; no-op in machine output modes. */ + readonly emitStatus: (line: string) => Effect.Effect; +} + +/** + * Port of Go's `DiffSchemaPgAdmin` (`pgadmin.go:91-121`) — one differ container run when no + * `--schema` is given, else one run per `--schema` (in flag order), each preceded by its own + * `Diffing schema: ` status. `runCapture`, not `runStream`, because the differ's progress + * lines arrive on STDERR, and `LegacyDockerRun.runStream` only exposes an `onStdout` streaming + * hook — there is no `onStderr` equivalent to observe stderr incrementally through this + * service today. Go, by contrast, DOES live-stream: `NewDiffStream` pipes the container's + * stderr through an `io.Pipe`, with a goroutine scanning `ProcessDiffProgress` off the read end + * WHILE the container is still running, so a status line prints the instant its underlying + * stderr line arrives. This port instead buffers each run's stderr in full via `runCapture` and + * only filters/flushes it (`legacyProcessPgAdminDiffProgress` + `emitStatus`, below) once that + * run's container has already exited — so a multi-`--schema` diff still gets one status batch + * per run, but within a single run every one of its status lines appears together, after the + * fact, instead of as the differ actually emits them. A real fix would add an `onStderr` + * streaming hook to `runStream`, mirroring `onStdout`, and switch this function to it. + * `teeStderr` stays off regardless (Go never tees the differ's raw stderr to the parent + * terminal). The image is passed raw (not pre-resolved via `legacyGetRegistryImageUrl`, unlike + * `diffMigraBash`): `legacyDockerRunLayer`'s own resolver builds the ECR→GHCR→docker.io + * candidate ladder from it — reading `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight off + * `process.env` at call time (no `projectEnvValues` passed through). It is the caller's + * (`diff.handler.ts`) own `legacyApplyProjectEnv` scope, applied right after the config + * load, that makes a registry override set only in the project's `supabase/.env` (not + * the ambient shell) visible to that resolver by the time this function's `runCapture` + * call reaches it. + */ +export const legacyDiffSchemaPgAdmin = ( + params: LegacyDiffSchemaPgAdminParams, +): Effect.Effect => + Effect.gen(function* () { + const docker = yield* LegacyDockerRun; + const labels = { + [LEGACY_CLI_PROJECT_LABEL]: params.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: params.projectId, + }; + const network = { _tag: "named" as const, name: params.networkId }; + const runs: ReadonlyArray = + params.schema.length === 0 ? [undefined] : params.schema; + + const ddls: Array = []; + for (const s of runs) { + if (s !== undefined) yield* params.emitStatus(`Diffing schema: ${s}`); + const cmd = + s === undefined + ? ["--json-diff", params.source, params.target] + : ["--schema", s, "--json-diff", params.source, params.target]; + const result = yield* docker + .runCapture({ + image: LEGACY_DIFFER_IMAGE, + cmd, + env: {}, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts: params.extraHosts, + network, + labels, + }) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbDiffPgAdminError({ + message: cause.message, + reason: legacyPgAdminDockerReason(cause.reason, cause.daemonDown), + }), + ), + ); + // Emitted BEFORE the exit-code check below, matching Go's stderr goroutine: it scans + // `ProcessDiffProgress` off the container's stderr concurrently with the container + // still running (`NewDiffStream`'s `io.Pipe`), so a failed run's own status lines still + // print ahead of the container error surfacing. Returning early on a nonzero exit + // before reaching this would silently drop that run's already-captured statuses. + for (const line of legacyProcessPgAdminDiffProgress(result.stderr)) { + yield* params.emitStatus(line); + } + if (result.exitCode !== 0) { + // Go's `error running container: exit %d` (`docker.go:582-590`) — the differ's own + // stderr is never surfaced beyond the progress-line filter above; any non-matching + // line is silently dropped, even under `--debug`. + return yield* Effect.fail( + new LegacyDbDiffPgAdminError({ + message: `error running container: exit ${result.exitCode}`, + reason: "differ", + }), + ); + } + const stdout = new TextDecoder().decode(result.stdout); + // Parsed per run — completing the intended shared-buffer algorithm's actual purpose + // (see this module's own header comment) rather than round 1's literal-minded + // concatenate-then-parse-once, which turned a multi-`--schema` diff whose every run + // individually parsed fine into a spurious "trailing data" `JSON.parse` failure. + const parsed = legacyParsePgAdminDiffEntries(stdout); + if (Result.isFailure(parsed)) { + return yield* Effect.fail( + new LegacyDbDiffPgAdminError({ + message: parsed.failure.message, + reason: "invalid_output", + }), + ); + } + ddls.push(...parsed.success); + } + + return legacyRenderPgAdminDiff(ddls); + }); diff --git a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts new file mode 100644 index 0000000000..b533eb8cfb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Result } from "effect"; + +import { + LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX, + LEGACY_PGADMIN_DIFF_HEADER, + legacyParsePgAdminDiffEntries, + legacyProcessPgAdminDiffOutput, + legacyProcessPgAdminDiffProgress, + legacyRenderPgAdminDiff, +} from "./legacy-pgadmin-diff.ts"; + +/** Go's `DiffEntry` (`container_output.go:127-134`) shape, defaulting to a kept entry. */ +function entry(overrides: Record = {}) { + return { + type: "table", + status: "Different", + diff_ddl: "ALTER TABLE test;", + group_name: "public", + ...overrides, + }; +} + +const headerPlus = (ddl: string) => `${LEGACY_PGADMIN_DIFF_HEADER}\n\n${ddl}\n`; + +describe("legacyProcessPgAdminDiffOutput", () => { + describe("filtering rules (container_output.go:154-195)", () => { + it("keeps DDL from every whitelisted entry type, joined under the exact 4-line pgAdmin header", () => { + // Go test parity: `TestProcessDiffOutput/processes valid diff entries`. + const types = ["extension", "function", "mview", "table", "trigger_function", "type", "view"]; + const entries = types.map((type, i) => entry({ type, diff_ddl: `DDL_${i};` })); + const result = legacyProcessPgAdminDiffOutput(JSON.stringify(entries)); + const expectedDdls = types.map((_, i) => `DDL_${i};`).join("\n\n"); + expect(result).toEqual(Result.succeed(headerPlus(expectedDdls))); + }); + + it("skips an entry whose status is Identical, even with a non-empty diff_ddl", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ status: "Identical" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips an entry whose diff_ddl is empty", () => { + const result = legacyProcessPgAdminDiffOutput(JSON.stringify([entry({ diff_ddl: "" })])); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips an entry whose diff_ddl is only whitespace after Go's TrimSpace", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ diff_ddl: " \n\t " })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips entries whose type is outside the pgAdmin allow-list (e.g. sequence, index)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ type: "sequence" }), entry({ type: "index" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it('skips an entry with no type field at all, given a non-empty diff_ddl (defaults to "", outside the allow-list)', () => { + // Distinct from the `[{"unknown":1}]` acceptance-rule case below, whose empty + // `diff_ddl` short-circuits at the PRIOR `status === "Identical" || diff_ddl === ""` + // check — this covers the `type` fallback itself. + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([ + { status: "Different", diff_ddl: "ALTER TABLE test;", group_name: "public" }, + ]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps an entry with no group_name field at all (empty group name is not an internal schema)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([{ type: "table", status: "Different", diff_ddl: "ALTER TABLE test;" }]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("skips an entry when any dependency has type extension", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [{ type: "table" }, { type: "extension" }] })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps an entry whose dependencies are all non-extension types", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [{ type: "table" }, { type: "view" }] })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("skips an entry whose group_name is an internal schema (auth)", () => { + // Go test parity: `TestProcessDiffOutput/filters out internal schemas`. + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ group_name: "auth" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips a trigger_function entry whose source_schema_name is an internal schema", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([ + entry({ type: "trigger_function", group_name: "public", source_schema_name: "auth" }), + ]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps group_name pg_catalog — internal-schema filtering is exact-string, not a pg_* glob", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ group_name: "pg_catalog" })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("trims each kept DDL with Go's TrimSpace before joining", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ diff_ddl: " ALTER TABLE test; \n" })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + }); + + describe("empty / DESKTOP-mode-prefix handling (container_output.go:141-147)", () => { + it("returns an empty string for an entirely empty buffer", () => { + expect(legacyProcessPgAdminDiffOutput("")).toEqual(Result.succeed("")); + }); + + it("trims the DESKTOP-mode NOTE prefix from the front of the buffer before parsing", () => { + const payload = LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX + JSON.stringify([entry()]); + expect(legacyProcessPgAdminDiffOutput(payload)).toEqual( + Result.succeed(headerPlus("ALTER TABLE test;")), + ); + }); + + it("returns an empty string when the buffer is only the DESKTOP-mode NOTE prefix", () => { + expect(legacyProcessPgAdminDiffOutput(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX)).toEqual( + Result.succeed(""), + ); + }); + + it("does not trim the DESKTOP-mode NOTE prefix when it isn't at the very front (Go's bytes.TrimPrefix is front-anchored only)", () => { + const payload = `[]${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}`; + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }); + }); + + // Go-acceptance-rules table (`json.Unmarshal` into `[]DiffEntry`, `container_output.go:127-134`), + // verified against Go 1.26 `encoding/json`. + describe("Go encoding/json acceptance rules", () => { + it("treats a top-level JSON null the same as Go's nil-slice no-op", () => { + expect(legacyProcessPgAdminDiffOutput("null")).toEqual(Result.succeed("")); + }); + + it("returns an empty string for an empty array", () => { + expect(legacyProcessPgAdminDiffOutput("[]")).toEqual(Result.succeed("")); + }); + + it("accepts a null array element (Go unmarshals it into the zero-valued struct) and skips it", () => { + expect(legacyProcessPgAdminDiffOutput("[null]")).toEqual(Result.succeed("")); + }); + + it.each(["{}", '"x"', "1", "true"])( + "rejects a non-array top-level JSON value (%s)", + (payload) => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }, + ); + + it("rejects an array whose element is neither an object nor null (e.g. a bare number)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput("[1]"))).toBe(true); + }); + + it("rejects an array whose element is neither an object nor null (e.g. a bare string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('["x"]'))).toBe(true); + }); + + it("rejects an array element that is itself an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput("[[]]"))).toBe(true); + }); + + it("accepts an unknown field and treats the entry as if absent, skipping it", () => { + expect(legacyProcessPgAdminDiffOutput('[{"unknown":1}]')).toEqual(Result.succeed("")); + }); + + it("treats a null status field as absent, not as Identical", () => { + const result = legacyProcessPgAdminDiffOutput(JSON.stringify([entry({ status: null })])); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("treats a null dependencies field as absent (no dependency filtering applied)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: null })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("accepts a null dependency element and does not treat it as an extension dependency", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [null] })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects a dependencies array whose element is itself an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[[]]}]'))).toBe( + true, + ); + }); + + it("treats a null source_schema_name field as absent (not internal-schema-filtered)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ source_schema_name: null })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects a mistyped type field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"type":123}]'))).toBe(true); + }); + + it("rejects a mistyped status field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"status":123}]'))).toBe(true); + }); + + it("rejects a mistyped diff_ddl field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"diff_ddl":123}]'))).toBe(true); + }); + + it("rejects a mistyped group_name field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"group_name":123}]'))).toBe(true); + }); + + it("rejects a mistyped source_schema_name field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"source_schema_name":123}]'))).toBe( + true, + ); + }); + + it("rejects a dependencies field that isn't an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":{}}]'))).toBe(true); + }); + + it("rejects a dependencies array whose element isn't an object or null (e.g. a number)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[1]}]'))).toBe(true); + }); + + it("rejects a dependency element with a mistyped type field", () => { + expect( + Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[{"type":1}]}]')), + ).toBe(true); + }); + + it("accepts trailing whitespace after the JSON array", () => { + const result = legacyProcessPgAdminDiffOutput(`${JSON.stringify([entry()])}\n \n`); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects two concatenated JSON arrays in a SINGLE buffer (this function's own single-buffer contract — legacyDiffSchemaPgAdmin no longer feeds it a multi-run concatenation; see legacyParsePgAdminDiffEntries below for the per-run parse)", () => { + const payload = `${JSON.stringify([entry()])}${JSON.stringify([entry()])}`; + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }); + }); +}); + +describe("legacyParsePgAdminDiffEntries", () => { + it("returns an empty array for an entirely empty buffer", () => { + expect(legacyParsePgAdminDiffEntries("")).toEqual(Result.succeed([])); + }); + + it("returns an empty array when the buffer is only the DESKTOP-mode NOTE prefix", () => { + expect(legacyParsePgAdminDiffEntries(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX)).toEqual( + Result.succeed([]), + ); + }); + + it("trims the DESKTOP-mode NOTE prefix from the front of the buffer before parsing", () => { + const payload = LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX + JSON.stringify([entry()]); + expect(legacyParsePgAdminDiffEntries(payload)).toEqual(Result.succeed(["ALTER TABLE test;"])); + }); + + it("returns the ordered, filtered, trimmed DDLs — not the rendered header/join", () => { + const entries = [entry({ diff_ddl: "DDL_1;" }), entry({ diff_ddl: "DDL_2;" })]; + expect(legacyParsePgAdminDiffEntries(JSON.stringify(entries))).toEqual( + Result.succeed(["DDL_1;", "DDL_2;"]), + ); + }); + + it("fails on two concatenated JSON arrays within one buffer, same as legacyProcessPgAdminDiffOutput", () => { + const payload = `${JSON.stringify([entry()])}${JSON.stringify([entry()])}`; + expect(Result.isFailure(legacyParsePgAdminDiffEntries(payload))).toBe(true); + }); +}); + +describe("legacyRenderPgAdminDiff", () => { + it("returns an empty string for an empty DDL list", () => { + expect(legacyRenderPgAdminDiff([])).toBe(""); + }); + + it("renders the pgAdmin header followed by every DDL joined with a blank line", () => { + expect(legacyRenderPgAdminDiff(["DDL_1;", "DDL_2;"])).toBe(headerPlus("DDL_1;\n\nDDL_2;")); + }); +}); + +describe("legacyProcessPgAdminDiffProgress", () => { + it.each([ + ["Comparing Tables 45%", ["Comparing Tables "]], + ["Diffing 100%", ["Diffing 1"]], + // `container_output.go:96`'s real regexp and JS both produce group1="10", + // group2="00" for "1000%" (verified against Go 1.26 `regexp`) — NOT ["1"]. + ["1000%", ["10"]], + ["5%", []], + ["Starting schema diff...", []], + ["some random noise line", []], + ["", []], + ] as const)("%s => %j", (line, expected) => { + expect(legacyProcessPgAdminDiffProgress(line)).toEqual(expected); + }); + + it("scans multiple lines and strips \\r\\n line endings before matching", () => { + const input = + "Starting schema diff...\r\nComparing Tables 45%\r\nnoise line\r\nDiffing 100%\r\n"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual(["Comparing Tables ", "Diffing 1"]); + }); + + it("still emits a match on the final line even without a trailing newline", () => { + const input = "Starting schema diff...\nDiffing 100%"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual(["Diffing 1"]); + }); + + it("matches across embedded \\r within a single line (the `s`/dotAll flag, Go's RE2 . matches \\r)", () => { + // A `\r`-driven progress bar overwrites the same terminal line with multiple + // updates, none of them `\n`-terminated, so `legacyScanLines` treats the whole + // thing as ONE line. With the `s` flag, `.` matches `\r` too, so the greedy + // `(.*)` consumes across every embedded `\r` and the match is anchored on the + // LAST `%`-suffixed run, same as Go's RE2 (verified against Go 1.26 `regexp`) + // — not the first, which is what this pattern would wrongly match without `s` + // (JS's `.` excludes `\r` by default). + const input = "Comparing 10%\rComparing 20%\rComparing 30%"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual([ + "Comparing 10%\rComparing 20%\rComparing ", + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md index 477ee86159..08279b87d8 100644 --- a/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/dump/SIDE_EFFECTS.md @@ -5,13 +5,14 @@ script run inside the local Postgres image to stdout or `--file`. ## Files Read -| Path | Format | When | -| --------------------------------- | ---------- | ----------------------------------------------------------- | -| `supabase/config.toml` | TOML | always (db port/password/major_version, project_id) | -| `supabase/.temp/postgres-version` | plain text | always (best-effort) — pins the pg image tag when present | -| `supabase/.temp/pooler-url` | plain text | `--linked` when the direct host is unreachable (pooler URL) | -| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | -| `supabase/.env*` | dotenv | always (project env, feeds `SUPABASE_DB_PASSWORD` / `PG*`) | +| Path | Format | When | +| --------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `supabase/config.toml` | TOML | always (db port/password/major_version, project_id) | +| `supabase/.temp/postgres-version` | plain text | always (best-effort) — pins the pg image tag when present | +| `supabase/.temp/pooler-url` | plain text | `--linked` when the direct host is unreachable (pooler URL) | +| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | `--linked` (and the default target) ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`/config.toml `project_id`) is set | +| `supabase/.env*` | dotenv | always (project env, feeds `SUPABASE_DB_PASSWORD` / `PG*`) | ## Files Written @@ -44,6 +45,7 @@ script run inside the local Postgres image to stdout or `--file`. | ---- | ----------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | `--use-copy`/`--exclude` without `--data-only`; mutually-exclusive flags; bad `--file` path; connection failure; container exit ≠ 0 | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes / Divergences) | ## Output @@ -71,6 +73,14 @@ the IPv4 transaction-pooler suggestion (Go's `SetConnectSuggestion`/`ipv6Suggest - `--data-only` XOR `--role-only`; `--keep-comments` XOR `--data-only`; `--schema` XOR `--role-only`; `--db-url` XOR `--linked` XOR `--local`. `--use-copy` / `--exclude` require `--data-only`. `--linked` defaults to true. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution used for the connection and + the linked-project cache (flag > `SUPABASE_PROJECT_ID`/config.toml + `project_id` > `.temp/project-ref`) — it does not affect any local container + id. It never implies `--linked`: passing it with a resolved + `--local`/`--db-url` target is a hard error rather than a silently discarded + flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent + env var simply leaves unused on a non-linked target). - **Container-level pooler fallback is ported** (`RunWithPoolerFallback`, `internal/db/dump/pooler_fallback.go`). When a linked dump reaches the direct host from the host process but the `pg_dump` container fails over IPv6, the captured diff --git a/apps/cli/src/legacy/commands/db/dump/dump.command.ts b/apps/cli/src/legacy/commands/db/dump/dump.command.ts index 1251f15a70..00c8064b71 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.command.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.command.ts @@ -85,6 +85,11 @@ const config = { Flag.withDescription("Dumps from the local database."), Flag.optional, ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -122,12 +127,16 @@ export const legacyDbDumpCommand = Command.make("dump", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, // `password` must never be added to `safeFlags` — it is a credential and // must always reach telemetry as `` (matches Go, which never // marks `--password` telemetry-safe). password: flags.password, schema: flags.schema, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. // Map dump's shorthand flags to their canonical names so a shorthand // invocation (`-s`/`-x`/`-f`/`-p`) is reported in telemetry under the long // name, matching Go's `pflag.Visit` → `flag.Name` (`cmd/root_analytics.go`). diff --git a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts index d7de51c62d..4c97617bc9 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * `--use-copy` / `--exclude` were passed without `--data-only`. Reproduces @@ -9,7 +14,11 @@ export class LegacyDbDumpRequiresDataOnlyError extends Data.TaggedError( "LegacyDbDumpRequiresDataOnlyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Two mutually exclusive flags were set together. Reproduces cobra's @@ -20,7 +29,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbDumpMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Failed to open the `--file` output path. Byte-matches Go's @@ -28,7 +41,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( */ export class LegacyDbDumpOpenFileError extends Data.TaggedError("LegacyDbDumpOpenFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The pg_dump container exited non-zero. Byte-matches Go's @@ -41,4 +58,8 @@ export class LegacyDbDumpRunError extends Data.TaggedError("LegacyDbDumpRunError // transaction-pooler guidance. `Output.fail` prints it bare on stderr after the // error message, mirroring Go's `recoverAndExit`. readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index d7021d20ca..d67ae752cf 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; @@ -10,7 +11,6 @@ import { legacyLoadProjectEnv, legacyReadDbToml, } from "../../../shared/legacy-db-config.toml-read.ts"; -import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyIpv6Suggestion, @@ -32,14 +32,14 @@ import { legacyBuildRoleDumpEnv, legacyBuildSchemaDumpEnv, legacyExpandScript, -} from "../shared/legacy-pg-dump.env.ts"; -import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; import { legacyRunWithPoolerFallback } from "../shared/legacy-pooler-fallback.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "../shared/legacy-pg-dump.scripts.ts"; +} from "../../../shared/legacy-pg-dump.scripts.ts"; /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined @@ -82,7 +82,8 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // image), reverted when this scope closes. Go's `loadNestedEnv` `os.Setenv`s the // project `.env`; the pure `legacyLoadProjectEnv` no longer does that as a side // effect of `resolveDbPassword`, so `db dump` opts in explicitly here. - yield* legacyApplyProjectEnv(yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir)); + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + yield* legacyApplyProjectEnv(projectEnv); // The grouped boolean flags are modelled as `Option` (presence = pflag `Changed`) // for the mutex/target checks; resolve their effective values here for the places @@ -152,20 +153,30 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy : useLocal ? "local" : "linked"; + // `--project-ref` never implies `--linked` and must not be silently discarded + // on a non-linked target — one-liner: see push.handler.ts's identical guard + // for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyDbDumpMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } // Go's `LoadProjectRef` sets `flags.ProjectRef` BEFORE `NewDbConfigWithPassword` // (`flags/db_url.go:88` vs `:95`), and `ensureProjectGroupsCached` runs on failure // too (`cmd/root.go:176`), so a connection-resolution failure (IPv6 / pooler / - // login-role) still refreshes the linked-project cache. The resolver only returns - // the ref on success, so capture it up-front for the linked path. `db dump` has no - // `--project-ref` flag, so the ref comes from config.toml `project_id` then the - // `.temp/project-ref` file — the same chain `resolveOptional`/smart generate use. + // login-role) still refreshes the linked-project cache. Capture the ref up-front + // for the linked path via `loadProjectRef`, which implements the same flag > + // SUPABASE_PROJECT_ID/project_id > `.temp/project-ref` file precedence as the + // resolver, now validated — it raises the same invalid-ref/not-linked errors + // `resolver.resolve()` would raise right after, so the user-visible error surface + // is unchanged, and an unvalidated raw `--project-ref` value is never stored for + // the cache finalizer to send to the Management API. if (connType === "linked") { - const refOpt = Option.isSome(cliConfig.projectId) - ? cliConfig.projectId - : yield* legacyReadProjectRefFile(fs, path, cliConfig.workdir); - if (Option.isSome(refOpt)) { - linkedRefForCache = refOpt.value; - } + const refResolver = yield* LegacyProjectRefResolver; + linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); } const { conn, @@ -176,6 +187,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy connType, dnsResolver, password: flags.password, + linkedProjectRef: flags.projectRef, }); const db = isLocal ? "local" : "remote"; // On the linked path, re-read config with the resolved ref so a matching @@ -307,6 +319,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy env, onStdout: (chunk) => file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), + projectEnvValues: projectEnv, }); }), ), @@ -320,6 +333,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy script: mode.script, env, onStdout: (chunk) => output.rawBytes(chunk), + projectEnvValues: projectEnv, }); // 7b. Container-level IPv6 → IPv4-pooler retry (Go's `RunWithPoolerFallback`, @@ -344,6 +358,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy connType: "linked", dnsResolver, password: flags.password, + linkedProjectRef: flags.projectRef, }) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: (c) => runContainer(mode.buildEnv(c, opt)), diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 52226baada..4194eb9bf1 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -6,6 +6,7 @@ import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { + LEGACY_VALID_REF, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, @@ -16,6 +17,16 @@ import { LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "../../../config/legacy-project-ref.errors.ts"; +import { + INVALID_PROJECT_REF_MESSAGE, + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, + PROJECT_REF_PATTERN, +} from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags } from "../../../shared/legacy-db-config.types.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; @@ -63,10 +74,19 @@ function mockResolver(opts: { new LegacyDbConfigConnectTempRoleError({ message: "failed to create temp role" }), ); } + // A threaded `--project-ref` flag wins over the fixed `opts.ref` test + // fixture, same top precedence a real resolver would give it — lets a + // test prove the flag (not just `opts.ref`) drives the resolved (and + // later cached) ref. + const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); + const ref = + Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 + ? linkedProjectRef.value + : opts.ref; return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true, - ref: opts.ref === undefined ? undefined : Option.some(opts.ref), + ref: ref === undefined ? undefined : Option.some(ref), }); }, resolvePoolerFallback: (flags) => { @@ -89,6 +109,49 @@ function mockResolver(opts: { }; } +/** + * Mocks `LegacyProjectRefResolver` for the up-front `loadProjectRef` pre-capture + * (`dump.handler.ts`), mirroring push/diff's identical mock (`push.integration.test.ts`, + * `diff.integration.test.ts`): `loadProjectRef` gives an explicit `--project-ref` flag + * top precedence, same as Go's `flags.LoadProjectRef` — a real (non-empty) ref pattern + * is validated so a malformed flag surfaces `LegacyInvalidProjectRefError`, matching the + * real service. `opts.projectId` stands in for `LegacyCliConfig.projectId` + * (`SUPABASE_PROJECT_ID`/`project_id`), which `loadProjectRef` consults before falling + * back to `opts.ref` (the SAME ref `mockResolver`'s own mock embeds in its resolved + * `ref`, so both stay consistent regardless of which fixture a test sets). + * `opts.linkedFails` simulates a genuinely unlinked workdir absent an explicit flag. + */ +function mockProjectRefResolver(opts: { + projectId: Option.Option; + ref?: string; + linkedFails?: boolean; +}) { + const validate = (ref: string) => + PROJECT_REF_PATTERN.test(ref) + ? Effect.succeed(ref) + : Effect.fail( + new LegacyInvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }), + ); + const layer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? LEGACY_VALID_REF)), + loadProjectRef: (flagValue: Option.Option) => { + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + return validate(flagValue.value); + } + if (Option.isSome(opts.projectId)) { + return validate(opts.projectId.value); + } + return opts.linkedFails === true + ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) + : Effect.succeed(opts.ref ?? LEGACY_VALID_REF); + }, + promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + }); + return { layer }; +} + interface DockerResult { exitCode?: number; stdout?: string; @@ -112,7 +175,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); @@ -130,7 +197,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return yield* Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); @@ -176,6 +247,7 @@ interface SetupOpts { projectId?: Option.Option; resolveFails?: boolean; ref?: string; + linkedFails?: boolean; } function setup(opts: SetupOpts = {}) { @@ -190,10 +262,16 @@ function setup(opts: SetupOpts = {}) { resolveFails: opts.resolveFails, ref: opts.ref, }); + const projectRef = mockProjectRefResolver({ + projectId: opts.projectId ?? Option.none(), + ref: opts.ref, + linkedFails: opts.linkedFails, + }); const docker = mockDockerRun(opts); const layer = Layer.mergeAll( out.layer, resolver.layer, + projectRef.layer, docker.layer, mockLegacyCliConfig({ workdir: opts.workdir ?? "/work/project", @@ -223,6 +301,7 @@ const flags = (over: Partial = {}): LegacyDbDumpFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), local: over.local ?? Option.none(), + projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), schema: over.schema ?? [], }); @@ -518,6 +597,33 @@ describe("legacy db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + const { layer, docker } = setup({ isLocal: true, workdir: tmp.current }); + return Effect.gen(function* () { + yield* legacyDbDump(flags({ local: Option.some(true) })); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(layer), + ); + }, + ); + it.live("defaults to the linked connection when neither --local nor --db-url is set", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { @@ -544,11 +650,38 @@ describe("legacy db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "caches the flag ref, not the workdir's own config ref, when resolution fails (regression)", + () => { + // The pre-connect `linkedRefForCache` chain must check `flags.projectRef` + // FIRST — before config.toml's `project_id` and the `.temp/project-ref` + // file — so a `--project-ref` override still wins even when `resolve()` + // fails before ever returning its own `ref`. `opts.projectId` here stands + // in for the workdir's own linked ref (e.g. config.toml `project_id`); + // it must lose to the flag. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup({ + projectId: Option.some("abcdefghijklmnopqrst"), + resolveFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbDump( + flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("does not cache when the linked ref is unknown and resolution fails", () => { // No config project_id and no .temp/project-ref file (workdir is a throwaway - // path), so the ref is never loaded; Go gates ensureProjectGroupsCached on - // flags.ProjectRef != "", so nothing is cached. - const { layer, cache } = setup({ resolveFails: true }); + // path), so the up-front `loadProjectRef` pre-capture itself fails "not linked" + // (linkedFails) before `resolve()` is ever reached; Go gates + // ensureProjectGroupsCached on flags.ProjectRef != "", so nothing is cached. + const { layer, cache } = setup({ resolveFails: true, linkedFails: true }); return Effect.gen(function* () { const exit = yield* legacyDbDump(flags({ linked: Option.some(true) })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -569,6 +702,79 @@ describe("legacy db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("dumps the project given via --project-ref without a linked workdir", () => { + // No fixed `opts.ref` fixture — only the flag can resolve a ref for the + // resolver call and the linked-project cache. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache, resolver } = setup({ + conn: REMOTE_CONN, + isLocal: false, + stdout: "CREATE SCHEMA public;\n", + }); + return Effect.gen(function* () { + yield* legacyDbDump(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("--project-ref overrides an already-linked workdir's project ref", () => { + const FLAG_REF = "flagflagflagflagflag"; + // The workdir already resolves to a fixed ref (e.g. via .temp/project-ref) — + // the flag must win over it. + const { layer, cache } = setup({ + conn: REMOTE_CONN, + isLocal: false, + ref: "abcdefghijklmnopqrst", + stdout: "CREATE SCHEMA public;\n", + }); + return Effect.gen(function* () { + yield* legacyDbDump(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "rejects a malformed --project-ref on the linked path before resolving or caching", + () => { + // The pre-capture now runs the SAME validated `loadProjectRef` the resolver + // would raise right after (codex review on dump.handler.ts:182), so a malformed + // flag value must fail fast — never reaching `resolver.resolve()` (no + // connection/API work) and never writing the linked-project cache (no + // `GET /v1/projects/*`). + const { layer, cache, resolver } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyDbDump( + flags({ linked: Option.some(true), projectRef: Option.some("BADREF") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe(INVALID_PROJECT_REF_MESSAGE); + expect(resolver.calls).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver, cache } = setup({ isLocal: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbDump( + flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + // The guard fires before any connection resolution or cache write. + expect(resolver.calls).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("writes the dump to --file and reports the absolute path on stderr", () => { const filePath = join(tmp.current, "out.sql"); const { layer, out } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.layers.ts b/apps/cli/src/legacy/commands/db/dump/dump.layers.ts index b53df45a04..25e9ddf682 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.layers.ts @@ -2,7 +2,9 @@ import { Layer } from "effect"; import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; @@ -16,11 +18,14 @@ import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime. * Runtime layer for `supabase db dump`. * * Mirrors `test db`'s composition (`legacy/shared/legacy-test-db.layers.ts`): the - * Management API stack is built lazily inside the resolver's `--linked` branch, - * so this layer only exposes the always-needed, auth-free services. The dump - * handler reaches the database through a pg_dump container (`LegacyDockerRun`), - * never a direct connection, but the resolver still needs `LegacyDbConnection` - * for the linked pooler temp-role probe. + * bulk of the Management API stack is still built lazily inside the resolver's + * `--linked` branch. The one exception is `LegacyProjectRefResolver`, exposed here + * (same shape as `db push`, `push.layers.ts:40-50`) so the handler's up-front + * `loadProjectRef` pre-capture can validate `--project-ref` before the + * linked-project-cache finalizer ever sees it. The dump handler reaches the + * database through a pg_dump container (`LegacyDockerRun`), never a direct + * connection, but the resolver still needs `LegacyDbConnection` for the linked + * pooler temp-role probe. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -29,6 +34,24 @@ const credentials = legacyCredentialsLayer.pipe( Layer.provide(legacyDebugLoggerLayer), ); +// Deliberately the **lazy** `legacyPlatformApiFactoryLayer` (not the eager +// management-API runtime), so dump's auth-free `--linked --password` path never +// resolves an access token at layer-build time — same rationale as `db push` +// (`push.layers.ts:26-31`). +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +// Exposed so the handler can pre-validate `--project-ref` via `loadProjectRef` +// before the linked-project-cache finalizer ever sees it. +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + // Exposed so the handler can cache the linked project (GET /v1/projects/{ref}) in // its post-run finalizer — Go's `ensureProjectGroupsCached` (cmd/root.go:214-234). // Shares the single `legacyIdentityStitchLayer` (Go's one `sync.Once`). @@ -56,6 +79,7 @@ export const legacyDbDumpRuntimeLayer = Layer.mergeAll( legacyDbConnectionLayer, legacyDockerRunLayer, cliConfig, + projectRef, linkedProjectCache, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md index 49f2aac8ed..85bc83dff5 100644 --- a/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/lint/SIDE_EFFECTS.md @@ -5,10 +5,11 @@ Native TypeScript port of Go's `internal/db/lint`. ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | -------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — to resolve the local / linked DB connection config | -| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| Path | Format | When | +| -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always — to resolve the local / linked DB connection config | +| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the project ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -62,6 +63,7 @@ the extension fails at step 3 (matching Go). | `1` | connection / `BEGIN` / list-schemas / enable-extension / query failure | | `1` | malformed `plpgsql_check` JSON | | `1` | an issue's level is at or above `--fail-on` | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -91,4 +93,11 @@ the process exits non-zero (no error envelope is written over the payload). - `--fail-on` (`none` default) sets the level that forces a non-zero exit. - `--schema` / `-s` restricts linting to specific schemas; omitted ⇒ all user schemas. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution used for the connection and + the linked-project cache (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`). It never implies `--linked`: passing it with a + resolved `--local`/`--db-url` target is a hard error rather than a silently + discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's + equivalent env var simply leaves unused on a non-linked target). - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/legacy/commands/db/lint/lint.command.ts b/apps/cli/src/legacy/commands/db/lint/lint.command.ts index f754b6f42c..ae0d197656 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.command.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.command.ts @@ -19,6 +19,11 @@ const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Lints the local database for schema errors."), ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), schema: Flag.string("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), @@ -50,6 +55,7 @@ export const legacyDbLintCommand = Command.make("lint", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, schema: flags.schema, level: flags.level, "fail-on": flags.failOn, @@ -57,6 +63,10 @@ export const legacyDbLintCommand = Command.make("lint", config).pipe( // level/fail-on are Flag.choice and are auto-detected as safe via // `config` below (Go's isEnumFlag, cmd/root_analytics.go:110-116). // --schema stays redacted: it's a []string slice flag in Go, not an EnumFlag. + // --project-ref is a TS-only flag with no Go telemetry-safety baseline + // either; Go's nearest --project-ref registrations + // (cmd/pgdelta_catalog.go:44 and most others) are unmarked, so it stays + // redacted too. config, // Go's changedFlags() uses pflag Visit, which reports the canonical // `schema` name even for the `-s` shorthand (cmd/db.go:506); map it so diff --git a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts index 73c295a688..de21f7b547 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Tagged errors for `db lint`, one per Go failure path * (`internal/db/lint/lint.go`). The `message` byte-matches Go's `errors.Errorf` @@ -12,34 +18,62 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbLintMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbLintMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `failed to begin transaction: %w` (`lint.go:111`). */ export class LegacyDbLintBeginTxError extends Data.TaggedError("LegacyDbLintBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to list schemas: %w` (`drop.go:46`, via `ListUserSchemas`). */ export class LegacyDbLintListSchemasError extends Data.TaggedError("LegacyDbLintListSchemasError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to enable pgsql_check: %w` (`lint.go:126`). */ export class LegacyDbLintEnableCheckError extends Data.TaggedError("LegacyDbLintEnableCheckError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to query rows: %w` (`lint.go:140`). */ export class LegacyDbLintQueryError extends Data.TaggedError("LegacyDbLintQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to marshal json: %w` (`lint.go:151`). */ export class LegacyDbLintMalformedJsonError extends Data.TaggedError( "LegacyDbLintMalformedJsonError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `fail-on is set to %s, non-zero exit` (`lint.go:72`). */ export class LegacyDbLintFailOnError extends Data.TaggedError("LegacyDbLintFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/lint/lint.handler.ts b/apps/cli/src/legacy/commands/db/lint/lint.handler.ts index d302197029..caf3e9ddd6 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.handler.ts @@ -119,6 +119,18 @@ const runLint = Effect.fnUntraced(function* ( ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && target.connType !== "linked") { + return yield* Effect.fail( + new LegacyDbLintMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const level = Option.getOrElse(flags.level, () => "warning"); const failOn = Option.getOrElse(flags.failOn, () => "none"); @@ -136,6 +148,7 @@ const runLint = Effect.fnUntraced(function* ( dbUrl: flags.dbUrl, connType: target.connType ?? "local", dnsResolver, + linkedProjectRef: flags.projectRef, }); const results = yield* Effect.scoped( @@ -214,7 +227,7 @@ const runLint = Effect.fnUntraced(function* ( if (target.connType === "linked") { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.projectRef); return yield* lintBody.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); } return yield* lintBody; diff --git a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts index 3d5d8f129c..bb914159a1 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts @@ -147,10 +147,15 @@ function mockProjectRef() { resolve: () => Effect.succeed(LEGACY_VALID_REF), resolveForLink: () => Effect.succeed(LEGACY_VALID_REF), resolveOptional: () => Effect.succeed(Option.some(LEGACY_VALID_REF)), - loadProjectRef: () => + // Gives an explicit `--project-ref` flag top precedence, same as Go's + // `flags.LoadProjectRef` — mirrors the real resolver so a test can prove the + // flag (not just the hardcoded fallback) drives the linked ref. + loadProjectRef: (flagValue: Option.Option) => Effect.sync(() => { calls.push("loadProjectRef"); - return LEGACY_VALID_REF; + return Option.isSome(flagValue) && flagValue.value.length > 0 + ? flagValue.value + : LEGACY_VALID_REF; }), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -208,6 +213,7 @@ const flags = (over: Partial = {}): LegacyDbLintFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), schema: over.schema ?? [], level: over.level ?? Option.none<"warning" | "error">(), failOn: over.failOn ?? Option.none<"none" | "warning" | "error">(), @@ -481,6 +487,24 @@ describe("legacy db lint", () => { }).pipe(Effect.provide(layer)); }); + it.live("lints the project given via --project-ref, overriding the workdir's own ref", () => { + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag (e.g. .temp/project-ref) — + // the flag must win over it and drive the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup({ + isLocal: false, + checkRows: { public: [] }, + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* legacyDbLint(flags({ schema: ["public"], projectRef: Option.some(FLAG_REF) })); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + it.live("does not write the linked-project cache for a local run", () => { const { layer, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { @@ -490,6 +514,27 @@ describe("legacy db lint", () => { }).pipe(Effect.provide(layer)); }); + it.live("rejects --project-ref on the default local target", () => { + // lint defaults to local when no target flag is set — the guard must fire + // from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, connection, cache } = setup({ checkRows: { public: [] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbLint(flags({ schema: ["public"], projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection or cache write. + expect(connection.execs).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("flushes telemetry on success and on failure", () => { const success = setup({ checkRows: { public: [] } }); const failure = setup({ enableFails: true }); diff --git a/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts index 0ae5403b2c..94e01b7d4d 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts @@ -30,15 +30,16 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, mockTelemetryRuntime, mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -59,6 +60,8 @@ import { LegacyIdentityStitch } from "../../../shared/legacy-identity-stitch.ts" import { legacyDbAdvisorsRuntimeLayer } from "../advisors/advisors.layers.ts"; import { legacyDbLintRuntimeLayer } from "./lint.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-lint-layers-"); + /** * Builds a stub ambient layer that satisfies every external service required by * `legacyDbLintRuntimeLayer` and `legacyDbAdvisorsRuntimeLayer` from the root @@ -104,7 +107,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. + legacyIsolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, analytics.layer, diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 8e6fcf5d65..daa7fa1fb1 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -26,12 +26,16 @@ Notes/Delegation section below). ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | --------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | linked ref resolution | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (`--declarative` and migration-style pull; not the delegated `--experimental` structured-dump path) | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | +| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative`'s bare shadow skips `SetupDatabase`); missing file tolerated | +| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | migration-style pull against the local target only: 3-source declarative-schema fallback ladder, first non-empty source wins (same as `db diff`) | ## Files Written @@ -46,7 +50,10 @@ Notes/Delegation section below). ## Docker - Edge-runtime container (pg-delta export / pg-delta or migra diff). -- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). +- Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in + `legacy/commands/db/shared/legacy-shadow-source.ts` / `legacyPrepareRawShadow` in + `legacy/shared/db-bootstrap/shadow-database.ts`, which also owns the lower-level primitives + both build on), no longer via a Go seam. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump (`legacyStreamPgDump`, shared with `db dump`). @@ -63,13 +70,17 @@ Notes/Delegation section below). ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | -------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | -| `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | -| `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | +| `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | +| `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | ## Exit Codes @@ -77,6 +88,7 @@ Notes/Delegation section below). | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (migration written + optional history update; declarative export) | | `1` | target mutex; `--declarative`/`--use-pg-delta` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; connection/shadow/engine failure; file IO error | +| `1` | `--project-ref` set with a resolved target other than linked; `--project-ref` combined with the `--experimental` structured-dump pull (see Notes) | > Note: unlike `db diff`, an empty diff (`No schema changes found`) is a **non-zero > exit** for `db pull` — Go returns `errInSync` as an error. @@ -103,6 +115,18 @@ Progress strings still go to stderr; stdout carries a single structured envelope - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` + performs (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) — unlike + `SUPABASE_PROJECT_ID`, it does not affect the shadow container's project + id/labels. It never implies `--linked`: passing it with a resolved + `--local`/`--db-url` target is a hard error rather than a silently discarded + flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent + env var simply leaves unused on a non-linked target). It is also rejected up + front when combined with the delegated `--experimental` structured-dump pull + (see below) — `rebuildDelegateArgs` never forwards `--project-ref` to the + delegated Go child, which would otherwise silently re-resolve the workdir's + own linked ref instead. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. - The initial-migra pull (no local migrations) is native: it streams a `pg_dump` of the remote schema into the migration file, then appends the migra diff. An empty @@ -116,4 +140,9 @@ Progress strings still go to stderr; stdout carries a single structured envelope (CLI-1957): a TS-fork-only warning (no Go counterpart) pointing at `--declarative` prints to stderr before the delegated exec. The Go child's telemetry is disabled so the single `cli_command_executed` event comes from - this TS command. + this TS command. `--project-ref` combined with this mode is rejected up + front instead of silently dropped or forwarded via `SUPABASE_PROJECT_ID`: + the latter was considered and rejected because it also overrides the + delegated child's own `Config.ProjectId` (and therefore its shadow/ + edge-runtime container labels) — a coupling `--project-ref` deliberately + avoids. Mirrors `db diff --use-pg-schema`'s identical guard. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index d024c5e789..8e1219c24a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -57,6 +57,11 @@ const config = { Flag.withDescription("Pulls from the local database."), Flag.optional, ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -80,9 +85,13 @@ export const legacyDbPullCommand = Command.make("pull", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. aliases: { s: "schema", p: "password" }, config, }), diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index 25c3c36300..6a96dab6b8 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyDbPullTargetFlagsError extends Data.TaggedError("LegacyDbPullTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--declarative` / `--use-pg-delta` combined with `--diff-engine`. Reproduces @@ -18,7 +28,11 @@ export class LegacyDbPullEngineConflictError extends Data.TaggedError( "LegacyDbPullEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The remote migration history does not match local files. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The diff produced no schema changes. Byte-matches Go's `errInSync` @@ -40,7 +58,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( */ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyncError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Writing the migration file / updating the remote migration-history table failed. @@ -48,7 +70,11 @@ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyn */ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The initial-pull pg_dump container exited non-zero. Go's `dumpRemoteSchema` @@ -60,4 +86,17 @@ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteE export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ readonly message: string; readonly suggestion?: string; -}> {} + /** + * Set when the failure is opening/truncating the local migration file before + * any pg_dump attempt — a filesystem permission problem, not a database + * connection failure. The actual pg_dump-run failures leave it unset and keep + * the `dbConnection` classification. + */ + readonly fileOpen?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.fileOpen === true + ? { ...actionability.permission, fingerprint_suffix: "filesystem" } + : { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + } +} diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts new file mode 100644 index 0000000000..f33f918c58 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyDbPullDumpError } from "./pull.errors.ts"; + +describe("LegacyDbPullDumpError actionability", () => { + it("classifies a local migration-file open failure as a permission problem", () => { + const result = classifyCliErrorActionability( + new LegacyDbPullDumpError({ + message: "failed to open dump file: permission denied", + fileOpen: true, + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("permission"); + expect(result.error_fingerprint).toBe("tag:LegacyDbPullDumpError:filesystem"); + }); + + it("classifies a pg_dump-run failure as a db-connection problem", () => { + const result = classifyCliErrorActionability( + new LegacyDbPullDumpError({ message: "error running container: exit 1" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("db_connection"); + expect(result.error_fingerprint).toBe("tag:LegacyDbPullDumpError:connect"); + }); +}); 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 75a4716de8..c0855015b2 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -1,13 +1,17 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, + LegacyNetworkIdFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; @@ -32,6 +36,16 @@ 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 { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { + legacyCreateShadowDatabase, + legacyPrepareRawShadow, + legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, +} from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -46,14 +60,17 @@ import { } from "../../../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; -import { type LegacyDumpOptions, legacyBuildSchemaDumpEnv } from "../shared/legacy-pg-dump.env.ts"; -import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +import { + type LegacyDumpOptions, + legacyBuildSchemaDumpEnv, +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; import { legacyEmitPoolerFallbackWarning, legacyIsDirectLinkedHost, legacyRunWithPoolerFallback, } from "../shared/legacy-pooler-fallback.ts"; -import { legacyDumpSchemaScript } from "../shared/legacy-pg-dump.scripts.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.ts"; import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, @@ -65,9 +82,10 @@ import { legacyDiffPgDelta, legacyExportCatalogPgDelta, legacyIsPgDeltaDebugEnabled, + legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; +import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { LegacyDbPullDumpError, @@ -85,7 +103,7 @@ import { 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 `.`. +// registration message verbatim (`apps/cli-go/cmd/db.go:533`), which ends with a `.`. const DEPRECATION_LINE = "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; @@ -100,7 +118,7 @@ const MIGRATION_FILE_MODE = 0o644; // coverage differ (see SIDE_EFFECTS.md), so this mode is on a deprecation path — the // same DECISION CLI-1960 makes for `db diff --use-pg-schema` (keep delegating, flag for // removal), NOT the same OUTPUT: Go's `db diff --use-pg-schema` prints its own experimental -// warning from inside the delegated child (`cmd/db.go:121`), so the TS parent deliberately +// warning from inside the delegated child (`cmd/db.go:120`), so the TS parent deliberately // stays silent there. Go's `db pull --experimental` prints nothing of the kind — this line // is a TS-fork-only, forward-looking addition with no Go counterpart (unlike `DEPRECATION_LINE` // below, which byte-matches pflag's `MarkDeprecated`). Printed to stderr right alongside the @@ -132,13 +150,13 @@ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { // Delegation only ever happens in MIGRATION mode — the declarative branch // returns before reaching the delegate call sites — so the resolved decision // here is always `useDeclarative === false`. Go binds `--declarative` and - // `--use-pg-delta` to one last-occurrence-wins variable (`cmd/db.go:534-535`), so + // `--use-pg-delta` to one last-occurrence-wins variable (`cmd/db.go:531-532`), so // replaying only the truthy alias (e.g. forwarding `--declarative` for // `db pull --declarative --use-pg-delta=false`) would flip the child back to // declarative export. Forward an explicit `--declarative=false` when an alias was // passed so the child resolves migration mode deterministically. Never forward // `--use-pg-delta`: the parent already prints its deprecation line and Go's - // MarkDeprecated (`cmd/db.go:536`) would re-print it. The "alias present" guard + // MarkDeprecated (`cmd/db.go:533`) would re-print it. The "alias present" guard // also keeps us clear of Go's mutually-exclusive [declarative diff-engine] group // (which fires on `Changed`), since an alias and `--diff-engine` can't co-occur. if (Option.isSome(flags.declarative) || Option.isSome(flags.usePgDelta)) { @@ -159,7 +177,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; - const seam = yield* LegacyDeclarativeSeam; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -167,16 +184,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; const cliArgs = yield* CliArgs; // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320). Go // loads the project `.env` via `loadNestedEnv` inside `ParseDatabaseConfig` - // (config.go:701) before `PromptYesNo`, so a `SUPABASE_YES` set only in + // (config.go:789) before `PromptYesNo`, so a `SUPABASE_YES` set only in // `supabase/.env` auto-confirms the native initial-migra history repair too. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // Go resolves `EXPERIMENTAL` from *either* the global `--experimental` pflag or - // `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,327,334`), with the same + // `SUPABASE_EXPERIMENTAL` (`cmd/root.go:318-320,338,345`), with the same // bound-pflag-wins-over-env precedence `legacyResolveExperimentalWithProjectEnv` // already implements for `db reset`/declarative generate/sync — reuse it here // instead of re-deriving the gate. Resolved once up front, same as `yes` above; @@ -194,7 +212,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* legacyApplyProjectEnv(projectEnv); const name = Option.getOrElse(flags.name, () => "remote_schema"); // `--declarative` and the deprecated `--use-pg-delta` both bind to the same - // `useDeclarative` variable in Go (`cmd/db.go:534-535`), so when BOTH are + // `useDeclarative` variable in Go (`cmd/db.go:531-532`), so when BOTH are // passed the LAST occurrence in argv wins (e.g. `--declarative // --use-pg-delta=false` => migration mode). The parsed Options don't carry // order, so for the both-present case we replay pflag's last-occurrence rule @@ -218,7 +236,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } // cobra mutex groups: `[db-url linked local]`, `[declarative diff-engine]`, - // `[use-pg-delta diff-engine]` (`cmd/db.go:472-474`). "set" = pflag `Changed`. + // `[use-pg-delta diff-engine]` (`cmd/db.go:539-541`). "set" = pflag `Changed`. const targetSet: Array = []; if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); if (Option.isSome(flags.linked)) targetSet.push("linked"); @@ -249,47 +267,143 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ? "local" : "linked"; - // Go's `ParseDatabaseConfig` resolves the linked ref via the cheap, local-only - // `LoadProjectRef` (flag/env/`.temp/project-ref` file, no network) BEFORE any of - // the fallible work below (`internal/utils/flags/db_url.go:87-92`), and - // `Execute()`'s `PersistentPostRun` caches that ref regardless of what the rest - // of the command does next, including a mid-way failure (`cmd/root.go:170-181, - // 212-233`). Pre-load it here — same pattern as `reset.handler.ts`/`push.handler.ts` - // (CLI-1879) — so the post-run linked-project-cache finalizer still fires even if - // `resolver.resolve()` below fails partway through its login-role/pooler/DNS work - // (`resolved.ref` is only known once `resolve()` *succeeds*, which is too late for - // the finalizer on a failing run otherwise). + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyDbPullTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + + // `--experimental`'s structured-dump mode delegates the whole pull to the + // bundled Go binary via `rebuildDelegateArgs`, which cannot forward a + // TS-only flag: the delegated child re-resolves the workdir's own linked + // ref itself (Go's `LoadProjectRef`, `internal/utils/flags/ + // project_ref.go:54-76`), so `--project-ref` would be silently dropped and + // the child would target the wrong project — the exact wrong-project + // hazard the guard above exists to prevent for the native paths. Passing + // `SUPABASE_PROJECT_ID` through the child's env instead was considered and + // rejected: that variable ALSO overrides the child's own `Config.ProjectId` + // (and therefore its shadow/edge-runtime container labels, + // `pkg/config/config.go:563-570`) — a coupling `--project-ref` deliberately + // avoids (see `LegacyProjectRefResolver`'s use below). Mirrors + // `diff.handler.ts`'s identical `--use-pg-schema` guard. + if (Option.isSome(flags.projectRef) && delegatesExperimentalPull) { + return yield* Effect.fail( + new LegacyDbPullTargetFlagsError({ + message: + "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", + }), + ); + } + + // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN + // reads the `[remotes.]`-merged config (`LoadConfig`, which prints "Loading config + // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — + // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection + // work (TCP probe / temp-role mint over the Management API, `internal/utils/flags/ + // db_url.go:87-97`). Pre-load the ref and re-read config here, before `resolver.resolve()` + // below, so the override print (and the merged-config validation) happen in that same + // order. Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` + // failure (bad password, unreachable host, network-ban lookup, …) left the user never + // knowing which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl). `--local`/ + // `--db-url` never merge a remote block, so only the linked path pre-resolves a ref. + let linkedRef: string | undefined; if (connType === "linked") { - const refResolver = yield* LegacyProjectRefResolver; - linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); + // Cache the ref the moment it's known, not after `toml`/`localInputs` below (both + // fallible) resolve — Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the + // GLOBAL `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored (`cmd/root.go:169-175` never checks `err` before calling it) — so Go caches a + // resolved ref even when a LATER step (config validation, connection, the pull itself) + // fails. Setting `linkedRefForCache` here, right after the ref resolves, reproduces that + // instead of only doing so after `toml`/`localInputs`/`resolver.resolve()` all succeed + // (`diff.handler.ts`'s identical fix). + linkedRefForCache = linkedRef; + } + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Build (and validate) the shadow's own local container inputs BEFORE `resolver.resolve()` + // below, not after: `legacyBuildLocalDbContainerInputs` -> `legacyResolveLocalConfigValues`/ + // `legacyResolveDbBootstrapConfig` read/validate fields (e.g. enabled API TLS's cert/key + // files) that `toml` above never touches (`legacy-db-config.toml-read.ts` only tracks their + // dotted keys for remote-override gating, it doesn't read the files). Go performs this exact + // validation as part of `LoadConfig`, in the root `PersistentPreRunE`, strictly before + // `NewDbConfigWithPassword` — `resolver.resolve()`'s own parity target, see that call's doc + // comment below — or `pull.Run`'s `ConnectByConfig` ever run (`internal/utils/flags/ + // db_url.go:87-93` -> `config_path.go:11-12`). Previously this validation ran inside the + // declarative/migration-file branches further down, AFTER both `resolver.resolve()` (a + // linked target's temp-role mint over the Management API) and `connection.connect()` — so a + // config broken only in a field this build reads (e.g. a missing `api.tls.cert_path` file) + // surfaced after those network side effects instead of before them, unlike Go (review: + // PRRT_kwDOErm0O86XIUK1). Skipped for the delegated `--experimental` path: that spawns the + // real Go binary, which performs this exact validation itself in its OWN `PersistentPreRunE` + // — building it here too would run (and, for any WARN branch, print) it twice for the same + // invocation. Kept as an `Option`, not built directly into a bare value, so the two + // non-delegate branches below (declarative and migration-file — the exact set + // `delegatesExperimentalPull` excludes) can unwrap it without an `undefined` check; both + // `Option.getOrThrow` call sites document why that unwrap is always `Some` there. Cheap + // either way: image resolution stays lazy (`resolvePostgresImage`), so this doesn't pull the + // shadow's Docker image yet. + const localInputs: Option.Option = delegatesExperimentalPull + ? Option.none() + : Option.some( + yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + // So the shadow's own container spec reflects the matching `[remotes.]` + // override, same as `toml` above — see `diff.handler.ts`'s identical call site. + connType === "linked" ? linkedRef : undefined, + // `toml`'s OWN remote-override-key tracking (same matched block) — so a + // remote-set bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` + // env var when deriving the shadow's container spec. + toml.remoteOverrideKeys, + ), + ); + const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, password: flags.password ?? Option.none(), + linkedProjectRef: flags.projectRef, }); - const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + } if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = legacyToPostgresURL(resolved.conn); - - // Reload config with the resolved linked ref so a matching `[remotes.]` - // block merges before the engine/format/runtime/declarative paths are read — - // Go loads config after `LoadProjectRef` on the linked path - // (`internal/utils/flags/db_url.go:87-97`). `--local`/`--db-url` never merge a - // remote block, so only the linked path passes the ref. - const toml = yield* legacyReadDbToml( - fs, - path, - cliConfig.workdir, - connType === "linked" ? linkedRef : undefined, - ); const ctx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // `legacyResolvePgDeltaProjectId` mirrors Go's `UpdateDockerIds`, which derives + // `EdgeRuntimeId` from the ALREADY-sanitized `Config.ProjectId` singleton + // (`internal/utils/config.go:57-76`, sanitized once by `Config.Validate` at + // config-load time): `SUPABASE_PROJECT_ID` env override wins, then config.toml's + // `project_id`, then the workdir basename fallback (`pkg/config/config.go:563-570`), + // with the matched `[remotes.]` block's own `project_id` (`toml.projectId`, + // already gated on `remoteOverrideKeys` by `legacyReadDbToml`) suppressing the raw env + // argument on the linked path — see that helper's own doc comment (review: + // PRRT_kwDOErm0O86XAlIw, PRRT_kwDOErm0O86XI1w8), and `diff.handler.ts`'s identical + // call site. + projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, toml, cliConfig.workdir), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; const formatOptions = Option.getOrElse(toml.pgDelta.formatOptions, () => ""); @@ -329,6 +443,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy connType: "linked", dnsResolver, password: flags.password ?? Option.none(), + linkedProjectRef: flags.projectRef, }) .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { @@ -368,7 +483,11 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy Effect.gen(function* () { const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; if (output.format !== "text") { - yield* proxy.execCapture(rebuildDelegateArgs(flags), { env, stdin: "ignore" }); + yield* proxy.execCapture(rebuildDelegateArgs(flags), { + env, + stdin: "ignore", + suppressChildTelemetry: true, + }); yield* output.success("Schema pulled.", { declarative: false, schemaWritten: null, @@ -377,14 +496,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }); return; } - yield* proxy.exec(rebuildDelegateArgs(flags), { env }); + yield* proxy.exec(rebuildDelegateArgs(flags), { env, suppressChildTelemetry: true }); }); // Connectivity check (Go's `ConnectByConfig` at the top of `pull.Run`). yield* Effect.scoped( Effect.gen(function* () { // Go's `ConnectByConfigStream` prints this to stderr before dialing - // (`internal/utils/connect.go:344-348`), local vs remote keyed off + // (`internal/utils/connect.go:330-335`), local vs remote keyed off // `utils.IsLocalDatabase` (mirrored by the resolver's `isLocal`). The // delegated `--experimental` branch skips it: the Go child's own // `ConnectByConfig` already prints the line, so the parent printing too @@ -407,23 +526,59 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); - const shadow = yield* seam.provisionShadow({ - mode: "declarative", - targetLocal: false, - usePgDelta: true, - schema: flags.schema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const exported = yield* withPoolerFallback(targetUrl, (targetRef) => - legacyDeclarativeExportPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: flags.schema, - formatOptions, - }), - ).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + // Built above, before `resolver.resolve()` — see that build's doc comment. + // `Option.getOrThrow` is safe here: `useDeclarative` is true in this branch, and + // `delegatesExperimentalPull` is defined as `!useDeclarative && (...)`, so + // `localInputs` was always built (never the `Option.none()` delegate case) by the + // time this branch runs. + const declLocalInputs = Option.getOrThrow(localInputs); + const resolvedDeclShadowImage = yield* declLocalInputs.resolvePostgresImage; + // `legacyPrepareRawShadow` needs none of the `setup`/declarative-branch fields the + // adapter also returns (a bare shadow never runs `MigrateShadowDatabase`) — its own + // input type (`LegacyShadowConnectionInput`) is structurally narrower, so the extra + // fields are simply never read. + const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( + declLocalInputs, + resolvedDeclShadowImage, + toml, + fs, + path, + ); + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))` (see this file's migration-path + // call site below, and `diff.handler.ts`'s identical call site, for the full + // rationale): the latter shape leaves a gap between the shadow's successful creation + // and the `Effect.ensuring` finalizer actually being attached, where a fiber interrupt + // would skip `legacyRemoveShadowDatabase` and leak the shadow container. + // `acquireUseRelease` registers the release finalizer in the same uninterruptible + // continuation the acquire resolves into, matching Go's `defer DockerRemove` + // immediately after successful creation (review: PRRT_kwDOErm0O86XEuqJ). This does + // NOT make removal unconditional, though — see `legacyCreateShadowDatabase`'s own doc + // comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity leak + // window when `acquire` itself fails partway through (a `docker create` success + // followed by a `docker cp`/`docker start` failure). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT the + // health-wait `legacyPrepareRawShadow` performs; that runs inside the `use` phase + // below instead, so a SIGINT can still interrupt it, matching Go's single cancellable + // `ctx` (see `shadow-database.ts`'s own doc comment on `legacyPrepareRawShadow` for + // the full rationale, review: PRRT_kwDOErm0O86XMrID). + const exported = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, rawShadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareRawShadow(spawner, handle, rawShadowInput); + return yield* withPoolerFallback(targetUrl, (targetRef) => + legacyDeclarativeExportPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef, + schema: flags.schema, + formatOptions, + }), + ); + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); @@ -431,7 +586,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // the declarative dir, but only when pg-delta is *disabled* in config // (declarative.go:260-268, gated on IsPgDeltaEnabled which reads the config // value). db pull --declarative does not force-enable pg-delta - // (cmd/db.go:180-182), so unlike generate/sync this branch is reachable: + // (cmd/db.go:183-186), so unlike generate/sync this branch is reachable: // without it, subsequent db reset/db diff keep reading supabase/migrations // and ignore the files just pulled. if (!toml.pgDelta.enabled) { @@ -509,13 +664,21 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`dumpRemoteSchema`, `pull.go:144-158`), then run the migra diff below as a // second pass appended to the same file (`diffRemoteSchema(ctx, nil, …)`), // which captures default privileges / managed schemas pg_dump can't emit. - // pg-delta initial pulls skip the dump (`pull.go:126` `if !usePgDeltaDiff`): + // pg-delta initial pulls skip the dump (`pull.go:134` `if !usePgDeltaDiff`): // they diff against an empty shadow, which already yields the full schema. const seededFromDump = sync.kind === "missing" && !usePgDeltaDiff; // Tracks whether the pg_dump seed wrote any bytes, for Go's - // `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff + // `swallowInitialInSync` (`pull.go:282-287`): an empty dump + empty diff // is "in sync", a non-empty dump is a valid initial migration on its own. let seedWroteBytes = false; + + // Built above, before `resolver.resolve()` (see that build's doc comment — it's what + // used to run here, right before the initial-dump write below, but even that was still + // after `resolver.resolve()`/`connection.connect()`). `Option.getOrThrow` is safe here: + // this point is only reached after the `if (delegatesExperimentalPull) { …; return; }` + // check above already returned, so `localInputs` was always built. + const pullLocalInputs = Option.getOrThrow(localInputs); + if (seededFromDump) { yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), @@ -536,14 +699,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy columnInsert: false, }; const toDumpOpenError = (cause: { readonly message: string }) => - new LegacyDbPullDumpError({ message: `failed to open dump file: ${cause.message}` }); + new LegacyDbPullDumpError({ + message: `failed to open dump file: ${cause.message}`, + fileOpen: true, + }); // Stream pg_dump → migration file, (re)truncating per attempt so a pooler // retry leaves only the successful attempt's bytes (Go's `resetOutput`). const runSchemaDump = (target: LegacyPgConnInput) => { // Reset per attempt alongside the truncate, mirroring Go's `resetOutput` // (`pooler_fallback.go:98-113`) which zeroes the file before the pooler // retry. Go decides in-sync from the file on disk (`hasMigrationContent`, - // `pull.go:251-268`), so only the final successful attempt's bytes count: a + // `pull.go:277-280`), so only the final successful attempt's bytes count: a // partial direct write that then IPv6-fails must not leave this flag stuck // true, or an empty pooler retry would be mis-reported as a schema write. seedWroteBytes = false; @@ -561,6 +727,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy image, script: legacyDumpSchemaScript, env: legacyBuildSchemaDumpEnv(target, dumpEnvOpt), + projectEnvValues: projectEnv, onStdout: (chunk) => { if (chunk.length > 0) seedWroteBytes = true; return file.writeAll(chunk).pipe( @@ -596,6 +763,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy connType: "linked", dnsResolver, password: flags.password ?? Option.none(), + linkedProjectRef: flags.projectRef, }) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: runSchemaDump, @@ -617,90 +785,145 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // For the initial pull (no local migrations) the schema filter is ignored, // matching Go's `diffRemoteSchema(ctx, nil, …)`. const diffSchema = sync.kind === "missing" ? [] : flags.schema; - // Go's `DiffDatabase` emits these to stderr before provisioning + diffing - // (`internal/db/diff/diff.go:189,234-237`); the shadow seam doesn't, so the - // pull handler emits them itself to match the migration-style `db pull` output. - yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", - // Mirror Go's `DiffDatabase` → `PrepareShadowSource(ctx, schema, - // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:190`): - // a local target with declarative schema files gets a second - // `contrib_regression` shadow returned as the target override. - targetLocal: resolved.isLocal, - usePgDelta: usePgDeltaDiff, - schema: diffSchema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const diffOutcome = yield* Effect.gen(function* () { - // Use the declarative target override when present (Go substitutes it - // for the diff target, `diff.go:196-197`); for remote pulls it's - // undefined, so this is the direct target URL as before. - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - diffSchema.length > 0 - ? `Diffing schemas: ${diffSchema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - return yield* withPoolerFallback(target, (targetRef) => - // Wrap the engine choice in a gen so both branches' error/requirement - // channels unify into one `Effect` the helper can retry generically. - Effect.gen(function* () { - if (usePgDeltaDiff) { - // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an - // empty diff can be inspected later (Go's DiffDatabase, - // `internal/db/diff/diff.go:205-214`); a failed export only warns. - const debug = legacyIsPgDeltaDebugEnabled(); - const sourceCatalog = debug - ? yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output - .raw( - `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, - "stderr", - ) - .pipe(Effect.as(undefined)), - ), - ) - : undefined; - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: diffSchema, - formatOptions, - }); - return { - sql: result.sql, - files: result.files, - capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, - }; - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target: targetRef, - schema: diffSchema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - return { sql, files: undefined, capture: undefined }; - }), - ); - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + // Go's `diffRemoteSchema` retries the ENTIRE `diff.DiffDatabase` call — shadow + // provisioning included — against the pooler config on an IPv6 failure, not + // just the diff step (`internal/db/pull/pull.go:176-190`): `DiffDatabase` + // prints "Creating shadow database..." and runs `PrepareShadowSource` before + // ever touching the remote/target connection (`internal/db/diff/diff.go:211- + // 217`), so a pooler retry re-prints the creation/diff banners and provisions + // + tears down a second, fresh shadow. Mirror that observable behavior by + // wrapping the full prepare-shadow-then-diff operation in the retried + // closure — each attempt gets its own shadow and its own teardown — instead + // of provisioning one shadow and only retrying the diff engine against it. + const runShadowDiff = (targetRef: string) => + Effect.gen(function* () { + // Go's `DiffDatabase` emits these to stderr before provisioning + diffing + // (`internal/db/diff/diff.go:212,223-226`); `legacyPrepareShadowSource` + // doesn't print its own banner, so the pull handler emits it itself to + // match the migration-style `db pull` output. + yield* output.raw("Creating shadow database...\n", "stderr"); + // Resolved AFTER the banner, inside the retried closure — Go's + // `CreateShadowDatabase` → `utils.DockerStart` (where the postgres image is + // resolved/pulled) runs inside `PrepareShadowSource`, which is itself called + // after `DiffDatabase` prints "Creating shadow database..." above, and is + // re-run fresh on every pooler-retry attempt (see the comment above). Resolving + // it earlier, outside this closure (as `diff.handler.ts`'s sibling call site does + // NOT do — it also resolves after its own banner), would both print nothing on an + // image-resolution failure before the banner and skip re-resolving it on retry. + const resolvedPullShadowImage = yield* pullLocalInputs.resolvePostgresImage; + // Mirror Go's `DiffDatabase` → `PrepareShadowSource(ctx, schema, + // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:213`): a + // local target with declarative schema files gets a second + // `contrib_regression` shadow returned as the target override. + const shadowInput = { + ...legacyShadowRunInputFromLocalContainerInputs( + pullLocalInputs, + resolvedPullShadowImage, + toml, + fs, + path, + ), + targetLocal: resolved.isLocal, + usePgDelta: usePgDeltaDiff, + // `toml.schemaPathPatterns`, NOT `pullLocalInputs.context.config.db.migrations. + // schema_paths`: the latter is the raw `@supabase/config` field, which never + // applies `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no + // viper-`AutomaticEnv` equivalent) — `toml` above (`legacyReadDbToml`) already + // resolves that env override the same way Go's `utils.Config.Db.Migrations. + // SchemaPaths` does (review: PRRT_kwDOErm0O86XDr4S). + schemaPaths: toml.schemaPathPatterns, + pgDelta: toml.pgDelta, + ctx, + }; + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))` (see `diff.handler.ts`'s + // identical call site for the full rationale): the latter shape leaves a gap + // between the shadow's successful creation and the `Effect.ensuring` finalizer + // actually being attached, where a fiber interrupt would skip + // `legacyRemoveShadowDatabase` and leak the shadow container. `acquireUseRelease` + // registers the release finalizer in the same uninterruptible continuation the + // acquire resolves into, matching Go's `defer DockerRemove` immediately after + // successful creation (review: PRRT_kwDOErm0O86XDr4Y). This does NOT make removal + // unconditional, though — see `legacyCreateShadowDatabase`'s own doc comment + // (`shadow-database.ts`) for the still-present, deliberate-Go-parity leak window + // when `acquire` itself fails partway through (a `docker create` success followed + // by a `docker cp`/`docker start` failure). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT + // the health-wait/migrate/declarative-apply `legacyPrepareShadowSource` performs; + // those run inside the `use` phase below instead, so a SIGINT can still interrupt + // them, matching Go's single cancellable `ctx` (see `legacy-shadow-source.ts`'s own + // doc comment on `legacyPrepareShadowSource` for the full rationale, review: + // PRRT_kwDOErm0O86XMrID). + return yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + // Use the declarative target override when present (Go substitutes it + // for the diff target, `diff.go:219-220`); for remote pulls it's + // undefined, so this is this attempt's resolved target URL. + const target = shadow.targetUrlOverride ?? targetRef; + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (usePgDeltaDiff) { + // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an + // empty diff can be inspected later (Go's DiffDatabase, + // `internal/db/diff/diff.go:234-244`); a failed export only warns. + const debug = legacyIsPgDeltaDebugEnabled(); + const sourceCatalog = debug + ? yield* legacyExportCatalogPgDelta(ctx, { + targetRef: shadow.sourceUrl, + role: "postgres", + }).pipe( + Effect.catch((error) => + output + .raw( + `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, + "stderr", + ) + .pipe(Effect.as(undefined)), + ), + ) + : undefined; + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: target, + schema: diffSchema, + formatOptions, + }); + return { + sql: result.sql, + files: result.files, + capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, + }; + } + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: diffSchema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + return { sql, files: undefined, capture: undefined }; + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); + }); + const diffOutcome = yield* withPoolerFallback(targetUrl, runShadowDiff); const out = diffOutcome.sql; const diffEmpty = out.trim().length === 0; // A non-initial pull with an empty diff is "in sync" and fails (Go's // `diffRemoteSchema`). The initial-migra path seeded the file with a pg_dump // above, so its empty second pass is swallowed (`swallowInitialInSync`, - // `pull.go:256-261`) and falls through to the shared tail below. + // `pull.go:282-287`) and falls through to the shared tail below. if (diffEmpty && !seededFromDump) { // Go saves a pg-delta debug bundle and embeds its path in the in-sync - // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:176-185`); a + // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:192-201`); a // bundle-save failure falls through to the plain in-sync error. if (diffOutcome.capture !== undefined) { const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ @@ -767,7 +990,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (!diffEmpty) { if (seededFromDump) { // Append the migra diff to the dump-seeded file (Go's `diffRemoteSchema` - // opens the migration file `O_APPEND`, `pull.go:191`). + // opens the migration file `O_APPEND`, `pull.go:217`). yield* Effect.scoped( Effect.gen(function* () { const file = yield* fs.open(migrationPath, { flag: "a" }).pipe( @@ -803,7 +1026,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } } - // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): a dump that produced + // Go's `swallowInitialInSync` (`pull.go:282-287`): a dump that produced // nothing followed by an empty diff leaves the file empty → in sync. if (seededFromDump && !seedWroteBytes && diffEmpty) { return yield* Effect.fail( @@ -826,12 +1049,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Prompt to update the remote migration history table. Go calls // `PromptYesNo(ctx, "Update remote migration history table?", true)` - // (`internal/db/pull/pull.go:73`), which returns the default (`true`) on + // (`internal/db/pull/pull.go:79`), which returns the default (`true`) on // `--yes`, on a non-interactive stdin, or on any prompt error // (`internal/utils/console.go:74-82`) — it never fails the command. let remoteHistoryUpdated = false; const updateHistoryTitle = "Update remote migration history table?"; - // Go's `PromptYesNo(ctx, title, true)` (`internal/db/pull/pull.go:73`): honors + // Go's `PromptYesNo(ctx, title, true)` (`internal/db/pull/pull.go:79`): honors // `--yes`, scans piped stdin on a non-TTY before falling back to the default // (`console.go:64-82`), and otherwise prompts on a real TTY. const shouldUpdate = yield* legacyPromptYesNo(output, yes, updateHistoryTitle, true); 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 128205371d..38d36df039 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 @@ -1,14 +1,18 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { + LEGACY_VALID_REF, legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -19,6 +23,7 @@ import { mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, @@ -27,20 +32,33 @@ import { import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.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 { + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, +} from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + const EXPORT_JSON = JSON.stringify({ version: 1, mode: "declarative", @@ -71,7 +89,6 @@ interface SetupOpts { readonly pipedAnswers?: ReadonlyArray; readonly yes?: boolean; readonly experimental?: boolean; - readonly shadowTargetOverride?: string; readonly promptConfirmResponses?: ReadonlyArray; readonly resolvedRef?: string; // Fail the first edge-runtime run with this message (the second succeeds with @@ -98,6 +115,14 @@ interface SetupOpts { readonly args?: ReadonlyArray; // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. readonly failWriteOnCall?: number; + // `LegacyCliConfig.projectId` (Go's `SUPABASE_PROJECT_ID` env-only reader). Defaults to + // `Option.some("test")`; pass `Option.none()` to exercise the config.toml/workdir-basename + // fallback `legacyResolveLocalProjectId` provides for the pg-delta edge-runtime cache bind. + readonly projectId?: Option.Option; + // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with + // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, + // instead of silently falling back to `opts.resolvedRef ?? LEGACY_VALID_REF`. + readonly linkedFails?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -108,35 +133,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ - mode: string; - usePgDelta: boolean; - targetLocal: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; - const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { - provisionCalls.push({ mode, usePgDelta, targetLocal, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.shadowTargetOverride, - }); - }, - removeShadowContainer: (container) => - Effect.sync(() => { - removedContainers.push(container); - }), - }); + // Shadow provisioning is native (CLI-1956): a real docker-spawner fake backs + // container create/start/health-inspect/cleanup. + const shadowSpawner = mockLegacyShadowContainerCliSpawner(); let edgeRunCount = 0; + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { edgeRunCount += 1; + edgeCalls.push(runOpts); if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); } @@ -153,15 +159,28 @@ function setup(workdir: string, opts: SetupOpts = {}) { // `runStream`; deliver the configured bytes to `onStdout` (as Go's StdCopy would), // then report the exit code + stderr. `dumpFailFirstWith` fails the first attempt // so the pooler retry runs. - const dumpCalls: Array<{ env: Readonly>; image: string }> = []; + const dumpCalls: Array<{ + env: Readonly>; + image: string; + network: LegacyDockerRunOpts["network"]; + }> = []; let dumpRunCount = 0; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), runCapture: () => Effect.die("runCapture unused"), runStream: (runOpts, streamOpts) => Effect.gen(function* () { + // The native shadow's PG15+ one-shot platform-baseline jobs + // (`legacyRunStartMigrateJob`) go through this same `runStream`, always + // `skipImageResolve: true` (the real `pg_dump` `runStream` call never sets + // it) — succeed unconditionally so shadow setup itself never fails; this + // suite has no assertions over the one-shot jobs' own output, and they must + // not be counted alongside the real `dumpCalls` this suite DOES assert on. + if (runOpts.skipImageResolve === true) { + return { exitCode: 0, stderr: "" }; + } dumpRunCount += 1; - dumpCalls.push({ env: runOpts.env, image: runOpts.image }); + dumpCalls.push({ env: runOpts.env, image: runOpts.image, network: runOpts.network }); if (opts.dumpFailFirstWith !== undefined && dumpRunCount === 1) { if (opts.dumpFailFirstPartialBytes !== undefined) { const partial = new TextEncoder().encode(opts.dumpFailFirstPartialBytes); @@ -177,27 +196,46 @@ function setup(workdir: string, opts: SetupOpts = {}) { const execLog: string[] = []; const historyUpserts: ReadonlyArray[] = []; - const session = { + const connectedDatabases: Array = []; + // The resolver mock's own target connection always dials port 5432; the native + // shadow (platform baseline, `CREATE_TEMPLATE`, migrations, and — on the + // declarative branch — the `contrib_regression` override) always dials the + // schema-default shadow port (54320) instead — a reliable way to tell "the + // REAL remote/local target's own history upsert" (which `historyUpserts` is + // meant to count) apart from the shadow's OWN internal migration replay (which + // ALSO issues a parameterized `INSERT_MIGRATION_VERSION` query, into its own + // separate in-shadow history table). + const TARGET_PORT = 5432; + const makeSession = (isShadow: boolean) => ({ exec: (sql: string) => Effect.sync(() => void execLog.push(sql)), query: (sql: string, params?: ReadonlyArray) => { if (/SELECT version/u.test(sql)) { return Effect.succeed((opts.remoteVersions ?? []).map((v) => ({ version: v }))); } - if (params !== undefined) historyUpserts.push(params); + if (!isShadow && params !== undefined) historyUpserts.push(params); return Effect.succeed([] as ReadonlyArray>); }, extensionExists: () => Effect.die("extensionExists unused"), copyToCsv: () => Effect.die("copyToCsv unused"), queryRaw: () => Effect.die("queryRaw unused"), - }; + }); + const targetSession = makeSession(false); + const shadowSession = makeSession(true); const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: () => Effect.succeed(session), + connect: (cfg: { readonly database: string; readonly port: number }) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + return cfg.port === TARGET_PORT ? targetSession : shadowSession; + }), }); const poolerFallbackCalls: unknown[] = []; + const resolveCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { - resolve: ({ connType }) => - Effect.succeed({ + resolve: (resolveFlags) => { + resolveCalls.push(resolveFlags); + const { connType } = resolveFlags; + return Effect.succeed({ conn: { // A direct `db..` host so the pooler-fallback gate // (Go's ProjectRefFromDirectDbHost) matches on the linked path. @@ -209,7 +247,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, isLocal: connType === "local", ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), - }), + }); + }, resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); return Effect.succeed( @@ -241,31 +280,46 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - // The linked ref is pre-loaded (for the post-run cache) before `resolve()`, - // mirroring Go's `LoadProjectRef`-before-`NewDbConfigWithPassword` order (see the - // pre-load block in `pull.handler.ts`, CLI-1879). Default to the same ref the - // `LegacyDbConfigResolver` mock above uses for its `db..…` host so both stay - // consistent unless a test overrides `resolvedRef`. + // The linked ref is now pre-loaded (for the config-override print, ahead of + // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via + // `LegacyProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in + // its `db..` connection host above, so both stay consistent regardless of + // whether a test sets `opts.resolvedRef` (mirrors `reset.integration.test.ts`'s + // identical mock). + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just `opts.resolvedRef`) drives the linked ref. const projectRefResolver = Layer.succeed(LegacyProjectRefResolver, { - resolve: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - resolveForLink: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? "abcdefghijklmnopqrst")), - loadProjectRef: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), + resolve: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? LEGACY_VALID_REF)), + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) + : Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), }); const baseLayer = Layer.mergeAll( + // `BunServices.layer` is listed FIRST so every fake service layer below (most + // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its + // real implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `start.integration.test.ts`'s own established ordering. + BunServices.layer, out.layer, telemetry.layer, cache.layer, - seam, edge, docker, dbConnection, + shadowSpawner.layer, + alwaysReadyHttpClientLayer, resolver, - proxy, projectRefResolver, - mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + proxy, + mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin( opts.stdinIsTty ?? false, @@ -273,6 +327,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyDebugFlag, false), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyNetworkIdFlag, Option.none()), Layer.succeed(LegacyPgDeltaSslProbe, { @@ -281,10 +336,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockRuntimeInfo(), - BunServices.layer, ); - // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` - // still resolves from `BunServices`. + // Merged last so its `FileSystem` overrides everything above (last-wins). const layer = opts.failWriteOnCall === undefined ? baseLayer @@ -293,18 +346,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, out, - cache, - provisionCalls, - removedContainers, proxyCalls, proxyCaptureCalls, historyUpserts, execLog, + connectedDatabases, poolerFallbackCalls, + resolveCalls, dumpCalls, + shadowSpawned: shadowSpawner.spawned, get edgeRunCount() { return edgeRunCount; }, + edgeCalls, + cache, }; } @@ -317,6 +372,7 @@ const flags = (over: Partial = {}): LegacyDbPullFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), local: over.local ?? Option.none(), + projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), }); @@ -374,6 +430,84 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("pulls from the project given via --project-ref without a linked workdir", () => { + // The fake resolver fails as "unlinked" (`LegacyProjectNotLinkedError`) + // absent the flag — only the flag can resolve a ref here. + const FLAG_REF = "flagflagflagflagflag"; + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), + yes: true, + projectId: Option.none(), + linkedFails: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull( + flags({ diffEngine: Option.some("pg-delta"), projectRef: Option.some(FLAG_REF) }), + ); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + const FLAG_REF = "flagflagflagflagflag"; + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), + yes: true, + // The workdir already resolves to LEGACY_VALID_REF (e.g. via + // .temp/project-ref) — the flag must win over it. + resolvedRef: "abcdefghijklmnopqrst", + }); + return Effect.gen(function* () { + yield* legacyDbPull( + flags({ diffEngine: Option.some("pg-delta"), projectRef: Option.some(FLAG_REF) }), + ); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); + expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current, {}); + return Effect.gen(function* () { + const exit = yield* legacyDbPull( + flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + // The guard fires before any connection resolution or cache write. + expect(s.resolveCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --project-ref combined with --experimental before delegating", () => { + // The bundled Go binary's own `db pull --experimental` re-resolves the + // workdir's own linked ref itself, and `rebuildDelegateArgs` never registered + // `--project-ref` to forward — fail up front instead of silently dropping it. + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags({ projectRef: Option.some(FLAG_REF) })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", + ); + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "a pg-delta plan with transaction boundaries writes one ordered migration file per unit", () => { @@ -516,7 +650,8 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + // Migra engine selection is proven by `edgeStdout` parsing as raw SQL below + // (a pg-delta selection would instead try — and fail — to `JSON.parse` it). const err = streamText(s.out, "stderr"); // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing // (`internal/utils/connect.go:348`), ahead of any other pull output. @@ -531,6 +666,40 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", + () => { + // `toml` (`legacyReadDbToml`'s "D" pipeline) only tracks `api.tls`'s dotted keys for + // remote-override gating, it never reads the cert/key files — that read lives in + // `legacyBuildLocalDbContainerInputs`'s own "L" pipeline (see that call's doc comment, + // and `diff.handler.ts`'s identical fix). Go validates it as part of `LoadConfig`, in + // the root `PersistentPreRunE`, strictly before `NewDbConfigWithPassword` + // (`resolver.resolve()`'s parity target) or `pull.Run`'s `ConnectByConfig` ever run + // (review: PRRT_kwDOErm0O86XIUK1) — so `resolveCalls` must stay empty here, proving the + // shadow's config validation ran first, not just that the command failed. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { remoteVersions: [], edgeStdout: "" }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("failed to read TLS cert"); + expect(s.resolveCalls).toHaveLength(0); + expect(s.connectedDatabases).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("pull --declarative exports declarative files (no migration)", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { @@ -549,7 +718,10 @@ describe("legacy db pull", () => { expect( existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), ).toBe(true); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + // Declarative mode's bare shadow (`legacyPrepareRawShadow`) never connects to set + // up a platform baseline or `contrib_regression` template — the only connect is + // the top-level target connect (`resolved.conn`, database "postgres"). + expect(s.connectedDatabases).toEqual(["postgres"]); }).pipe(Effect.provide(s.layer)); }); @@ -616,6 +788,58 @@ describe("legacy db pull", () => { }, ); + it.effect( + "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", + () => { + // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's + // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) + // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value + // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from + // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting + // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project — reachable + // here via the declarative-export path (`legacyDeclarativeExportPgDelta`), which reads + // `ctx.projectId` before any local shadow diff even starts. + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.none() }); + const expectedProjectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", + () => { + // `legacyReadDbToml` already gates `toml.projectId` behind `remoteOverrideKeys` so it + // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but + // `legacyResolveLocalProjectId` tries `cliConfig.projectId` (raw, ungated env) FIRST, so + // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be + // suppressed here too, or it silently wins back over the already-gated `toml.projectId` + // (mirrors `diff.integration.test.ts`'s identically-named test). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), + ); + const s = setup(tmp.current, { + edgeStdout: EXPORT_JSON, + resolvedRef: "abcdefghijklmnopqrst", + // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) + // project — must NOT win over the matched remote's own `project_id`. + projectId: Option.some("unrelated-env-project"), + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true), linked: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect( "--declarative --use-pg-delta=false stays in migration mode (Go last-occurrence-wins)", () => { @@ -633,7 +857,6 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ declarative: Option.some(true), usePgDelta: Option.some(false) }), ); - expect(s.provisionCalls[0]?.mode).toBe("diff"); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }, @@ -653,7 +876,6 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ declarative: Option.some(false), usePgDelta: Option.some(true) }), ); - expect(s.provisionCalls[0]?.mode).toBe("diff"); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }, @@ -666,7 +888,12 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(true) })); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + // Reaching the declarative write (rather than a migration file / history + // upsert) proves the declarative export path ran. + expect( + existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), + ).toBe(true); + expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); @@ -698,8 +925,6 @@ describe("legacy db pull", () => { expect(s.dumpCalls).toHaveLength(1); expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); - // The diff ran against the shadow with the migra engine (no schema filter). - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); // The migration file holds the dump output followed by the appended diff. const dir = join(tmp.current, "supabase", "migrations"); const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); @@ -823,7 +1048,7 @@ describe("legacy db pull", () => { const error = yield* legacyDbPull(flags()).pipe(Effect.flip); expect(error.message).toContain("error running container: exit 1"); // The diff pass never ran — the dump failure aborts before provisioning a shadow. - expect(s.provisionCalls).toHaveLength(0); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -1133,6 +1358,39 @@ describe("legacy db pull", () => { }, ); + it.effect( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + const s = setup(tmp.current, { + remoteVersions: [], // no remote history → initial-migra pg_dump path + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); + expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { // Go binds `--yes` to viper, so an explicit `--yes=false` wins over the // SUPABASE_YES env (AutomaticEnv). `printf 'n\n' | SUPABASE_YES=1 supabase @@ -1470,24 +1728,28 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + // pg-delta selection is proven by `edgeStdout`'s envelope shape parsing + // successfully below (a migra selection would instead treat it as raw SQL). }).pipe(Effect.provide(s.layer)); }); it.effect("db pull --local provisions a local-target shadow and uses the target override", () => { // Go derives the shadow targetLocal from utils.IsLocalDatabase and substitutes - // the declarative contrib_regression target override (diff.go:190,196-197); - // the native handler must pass targetLocal and honor shadow.targetUrlOverride. + // the declarative contrib_regression target override (diff.go:190,196-197); a + // real declarative schema file makes the native `loadDeclaredSchemas` branch + // non-empty, so `legacyPrepareShadowSource` redirects the diff target to the + // shadow's own `contrib_regression` override database. seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, - shadowTargetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", }); return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(true); + expect(s.connectedDatabases).toContain("contrib_regression"); // A local target prints the local wording (Go's `IsLocalDatabase` branch in // `ConnectByConfigStream`, `internal/utils/connect.go:344-346`). expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); @@ -1597,18 +1859,98 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The resolved ref is forwarded to the shadow so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + // pg-delta selection is ref-aware (read from the remote-merged `toml.pgDelta`) + // and is proven by `edgeStdout`'s envelope shape parsing successfully below. + expect(streamText(s.out, "stderr")).toMatch( + /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, + ); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", + () => { + // Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the GLOBAL + // `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored — so a ref resolved via `LoadProjectRef` gets cached even when a LATER step + // (here, `legacyReadDbToml`'s own config-load) fails. `db.migrations.enabled = "notabool"` + // fails `legacyReadDbToml`'s own bool parse AFTER the ref is already known, exercising + // exactly that gap (`diff.integration.test.ts`'s identical fix/test). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + yes: true, + resolvedRef: "abcdefghijklmnopqrst", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags({ linked: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just toml", + () => { + // Go remote-merges the WHOLE config uniformly on the linked path (`LoadConfig` seeds + // `flags.ProjectRef` before every field read) — the shadow's container spec (image, JWT + // secret, root key, db.settings, service enabled-for-setup flags) must reflect the + // matched `[remotes.]` override too, not just the `toml` read used for + // pg-delta/schema_paths (mirrors `diff.integration.test.ts`'s identically-named test). + // `major_version` is a clean, directly-observable probe: PG <= 14 is the ONLY branch + // that emits a `--tmpfs` flag on the shadow's `docker create` argv + // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) + // overridden by a remote block's `major_version = 14` must flip that flag on. + seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "alter table x;\n", + yes: true, + resolvedRef: "abcdefghijklmnopqrst", + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ linked: Option.some(true) })); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("retries the migration-style diff through the IPv4 pooler on an IPv6 error", () => { // Go wraps the linked diff with PoolerFallbackConfig and retries against the // IPv4 pooler when the direct host is unreachable over IPv6 from the container // (internal/db/pull/pull.go, diffRemoteSchema). The first edge run fails with // an IPv6 connectivity error; the retry succeeds and the migration is written. + // + // Go's `diffRemoteSchema` retries the WHOLE `diff.DiffDatabase` call on this + // path, not just the diff engine (`internal/db/diff/diff.go:211-217` runs + // `PrepareShadowSource` and prints "Creating shadow database..."/"Diffing + // schemas..." before ever touching the target connection) — so the pooler + // retry re-provisions and tears down a FRESH shadow and re-prints both + // banners, rather than reusing the first attempt's shadow. Assert that shape + // directly, not just that the migration eventually gets written. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -1621,18 +1963,31 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), ); - expect(streamText(s.out, "stderr")).toContain("does not support IPv6"); - expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); + const err = streamText(s.out, "stderr"); + expect(err).toContain("does not support IPv6"); + expect(err).toContain("Retrying via the IPv4 connection pooler"); expect(s.edgeRunCount).toBe(2); - expect(streamText(s.out, "stderr")).toMatch( + expect(err).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(2); + expect( + s.shadowSpawned.filter((c) => c.args[0] === "rm" && c.args.includes("-f")), + ).toHaveLength(2); + expect(err.split("Creating shadow database...")).toHaveLength(3); + expect(err.split("Diffing schemas...")).toHaveLength(3); }).pipe(Effect.provide(s.layer)); }); it.effect("retries the declarative export through the IPv4 pooler on an IPv6 error", () => { // Go's pullDeclarativePgDelta retries DeclarativeExportPgDelta through the - // pooler in the same IPv6 scenario (internal/db/pull/pull.go). + // pooler in the same IPv6 scenario (internal/db/pull/pull.go), but unlike + // diffRemoteSchema/DiffDatabase it calls `diff.PrepareRawShadow` ONCE before + // the retry and only re-runs the export against the same shadow + // (`pull.go:92-115`) — a deliberate asymmetry in Go's own code, not a gap to + // close. Assert the single-shadow-reuse shape so a future change doesn't + // accidentally "fix" this path to double-provision like the migration-style + // diff path correctly does. const s = setup(tmp.current, { edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", edgeStdout: EXPORT_JSON, @@ -1645,6 +2000,7 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "database")}\n`, ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 821fd07acd..0a3028fe79 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; @@ -12,13 +13,17 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-manag import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; -import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; /** - * Runtime layer for `supabase db pull`. Same composition as `db diff`: the - * db-config resolver, the native pg-delta / migra stack (edge-runtime, SSL probe, - * the Go shadow seam), `LegacyDbConnection` (remote connect + `schema_migrations` - * reconciliation / history update), and `LegacyDockerRun` for the migra fallback. + * Runtime layer for `supabase db pull`. The db-config resolver, the native pg-delta / migra + * stack (edge-runtime, SSL probe, `HttpClient` for the native shadow's health-check wait — + * shadow provisioning itself is native, see `commands/db/shared/legacy-shadow-source.ts` / + * `shared/db-bootstrap/shadow-database.ts`), `LegacyDbConnection` (remote connect + + * `schema_migrations` reconciliation / history update), and `LegacyDockerRun` for the migra + * fallback. No `LegacyDeclarativeSeam` — neither `db pull` nor `db diff` has a Go-delegate + * branch that needs it any more (native shadow provisioning replaced the Go seam entirely, + * CLI-1956/CLI-1959); `--use-pgadmin`/`--use-pg-schema` delegate through `LegacyGoProxy` + * instead, not this seam. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -34,7 +39,7 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); -const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); export const legacyDbPullRuntimeLayer = Layer.mergeAll( dbConfig, @@ -42,7 +47,7 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, - seam, + httpClient, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index d193dd88b6..8e380f8830 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -2,19 +2,20 @@ Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending local migrations (and optionally seed data and custom roles) to the local or -linked/remote Postgres database. +linked/remote Postgres database, updating configured Vault secrets before migrations +unless `--skip-vault` is set. ## Files Read -| Path | Format | When | -| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | -| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | -| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | -| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | -| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | -| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| Path | Format | When | +| ------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written @@ -32,7 +33,7 @@ linked/remote Postgres database. | `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation); pipeline-incompatible statements run standalone between batches — see Notes | | `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | | `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | -| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | | `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes @@ -43,14 +44,16 @@ linked/remote Postgres database. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | +| Variable | Purpose | Required? | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | +| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position); also independently feeds the pg-delta migrations-catalog cache's project id, which `--project-ref` does NOT affect — see Notes | no | +| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | +| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | ## Exit Codes @@ -63,6 +66,7 @@ linked/remote Postgres database. | `1` | user declined a confirmation prompt (`context canceled`) | | `1` | `config.toml` parse failure | | `1` | database connection / migration / seed / roles / vault apply failure | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -95,13 +99,23 @@ stdout is payload-only. A single `result` object is emitted: - **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually exclusive; with no flag the target defaults to linked, matching Go. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` + performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`) — + it does not affect the pg-delta migrations-catalog cache's project id, which + still derives from `SUPABASE_PROJECT_ID`/config.toml/workdir basename only. + It never implies `--linked`: passing it with a resolved `--local`/`--db-url` + target is a hard error rather than a silently discarded flag (deliberately + stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply + leaves unused on a non-linked target). - **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and declining returns `context canceled`. - **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. - **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a skip notice naming the project ref (empty for local/db-url). - **Vault**: non-empty, non-`env()` `[db.vault]` values are synced after config - load, including decrypted `encrypted:` values. + load, including decrypted `encrypted:` values. `--skip-vault` leaves them unchanged + and does not resolve or decrypt their configured values. - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, and `CLUSTER` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 8c936315ca..22e9547ff9 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -16,6 +16,9 @@ const config = { includeSeed: Flag.boolean("include-seed").pipe( Flag.withDescription("Include seed data from your config."), ), + skipVault: Flag.boolean("skip-vault").pipe( + Flag.withDescription("Skip updating vault secrets from config.toml."), + ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription( "Print the migrations that would be applied, but don't actually apply them.", @@ -29,6 +32,23 @@ const config = { ), linked: Flag.boolean("linked").pipe(Flag.withDescription("Pushes to the linked project.")), local: Flag.boolean("local").pipe(Flag.withDescription("Pushes to the local database.")), + // TS-only flag on every user-facing `db` subcommand (Go's user-facing `db` + // commands never registered --project-ref; only the SUPABASE_PROJECT_ID env + // var could override the linked ref). The one Go exception is a hidden seam, + // not a user-facing flag: `db declarative __catalog --project-ref` exists + // solely so the native TS declarative commands can forward the resolved + // linked ref to the bundled Go binary (`apps/cli-go/cmd/pgdelta_catalog.go:44`). + // Feeds LegacyProjectRefResolver.loadProjectRef, which keeps Go's precedence: + // flag > SUPABASE_PROJECT_ID > supabase/.temp/project-ref. Unlike that env + // var, this flag ONLY feeds ref resolution — it does not affect local + // container ids or the pg-delta project id (see legacy-db-config.types.ts's + // `linkedProjectRef` doc for the full non-overlap), and is rejected outright + // on a non-linked target rather than silently ignored (see the handler's + // guard). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -39,7 +59,9 @@ const config = { export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( - Command.withDescription("Push new migrations to the remote database."), + Command.withDescription( + "Push new migrations to the remote database. Vault secrets from config.toml are updated before migrations unless --skip-vault is set.", + ), Command.withShortDescription("Push new migrations to the remote database"), Command.withHandler((flags) => legacyDbPush(flags).pipe( @@ -48,13 +70,18 @@ export const legacyDbPushCommand = Command.make("push", config).pipe( "include-all": flags.includeAll, "include-roles": flags.includeRoles, "include-seed": flags.includeSeed, + "skip-vault": flags.skipVault, "dry-run": flags.dryRun, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts b/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts new file mode 100644 index 0000000000..e8f17a954f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts @@ -0,0 +1,52 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const UNREACHABLE_DB_URL = "postgresql://postgres:postgres@127.0.0.1:1/postgres"; + +describe("supabase db push --skip-vault (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-push-skip-vault-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync( + join(projectDir, "supabase", "config.toml"), + '[db.vault]\nmy_secret = "encrypted:not-valid"\n', + ); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("fails during config loading without the flag", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stderr } = await runSupabase(["db", "push", "--db-url", UNREACHABLE_DB_URL], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(stderr).toContain("failed to parse config:"); + expect(stderr).not.toContain("Connecting to remote database..."); + }); + + test( + "reaches the database connection without decrypting vault secrets", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase( + ["db", "push", "--db-url", UNREACHABLE_DB_URL, "--skip-vault"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toContain("failed to connect"); + expect(stderr).not.toContain("failed to parse config:"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts index 3849d55add..a22977b18d 100644 --- a/apps/cli/src/legacy/commands/db/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Remote migration versions are missing from the local directory. Byte-matches @@ -19,7 +28,11 @@ export class LegacyDbPushMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Local migration files are ordered before the remote head and `--include-all` @@ -31,7 +44,11 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` @@ -39,12 +56,20 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( */ export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A migration / seed / globals / vault statement failed while applying. Carries @@ -53,4 +78,8 @@ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesE */ export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index 9efc51a944..d5da94197b 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -61,21 +61,38 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy // Go's push defaults `--linked` to true, so no target flag → linked. const connType = target.connType ?? "linked"; + // TS-only guard: `--project-ref` never implies `--linked` and must not be + // silently discarded on a non-linked target. Deliberately STRICTER than the + // `SUPABASE_PROJECT_ID` env var, which Go's `loadProjectRef` equivalent + // reads unconditionally but which simply goes unused (no error) on a + // `--local`/`--db-url` target — an explicitly typed `--project-ref` flag + // silently doing nothing on e.g. `db push --local` is a footgun the env var + // doesn't share, so this errors instead. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + // The linked path resolves the project ref before loading config so a matching // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. let projectRef = ""; if (connType === "linked") { const refResolver = yield* LegacyProjectRefResolver; - projectRef = yield* refResolver.loadProjectRef(Option.none()); + projectRef = yield* refResolver.loadProjectRef(flags.projectRef); linkedRefForCache = projectRef; } - // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): + // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`), + // except that `--skip-vault` omits only `[db.vault]` secret resolution: // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` - // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts every - // `encrypted:` secret with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — + // AutomaticEnv overrides, merges a matching `[remotes.]` block, and decrypts selected + // `encrypted:` secrets with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — // aborting here (before connecting or writing) on any undecryptable/invalid config. // This must resolve BEFORE `resolver.resolve()`'s network activity (temp-role minting, // pooler fallback) so a matching `[remotes.]` override prints before it, matching @@ -85,6 +102,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy path, workdir, projectRef !== "" ? projectRef : undefined, + { resolveVaultSecrets: !flags.skipVault }, ); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); @@ -95,6 +113,8 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy connType, dnsResolver, password: flags.password, + resolveVaultSecrets: !flags.skipVault, + linkedProjectRef: flags.projectRef, }); yield* legacyDbPushCore({ @@ -107,6 +127,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy includeAll: flags.includeAll, includeRoles: flags.includeRoles, includeSeed: flags.includeSeed, + includeVault: !flags.skipVault, dnsResolver, projectId: cliConfig.projectId, toml, diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 97731ea39a..7f00f24da1 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -43,6 +43,8 @@ const LIST_MIGRATIONS = const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; +const FLAG_PROJECT_REF = "flagflagflagflagflag"; + const LOCAL_CONN: LegacyPgConnInput = { host: "127.0.0.1", port: 54322, @@ -55,18 +57,24 @@ const DEFAULT_FLAGS: LegacyDbPushFlags = { includeAll: false, includeRoles: false, includeSeed: false, + skipVault: false, dryRun: false, dbUrl: Option.none(), linked: false, local: true, + projectRef: Option.none(), password: Option.none(), }; -function mockResolver(opts: { isLocal?: boolean; onResolve?: () => void } = {}) { - return Layer.succeed(LegacyDbConfigResolver, { - resolve: (_flags: LegacyDbConfigFlags) => +function mockResolver( + opts: { isLocal?: boolean; onResolve?: (flags: LegacyDbConfigFlags) => void } = {}, +) { + const calls: Array = []; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags: LegacyDbConfigFlags) => Effect.sync(() => { - opts.onResolve?.(); + calls.push(flags); + opts.onResolve?.(flags); return { conn: LOCAL_CONN, isLocal: opts.isLocal ?? true, @@ -74,6 +82,7 @@ function mockResolver(opts: { isLocal?: boolean; onResolve?: () => void } = {}) }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); + return { layer, calls }; } function mockConnection(opts: { @@ -215,29 +224,35 @@ function setup( resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), - loadProjectRef: () => - opts.linkedFails === true - ? Effect.fail( - new LegacyProjectNotLinkedError({ - message: "Cannot find project ref. Have you run supabase link?", - }), - ) - : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + // Go's `loadProjectRef` gives `--project-ref` top precedence, short-circuiting + // BEFORE the "not linked" failure — mirror that here so a test can prove the + // flag resolves a ref even when the workdir would otherwise fail to link. + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), }); + const resolver = mockResolver({ + isLocal: opts.isLocal ?? true, + onResolve: + opts.simulateInitialisingLoginRole === true + ? () => { + out.rawChunks.push({ text: "Initialising login role...\n", stream: "stderr" }); + } + : undefined, + }); const layer = Layer.mergeAll( out.layer, conn.layer, - mockResolver({ - isLocal: opts.isLocal ?? true, - onResolve: - opts.simulateInitialisingLoginRole === true - ? () => { - out.rawChunks.push({ text: "Initialising login role...\n", stream: "stderr" }); - } - : undefined, - }), + resolver.layer, mockLegacyCliConfig({ workdir, ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), @@ -263,6 +278,7 @@ function setup( conn, telemetry, linkedCache, + resolver, edgeRunCalls, registryEnvAtRunTime, }; @@ -571,6 +587,21 @@ describe("legacy db push", () => { }); }); + it.live("skips vault decryption in dry-run mode with --skip-vault", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:not-valid"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, skipVault: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(conn.queries.some((query) => query.sql.includes("vault."))).toBe(false); + expect(conn.execs).toEqual([]); + }); + }); + it.live( "prints the DRY RUN heads-up line after the connection resolves, not before (Go's push.Run order)", () => { @@ -906,7 +937,7 @@ describe("legacy db push", () => { }); it.live("upserts vault secrets (update existing, create new) before migrating", () => { - const { layer, out, conn } = setup(tmp.current, { + const { layer, out, conn, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', files: migrationFile("20240101000000"), // `existing` already present remotely → update; `fresh` → create. @@ -916,12 +947,65 @@ describe("legacy db push", () => { return Effect.gen(function* () { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Updating vault secrets..."); + expect(resolver.calls[0]?.resolveVaultSecrets).toBe(true); const sqls = conn.queries.map((q) => q.sql); expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); }); }); + it.live("applies migrations without touching vault when --skip-vault is set", () => { + const { layer, out, conn, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, skipVault: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("Updating vault secrets..."); + expect(resolver.calls[0]?.resolveVaultSecrets).toBe(false); + expect(conn.queries.some((query) => query.sql.includes("vault."))).toBe(false); + expect( + conn.queries.some((query) => query.sql.includes("INSERT INTO supabase_migrations")), + ).toBe(true); + }); + }); + + it.live("does not decrypt vault secrets skipped by --skip-vault", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:not-valid"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, skipVault: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(conn.queries.some((query) => query.sql.includes("vault."))).toBe(false); + }); + }); + + it.live("still validates non-vault secrets with --skip-vault", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db]\nroot_key = "encrypted:not-valid"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, skipVault: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain("failed to parse config:"); + expect(out.stderrText).not.toContain("Connecting to local database..."); + expect(conn.queries).toEqual([]); + }); + }); + it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { // Regression: the old point-of-use vault decryption keyed only on `process.env`, so a // `DOTENV_PRIVATE_KEY` present only in the project `.env` failed to decrypt. Go's config @@ -1181,4 +1265,100 @@ describe("legacy db push", () => { expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); }); }); + + it.live("pushes to the project given via --project-ref without a linked workdir", () => { + // No `.temp/project-ref` and the resolver's own ref-file fallback is + // simulated as failing (`linkedFails`) — only the flag can resolve a ref. + const { layer, out, linkedCache, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + linkedFails: true, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + local: false, + linked: true, + projectRef: Option.some(FLAG_PROJECT_REF), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); + expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_PROJECT_REF)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); + + it.live("--project-ref drives which [remotes.] block merges into config", () => { + // The `[remotes.staging]` block's `project_id` matches the FLAG ref, not the + // resolver's own `LEGACY_VALID_REF` fallback — the override only announces if + // the flag (not the fallback) actually resolved the ref config merges against. + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.staging]\nproject_id = "${FLAG_PROJECT_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + local: false, + linked: true, + projectRef: Option.some(FLAG_PROJECT_REF), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); + }); + }); + + it.live("--project-ref overrides an already-linked workdir's project ref", () => { + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + // The workdir is linked to LEGACY_VALID_REF (e.g. via .temp/project-ref) — + // the flag must win over it. + projectRef: LEGACY_VALID_REF, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + local: false, + linked: true, + projectRef: Option.some(FLAG_PROJECT_REF), + }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); + expect(linkedCache.cachedRef).not.toBe(LEGACY_VALID_REF); + }); + }); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const { layer, conn, resolver, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ + ...DEFAULT_FLAGS, + projectRef: Option.some(FLAG_PROJECT_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection/config resolution or cache write. + expect(conn.execs).toEqual([]); + expect(resolver.calls).toEqual([]); + expect(linkedCache.cached).toBe(false); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/query/SIDE_EFFECTS.md index 775bac2733..56e91f2d34 100644 --- a/apps/cli/src/legacy/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/query/SIDE_EFFECTS.md @@ -6,13 +6,14 @@ the result as a table or JSON. ## Files Read -| Path | Format | When | -| ------------------------------------ | ---------- | ------------------------------------------------------------- | -| `` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | -| stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | -| `supabase/config.toml` | TOML | local / `--db-url` connection resolution | -| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | -| `supabase/.temp/linked-project.json` | JSON | `--linked` existence check before the cache write (see below) | +| Path | Format | When | +| ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------ | +| `` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | +| stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | +| `supabase/config.toml` | TOML | local / `--db-url` connection resolution | +| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `supabase/.temp/linked-project.json` | JSON | `--linked` existence check before the cache write (see below) | ## Files Written @@ -40,6 +41,7 @@ the result as a table or JSON. | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | conflicting `--db-url`/`--linked`/`--local`; no SQL provided; empty stdin; unreadable `--file`; `--linked` without login; query exec failure; non-201 linked status | +| `1` | `--project-ref` set without `--linked` (see Notes / Divergences) | ## Output @@ -80,6 +82,13 @@ from the environment. Agent mode defaults the format to JSON (table for humans). matching Go's per-command enum validation. See `legacy-go-output-flag.ts`. - **Local DDL command tags** use the raw `commandComplete` protocol tag (so `CREATE TABLE` etc. survive node-postgres' first-word-only parse of the tag). +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution used for the connection and + the linked-project cache (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`). It never implies `--linked`: passing it without + `--linked` (i.e. targeting local or `--db-url`) is a hard error rather than a + silently discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, + which Go's equivalent env var simply leaves unused on a non-linked target). - **Linked-project cache (`PersistentPostRun` parity).** On the `--linked` path, after the query runs — whether it succeeds or fails — the handler mirrors Go's `ensureProjectGroupsCached` (`apps/cli-go/cmd/root.go:176,214-234`): it issues diff --git a/apps/cli/src/legacy/commands/db/query/query.command.ts b/apps/cli/src/legacy/commands/db/query/query.command.ts index 4d4b04bf9b..2a5fe7ba41 100644 --- a/apps/cli/src/legacy/commands/db/query/query.command.ts +++ b/apps/cli/src/legacy/commands/db/query/query.command.ts @@ -46,6 +46,11 @@ const config = { Flag.withDescription("Queries the local database."), Flag.optional, ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), file: Flag.string("file").pipe( Flag.withAlias("f"), Flag.withDescription("Path to a SQL file to execute."), @@ -65,8 +70,12 @@ export const legacyDbQueryCommand = Command.make("query", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, file: flags.file, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. // db query's Go enum is `json|table|csv`, not the resource-command set. outputFormats: LEGACY_QUERY_OUTPUT_FORMATS, // Go registers `--file` with shorthand `-f` (`cmd/db.go:527`) and telemetry diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index ac7f3f53e3..ac029a4a6e 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * No SQL was provided by any source. Byte-matches Go's @@ -7,17 +13,29 @@ import { Data } from "effect"; */ export class LegacyDbQueryNoSqlError extends Data.TaggedError("LegacyDbQueryNoSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Stdin was piped but empty. Byte-matches Go's `"no SQL provided via stdin"`. */ export class LegacyDbQueryNoStdinSqlError extends Data.TaggedError("LegacyDbQueryNoStdinSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `--file` could not be read. Byte-matches Go's `"failed to read SQL file: " + err`. */ export class LegacyDbQueryReadFileError extends Data.TaggedError("LegacyDbQueryReadFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` was used without an access token. Mirrors Go's PreRunE, which @@ -29,12 +47,30 @@ export class LegacyDbQueryLoginRequiredError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** Query execution failed. Byte-matches Go's `"failed to execute query: " + err`. */ export class LegacyDbQueryExecError extends Data.TaggedError("LegacyDbQueryExecError")<{ readonly message: string; -}> {} + /** + * Set when this failure came from the linked path's HTTP transport + * (`httpClient.execute`/body read against `/v1/projects/{ref}/database/query`) + * rather than the user's SQL failing to execute. + */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + // The user's own SQL failed — same bucket as every sibling exec error. + return actionability.dbFinding; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -46,7 +82,11 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbQueryMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The linked Management API returned a non-201 status. Byte-matches Go's @@ -55,5 +95,15 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( export class LegacyDbQueryUnexpectedStatusError extends Data.TaggedError( "LegacyDbQueryUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The endpoint executes the user's SQL: a 400 is the remote twin of the + // local LegacyDbQueryExecError (syntax/constraint failures in user SQL). + if (this.status === 400) { + return { ...actionability.dbFinding, fingerprint_suffix: "query" }; + } + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 4a51f9e301..cc86814118 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -200,12 +200,17 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega return { status: response.status, body: text }; }).pipe( Effect.mapError( - (cause) => new LegacyDbQueryExecError({ message: `failed to execute query: ${cause}` }), + (cause) => + new LegacyDbQueryExecError({ + message: `failed to execute query: ${cause}`, + transport: true, + }), ), ); if (status !== 201) { return yield* Effect.fail( new LegacyDbQueryUnexpectedStatusError({ + status, message: `unexpected status ${status}: ${body}`, }), ); @@ -255,6 +260,18 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && Option.isNone(flags.linked)) { + return yield* Effect.fail( + new LegacyDbQueryMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + // PreRun parity: for --linked, Go checks the access token and loads the project // ref BEFORE RunE's ResolveSQL (`cmd/db.go`), so a missing `--file` or a blocking // stdin pipe must not mask the expected login / not-linked error. Run that @@ -275,7 +292,7 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega // surfaces `failed to load project ref` on a real (non-not-exist) ref-file // read error rather than masking it as not-linked (the soft `resolveOptional` // swallows that to None; `cmd/utils/flags/project_ref.go:70-75`). - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.projectRef); // Record the ref now (Go's `LoadProjectRef` sets `flags.ProjectRef` here), // so the linked-project cache finalizer fires even if the DB resolution or // token check below fails. @@ -286,7 +303,12 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega // login role must be minted (matching Go), so this stays before the token-only // check. The linked query itself uses the Management API, so the resolved // connection is discarded — this runs purely for Go's pre-run failures. - yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", dnsResolver }); + yield* resolver.resolve({ + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + linkedProjectRef: flags.projectRef, + }); // 3. Command `PreRunE` token check (`cmd/db.go:303`): Go still requires a token // for the Management API query even when config resolved without minting a // login role (e.g. a direct `DB_PASSWORD` was set), so keep this — but after 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 591a3efb4b..5286ed75e3 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 @@ -122,16 +122,21 @@ function mockProjectRef(unlinked = false, refReadFails = false) { // The linked query preflight uses the hard `loadProjectRef`: it fails with // ErrNotLinked when absent and surfaces a `failed to load project ref` read error // (LegacyProjectRefReadError) on an unreadable ref file, rather than masking it. - const loadProjectRef = () => - refReadFails - ? Effect.fail( - new LegacyProjectRefReadError({ - message: "failed to load project ref: permission denied", - }), - ) - : unlinked - ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(REF); + // An explicit `--project-ref` flag gets top precedence, same as Go's + // `flags.LoadProjectRef` — short-circuiting BEFORE either failure mode, so a + // test can prove the flag resolves a ref even for an "unlinked" workdir. + const loadProjectRef = (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : refReadFails + ? Effect.fail( + new LegacyProjectRefReadError({ + message: "failed to load project ref: permission denied", + }), + ) + : unlinked + ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) + : Effect.succeed(REF); return Layer.succeed(LegacyProjectRefResolver, { resolve: () => Effect.succeed(REF), resolveForLink: () => Effect.succeed(REF), @@ -166,10 +171,12 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { } function mockHttpClient(opts: { status?: number; body?: string; networkFail?: boolean }) { - return Layer.succeed( + const requests: Array = []; + const layer = Layer.succeed( HttpClient.HttpClient, - HttpClient.make((request) => - opts.networkFail === true + HttpClient.make((request) => { + requests.push(request.url); + return opts.networkFail === true ? Effect.fail( new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, description: "ECONNREFUSED" }), @@ -183,9 +190,15 @@ function mockHttpClient(opts: { status?: number; body?: string; networkFail?: bo headers: { "content-type": "application/json" }, }), ), - ), - ), + ); + }), ); + return { + layer, + get requests() { + return requests; + }, + }; } interface SetupOpts { @@ -216,6 +229,11 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); const telemetryOutputFormat = mockTelemetryOutputFormat(); + const httpClient = mockHttpClient({ + status: opts.linkedStatus, + body: opts.linkedBody, + networkFail: opts.networkFail, + }); const layer = Layer.mergeAll( out.layer, telemetry.layer, @@ -255,14 +273,10 @@ function setup(opts: SetupOpts = {}) { deleteProjectCredential: () => Effect.die("unexpected legacy project-credential delete in test"), }), - mockHttpClient({ - status: opts.linkedStatus, - body: opts.linkedBody, - networkFail: opts.networkFail, - }), + httpClient.layer, BunServices.layer, ); - return { layer, out, telemetry, cache, telemetryOutputFormat }; + return { layer, out, telemetry, cache, telemetryOutputFormat, httpClient }; } const flags = (over: Partial = {}): LegacyDbQueryFlags => ({ @@ -270,6 +284,7 @@ const flags = (over: Partial = {}): LegacyDbQueryFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), local: over.local ?? Option.none(), + projectRef: over.projectRef ?? Option.none(), file: over.file ?? Option.none(), }); @@ -682,6 +697,79 @@ describe("legacy db query integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("queries the project given via --project-ref without a linked workdir", () => { + // The fake resolver would otherwise fail as "unlinked" (`ErrNotLinked`) — + // only the flag can resolve a ref here. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, out, cache, httpClient } = setup({ + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + unlinked: true, + }); + return Effect.gen(function* () { + yield* legacyDbQuery( + flags({ + sql: Option.some("select 1"), + linked: Option.some(true), + projectRef: Option.some(FLAG_REF), + }), + ); + expect(out.stdoutText).toContain("│ name │ id │"); + // The request path itself must be scoped to the FLAG ref, not merely + // any successful query — proving the flag (not a fallback) drove the + // API call the same way it drove the cache below. + expect( + httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + ).toBe(true); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("--project-ref overrides an already-linked workdir's project ref", () => { + const FLAG_REF = "flagflagflagflagflag"; + // The workdir already resolves to REF (e.g. via .temp/project-ref) — the + // flag must win over it. + const { layer, cache, httpClient } = setup({ + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + }); + return Effect.gen(function* () { + yield* legacyDbQuery( + flags({ + sql: Option.some("select 1"), + linked: Option.some(true), + projectRef: Option.some(FLAG_REF), + }), + ); + expect( + httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + ).toBe(true); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-ref without --linked (query defaults to the local target)", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, out, cache, httpClient } = setup({ result: SELECT_RESULT }); + return Effect.gen(function* () { + const exit = yield* legacyDbQuery( + flags({ sql: Option.some("select 1"), projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + // The guard fires before any local connection/query, linked API call, or + // cache write — no query result is ever rendered. + expect(out.stdoutText).toBe(""); + expect(httpClient.requests).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("treats --linked=false as an explicit linked target (Go gates on flag.Changed)", () => { // pflag marks `--linked=false` as Changed, and Go's PreRun/RunE gate the linked // path on flag.Changed (not the value), so this still runs the linked HTTP path 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 8591bad33f..0e9d9355b6 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,20 +1,23 @@ # `supabase db reset` Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a -database from local migrations (plus seed). The **remote** path (`--linked`, or a -remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then -re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` -pointing at the local stack) is ALSO fully native (CLI-1955 removed the hidden Go -`db __db-bootstrap` seam this used to delegate to): the running check, the PG14/PG15 -container-recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`, -reusing the same container-bootstrap primitives `db start` uses — see that command's -own `SIDE_EFFECTS.md`), the post-recreate satellite-restart + Kong reload +database from local migrations (plus seed). Both targets are fully native — no +remaining Go delegation. The **remote** path (`--linked`, or a remote `--db-url`) +drops all user schemas, upserts vault secrets, then either re-applies migrations +(the default) or, on a versionless `--experimental`/`SUPABASE_EXPERIMENTAL` reset +with pg-delta not enabled, applies the declarative `[db.migrations].schema_paths` +files instead (Go's `apply.MigrateAndSeed` EXPERIMENTAL branch, CLI-1958), then +seeds. The **local** path (`--local`/default, or a `--db-url` pointing at the local +stack) is ALSO fully native (CLI-1955 removed the hidden Go `db __db-bootstrap` seam +this used to delegate to): the running check, the PG14/PG15 container-recreate +composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`, reusing the +same container-bootstrap primitives `db start` uses — see that command's own +`SIDE_EFFECTS.md`), the post-recreate satellite-restart + Kong reload (`legacy/shared/db-bootstrap/restart-services.ts`), the storage-health gate (`legacy/shared/db-bootstrap/await-storage-ready.ts`), bucket seeding, and the -git-branch line are all native TS. Only the niche **`--experimental`** schema-files -path with no resolved version still delegates to the Go binary, and only for the -**remote** target — the local target's `--experimental` path is fully native (see -"Notes"). +git-branch line are all native TS — including the local target's own +`--experimental` schema-files apply (`legacyMigrateAndSeed`, shared with the remote +path's branch above). The whole local-reset composition is hoisted into `legacy/shared/db-bootstrap/ reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062), which this @@ -34,50 +37,55 @@ removed `LegacyDeclarativeSeam.execInherit` seam — see those commands' own | `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | | `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | | `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | | `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | | seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config | +| schema files from `[db.migrations].schema_paths` | SQL | when the `--experimental` schema-files branch is taken, either target (see Notes) | | `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | | `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `legacyStartSetupLocalDatabase` pipeline — missing file tolerated | | `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | --------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after migrations/seeding succeed, when no `--version`/`--last` resolved a version AND pg-delta is enabled (`[experimental.pgdelta].enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`); a failure only warns on stderr and never fails the reset — see Notes. Native TS on both targets: **remote path** (`` = the project ref/URL hash) after either apply branch (schema-files or migrations) (Go's `down.ResetAll` → `pgcache.TryCacheMigrationsCatalog`, `down.go:58-59`); **local path** (`` = `"local"`) PG15 only, via the reused `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) after `MigrateAndSeed` — the PG≤14 branch never calls this at all, so a PG≤14 local project never writes this file regardless of pg-delta config | On the local path, the native recreate additionally recreates the `supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` databases in place (PG14), and applies the initial schema (`SetupLocalDatabase` -equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14); the `--experimental` -remote path produces whatever the delegated Go binary writes. +equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). ## Subprocesses -| Command | When | Purpose | -| ----------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | -| `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | -| `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | -| `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`legacyStartSetupLocalDatabase`) | -| `docker restart ` | local path, PG14 | `RestartDatabase` — pg_cron must restart after `pg_terminate_backend` | -| `docker restart ` | local path, both PG14 and PG15 | concurrent satellite-container restart, not-found tolerated per service | -| `docker container inspect ` + `docker exec kong reload` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) | -| `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | -| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | +| Command | When | Purpose | +| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | +| `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | +| `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`legacyStartSetupLocalDatabase`) | +| `docker restart ` | local path, PG14 | `RestartDatabase` — pg_cron must restart after `pg_terminate_backend` | +| `docker restart ` | local path, both PG14 and PG15 | concurrent satellite-container restart, not-found tolerated per service | +| `docker container inspect ` + `docker exec kong reload --nginx-conf /home/kong/custom_nginx.template` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) — the `--nginx-conf` flag is load-bearing: a bare `kong reload` regenerates nginx.conf from Kong's default template and drops the custom `email_templates` server (#6059) | +| `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | + +No subprocess delegation remains on either target — the remote path's +`--experimental` schema-files apply (formerly delegated to a `supabase-go db reset` +child) is fully native as of CLI-1958. ## Database Mutations ### Remote path (native, in TS) -| Statement | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | -| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | -| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | -| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | when `[db.migrations].enabled`, for migrations `≤ --version` | -| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | +| Statement | When | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) | +| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` (runs after either branch above) | ### Local path (native, in TS) @@ -120,43 +128,48 @@ the whole reset** (not just "skip buckets"). ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | routes the remote experimental schema-files path to Go; on the local path, applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (native) | no (also `--experimental`) | -| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | -| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | +| Variable | Purpose | Required? | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | +| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-reset migrations-catalog cache (see Files Written) when `[experimental.pgdelta].enabled` is unset — distinct from `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` above, which switches the reset's own apply branch instead | no (project `.env` or shell) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | +| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Exit Codes -| Code | Condition | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `0` | success | -| `1` | mutually exclusive target flags (`[db-url linked local]`) | -| `1` | `--version` + `--last` together (`[last version]`) | -| `1` | `--version` not an integer (`invalid version number`) | -| `1` | `--version` has no matching migration file | -| `1` | local: database not running (`supabase start is not running.`) | -| `1` | user declined the reset confirmation (`context canceled`) | -| `1` | `config.toml` parse failure | -| `1` | drop / migrate / seed / vault apply failure, or connection error | -| `1` | local: container/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | -| child's exact code\* | `--experimental`/`--linked` remote delegate (proxy) child exit | - -\* The `--experimental` remote delegate propagates the spawned `supabase-go` child's -real exit code (e.g. `130` after a Ctrl-C) instead of collapsing every failure to `1` -— in every `--output-format` (CLI-1879). The local path has no Go child at all -anymore (CLI-1955) — every local failure is a native, typed TS error. +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| `1` | no `[db.migrations].schema_paths` pattern matched anything on the `--experimental` branch, either target | +| `1` | local: container/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | + +There is no remaining Go child on either target (CLI-1955 removed it for local, +CLI-1958 for remote) — every failure is a native, typed TS error surfaced as `1`. ## Output -The remote path prints `Resetting remote database…` to **stderr**, then the -drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go -connects with `io.Discard`, so there is **no** `Connecting to … database…` line and -**no** `Finished …` line on the remote path. +The remote path prints `Resetting remote database…` to **stderr**, then either the +schema-files branch's apply (no per-file progress — Go's `applySchemaFiles` prints +nothing, CLI-1958) or the migrate/seed progress (`Applying migration …`, `Seeding +data from …`). Go connects with `io.Discard`, so there is **no** `Connecting to … +database…` line and **no** `Finished …` line on the remote path. The local path prints `Resetting local database…` to **stderr**, then `Recreating database...` (PG15) or nothing extra (PG14, until the restart step) / @@ -165,9 +178,8 @@ branch .` (`supabase db reset` and `` in Aqua). ### `--output-format text` (Go CLI compatible) -Byte-matches Go's stderr progress for both the remote and local paths. The -`--experimental` remote path passes the delegated Go binary's output through -unchanged. +Byte-matches Go's stderr progress for both the remote and local paths, including the +silent (no-progress-line) `--experimental` schema-files apply. ### `--output-format json` / `stream-json` @@ -185,6 +197,14 @@ path has no confirmation prompt. - **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` + command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` + performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`) — + unlike `SUPABASE_PROJECT_ID`, it does not affect the local container id. It + never implies `--linked`: passing it with a resolved `--local`/`--db-url` + target is a hard error rather than a silently discarded flag (deliberately + stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply + leaves unused on a non-linked target). - **Pipeline-incompatible statements** (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the per-file transaction batch, with the same non-atomic flush behaviour as `db push` — see `db push`'s SIDE_EFFECTS Notes (supabase/cli#5139, @@ -201,18 +221,55 @@ path has no confirmation prompt. - `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. -- The local target's `--experimental` schema-files path (no resolved version, no - pg-delta) is fully native: it was never actually delegated even before this port - (the removed seam forwarded `--experimental` straight through to its own Go child), - and `legacyMigrateAndSeed` (reused by both PG14 and PG15) already implements Go's - `apply.MigrateAndSeed` declarative-schema-files branch. -- The best-effort pg-delta migrations-catalog cache write - (`pgcache.TryCacheMigrationsCatalog`, reachable from the PG15 recreate via - `SetupLocalDatabase`) IS reached on the local PG15 path, same as `db start` — - `reset.layers.ts` composes `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` - for it (see `db-setup.ts`'s own header for the exact gate). The write is silent on - success; a failure only warns on stderr and never fails the reset, matching Go. -- `encrypted:` vault secrets are skipped on the remote path. +- **`--experimental` schema-files apply** (Go's `apply.MigrateAndSeed` EXPERIMENTAL + branch, `apps/cli-go/internal/migration/apply/apply.go:19,51-68`) is taken on + EITHER target when `--experimental`/`SUPABASE_EXPERIMENTAL` is set, no + `--version`/`--last` resolved a version, AND `[experimental.pgdelta].enabled` is + NOT set. A hard `if`/`else if` in Go — taking this branch means timestamped + migrations never run at all, even when `[db.migrations].schema_paths` matches + nothing. Faithfully reproduces two undocumented Go quirks: (1) the `schema_paths` + default is `[]`, so a stock project running an experimental reset silently applies + NOTHING (drops schemas, seeds, but replays no SQL) rather than falling back to + migrations; (2) a partial glob failure (some patterns match, others don't) is + silently dropped — only a TOTAL failure (no pattern matches anything) aborts the + reset, with Go's joined `no files matched pattern: …` text and no `CmdSuggestion`. + A per-file apply failure attaches Go's `See schema file: ` suggestion. No + progress line is printed per file (Go's `applySchemaFiles` has no output), no + migration-history row is inserted, and no `RESET ALL` runs between files. Seeding + still runs afterward, unconditionally, exactly as on the migrations branch. The + local target's branch was already native before this port (`legacyMigrateAndSeed`, + reused by both the PG14 and PG15 recreate branches, already implements this exact + Go branch); CLI-1958 ports the remote target's copy of the same branch + (`legacyApplySchemaFiles`), removing the last Go delegation on this command. + `encrypted:` vault secrets are NOT skipped on the remote path — `legacyCheckDbToml` + decrypts them into `toml.vault`, and `legacyUpsertVaultSecrets` upserts the + decrypted values unconditionally, before either branch (schema-files or migrations) + runs. +- **Migrations catalog cache**: gated on BOTH no `--version`/`--last` having resolved + a version AND pg-delta being enabled (`[experimental.pgdelta].enabled` or + `SUPABASE_EXPERIMENTAL_PG_DELTA` — see Environment Variables); a versioned reset + never refreshes the cache, matching Go's own `len(version) > 0` no-op inside + `TryCacheMigrationsCatalog` itself. A failure only warns on stderr and never fails + the reset, matching Go exactly. Writes under `supabase/.temp/pgdelta/` (see Files + Written), pruning older snapshots for the same prefix (retains 2). Native TS on + BOTH paths now, on different call chains: + - **Remote path** (ported CLI-1958): Go's best-effort `down.ResetAll` → + `pgcache.TryCacheMigrationsCatalog` (`down.go:48-61`), after either apply branch + (schema-files or migrations) and seeding complete. Exports the target's pg-delta + catalog via the edge-runtime stack. Reuses `legacyExportCatalogPgDelta` and + `legacyTryCacheMigrationsCatalog` — the same helpers `db push` uses for its own + post-apply cache (see that command's SIDE_EFFECTS Notes) — rather than a second + copy. + - **Local path** (native since CLI-1955/2062, no Go child involved): the reused + `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) calls the same + `legacyTryCacheMigrationsCatalog` (with prefix `"local"`) right after + `MigrateAndSeed` succeeds, warning the same way on failure. `reset.layers.ts` + composes `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for this — + the same pair `db start`/`db push` already compose for their own calls into the + same function. This only happens on the **PG15** recreate branch — the + **PG≤14** branch returns immediately after `MigrateAndSeed` and never calls + `legacyTryCacheMigrationsCatalog` at all, so a PG≤14 local project never writes + this file, no matter how pg-delta is configured. - `db schema declarative`/`db schema sync`'s own local-reset paths now call `legacyResetLocalDatabase` in-process too (CLI-2062) — the previous scope boundary (those two commands shelling out to a second `supabase-go` child via the now-removed 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 979d088f84..504be67b37 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -21,6 +21,11 @@ const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Resets the local database with local migrations."), ), + // TS-only override of the linked project ref — see push.command.ts. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), noSeed: Flag.boolean("no-seed").pipe( Flag.withDescription("Skip running the seed script after reset."), ), @@ -53,6 +58,7 @@ export const legacyDbResetCommand = Command.make("reset", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, "no-seed": flags.noSeed, "sql-paths": flags.sqlPaths, version: flags.version, @@ -61,6 +67,9 @@ export const legacyDbResetCommand = Command.make("reset", config).pipe( // NO safeFlags: `markFlagTelemetrySafe` is per flag INSTANCE, and Go only // marks migration squash's `--version` (cmd/migration.go:134). db reset's // `--version` (cmd/db.go) is unmarked, so Go redacts it — match that. + // `--project-ref` is TS-only with no Go telemetry-safety baseline either + // (Go's nearest registrations, cmd/pgdelta_catalog.go:44 and most others, + // are unmarked too), so it stays redacted as well. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index 43dba0f603..cde98e05a2 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). @@ -8,7 +14,11 @@ export class LegacyDbResetTargetFlagsError extends Data.TaggedError( "LegacyDbResetTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` and `--last` together. Reproduces cobra's @@ -18,7 +28,11 @@ export class LegacyDbResetVersionFlagsError extends Data.TaggedError( "LegacyDbResetVersionFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` is not a valid integer. Byte-matches Go's bare @@ -30,7 +44,11 @@ export class LegacyDbResetInvalidVersionError extends Data.TaggedError( "LegacyDbResetInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No migration file matches `--version`. Byte-matches Go's @@ -41,7 +59,11 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( "LegacyDbResetMigrationFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The user declined the reset confirmation. Go returns @@ -49,12 +71,26 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( */ export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} -/** A drop / migrate / seed / vault statement failed during the remote reset. */ +/** + * A drop / migrate / seed / vault statement failed during the remote reset. `suggestion` + * is Go's `CmdSuggestion` — set only by the `--experimental` schema-files apply branch + * (`"See schema file: "`, `apply.go:63`); every other apply failure on this + * command leaves it unset, matching Go. + */ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ readonly message: string; -}> {} + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * `--last` was given a negative value. Go declares `--last` as an unsigned flag @@ -63,7 +99,11 @@ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetAppl */ export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` @@ -77,4 +117,8 @@ export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbReset * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} 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 d00c8ff428..b91ccaf58f 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -6,34 +6,45 @@ import { legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { legacyResolveResetSeedConfig } from "../../../shared/db-bootstrap/db-setup.ts"; import { legacyResetLocalDatabase } from "../../../shared/db-bootstrap/reset-local-database.ts"; +import { legacyParseBoolEnv } from "../../../shared/legacy-diff-engine.ts"; +import { redactLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { + legacyApplyProjectEnv, legacyCheckDbToml, legacyLoadProjectEnv, } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../../shared/legacy-docker-ids.ts"; +import { + legacyApplyMigrations, + legacyApplySchemaFiles, +} from "../../../shared/legacy-migration-apply.ts"; import { legacyParseMigrationVersion } from "../../../shared/legacy-migration-timestamp.format.ts"; -import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { - type LegacyDbConnType, - resolveLegacyDbTargetFlags, -} from "../../../shared/legacy-db-target-flags.ts"; -import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; -import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; -import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache.ts"; + legacyListLocalMigrations, + legacyTryCacheMigrationsCatalog, +} from "../../../shared/legacy-pgdelta.cache.ts"; +import { type LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../../../shared/legacy-seed-ops.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 { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; import { LegacyDbResetApplyError, @@ -48,69 +59,36 @@ import { const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; -const applyError = (message: string) => new LegacyDbResetApplyError({ message }); +const applyError = (message: string, suggestion?: string) => + new LegacyDbResetApplyError({ message, ...(suggestion !== undefined ? { suggestion } : {}) }); /** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; -/** - * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote - * `--experimental` reset with no resolved version. Only the flags reachable on - * that path are forwarded — `--local` always takes the native path, and a set - * `--version`/`--last` resolves a non-empty version which disables the experimental - * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical - * whether or not it is forwarded, so it is omitted). - * - * The target selector is forwarded from the RESOLVED `connType`, not the raw `--linked` - * boolean: the parent's `resolveLegacyDbTargetFlags` follows Cobra's `Changed` semantics, so - * `--linked=false` selects the linked/remote target (this path is remote-only). Forwarding - * only when `flags.linked === true` would drop the selector for `--linked=false` and let the - * Go child fall back to its local default — resetting the wrong database. - */ -const buildResetArgs = ( - flags: LegacyDbResetFlags, - connType: LegacyDbConnType, - yes: boolean, -): Array => { - const args = ["db", "reset"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - else if (connType === "linked") args.push("--linked"); - if (flags.noSeed) args.push("--no-seed"); - for (const p of flags.sqlPaths) args.push("--sql-paths", p); - // Forward the parent's RESOLVED `yes` as a bound flag. Go's `--yes` beats `AutomaticEnv`, - // so `--yes=false` overrides an inherited `SUPABASE_YES=true` (the child no longer - // auto-confirms a reset the user protected with `--yes=false`), while `--yes=true` honors - // an explicit `--yes` / env even in machine mode where the child's stdin is ignored. - // `--yes=false` still prompts on a TTY (Go's PromptYesNo only short-circuits on true), so - // this matches the default behavior when neither flag nor env is set. - args.push(`--yes=${yes}`); - return args; -}; - /** * `supabase db reset` — reinitialise a database from local migrations (+ seed). * - * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path - * (`--linked` / a remote `--db-url`) is native. The local path's container-recreate - * primitives are ALSO native now — the hidden `db __db-bootstrap` Go seam this used to - * delegate to (CLI-1325 Stage 3's documented interim) is gone (CLI-1955), and the + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. Fully native — no + * remaining Go delegation on either target. The local path's container-recreate + * primitives are native (the hidden `db __db-bootstrap` Go seam this used to + * delegate to, CLI-1325 Stage 3's documented interim, is gone — CLI-1955), and the * local-reset composition itself is hoisted into `legacyResetLocalDatabase` * (`legacy/shared/db-bootstrap/reset-local-database.ts`, CLI-2062) so `db schema - * declarative`'s smart-target/sync recovery reset can call it in-process too, instead - * of shelling out to a second `supabase-go` child. Only the REMOTE target's niche - * `--experimental` schema-files path with NO resolved version still delegates to the - * Go binary (`shouldDelegateExperimental`) — the LOCAL target never delegated this at - * all (the removed seam forwarded `--experimental` straight through to its own Go - * child), and stays fully native on this path too: `legacyMigrateAndSeed` (reused by - * both the PG14 and PG15 recreate branches) already implements Go's - * `apply.MigrateAndSeed` experimental-schema-files branch. + * declarative`'s smart-target/sync recovery reset can call it in-process too, + * instead of shelling out to a second `supabase-go` child. The remote target's + * `--experimental` schema-files path — the last remaining Go delegation on this + * command — is now also native (`legacyApplySchemaFiles`, CLI-1958): a versionless + * `--experimental`/`SUPABASE_EXPERIMENTAL` remote reset with pg-delta NOT enabled + * takes Go's EXPERIMENTAL declarative schema-files branch of `apply.MigrateAndSeed` + * instead of replaying timestamped migrations, mirroring `legacyMigrateAndSeed`'s + * already-native local-side implementation of the exact same Go branch (reused by + * both the PG14 and PG15 recreate paths). */ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const dbConn = yield* LegacyDbConnection; - const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -131,6 +109,16 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega let linkedRefForCache: string | undefined; const body = Effect.gen(function* () { + // Go's `loadNestedEnv` (`os.Setenv`) makes every project-`.env` key visible to the + // WHOLE reset run, not just the flag-gate reads above — in particular + // `legacyGetRegistryImageUrl` / `legacyPgDeltaNpmRegistryOption` read + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` straight from + // `process.env` for the pg-delta catalog export below (review CLI-1958). `db push` + // (`push.handler.ts`) scopes this the same way, as the first statement of its own + // `body` — mirror that exactly so a private/air-gapped registry configured only in + // `supabase/.env` reaches the catalog export instead of silently falling back to the + // default registries. + yield* legacyApplyProjectEnv(projectEnv); const target = resolveLegacyDbTargetFlags(cliArgs.args); // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). if (target.setFlags.length > 1) { @@ -240,35 +228,18 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega } const connType = target.connType ?? "local"; - // Single source of truth for "does this reset delegate to the Go child?" — - // checked at both delegation sites below (before `resolve()` for a linked - // target, after it for a `--db-url` target) so the two call sites can never - // drift apart. - const shouldDelegateExperimental = experimental && resolvedVersion === ""; - // Delegates the remaining `--experimental` schema-files apply path - // (`apply.MigrateAndSeed`, not ported) to the Go child. In text mode inherit - // its stdio. Under a machine-output mode (`--output-format json|stream-json`) - // the Go child emits no TS envelope, so suppress its stdout (capture + discard) - // and emit the same structured success the native local and remote paths do, - // keeping the JSON contract consistent across all reset paths. - const delegateExperimentalReset = () => - Effect.gen(function* () { - const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - if (output.format === "text") { - yield* proxy.exec(buildResetArgs(flags, connType, yes), { env }); - } else { - // Machine-output mode is non-interactive: give the Go child a non-TTY stdin - // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's - // destructive reset prompt — it takes the default `false`, matching the - // native reset path which suppresses prompts under json/stream-json. - yield* proxy.execCapture(buildResetArgs(flags, connType, yes), { env, stdin: "ignore" }); - yield* output.success("Reset remote database.", { - target: "remote", - version: resolvedVersion, - }); - } - }); + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } // Go's ParseDatabaseConfig runs LoadProjectRef BEFORE the fallible linked // resolution (db_url.go:87-95), and Execute() writes the linked-project cache @@ -277,25 +248,15 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // config, temp-role mint, connection) — mirrors push.handler. if (connType === "linked") { const refResolver = yield* LegacyProjectRefResolver; - linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); - - // A linked target is never local (`resolver.resolve()`'s "linked" branch - // always returns `isLocal: false`), so the delegated-experimental check can - // run BEFORE calling `resolve()`. This matters: for `connType === "linked"`, - // `resolve()` mints/verifies a temporary Postgres login role over the - // Management API — and the delegated Go child re-runs that exact same - // `ParseDatabaseConfig` work itself once delegation happens. Calling - // `resolve()` here would mint the temp role twice for zero downstream use on - // this branch (Go's own reset flow mints it exactly once, as part of the code - // path being delegated to — confirmed against `apps/cli-go/internal/utils/ - // flags/db_url.go`'s `NewDbConfigWithPassword`/`initLoginRole`). CLI-1879. - if (shouldDelegateExperimental) { - yield* delegateExperimentalReset(); - return; - } + linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); } - const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + linkedProjectRef: flags.projectRef, + }); // Local target → native local reset. Mirrors `internal/db/reset/reset.go:57-77`; // the actual composition (running check, container recreate, storage-health gate, @@ -319,22 +280,12 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega } // Re-confirm `linkedRefForCache` from the now-resolved `cfg.ref` for the native - // remote linked path below (a linked+experimental+versionless target already - // delegated and returned above, before `resolve()` was ever called — see the - // `connType === "linked"` block earlier in this function). A `connType === - // "db-url"` target leaves `linkedRefForCache` as whatever the pre-load block - // set (nothing, for `db-url`), since this assignment only fires when linked. + // remote path below. A `connType === "db-url"` target leaves `linkedRefForCache` + // as whatever the pre-load block set (nothing, for `db-url`), since this + // assignment only fires when linked. const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; - // Remaining remote target: a `--db-url` pointing at a non-local host (the - // `connType === "linked"` case already delegated above, before `resolve()`, - // without resolving a connection at all). - if (shouldDelegateExperimental) { - yield* delegateExperimentalReset(); - return; - } - // Single Go-parity config load (`flags.LoadConfig` → `config.Load` + `Validate`): // decodes the whole config with Go's env-expansion + `strconv.ParseBool` weak typing // (so `enabled = "env(SEED_ENABLED)"` etc. load like Go), applies `SUPABASE_*` @@ -371,7 +322,28 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega yield* legacyDropUserSchemas(session, applyError); yield* legacyUpsertVaultSecrets(session, vaultSecrets); - if (toml.migrationsEnabled) { + // Go's three-conjunct EXPERIMENTAL gate (`apply.MigrateAndSeed`, `apply.go:19`): + // `--experimental`/`SUPABASE_EXPERIMENTAL` + no resolved version + pg-delta NOT + // enabled. A hard `if`/`else if` in Go (`apply.go:19-27`) — taking the + // schema-files branch means timestamped migrations never run at all, even when + // the glob matches nothing (Go's `schema_paths = []` default silently applies + // NOTHING rather than falling back to migrations — CLI-1958). + const useSchemaFiles = experimental && resolvedVersion === "" && !toml.pgDelta.enabled; + if (useSchemaFiles) { + // `projectEnv` (loaded above, before `experimental`/`yes` resolve) is threaded + // through so a `SUPABASE_SCANNER_BUFFER_SIZE` set only in `supabase/.env` is + // honored here exactly like Go's `loadNestedEnv` (see + // `checkScannerBufferSize`'s doc comment, `legacy-migration-apply.ts`). + yield* legacyApplySchemaFiles( + session, + fs, + path, + workdir, + toml.schemaPaths, + applyError, + projectEnv, + ); + } else if (toml.migrationsEnabled) { const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); // LoadPartialMigrations filter: version === "" || v <= version. const pending = locals.filter((p) => { @@ -403,7 +375,47 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega ); yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } - // Go's best-effort pgcache catalog warning is not ported (no output impact). + + // Go's `down.ResetAll` (`internal/migration/down/down.go:48-61`) — the function + // `resetRemote` delegates to — best-effort caches the migrations catalog for + // pg-delta right after `apply.MigrateAndSeed` succeeds, warning (never failing + // the reset) on error. `pgcache.TryCacheMigrationsCatalog` itself no-ops when + // `resolvedVersion` is non-empty (`len(version) > 0`, `pgcache/cache.go:73`) — + // a versioned reset (`--version`/`--last`) never refreshes the cache — so gate + // the call the same way rather than threading that check into the shared + // native helper (already used by `db push`, which has no version concept). + const cacheEnabled = + resolvedVersion === "" && + (toml.pgDelta.enabled || + legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); + const pgDeltaCtx: LegacyPgDeltaContext = { + projectId: legacySanitizeProjectId( + legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(toml.projectId) ?? + (linkedRef !== undefined && linkedRef !== "" ? linkedRef : undefined), + workdir, + ), + ), + cwd: workdir, + npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), + denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, + }; + yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { + enabled: cacheEnabled, + targetUrl: legacyToPostgresURL(cfg.conn), + conn: cfg.conn, + isLocal: false, + migrationsDir, + }).pipe( + Effect.catch((error) => + output.raw( + `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, + "stderr", + ), + ), + ); }), ); @@ -424,5 +436,8 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega ), ), Effect.ensuring(telemetryState.flush), + // Closes the `Scope` `legacyApplyProjectEnv` (above) acquires its `process.env` + // reverts against — mirrors `push.handler.ts`'s own `body.pipe(..., Effect.scoped)`. + Effect.scoped, ); }); 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 index fd703e747d..46f6f3fda4 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -25,7 +25,11 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; -import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, +} from "../../../config/legacy-project-ref.service.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugFlag, @@ -34,11 +38,13 @@ import { LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; -import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; +import { + LegacyEdgeRuntimeScript, + type LegacyEdgeRuntimeRunOpts, +} from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { @@ -72,6 +78,7 @@ const DEFAULT_FLAGS: LegacyDbResetFlags = { dbUrl: Option.none(), linked: false, local: false, + projectRef: Option.none(), noSeed: false, sqlPaths: [], version: Option.none(), @@ -80,10 +87,8 @@ const DEFAULT_FLAGS: LegacyDbResetFlags = { /** * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a - * connection was (or, for the delegated-experimental path, was NOT) resolved — - * `resolve()` mints/verifies a temporary Postgres login role over the Management - * API, so calling it on a path that immediately discards the result is wasted - * (and duplicated) work (CLI-1879). + * connection was resolved exactly once per reset — `resolve()` mints/verifies a + * temporary Postgres login role over the Management API for a `--linked` target. */ function mockResolver(opts: { isLocal: boolean; @@ -93,8 +98,16 @@ function mockResolver(opts: { }) { let calls = 0; const layer = Layer.succeed(LegacyDbConfigResolver, { - resolve: (_flags: LegacyDbConfigFlags) => { + resolve: (flags: LegacyDbConfigFlags) => { calls++; + // A threaded `--project-ref` flag takes the same top precedence a real + // resolver would give it, so a test can prove the flag (not just the + // fixed `opts.ref`) drives the resolved (and later cached) ref. + const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); + const resolvedRef = + Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 + ? linkedProjectRef.value + : opts.ref; return opts.resolveFails === true ? Effect.fail( new LegacyDbConfigConnectTempRoleError({ @@ -107,7 +120,7 @@ function mockResolver(opts: { : { conn: CONN, isLocal: opts.isLocal, - ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + ref: resolvedRef !== undefined ? Option.some(resolvedRef) : Option.none(), }) satisfies LegacyResolvedDbConfig, ); }, @@ -141,6 +154,9 @@ function mockConnection( replicationSlotQueryFails?: boolean; /** Fails one exact statement with the given SQLSTATE `code` (or no code, for a non-PgError failure). */ failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; + /** When set, an `exec` whose SQL contains this substring fails instead of succeeding. */ + execFailsOn?: string; + execFailsMessage?: string; } = {}, ) { const execs: Array = []; @@ -154,6 +170,11 @@ function mockConnection( queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), exec: (sql: string): Effect.Effect => Effect.suspend((): Effect.Effect => { + if (opts.execFailsOn !== undefined && sql.includes(opts.execFailsOn)) { + return Effect.fail( + new LegacyDbExecError({ message: opts.execFailsMessage ?? "syntax error" }), + ); + } execs.push(sql); if (opts.failStatement !== undefined && sql === opts.failStatement.sql) { return Effect.fail( @@ -203,42 +224,6 @@ function mockConnection( }; } -/** - * `execCaptureExitCode`, when set, makes `execCapture` fail with a - * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating - * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). - */ -function mockProxy(opts: { execCaptureExitCode?: number } = {}) { - const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }), - execCapture: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }).pipe( - Effect.flatMap(() => - opts.execCaptureExitCode !== undefined - ? Effect.fail( - new LegacyGoChildExitError({ - exitCode: opts.execCaptureExitCode, - message: `supabase-go exited with code ${opts.execCaptureExitCode}`, - }), - ) - : Effect.succeed(""), - ), - ), - }); - return { - layer, - get calls() { - return calls; - }, - }; -} - // --------------------------------------------------------------------------- // Native local-reset harness — mirrors `db/start/start.integration.test.ts`'s own // `mockContainerCliSpawner`/`defaultRoute`/`fakeDbSession`, adapted for reset's @@ -358,7 +343,6 @@ interface DefaultRouteOpts { readonly kongNotRunning?: boolean; readonly kongReloadFails?: boolean; readonly storageMissing?: boolean; - readonly storageUnhealthy?: boolean; readonly restartFails?: ReadonlyArray; } @@ -368,6 +352,7 @@ function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; if (args[0] === "container" && args[1] === "rm") return { exitCode: 0 }; if (args[0] === "volume" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "inspect") return { exitCode: 1 }; if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; if (args[0] === "create") { @@ -397,7 +382,11 @@ function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { if (id === STORAGE_ID) { if (opts.storageMissing === true) return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; - return { stdout: [opts.storageUnhealthy === true ? STARTING_STATE : HEALTHY_STATE] }; + // A present-but-unhealthy storage container's wait-then-timeout-fails-the-reset + // behavior is pinned precisely (exact 30s boundary) by + // `await-storage-ready.unit.test.ts`'s own fake-clock tests — no route knob for + // it here (review CLI-1958). + return { stdout: [HEALTHY_STATE] }; } if (opts.running === false) return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; @@ -431,16 +420,25 @@ function setup( /** `--debug`. Defaults to `false`. */ debug?: boolean; remoteSeeds?: Readonly>; + execFailsOn?: string; + execFailsMessage?: string; yes?: boolean; omitRef?: boolean; resolveFails?: boolean; - execCaptureExitCode?: number; // Local-reset-only knobs. route?: (args: ReadonlyArray) => RouteResult; routeOpts?: DefaultRouteOpts; replicationSlotCounts?: ReadonlyArray; replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; + // pg-delta migrations-catalog cache (Go's `down.ResetAll` → `pgcache.TryCacheMigrationsCatalog`, + // wired into the remote-reset path after a successful migrate/schema-files + seed). + catalogStdout?: string; + catalogExportFailWith?: string; + // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with + // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, + // instead of silently falling back to `opts.ref ?? LEGACY_VALID_REF`. + linkedFails?: boolean; }, ) { if (opts.toml !== undefined) { @@ -455,7 +453,6 @@ function setup( const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); - const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); // The local-reset bucket-seed core statically requires the (lazy) Management-API @@ -469,13 +466,24 @@ function setup( }); const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); const child = mockContainerCliSpawner(route); - // Never actually invoked by the tests in this file — the pg-delta migrations-catalog - // warmup `legacyStartSetupLocalDatabase` reaches on a PG15 recreate (`db-setup.ts`) gates - // on `[experimental.pgdelta] enabled`/`SUPABASE_EXPERIMENTAL_PG_DELTA`, neither of which - // any config here sets — present only to satisfy the effect's widened requirements, same - // as `db push`'s own integration tests (`push.integration.test.ts`). + // Backs both the local recreate's post-setup pg-delta migrations-catalog warmup + // (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`) and the remote path's own + // post-reset catalog-cache call — tracked so tests can assert on it directly + // (`edgeRunCalls`/`registryEnvAtRunTime`), same as `db push`'s own integration + // tests (`push.integration.test.ts`). + const edgeRunCalls: Array = []; + const registryEnvAtRunTime: Array = []; const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), + run: (runOpts: LegacyEdgeRuntimeRunOpts) => { + edgeRunCalls.push(runOpts); + registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + if (opts.catalogExportFailWith !== undefined) { + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), + ); + } + return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); + }, }); const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), @@ -485,7 +493,6 @@ function setup( const layer = Layer.mergeAll( out.layer, conn.layer, - proxy.layer, resolver.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, @@ -508,11 +515,19 @@ function setup( mockStdin(true), // The linked ref is pre-loaded (for the post-run cache) before resolve, // mirroring Go's LoadProjectRef-before-NewDbConfigWithPassword order. + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, + // same as Go's `flags.LoadProjectRef` — mirror that so a test can prove the + // flag (not just `opts.ref`) drives the linked ref. Layer.succeed(LegacyProjectRefResolver, { resolve: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), resolveForLink: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? LEGACY_VALID_REF)), - loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail(new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) + : Effect.succeed(opts.ref ?? LEGACY_VALID_REF), promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), }), Layer.succeed(LegacyPlatformApiFactory, { @@ -526,7 +541,17 @@ function setup( telemetry.layer, linkedCache.layer, ); - return { layer, out, conn, proxy, telemetry, linkedCache, resolver, child }; + return { + layer, + out, + conn, + telemetry, + linkedCache, + resolver, + child, + edgeRunCalls, + registryEnvAtRunTime, + }; } const migrationFile = (version: string, body = "create table t ();") => ({ @@ -901,6 +926,35 @@ describe("legacy db reset", () => { }, ); + it.live( + "attaches Go's ExecBatch error context to a failed DROP/CREATE DATABASE statement", + () => { + // Go builds these four statements as a `migration.MigrationFile` and runs them + // through `.ExecBatch` (`reset.go:165-173`), so a failure gets the same rich + // context (`At statement: ` + the statement text) a real migration file + // failure would — not the bare driver error (review CLI-1958). + const { layer } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "CREATE DATABASE postgres WITH OWNER postgres", + message: "permission denied to create database", + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("permission denied to create database"); + expect(cause).toContain("At statement: 1"); + expect(cause).toContain("CREATE DATABASE postgres WITH OWNER postgres"); + } + }); + }, + ); + it.live("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, @@ -1440,6 +1494,74 @@ describe("legacy db reset", () => { }); }); + it.live("resets the project given via --project-ref without a linked workdir", () => { + // The fake resolver fails as "unlinked" (`LegacyProjectNotLinkedError`) + // absent the flag — only the flag can resolve a ref here. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [true], + linkedFails: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }); + + it.live("--project-ref overrides an already-linked workdir's project ref", () => { + const FLAG_REF = "flagflagflagflagflag"; + // The workdir already resolves to LEGACY_VALID_REF (e.g. via + // .temp/project-ref) — the flag must win over it. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + ref: LEGACY_VALID_REF, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + expect(linkedCache.cachedRef).not.toBe(LEGACY_VALID_REF); + }); + }); + + it.live("rejects --project-ref on the default local target", () => { + // reset defaults to local when no target flag is set — the guard must + // fire from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, conn, resolver, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or cache write. + expect(conn.execs).toEqual([]); + expect(resolver.calls).toBe(0); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("resets to a specific version, applying only migrations up to it", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1512,153 +1634,459 @@ describe("legacy db reset", () => { }); }); - it.live("delegates an experimental remote reset to the Go binary", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + it.live( + "caches the migrations catalog after a successful remote reset with SUPABASE_EXPERIMENTAL_PG_DELTA set", + () => { + // Go's `down.ResetAll` (`internal/migration/down/down.go:48-61`, the function + // `resetRemote` delegates to) best-effort caches the pg-delta migrations + // catalog right after `apply.MigrateAndSeed` succeeds — gated on + // `pgcache.ShouldCacheMigrationsCatalog()` (`experimental.pgdelta.enabled` OR + // the legacy `SUPABASE_EXPERIMENTAL_PG_DELTA` env switch), independent of + // `--experimental`'s own schema-files gate. + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(1); + }); + }, + ); + + it.live( + "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + // Go's `loadNestedEnv` (`os.Setenv`) makes a `supabase/.env`-only + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` visible to the WHOLE reset run, including + // the pg-delta catalog export the reset handler triggers after a successful + // remote reset (review CLI-1958 round 18) — mirroring `db push`'s own + // `legacyApplyProjectEnv(projectEnv)` scoping (same-named test in + // `push.integration.test.ts`). Without that scoping, this reads only real + // `process.env` and falls back to the default registry instead. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + const { layer, registryEnvAtRunTime } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); + // The finalizer reverted it — never leaks into the surrounding process. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + ); + }, + ); + + it.live("warns without failing the reset when the migrations-catalog cache write fails", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: migrationFile("20240101000000"), + confirm: [true], + catalogExportFailWith: "edge-runtime script produced no output", }); return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); - expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain( + "Warning: failed to cache migrations catalog: edge-runtime script produced no output", + ); }); }); it.live( - "does not resolve a linked DB connection before delegating an experimental reset", + "falls back to the linked project ref for the pg-delta cache when config.toml has no project_id", + () => { + // Go's `flags.LoadConfig` seeds `Config.ProjectId = ProjectRef` BEFORE + // `Config.Load` runs, so on the linked remote path an absent `project_id` + // retains the linked ref rather than falling to the workdir basename. + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: "[experimental.pgdelta]\nenabled = true\n", + ref: LEGACY_VALID_REF, + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(1); + }); + }, + ); + + it.live( + "skips the migrations-catalog cache for a versioned remote reset even with pg-delta caching enabled", + () => { + // `pgcache.TryCacheMigrationsCatalog` no-ops on any non-empty `version` + // (`pgcache/cache.go:73`, `len(version) > 0`) — a `--version`/`--last` reset + // never refreshes the cache, unlike a full (versionless) reset. + const { layer, out, edgeRunCalls } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + expect(edgeRunCalls).toHaveLength(0); + }); + }, + ); + + it.live( + "applies configured schema files instead of replaying migrations on an experimental remote reset", + () => { + // `--linked=false` still selects the linked/remote target (Cobra `Changed` + // semantics) — exercised here alongside the schema-files branch itself. + const { layer, out, conn, resolver, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + ...migrationFile("20240101000000", "create table migrated_table ();"), + "supabase/seed.sql": "insert into t values (1);", + }, + experimental: true, + args: ["db", "reset", "--linked=false"], + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + // The configured schema file ran... + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + // ...but the timestamped migration did NOT — Go's `if`/`else if` is mutually + // exclusive (`apply.go:19-27`); taking the schema-files branch means migrations + // never run at all. + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); + expect(out.stderrText).not.toContain("Applying migration"); + // Seeding still runs afterward — Go's `applySeedFiles` sits outside the + // if/else if (`apply.go:26`). + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + // A real connection is resolved now — this is a fully native path, not a + // delegated one that discarded the resolve (CLI-1958 removed the delegate). + expect(resolver.calls).toBe(1); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }, + ); + + it.live( + "applies schema files across multiple schema_paths patterns in declaration order, sorted within each pattern", + () => { + // Go sorts matches WITHIN each pattern (`sort.Strings`, `config.go:155`) but + // preserves DECLARATION order ACROSS patterns (no global re-sort) — `zz/*.sql`'s + // files must all run before `aa/*.sql`'s, even though "aa" sorts before "zz". + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["zz/*.sql", "aa/*.sql"]\n', + files: { + "supabase/zz/b.sql": "create table zz_b ();", + "supabase/zz/a.sql": "create table zz_a ();", + "supabase/aa/b.sql": "create table aa_b ();", + "supabase/aa/a.sql": "create table aa_a ();", + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const order = conn.execs + .map((s) => /create table (\w+) \(\)/.exec(s)?.[1]) + .filter((name): name is string => name !== undefined); + expect(order).toEqual(["zz_a", "zz_b", "aa_a", "aa_b"]); + }); + }, + ); + + it.live( + "expands a schema_paths directory entry to its nested .sql files on an experimental remote reset", + () => { + // `[db.migrations].schema_paths` resolves through Go's `Glob.SQLFiles` (not + // `Glob.Files`), which expands a directory match to its regular `.sql` files, + // recursively — unlike a plain glob pattern. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["some-dir"]\n', + files: { + "supabase/some-dir/01_top.sql": "create table dir_top ();", + "supabase/some-dir/nested/02_nested.sql": "create table dir_nested ();", + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("create table dir_top"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table dir_nested"))).toBe(true); + }); + }, + ); + + it.live( + "silently applies nothing when schema_paths is unset on an experimental remote reset (Go's undocumented default-config behavior)", () => { - const { layer, proxy, resolver } = setup(tmp.current, { + // Go's `schema_paths` default is `[]` (`pkg/config/templates/config.toml:64`). + // With no patterns to glob, `SQLFiles` returns a nil error, so `applySchemaFiles` + // is a silent no-op — Go does NOT fall back to replaying migrations (`apply.go: + // 19-27` is a hard `if`/`else if`). + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "create table migrated_table ();"), experimental: true, + confirm: [true], }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - // The delegated Go child re-runs its own connection resolution (including - // minting/verifying the temp login role) once it starts — the TS wrapper - // must not do that same Management-API work first only to discard it (CLI-1879). - expect(resolver.calls).toBe(0); + // Schemas are still dropped (ResetAll drops before MigrateAndSeed)... + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + // ...but the local migration is silently skipped, not applied. + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); + expect(out.stderrText).not.toContain("Applying migration"); }); }, ); - it.live("still caches the linked ref when delegating an experimental reset", () => { - // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` - // separately from `resolver.resolve()`, specifically so the post-run - // linked-project-cache finalizer still fires on this path even though - // `resolve()` itself is skipped entirely (CLI-1879). - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', + it.live( + "replays migrations instead of schema files on an experimental remote reset when pg-delta is enabled", + () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = true\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + ...migrationFile("20240101000000", "create table migrated_table ();"), + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // `IsPgDeltaEnabled()` disables the schema-files branch (`apply.go:19`) even + // though `--experimental` and `schema_paths` are both set. + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + expect(out.stderrText).toContain("Applying migration"); + }); + }, + ); + + it.live( + "replays migrations instead of schema files on an experimental remote reset with a resolved version", + () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + ...migrationFile("20240101000000", "create table migrated_table ();"), + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + // A resolved --version disables the schema-files branch (`apply.go:19` requires + // `len(version) == 0`), even with `--experimental` set. + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }); + }, + ); + + it.live( + "fails an experimental remote reset when no schema_paths pattern matches anything", + () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["nomatch/*.sql"]\n', + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("no files matched pattern: supabase/nomatch/*.sql"); + // No CmdSuggestion on this failure mode — only a per-file exec failure sets one. + expect(cause).not.toContain("See schema file"); + } + // Schemas were already dropped before the failed apply step (Go's ResetAll order). + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }, + ); + + it.live("ignores a partial schema_paths glob failure once at least one pattern matches", () => { + // Go's `applySchemaFiles` only surfaces the joined glob error when NO pattern + // matched anything at all (`apply.go:53-55`); a partial failure is silently dropped. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + // Present so the (unrelated) seed glob's own "no files matched" WARN line + // doesn't show up and get confused with the schema-files warning below. + "supabase/seed.sql": "insert into t values (1);", + }, experimental: true, - ref: LEGACY_VALID_REF, + confirm: [true], }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + expect(out.stderrText).not.toContain("no files matched pattern"); }); }); it.live( - "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + "attaches Go's schema-file suggestion when a schema file fails to apply on an experimental remote reset", () => { const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { "supabase/schemas/01_users.sql": "not valid sql;" }, experimental: true, - format: "json", - execCaptureExitCode: 3, + confirm: [true], + execFailsOn: "not valid sql", + execFailsMessage: 'syntax error at or near "not"', }); return Effect.gen(function* () { - // Under json/stream-json, the delegated path uses `execCapture` (non-text - // branch of `delegateExperimentalReset`) — this must flow through the normal - // Effect failure channel (reachable by `withJsonErrorHandling` at the - // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a - // handler-level test could never observe (CLI-1879). const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(3); + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("syntax error at or near"); + // Go's `CmdSuggestion = "See schema file: "` (`apply.go:63`). + expect(cause).toContain("See schema file:"); + expect(cause).toContain("supabase/schemas/01_users.sql"); } }); }, ); - it.live("forwards the linked selector to the delegate even for --linked=false", () => { - // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in - // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls - // back to its local default and resets the wrong database. - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked=false"], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); - }); - }); + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; - it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { - // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the - // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and - // drop the remote schemas the user tried to protect. - const previous = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "true"; - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes=false"], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=false"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = previous; - }), - ), - ); - }); + it.live.skipIf(isRoot)( + "does not attach the schema-file suggestion when a schema file cannot be READ on an experimental remote reset", + () => { + // Go's `NewMigrationFromFile` (the file-read/parse step, `apply.go:57-59`) returns + // BEFORE `CmdSuggestion` is ever set — only a later `ExecBatch` (statement + // execution) failure attaches it (`apply.go:61-63`). A file that glob-matches but + // can't be read (permissions changed after the glob) must fail WITHOUT the + // suggestion, unlike the exec-failure case above. + const schemaFile = join(tmp.current, "supabase", "schemas", "01_users.sql"); + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, + experimental: true, + confirm: [true], + }); + chmodSync(schemaFile, 0o000); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).not.toContain("See schema file"); + } + // The statement was never reached, so it was never executed. + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemaFile, 0o644)))); + }, + ); - it.live("forwards --yes=true to the delegate when --yes is set", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes"], - yes: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=true"); - }); - }); + it.live.skipIf(isRoot)( + "fails an experimental remote reset (without silently succeeding) when a matched schema_paths directory cannot be walked", + () => { + // Go's `fs.WalkDir` stops on the first `ReadDir` failure and `applySchemaFiles` + // only silently drops that error when at least one OTHER file was still found + // (`apply.go:53-55`); with a single pattern matching only the unreadable + // directory, `declared` stays empty and Go aborts the command — it must not + // report success having applied nothing. Verified empirically against `apps/cli-go`. + const schemasDir = join(tmp.current, "supabase", "schemas"); + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas"]\n', + files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, + experimental: true, + confirm: [true], + }); + chmodSync(schemasDir, 0o000); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("failed to walk matched directory"); + expect(cause).not.toContain("See schema file"); + } + // Schemas were already dropped before the failed apply step (Go's ResetAll order). + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemasDir, 0o755)))); + }, + ); it.live( - "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + "takes the native experimental schema-files path via SUPABASE_EXPERIMENTAL in the project .env", () => { - // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote - // reset delegates to the Go binary rather than replaying migrations natively. + // Go loads nested env before `reset.Run` reads viper's EXPERIMENTAL, so a + // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` reaches the native + // three-conjunct gate the same way an explicit `--experimental` does. const previous = process.env["SUPABASE_EXPERIMENTAL"]; delete process.env["SUPABASE_EXPERIMENTAL"]; - const { layer, proxy, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { + "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n", + "supabase/schemas/01_users.sql": "create table schema_users ();", + ...migrationFile("20240101000000", "create table migrated_table ();"), + }, + confirm: [true], // No experimental flag / shell env — only the project .env sets it. }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - // Delegated, so the native remote path never dropped schemas. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); + expect(out.stderrText).not.toContain("Applying migration"); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -1687,32 +2115,30 @@ describe("legacy db reset", () => { }); }); - it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { - const { layer, proxy, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), - noSeed: true, - }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual([ - "db", - "reset", - "--db-url", - "postgresql://db.example.com:5432/postgres", - "--no-seed", - "--yes=false", - ]); - // Unlike the `connType === "linked"` branch above, a `--db-url` target still - // resolves a connection before delegating — the pre-delegation skip (CLI-1879) - // is scoped to the linked branch only, not "never call resolve when delegating". - expect(resolver.calls).toBe(1); - }); - }); + it.live( + "applies configured schema files and skips seeding on an experimental remote --db-url reset", + () => { + const { layer, conn, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + expect(conn.execs.some((s) => s.includes("insert into"))).toBe(false); + // A `--db-url` target always resolves a real connection — this is no longer + // delegated at all (CLI-1958). + expect(resolver.calls).toBe(1); + }); + }, + ); it.live("recreates to a specific --version on a local db-url reset", () => { const { layer, out, conn } = setup(tmp.current, { @@ -1903,26 +2329,31 @@ describe("legacy db reset", () => { }); }); - it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - sqlPaths: ["custom-seed.sql"], - }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual([ - "db", - "reset", - "--linked", - "--sql-paths", - "custom-seed.sql", - "--yes=false", - ]); - }); - }); + it.live( + "seeds from --sql-paths on an experimental remote reset, independently of the schema-files apply", + () => { + // `--sql-paths` overrides `[db.seed].sql_paths` regardless of which branch of + // `apply.MigrateAndSeed` ran — Go's `applySeedFiles` sits outside the if/else if + // (`apply.go:26`), and the seed override is resolved entirely upstream of it. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + "supabase/custom-seed.sql": "insert into t values (2);", + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); + }, + ); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index 65e5ce64c1..7d900777d4 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -21,18 +21,25 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: * the Postgres connection, the db-config resolver, project-ref resolution, and the * linked-project cache, all over the lazy management-API factory so the local / - * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` - * (used to delegate the remaining `--experimental` reset path) is ambient from the - * root. `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot - * migrate jobs (`legacyStartSetupLocalDatabase`, reused via - * `legacyRecreateLocalDatabase`) — same reasoning as `db start`'s own - * `start.layers.ts`. `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back - * that same shared setup pipeline's best-effort pg-delta migrations-catalog warmup - * (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog` call, reachable from `db reset`'s - * PG15 recreate too) — the exact same pair `db start`/`db push` already compose for - * their own calls to that function (`db/start/start.layers.ts`, `push.layers.ts`). - * `LegacyCliConfig`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are - * ambient from the root runtime (`shared/cli/run.ts`). + * `--db-url` paths never resolve an access token at layer-build time. Both targets + * are fully native (CLI-1955/CLI-2062 for the local container-recreate primitives, + * CLI-1958 for the remote `--experimental` schema-files apply) — no Go delegation + * remains on this command, so `LegacyGoProxy` is not composed here. + * + * `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot migrate + * jobs (`legacyStartSetupLocalDatabase`, reused via `legacyRecreateLocalDatabase`) + * — same reasoning as `db start`'s own `start.layers.ts`. + * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same shared + * setup pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s + * `legacyTryCacheMigrationsCatalog` call, reachable from `db reset`'s PG15 recreate + * too) AND the remote path's own post-reset catalog-cache call — the exact same + * pair `db start`/`db push` already compose for their own calls to that function + * (`db/start/start.layers.ts`, `push.layers.ts`). Without them, a versionless reset + * with pg-delta enabled would hit an unhandled missing-service defect — not caught + * by the handler's typed `Effect.catch` — AFTER the database has already been + * reset, instead of writing the catalog or emitting Go's best-effort warning + * (review CLI-1958). `LegacyCliConfig`/`ChildProcessSpawner`/`FileSystem`/`Path`/ + * `RuntimeInfo` are ambient from the root runtime (`shared/cli/run.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -90,7 +97,8 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the // confirmation prompt fails with a missing-service defect instead of the default. stdinLayer, - // Backs the native local recreate's PG15+ one-shot migrate jobs. + // Backs the native local recreate's PG15+ one-shot migrate jobs, and the remote + // path's own post-reset pg-delta catalog-cache call. legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts new file mode 100644 index 0000000000..9f7fe90e68 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts @@ -0,0 +1,146 @@ +/** + * Layer-exposure test for `legacyDbResetRuntimeLayer`. + * + * Regression guard (review CLI-1958): the post-reset best-effort pg-delta + * catalog cache (`legacyTryCacheMigrationsCatalog` in `reset.handler.ts`, gated + * on `[experimental.pgdelta].enabled` / `SUPABASE_EXPERIMENTAL_PG_DELTA`) reaches + * `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` via + * `legacyExportCatalogPgDelta` (`legacy-pgdelta.ts`). `legacyDbResetRuntimeLayer` + * previously omitted both services (and the `LegacyDockerRun` layer the real + * edge-runtime implementation needs) — unlike `legacyDbPushRuntimeLayer`, which + * already composes all three. That gap was invisible to `reset.integration.test.ts` + * because that suite drives `legacyDbReset` directly with its own hand-built layer + * (which mocks `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in), bypassing + * `reset.layers.ts` entirely — so a versionless remote reset with pg-delta enabled + * would crash on a missing-service defect (uncaught by the handler's typed + * `Effect.catch`) AFTER the remote database was already reset. This test builds + * the REAL `legacyDbResetRuntimeLayer` (not a mock of the pg-delta services) and + * asserts both are actually present in its context. + * + * See `db/lint/lint.layers.unit.test.ts` for the canonical ambient-stub pattern. + */ + +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option } from "effect"; + +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTelemetryRuntime, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyCredentialsLayer, + mockLegacyLinkedProjectCacheLayer, + mockLegacyTelemetryStateLayer, +} from "../../../../../tests/helpers/legacy-mocks.ts"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, + LegacyOutputFlag, + LegacyProfileFlag, + LegacyWorkdirFlag, +} from "../../../../shared/legacy/global-flags.ts"; + +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; + +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; + +/** + * Builds a stub ambient layer that satisfies every external service required by + * `legacyDbResetRuntimeLayer` from the root runtime. Services whose logic is not + * under test are no-op stubs; `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` + * are deliberately NOT stubbed here — the point of this test is to prove the real + * `legacyDbResetRuntimeLayer` provides them itself. + */ +function ambientStubs() { + const analytics = mockAnalytics(); + const out = mockOutput(); + + const flagLayers = Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyProfileFlag, "supabase"), + Layer.succeed(LegacyWorkdirFlag, Option.none()), + Layer.succeed(LegacyOutputFlag, Option.none()), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(CliArgs, { args: ["db", "reset"] }), + ); + + // Stub out the heavy service layers so layer construction doesn't require a + // real DB, real API, or real credentials. + const heavyServiceStubs = Layer.mergeAll( + Layer.succeed(LegacyDbConnection, { + connect: () => Effect.die("db-connection not needed for layer-exposure test"), + }), + Layer.succeed(LegacyDbConfigResolver, { + resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolvePoolerFallback: () => + Effect.die("db-config-resolver not needed for layer-exposure test"), + }), + Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + }), + Layer.succeed(LegacyPlatformApiFactory, { + make: Effect.die("platform-api-factory not needed for layer-exposure test"), + }), + ); + + return Layer.mergeAll( + BunServices.layer, + mockRuntimeInfo(), + mockTty(), + mockProcessControl().layer, + mockStdin(false), + analytics.layer, + mockTelemetryRuntime(), + out.layer, + flagLayers, + mockLegacyCliConfig({ workdir: "/tmp/reset-layers-test" }), + mockLegacyCredentialsLayer, + mockLegacyLinkedProjectCacheLayer, + mockLegacyTelemetryStateLayer, + heavyServiceStubs, + ); +} + +describe("legacyDbResetRuntimeLayer — pg-delta service exposure (regression guard, review CLI-1958)", () => { + it.live( + "exposes LegacyEdgeRuntimeScript so the post-reset pg-delta catalog cache does not crash on a missing-service defect", + () => { + return Effect.gen(function* () { + const edgeRuntime = yield* Effect.serviceOption(LegacyEdgeRuntimeScript); + expect(Option.isSome(edgeRuntime)).toBe(true); + }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); + }, + ); + + it.live( + "exposes LegacyPgDeltaSslProbe so the post-reset pg-delta catalog cache does not crash on a missing-service defect", + () => { + return Effect.gen(function* () { + const sslProbe = yield* Effect.serviceOption(LegacyPgDeltaSslProbe); + expect(Option.isSome(sslProbe)).toBe(true); + }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index 4ff6cb9ab5..2d24019b64 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../../shared/telemetry/error-actionability.ts"; + /** * Declarative commands were invoked without `--experimental` and without * `[experimental.pgdelta] enabled = true`. Byte-matches Go's gate error @@ -12,7 +18,11 @@ export class LegacyDeclarativeNotEnabledError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A target could not be resolved in non-interactive mode. Byte-matches Go's @@ -24,7 +34,11 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( "LegacyDeclarativeNonInteractiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A mutually-exclusive flag group was violated. Reproduces cobra's @@ -37,7 +51,11 @@ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedErr "LegacyDeclarativeMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The interactive custom-database-URL prompt was empty or unparseable. Byte-matches @@ -48,7 +66,11 @@ export class LegacyDeclarativeInvalidDbUrlError extends Data.TaggedError( "LegacyDeclarativeInvalidDbUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `db schema declarative generate` ran but produced no declarative files (sync's @@ -59,7 +81,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( "LegacyDeclarativeNoFilesGeneratedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Diffing declarative schema to migrations failed. Wraps @@ -69,7 +95,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( */ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarativeDiffError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Applying the generated migration to the local database failed. Wraps Go's @@ -79,7 +109,38 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat */ export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclarativeApplyError")<{ readonly message: string; -}> {} + /** + * Set when this failure came from connecting to the local Postgres instance + * (`dbConnection.connect`) rather than the migration SQL failing to apply. + */ + readonly connect?: boolean; + /** + * Forwarded from the underlying typed failure this wraps (e.g. a + * `LegacyKongReloadError`'s recovery hint, or a health-timeout architecture + * hint) when the local-reset recovery path fails — the wrap must not drop it + * (review CLI-1958). + */ + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.connect === true) { + return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + } + return actionability.dbFinding; + } +} + +/** + * Duck-types an optional `suggestion: string` off an arbitrary typed failure — + * used when wrapping a lower-level error (e.g. `legacyResetLocalDatabase`'s + * `LegacyKongReloadError`) into a {@link LegacyDeclarativeApplyError} so its + * recovery hint isn't silently dropped by the wrap. + */ +export function legacyReadErrorSuggestion(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("suggestion" in error)) return undefined; + const { suggestion } = error as { suggestion: unknown }; + return typeof suggestion === "string" ? suggestion : undefined; +} /** * Materializing the declarative export on disk failed. Byte-matches Go's 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 00455e786d..fea0bce9c4 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 @@ -3,9 +3,24 @@ 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, FileSystem, Layer, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { mockOutput } from "../../../../../../tests/helpers/mocks.ts"; +import { mockLegacyShadowContainerCliSpawner } from "../../../../../../tests/helpers/legacy-mocks.ts"; +import { alwaysReadyHttpClientLayer } from "../../../../../../tests/helpers/legacy-local-reset.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, +} from "../../../../../shared/legacy/global-flags.ts"; +import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRun } from "../../../../shared/legacy-docker-run.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -32,13 +47,6 @@ import { function mockSeam(paths: Record) { const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; - const provisionCalls: Array<{ - mode: string; - targetLocal: boolean; - usePgDelta: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; const layer = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, noCache }) => { calls.push({ mode, noCache }); @@ -46,24 +54,55 @@ function mockSeam(paths: Record) { }, ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, - // The migrations-catalog source now resolves natively (CLI-1959) via - // `legacyGetMigrationsCatalogRef`, which provisions its shadow through this - // EXISTING `provisionShadow` (Go's unchanged `db __shadow --mode diff`) rather - // than the retired `exportCatalog({mode:"migrations"})` seam call. - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: undefined, - }); - }, - removeShadowContainer: (container) => + }); + return { layer, calls }; +} + +/** + * The native shadow-provisioning stack `legacyGetMigrationsCatalogRef`'s + * cache-miss path needs (CLI-1956): the SAME `legacyCreateShadowDatabase`/ + * `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db diff`/ + * `db pull` use for their own shadow, not the retired `db __shadow` seam — see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment. Mirrors + * `diff.integration.test.ts`'s own shadow mocks (`mockLegacyShadowContainerCliSpawner` + * + a fake `LegacyDbConnection`/`LegacyDockerRun`), scoped down to this file's + * lower-level, seam-free tests. + */ +function mockShadowInfra() { + const spawner = mockLegacyShadowContainerCliSpawner(); + const connectedDatabases: Array = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => Effect.sync(() => { - removedContainers.push(container); + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; }), }); - return { layer, calls, provisionCalls, removedContainers }; + // The shadow's own PG15+ one-shot platform-baseline job(s) — Go's `initSchema15`. + const docker = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("run unused"), + runCapture: () => Effect.die("runCapture unused"), + runStream: () => Effect.succeed({ exitCode: 0, stderr: "" }), + }); + const layer = Layer.mergeAll( + spawner.layer, + dbConnection, + docker, + mockRuntimeInfo(), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(CliArgs, { args: [] }), + alwaysReadyHttpClientLayer, + ); + return { layer, spawned: spawner.spawned, connectedDatabases }; } function mockEdge(stdout: string) { @@ -105,7 +144,13 @@ const probe = Layer.succeed(LegacyPgDeltaSslProbe, { }); const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext => ({ - pgDelta: { projectId: "cferry", cwd, npmVersion: undefined, denoVersion: 2 }, + pgDelta: { + projectId: "cferry", + cwd, + npmVersion: undefined, + denoVersion: 2, + projectEnv: {}, + }, formatOptions: "", declarativeDir, schema: [], @@ -126,6 +171,45 @@ const setupInputs: LegacySetupInputs = { rolesSql: "", }; +// A minimal, valid `LegacyDbTomlValues` — threaded into `legacyGetMigrationsCatalogRef` +// for the migrations-catalog shadow's own container spec (CLI-1956). Matches +// `legacy-db-config.toml-read.ts`'s own unconfigured defaults so this fixture +// doesn't silently drift from what `legacyReadDbToml` would resolve for these +// tests' bare temp dirs (none of them write a `config.toml`). +const toml: LegacyDbTomlValues = { + projectEnv: {}, + envLookup: () => undefined, + apiSchemas: ["public", "graphql_public"], + port: 54322, + shadowPort: 54320, + password: "postgres", + poolerConnectionString: Option.none(), + projectId: Option.none(), + majorVersion: 17, + orioledbVersion: Option.none(), + denoVersion: 2, + pgDelta: { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + npmVersion: Option.none(), + }, + baseline: { + authEnabled: true, + storageEnabled: true, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.none(), + vaultNames: [], + }, + migrationsEnabled: true, + schemaPaths: [], + schemaPathPatterns: [], + seed: { enabled: true, sqlPaths: [] }, + vault: [], + appliedRemote: undefined, + remoteOverrideKeys: new Set(), +}; + describe("legacyDiffDeclarativeToMigrations", () => { it.effect( "resolves the migrations catalog natively and diffs it against the seam-provisioned declarative catalog", @@ -139,16 +223,16 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( Effect.tap((result) => Effect.sync(() => { // "declarative" still resolves via the seam; "migrations" no longer does - // (it resolves natively, provisioning through `provisionShadow` instead). + // (it resolves natively, provisioning its shadow the same way `db diff`/ + // `db pull` do — CLI-1956). expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); - expect(seam.removedContainers).toEqual(["shadow-1"]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // No local migrations in the fresh temp dir → the zero-migrations branch // writes (and returns) the platform-baseline catalog, workdir-relative. expect(result.sourceRef).toMatch( @@ -166,7 +250,9 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -193,10 +279,11 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( Effect.tap((result) => Effect.sync(() => { - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); expect(result.sourceRef).toBe( join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), ); @@ -204,7 +291,9 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -228,6 +317,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -236,7 +326,11 @@ describe("legacyDiffDeclarativeToMigrations", () => { legacySetupInputsToken(setupInputs), migrationsHash, ); - const result = yield* legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); expect(result.sourceRef).toMatch( new RegExp( `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, @@ -244,13 +338,13 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); - expect(seam.removedContainers).toEqual(["shadow-1"]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -272,6 +366,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -285,13 +380,19 @@ describe("legacyDiffDeclarativeToMigrations", () => { legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), ); writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); expect(readFileSync(cachedPath, "utf8")).toBe('{"cached":true}'); - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -313,6 +414,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -331,18 +433,19 @@ describe("legacyDiffDeclarativeToMigrations", () => { writeFileSync(cachedPath, '{"cached":true}'); const result = yield* legacyDiffDeclarativeToMigrations( { ...ctx(dir, declDir), noCache: true }, + toml, setupInputs, ); expect(result.sourceRef).toBe( join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -352,7 +455,12 @@ describe("legacyDiffDeclarativeToMigrations", () => { const seam = mockSeam({ declarative: "d", baseline: "b" }); const edge = mockEdge(""); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, join(dir, "missing")), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations( + ctx(dir, join(dir, "missing")), + toml, + setupInputs, + ).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -364,11 +472,13 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); } expect(seam.calls).toEqual([]); - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index c423d987d3..e739fc8e2e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -9,6 +9,7 @@ import { type LegacySetupInputs, legacyGetMigrationsCatalogRef, } from "../../../../shared/legacy-pgdelta.cache.ts"; +import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; @@ -45,12 +46,24 @@ export interface LegacyDeclarativeSyncResult { * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the * declarative catalog (target) is still provisioned via the Go seam (shadow DB + * `SetupDatabase` + declarative apply); the migrations catalog (source) resolves - * natively (CLI-1959) via `legacyGetMigrationsCatalogRef`, which mirrors Go's - * `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly. Both are then - * diffed natively with pg-delta, as before. + * natively (CLI-1959 cache mechanics) via `legacyGetMigrationsCatalogRef`, which + * mirrors Go's `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly — + * including its own shadow provisioning, which is now ALSO native (CLI-1956: the + * same `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ + * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own + * shadow, not the retired `db __shadow` seam — see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment). Both catalogs + * are then diffed natively with pg-delta, as before. + * + * `toml` is the caller's own already-loaded `config.toml` read + * (`legacyReadDbToml`'s result), threaded through to + * `legacyGetMigrationsCatalogRef` for the migrations-catalog shadow's own + * container spec — distinct from `setupInputs`, the cache-key/baseline-setup + * subset of the same config. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, + toml: LegacyDbTomlValues, setupInputs: LegacySetupInputs, ) { const fs = yield* FileSystem.FileSystem; @@ -67,7 +80,7 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( ); } - const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, setupInputs, { + const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, toml, setupInputs, { noCache: run.noCache, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), }); 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 86823af931..38b42c78c3 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 { legacyToPostgresURL } from "../../../../shared/legacy-postgres-url.ts"; import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, + legacyReadErrorSuggestion, } from "./declarative.errors.ts"; import type { LegacyDeclarativeShadowDbError } from "../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; @@ -181,7 +182,10 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( yield* legacyResetLocalDatabase().pipe( Effect.mapError( (error) => - new LegacyDeclarativeApplyError({ message: `database reset failed: ${error.message}` }), + new LegacyDeclarativeApplyError({ + message: `database reset failed: ${error.message}`, + suggestion: legacyReadErrorSuggestion(error), + }), ), ); } 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 25cce1ccbe..e6ad3c01bb 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 @@ -18,6 +18,7 @@ import { import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyListLocalMigrations } from "../../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyResolvePgDeltaProjectId } from "../../../../../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNonInteractiveError, @@ -130,12 +131,20 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const run: LegacyDeclarativeRunContext = { pgDelta: { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // `legacyResolvePgDeltaProjectId` mirrors Go's `Config.ProjectId` singleton + // (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir + // basename) — NOT `cliConfig.projectId` alone, which is env-only and resolves to + // `""` for a project relying on config.toml's `project_id` or the workdir-basename + // default, mounting the WRONG `supabase_edge_runtime_` Deno-cache volume. `toml` + // reflects any `--linked` remote merge above, so its own `appliedRemote`/`projectId` + // suppress a conflicting ambient env var the same way `db diff`/`db pull` do. + projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, toml, cliConfig.workdir), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), // Merged config's deno_version (re-loaded with the linked ref above on // `--linked`), so pg-delta runs under the remote-configured Deno image. denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }, formatOptions: Option.getOrElse(toml.pgDelta.formatOptions, () => ""), declarativeDir, 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 389df21455..36f97641f5 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 @@ -151,8 +151,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { : Effect.void, ), ), - provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), - removeShadowContainer: () => Effect.void, }); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 35b3ac0534..522daa9e92 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -11,7 +11,7 @@ as a new timestamped migration. | `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | | `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | | `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto the shadow via `db __shadow --mode diff` | +| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto a natively-provisioned shadow (CLI-1956) | | `/supabase/roles.sql` | SQL | native migrations-catalog cache key (setup-inputs token; empty when absent) | | `/supabase/.temp/pgdelta/*.json` | JSON | migrations catalog cache (native, CLI-1959); declarative catalog cache (still the Go seam) | @@ -25,12 +25,12 @@ as a new timestamped migration. ## Subprocesses / Containers -| What | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `supabase-go db __shadow --mode diff` (seam, unchanged) — shadow Postgres + `SetupDatabase` + apply migrations; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Natively-provisioned shadow Postgres container (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, no longer a `supabase-go db __shadow` subprocess) + native migrate; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | +| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -84,12 +84,12 @@ are mutually exclusive. cycle rather than firing a second one from a `supabase-go` child). - **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline - reuse, and the pg-delta catalog export are all native TS; only the shadow-database - platform-baseline provisioning + migrations apply still runs via the bundled - `supabase-go`, reusing the SAME `db __shadow --mode diff` seam call `db diff` - uses (not a `__catalog`-specific shadow). The declarative-catalog diff target - still provisions its shadow-database platform baseline (and applies declarative - files) via the hidden `db schema declarative __catalog --mode declarative` seam, - since neither a baseline-only shadow nor `pgdelta.ApplyDeclarative` has a native - TS port yet (tracked by CLI-1956/CLI-1823). The diff itself is native pg-delta - either way. + reuse, and the pg-delta catalog export are all native TS; the shadow-database + platform-baseline provisioning + migrations apply is native too now (CLI-1956 — + `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, the SAME native primitives + `db diff` uses for its own shadow, not a `__catalog`-specific one). The + declarative-catalog diff target still provisions its shadow-database platform + baseline (and applies declarative files) via the hidden `db schema declarative +__catalog --mode declarative` seam, since neither a baseline-only shadow nor + `pgdelta.ApplyDeclarative` has a native TS port yet (tracked by CLI-1823). The diff + itself is native pg-delta either way. 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 c7f0ac387a..a5bb4bc4d2 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 @@ -1,4 +1,4 @@ -import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Clock, Effect, Exit, FileSystem, Option, Path, Result } from "effect"; import { LegacyDnsResolverFlag, @@ -28,6 +28,7 @@ import { legacyPgDeltaTempPath, legacyResolveSetupInputs, } from "../../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyResolvePgDeltaProjectId } from "../../../../../shared/legacy-pgdelta.ts"; import { legacyResolveSmartTargetUrl } from "../declarative.smart-target.ts"; import { type LegacyDebugBundle, @@ -41,6 +42,7 @@ import { LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNoFilesGeneratedError, LegacyDeclarativeNonInteractiveError, + legacyReadErrorSuggestion, } from "../declarative.errors.ts"; import { legacyResolveDeclarativeMigrationName, @@ -143,10 +145,16 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const tempDir = legacyPgDeltaTempPath(path, cliConfig.workdir); const run: LegacyDeclarativeRunContext = { pgDelta: { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // `legacyResolvePgDeltaProjectId` mirrors Go's `Config.ProjectId` singleton + // (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir + // basename) — NOT `cliConfig.projectId` alone, which is env-only and resolves to + // `""` for a project relying on config.toml's `project_id` or the workdir-basename + // default, mounting the WRONG `supabase_edge_runtime_` Deno-cache volume. + projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, toml, cliConfig.workdir), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }, formatOptions: Option.getOrElse(toml.pgDelta.formatOptions, () => ""), declarativeDir, @@ -277,6 +285,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); const result: LegacyDeclarativeSyncResult = yield* legacyDiffDeclarativeToMigrations( run, + toml, setupInputs, ).pipe( Effect.tapError((error) => @@ -364,10 +373,16 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara return; } + // A Ctrl-C or defect during the apply is not a migration-apply failure — + // propagate it unchanged instead of synthesizing a fake + // `LegacyDeclarativeApplyError` (review CLI-1958). + const applyFailure = Cause.findFail(applyExit.cause); + if (Result.isFailure(applyFailure)) { + return yield* Effect.failCause(applyFailure.failure); + } + // Apply failed: print, save a debug bundle, and (in a TTY) offer reset+reapply. - const applyError = - applyExit.cause.reasons.find(Cause.isFailReason)?.error ?? - new LegacyDeclarativeApplyError({ message: "failed to apply migration" }); + const applyError = applyFailure.success.error; yield* output.raw( `${legacyRed(`Migration failed to apply: ${applyError.message}`)}\n`, "stderr", @@ -396,13 +411,22 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // argv-forwarding is needed to stay on a custom network. const resetExit = yield* legacyResetLocalDatabase().pipe(Effect.exit); if (Exit.isFailure(resetExit)) { + // A Ctrl-C or defect during the recovery reset must cancel the command, + // not get rewritten into a synthetic "unknown error" apply failure — + // propagate it unchanged (review CLI-1958). + const resetFailure = Cause.findFail(resetExit.cause); + if (Result.isFailure(resetFailure)) { + return yield* Effect.failCause(resetFailure.failure); + } // Go returns `resetErr` here, surfacing the failure that actually blocked - // recovery — not the original apply error. Build the reset error from the - // real typed failure and use that one value for the message, debug bundle, - // and return. - const resetFailure = resetExit.cause.reasons.find(Cause.isFailReason)?.error; + // recovery — not the original apply error — and prints it exactly once (no + // extra "database reset failed:" wrapper). Build the reset error from the + // real typed failure and use that one value for the message, suggestion, + // debug bundle, and return. + const rawResetFailure = resetFailure.success.error; const resetError = new LegacyDeclarativeApplyError({ - message: `database reset failed: ${resetFailure?.message ?? "unknown error"}`, + message: rawResetFailure.message, + suggestion: legacyReadErrorSuggestion(rawResetFailure), }); yield* output.raw( `${legacyRed(`Database reset also failed: ${resetError.message}`)}\n`, @@ -492,7 +516,9 @@ const applyMigrationToLocal = ( { isLocal: true, dnsResolver: local.dnsResolver }, ) .pipe( - Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), + Effect.mapError( + (error) => new LegacyDeclarativeApplyError({ message: error.message, connect: true }), + ), ); yield* legacyApplyMigrationFile( session, 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 c4995d51df..de97ff9464 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 @@ -38,7 +38,10 @@ import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.servi import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../../../shared/legacy-db-connection.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -104,13 +107,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { // so tests can assert output ordering relative to the exports (e.g. the bootstrap's // written-to line lands after the declarative warm, before the diff's exports). const exportCatalogCalls: Array<{ mode: string; rawChunksAt: number }> = []; - // The migrations-catalog source now resolves natively (CLI-1959) via - // `legacyGetMigrationsCatalogRef`, which provisions its shadow through - // `provisionShadow` (Go's unchanged `db __shadow --mode diff`) instead of the - // retired `exportCatalog({mode:"migrations"})` seam call. "baseline"/ - // "declarative" still go through `exportCatalog`. - const provisionShadowCalls: Array<{ mode: string; targetLocal: boolean; rawChunksAt: number }> = - []; + // The migrations-catalog source now resolves natively (CLI-1959 cache mechanics + // + CLI-1956 shadow provisioning) via `legacyGetMigrationsCatalogRef`, which + // provisions its shadow through the SAME `legacyCreateShadowDatabase`/ + // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db + // diff`/`db pull` use for their own shadow — via `child.layer`/ + // `legacyDockerRunLayer` below (the same real container-lifecycle mocks + // `legacyResetLocalDatabase`'s own recovery-reset flow already needs), not the + // retired `db __shadow` seam. "baseline"/"declarative" still go through + // `exportCatalog`. const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode }) => Effect.sync(() => { @@ -132,16 +137,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { : Effect.void, ), ), - provisionShadow: ({ mode, targetLocal }) => - Effect.sync(() => { - provisionShadowCalls.push({ mode, targetLocal, rawChunksAt: out.rawChunks.length }); - return { - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: undefined, - }; - }), - removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -179,18 +174,27 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); const dbExec: string[] = []; + // Go's default `[db] shadow_port` (`legacy-db-config.toml-read.ts`'s + // `DEFAULT_SHADOW_PORT`) — none of these tests override it. The migrations- + // catalog resolution's shadow (CLI-1956) now ALSO connects through this same + // fake `LegacyDbConnection` for its own platform-baseline setup/migration + // replay, so its SQL (BEGIN/REVOKE.../CREATE DATABASE contrib_regression) must + // be excluded from `dbExec`, which every "not yet applied" assertion below + // expects to stay empty until the REAL local-apply connection + // (`applyMigrationToLocal`, `toml.port`) runs. + const SHADOW_PORT = 54320; const dbConn = Layer.succeed(LegacyDbConnection, { - connect: () => + connect: (cfg: LegacyPgConnInput) => Effect.succeed({ exec: (sql: string) => opts.applyFails === true && sql.startsWith("ALTER") ? Effect.fail({ _tag: "LegacyDbExecError", message: "boom" } as never) : Effect.sync(() => { - dbExec.push(sql); + if (cfg.port !== SHADOW_PORT) dbExec.push(sql); }), query: (sql: string) => Effect.sync(() => { - dbExec.push(sql); + if (cfg.port !== SHADOW_PORT) dbExec.push(sql); return []; }), extensionExists: () => Effect.succeed(false), @@ -266,7 +270,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, localPostgresImageChecks, exportCatalogCalls, - provisionShadowCalls, }; } @@ -554,12 +557,16 @@ describe("legacy db schema declarative sync integration", () => { // The warm (first declarative-mode export) fires before the line is printed… const warm = s.exportCatalogCalls.find((c) => c.mode === "declarative"); expect(warm?.rawChunksAt).toBeLessThanOrEqual(lineAt); - // …and the diff's migrations-catalog resolution (now native, CLI-1959 — - // provisions its shadow via `provisionShadow` instead of a seam `exportCatalog` - // call) fires after it, so the line sits at the end of the bootstrap, matching - // Go's ordering. - const diffStart = s.provisionShadowCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(diffStart?.rawChunksAt).toBeGreaterThan(lineAt); + // …and the diff's migrations-catalog resolution (native, CLI-1959 cache + // mechanics + CLI-1956 native shadow provisioning — no seam `exportCatalog` + // call for it at all) fires after it, so the line sits at the end of the + // bootstrap, matching Go's ordering. `legacyGetMigrationsCatalogRef` prints + // "Creating shadow database..." right before provisioning; use that line's + // own position as the "diff's shadow started" signal. + const diffStartIndex = s.out.rawChunks.findIndex( + (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", + ); + expect(diffStartIndex).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( existsSync( @@ -617,6 +624,46 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "validates the migrations-catalog shadow's own local config (api.tls cert file) BEFORE printing 'Creating shadow database...'", + () => { + // `legacyGetMigrationsCatalogRef`'s own second `@supabase/config` load + // (`legacyBuildLocalDbContainerInputs`, run via `legacyBuildShadowCatalogInputs`) + // validates fields (e.g. an enabled API TLS's cert/key files) that `toml` never + // reads — Go performs this exact validation once, in the root + // `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` + // ever prints "Creating shadow database..." (`declarative.go:490`). So a broken + // build must fail here without ever printing that banner. + seedDeclarative(tmp.current); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect((failError(exit) as { message: string }).message).toContain( + "failed to read TLS cert", + ); + expect( + s.out.rawChunks.some( + (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", + ), + ).toBe(false); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations // present it offers local/linked/custom rather than silently generating from @@ -945,13 +992,12 @@ describe("legacy db schema declarative sync integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ - message: "database reset failed: supabase start is not running.", + message: "supabase start is not running.", }); + // Printed exactly once — no "database reset failed:" double-wrap (review CLI-1958). expect( s.out.rawChunks.some((c) => - c.text.includes( - "Database reset also failed: database reset failed: supabase start is not running.", - ), + c.text.includes("Database reset also failed: supabase start is not running."), ), ).toBe(true); // A real failure, before any destructive container work. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts b/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts new file mode 100644 index 0000000000..5c9dfabba6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts @@ -0,0 +1,18 @@ +/** + * Go string-primitive helpers shared across the `db` command family. Currently + * just `strings.TrimSpace`/`bytes.TrimSpace` — hoisted here (per the repo's + * "hoist before you duplicate" rule, AGENTS.md) once a second `db`-family caller + * needed the exact same primitive: `legacy-pgdelta.apply.ts` (CLI-1956, apply + * error-detail trimming) and `legacy-pgadmin-diff.ts` (CLI-1968, `diff_ddl` + * trimming) each carried their own private, verbatim copy before this move. + */ + +/** + * Go's `strings.TrimSpace`/`bytes.TrimSpace` trim exactly the Unicode + * `White_Space` set — which, unlike JS's `String.prototype.trim`, does NOT + * include U+FEFF (BOM/ZWNBSP). A BOM-prefixed payload must therefore fail to + * parse (or render un-trimmed) here exactly like it does in Go, and + * BOM-adjacent fields must render it, not eat it. + */ +export const legacyTrimGoSpace = (value: string): string => + value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts index 642909c665..3ad13e2c76 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The migra diff failed (edge-runtime run, or the OOM bash fallback in the * `supabase/migra` Docker image). Byte-matches Go's @@ -8,7 +14,25 @@ import { Data } from "effect"; */ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError")<{ readonly message: string; -}> {} + /** + * Threaded from a wrapped `LegacyDockerRunError` in the OOM bash fallback so a + * docker-boundary failure (docker daemon down or registry pull) does not + * misclassify as a user-SQL (`dbFinding`) failure. `daemon` maps to + * docker-not-running, `pull` to an external network problem. `undefined` for + * genuine diff/script failures, which keep the user-SQL classification. + */ + readonly docker?: "daemon" | "pull"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.docker === "daemon") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (this.docker === "pull") { + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } + return actionability.dbFinding; + } +} /** * Loading the target's user-defined schemas for the migra bash fallback failed. @@ -18,4 +42,8 @@ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError */ export class LegacyMigraSchemaLoadError extends Data.TaggedError("LegacyMigraSchemaLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts new file mode 100644 index 0000000000..80674a14f7 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyMigraDiffError } from "./legacy-migra.errors.ts"; + +describe("LegacyMigraDiffError actionability", () => { + it("classifies a docker-daemon failure as docker-not-running", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema: ...", docker: "daemon" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("docker_not_running"); + expect(result.suggestion_type).toBe("start_docker"); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError:docker_not_running"); + }); + + it("classifies a registry-pull failure as an external network problem", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema: ...", docker: "pull" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError:registry_pull"); + }); + + it("classifies a non-docker diff failure as a user db finding", () => { + const result = classifyCliErrorActionability( + new LegacyMigraDiffError({ message: "error diffing schema:\n..." }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:LegacyMigraDiffError"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts index 167834b660..55fae1c7e1 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -225,7 +225,14 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { }) .pipe( Effect.mapError( - (cause) => new LegacyMigraDiffError({ message: `error diffing schema: ${cause.message}` }), + (cause) => + new LegacyMigraDiffError({ + message: `error diffing schema: ${cause.message}`, + // Thread the docker discriminant so a daemon-down / registry-pull + // failure at the docker boundary is not misclassified as user SQL, + // mirroring the edge-runtime-script fix. + docker: cause.reason === "spawn" || cause.daemonDown ? "daemon" : "pull", + }), ), ); if (result.exitCode !== 0) { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts deleted file mode 100644 index 5d6918bddd..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Effect, Option } from "effect"; - -import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; - -/** - * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its - * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit - * code + captured stderr for failure classification. Mirrors Go's `dockerExec` - * (`apps/cli-go/internal/db/dump/dump.go`): host networking by default (overridden - * by the global `--network-id`), no security-opt, and the Linux-only - * `host.docker.internal:host-gateway` extra host. - * - * Shared by `db dump` (streams to `--file`/stdout) and `db pull`'s initial-migra - * schema dump (streams to the migration file). The pooler-fallback *decision* - * stays with the caller — this helper runs a single attempt and surfaces its - * exit/stderr so the caller can classify with `legacyIsIPv6ConnectivityError`. - */ -export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { - /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ - readonly image: string; - /** The bash pg_dump/pg_dumpall script (`legacyDump{Schema,Data,Role}Script`). */ - readonly script: string; - readonly env: Readonly>; - /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ - readonly onStdout: (chunk: Uint8Array) => Effect.Effect; -}) { - const docker = yield* LegacyDockerRun; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* LegacyNetworkIdFlag; - - const networkId = Option.getOrUndefined(networkIdFlag); - const network = - networkId !== undefined && networkId.length > 0 - ? { _tag: "named" as const, name: networkId } - : { _tag: "host" as const }; - const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - - return yield* docker.runStream( - { - image: legacyGetRegistryImageUrl(params.image), - cmd: ["bash", "-c", params.script, "--"], - env: params.env, - binds: [], - workingDir: Option.none(), - securityOpt: [], - extraHosts, - network, - }, - { onStdout: params.onStdout, teeStderr: true }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts index e02b5c720f..d26a81f19d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts @@ -1,5 +1,10 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; import { legacyFormatMigrationTimestamp, @@ -20,7 +25,11 @@ export class LegacyPgDeltaMigrationWriteError extends Data.TaggedError( "LegacyPgDeltaMigrationWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Bounds the base-timestamp bump retry so a directory already full of same-second diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts new file mode 100644 index 0000000000..6522c8f877 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts @@ -0,0 +1,983 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +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, FileSystem, Layer } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDebugFlag } from "../../../../shared/legacy/global-flags.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + type LegacyEdgeRuntimeRunOpts, + type LegacyEdgeRuntimeRunResult, + LegacyEdgeRuntimeScript, +} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; +import { legacyApplyDeclarativePgDelta } from "./legacy-pgdelta.apply.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; + +const CTX: LegacyPgDeltaContext = { + projectId: "ref", + cwd: "/proj", + npmVersion: undefined, + denoVersion: 2, + projectEnv: {}, +}; + +function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { + const calls: Array = []; + const layer = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (opts: LegacyEdgeRuntimeRunOpts) => { + calls.push(opts); + if (outcome.fail !== undefined) { + return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); + } + return Effect.succeed({ + stdout: outcome.stdout ?? "", + stderr: outcome.stderr ?? "", + } satisfies LegacyEdgeRuntimeRunResult); + }, + }); + return { layer, calls }; +} + +function makeDeclarativeDir(): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-apply-")); + mkdirSync(join(dir, "declarative"), { recursive: true }); + writeFileSync(join(dir, "declarative", "public.sql"), "create table t ();"); + return join(dir, "declarative"); +} + +const failError = (exit: Exit.Exit) => + Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; + +describe("legacyApplyDeclarativePgDelta", () => { + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError interpolating the RELATIVE dir, not the absolute one, when the declarative dir doesn't exist", + () => { + // Go's `ApplyDeclarative` interpolates `utils.GetDeclarativeDir()` (relative) into + // this error, never the `filepath.Abs`-resolved dir it separately computes only for + // the bind (`apply.go:304-307`). + const edge = fakeEdgeRuntime(); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: "/does/not/exist", + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "declarative schema directory not found: supabase/database", + ); + // Never even reaches the edge-runtime — the exists() check runs first. + expect(edge.calls).toHaveLength(0); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect("maps an edge-runtime failure to LegacyPgDeltaDeclarativeApplyError", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ fail: "error running pg-delta script: boom" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "error running pg-delta script: boom", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect("fails with a parse error WITHOUT the raw stdout when --debug is unset", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).not.toContain("stdout:"); + expect(message).not.toContain("not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect("fails with a parse error INCLUDING the raw stdout when --debug is set", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).toContain("stdout: not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect( + "fails with a parse error INCLUDING the raw stdout when SUPABASE_DEBUG is set only in the project .env", + () => { + // Go's `Config.Load` -> `loadNestedEnv` `os.Setenv`s the project `supabase/.env` into the + // process before `pgdelta.ApplyDeclarative` ever reads `viper.GetBool("DEBUG")` + // (review: PRRT_kwDOErm0O86XL_oz) — so a `SUPABASE_DEBUG` set only in `supabase/.env`, + // never in the shell or via `--debug`, still surfaces the raw stdout. Delete any shell + // `SUPABASE_DEBUG` first: shell *presence* (even `false`) would otherwise suppress the + // project value entirely, per `legacyViperEnvBoolWithProjectFallback`'s own semantics. + const previous = process.env["SUPABASE_DEBUG"]; + delete process.env["SUPABASE_DEBUG"]; + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta( + { ...CTX, projectEnv: { SUPABASE_DEBUG: "true" } }, + { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }, + ).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).toContain("stdout: not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; + else process.env["SUPABASE_DEBUG"] = previous; + }), + ), + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with a normal status-failure summary (not a parse error) when stdout is a top-level JSON null", + () => { + // Go's `json.Unmarshal([]byte("null"), &result)` into the zero-valued (non-pointer) + // `ApplyResult` struct is a no-op that returns no error (verified empirically) — Go falls + // through to the normal `result.Status != "success"` branch and prints the usual + // failed-apply summary with every counter at its zero value, rather than treating `null` + // as a parse failure. `legacyApplyDeclarativePgDelta` must normalize `null` to `{}` before + // its own structural guard, matching that behavior (review: PRRT_kwDOErm0O86W8ZYo). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "null" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: ", + ); + expect((failError(exit) as { message: string }).message).not.toContain( + "failed to parse pg-delta apply output", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "".'); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when stdout is syntactically valid but non-object, non-null JSON", + () => { + // Unlike `null` (see the sibling test above), Go's `json.Unmarshal` genuinely rejects an + // array/string/number/bool payload for a struct destination with an UnmarshalTypeError — + // so a bare `JSON.parse(...) as LegacyPgDeltaApplyResult` cast would let `parsed.status` + // throw an unhandled TypeError instead of failing typed. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "42" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect("fails with LegacyPgDeltaDeclarativeApplyError when stdout is a JSON array", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "[1,2,3]" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an array arrives as an object", + () => { + // A configured or future pg-delta emitting `{"status":"error","errors":{"length":1}}` must + // not reach `legacyFormatApplyFailure`'s `for (const issue of errors)`, which would throw an + // unhandled TypeError on a non-iterable object — Go's `json.Unmarshal` rejects this the same + // way, since `Errors` is declared `[]ApplyIssue` (`apps/cli-go/internal/pgdelta/apply.go:33`). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "error", errors: { length: 1 } }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when an errors array element is a number", + () => { + // A configured or future pg-delta emitting `{"status":"success","errors":[123]}` must not + // be accepted as a successful apply. Verified against Go's real `ApplyIssue.UnmarshalJSON` + // (`apps/cli-go/internal/pgdelta/apply.go:124-142`): a numeric element fails BOTH its + // string-arm and its object-arm unmarshal, which fails the WHOLE `ApplyResult` decode — + // Go never reaches a "success" status in this case, so the TS guard must reject it too. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", errors: [123] }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when a diagnostics array element is a bare string", + () => { + // Unlike `ApplyIssue`, Go's `ApplyDiagnosis.UnmarshalJSON` (`apply.go:79-116`) has no + // bare-string acceptance branch, so `{"diagnostics":["boom"]}` fails Go's whole decode too + // (verified: unmarshaling a JSON string into `ApplyDiagnosis`'s shadow struct errors). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", diagnostics: ["boom"] }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a diagnostics element whose statementId is a mistyped, non-object/non-string value (Go degrades it silently)", + () => { + // Unlike a top-level array-element shape mismatch, Go's `ApplyDiagnosis.UnmarshalJSON` + // decodes `statementId` into a `json.RawMessage` first (accepts ANY valid JSON value), then + // tries `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil + // if BOTH fail — never propagating an error. A mistyped `statementId` must NOT fail the + // whole parse. + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + diagnostics: [{ message: "note", statementId: 42 }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "drops a diagnostics element's statementId when a nested field is mistyped, instead of rendering a bogus location (Go's nil fallback)", + () => { + // Unlike the mistyped-non-object/non-string `statementId` case above, this reproduces a + // mistyped FIELD INSIDE an otherwise object-shaped `statementId` + // (`{"filePath":123,...}`). Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:100-115`) + // tries the `ApplyStatementLocation` object shape first — the mistyped `filePath` fails + // that decode — then falls back to a bare string, which ALSO fails (it's an object, not a + // string) — so Go silently leaves `StatementID` nil rather than erroring the whole parse, + // verified empirically. Rendering the raw object anyway would show a bogus `(123#1)` + // location Go never emits. + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + diagnostics: [{ message: "note", statementId: { filePath: 123, statementIndex: 1 } }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null scalar field on an errors/diagnostics element and formats it as absent (Go's encoding/json leaves the zero value)", + () => { + // `ApplyIssue`'s non-`Statement` fields (`Code`/`Message`/`IsDependencyError`/`Position`/ + // `Detail`/`Hint`) and `ApplyDiagnosis`'s (`Code`/`Message`/`SuggestedFix`) are all plain, + // non-pointer Go types decoded via the default `encoding/json` — verified empirically that + // a JSON `null` for a non-pointer struct field produces NO error and leaves the zero value, + // so `{"errors":[{"message":null}]}` is a valid, Go-accepted payload, not a parse failure. + // The formatter's existing `String(issue.message ?? "")` already renders a zero-value + // message as "unknown pg-delta issue" once the guard lets the `null` through. + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [{ message: null, code: null, isDependencyError: null, position: null }], + diagnostics: [{ message: null, code: null, suggestedFix: null }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect(out.stderrText).toContain("- unknown pg-delta issue"); + expect(out.stderrText).toContain("- unknown pg-delta diagnostic"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null top-level counter and formats it as zero (Go's encoding/json leaves the zero value)", + () => { + // `ApplyResult` has no custom `UnmarshalJSON` of its own, so its plain, non-pointer `int` + // counters (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped`) decode via the + // default `encoding/json` — verified empirically that a JSON `null` for a non-pointer `int` + // field produces NO error and leaves the zero value, so + // `{"status":"success","totalApplied":null}` is a valid, Go-accepted payload, not a parse + // failure — same "null means absent" rule already applied to nested issue/diagnostic + // scalar fields above. + const dir = makeDeclarativeDir(); + const payload = { status: "success", totalApplied: null, totalRounds: null }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Applied 0 statements in 0 round(s)."); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts an absent or null top-level status and formats it as the empty-string zero value (Go's encoding/json)", + () => { + // `ApplyResult.Status` has no custom `UnmarshalJSON` of its own, so it's a plain, + // non-pointer `string` field decoded via the default `encoding/json` — verified + // empirically that `{}` and `{"status":null}` both decode with `err == nil` and + // `Status == ""`, reaching the normal failed-apply summary (not a parse failure). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: JSON.stringify({}) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: ", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "".'); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null errors/stuckStatements/validationErrors/diagnostics array and treats it as empty (Go's encoding/json leaves a nil slice)", + () => { + // `ApplyResult`'s array fields have no custom `UnmarshalJSON` of their own, so Go's + // `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, leaving a + // nil (zero-length) slice — verified empirically: + // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + // with `len(r.Errors) == 0`. A payload reporting all four as `null` must format as if none + // were reported at all, not fail the parse. + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: null, + stuckStatements: null, + validationErrors: null, + diagnostics: null, + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect(out.stderrText).toContain("No per-statement diagnostics were reported by pg-delta."); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as a number arrives as a string", + () => { + // Same reasoning as the array-typed-field test above, for `ApplyResult`'s numeric fields + // (`TotalApplied int`, etc.) — a malformed counter must fail the parse, not be silently + // treated as a genuine successful-apply summary. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", totalApplied: "5" }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an int arrives as a fractional number", + () => { + // Go's `TotalApplied int` (and its `int`-typed siblings) reject any JSON number literal + // with a decimal point via `strconv.ParseInt` on the raw literal text — verified + // empirically that `json.Unmarshal` on `{"totalApplied":1.5}` errors identically to a + // string-typed field mismatch, so `1.5` must fail the parse here too, not be treated as a + // truncated/rounded successful-apply count. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", totalApplied: 1.5 }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when an int field arrives as a value outside Go's int64 range", + () => { + // `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` of that same literal + // into `int` fails with "value out of range" (`strconv.ParseInt`'s int64 width) — so a + // mistyped/oversized numeric field must be rejected here too, not accepted as a (false) + // successful-apply count. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", totalApplied: 1e20 }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on a non-success status, prints the formatted failure to stderr but not the raw payload when --debug is unset", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["boom"], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: error", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "error".'); + expect(out.stderrText).toContain("- boom"); + expect(out.stderrText).not.toContain("pg-delta apply result:"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on a non-success status with --debug set, additionally dumps the pretty-printed raw payload", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["boom"], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(out.stderrText).toContain("pg-delta apply result:"); + expect(out.stderrText).toContain(JSON.stringify(payload, null, 2)); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on success, prints the applied-statements summary and forwards SCHEMA_PATH/TARGET/binds", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + totalStatements: 3, + totalApplied: 3, + totalRounds: 2, + totalSkipped: 0, + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + declarativeDirRel: "supabase/database", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }); + expect(out.stderrText).toContain("Applying declarative schemas via pg-delta..."); + expect(out.stderrText).toContain("Applied 3 statements in 2 round(s)."); + const opts = edge.calls[0]!; + expect(opts.env["SCHEMA_PATH"]).toBe("/declarative"); + expect(opts.env["TARGET"]).toBe( + "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + ); + expect(opts.binds).toEqual([ + "supabase_edge_runtime_ref:/root/.cache/deno:rw", + `${dir}:/declarative:ro`, + ]); + expect(opts.errPrefix).toBe("error running pg-delta script"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts new file mode 100644 index 0000000000..f9092b4b57 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts @@ -0,0 +1,1002 @@ +/** + * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`) — + * CLI-1956's declarative-apply runner: applies `supabase/database` (or the configured + * declarative dir) to the shadow's `contrib_regression` override database via pg-delta's + * declarative apply engine, run inside the edge-runtime container. + * + * This is genuinely NEW work, not a seam removal: the Deno script template itself + * (`legacyPgDeltaDeclarativeApplyScript`) already existed (ported for a different, now-dead + * seam), but nothing in TS ever invoked it — every declarative apply ran through the bundled + * Go binary until now. + */ + +import { Data, Effect, type FileSystem } from "effect"; + +import { legacyResolveDebugWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { legacyGoQuote } from "../../../shared/legacy-go-quote.ts"; +import { legacyTrimGoSpace } from "./legacy-go-string.ts"; +import { + legacyInterpolatePgDeltaScript, + legacyPgDeltaDeclarativeApplyScript, +} from "./legacy-pgdelta.deno-templates.ts"; +import { + legacyEdgeRuntimeId, + legacyPgDeltaNpmRegistryOption, + type LegacyPgDeltaContext, +} from "../../../shared/legacy-pgdelta.ts"; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * `pgdelta.ApplyDeclarative` failed — Go's own error messages at each step (see call sites + * below). `reason` narrows the actionability classification below beyond the "user's own + * SQL/schema" (`dbFinding`) default that a failed-status apply (Go's own `pg-delta declarative + * apply failed with status: %s`) and a plain reset/apply fallback (`sync.handler.ts`, + * `declarative.smart-target.ts`) both keep: `missing_schema_dir`/`output_parse` (this file's + * own directory-not-found/malformed-subprocess-output branches) and `connect`/`daemon`/`pull`/ + * `inspect` (a local-Postgres connect failure, or a docker-boundary failure threaded from + * `LegacyEdgeRuntimeScriptError.docker` — see that class's own doc comment for the same three + * values) are genuinely NOT the user's schema/SQL failing, and must not be misclassified as + * such. + */ +export class LegacyPgDeltaDeclarativeApplyError extends Data.TaggedError( + "LegacyPgDeltaDeclarativeApplyError", +)<{ + readonly message: string; + readonly reason?: + | "missing_schema_dir" + | "output_parse" + | "connect" + | "daemon" + | "pull" + | "inspect"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "missing_schema_dir": + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "output_parse": + return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; + case "connect": + return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + case "daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "pull": + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + case "inspect": + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + default: + return actionability.dbFinding; + } + } +} + +/** Go's `containerSchemaPath` (`apply.go:313`). */ +const LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH = "/declarative"; + +/** One statement/error entry — Go's `ApplyIssue`, which may arrive as a bare string or an object. */ +export interface LegacyPgDeltaApplyIssue { + readonly statement?: { + // Optional (not required): `legacyIsValidApplyIssueElement` only checks the TYPE of each + // present field (matching Go's per-field `json.Unmarshal` type check), not that every + // field is present — so a partially-populated `statement` object (e.g. a future pg-delta + // release that only reports `id`) must still render, not throw — see + // `legacyFormatApplyIssue`'s defensive `?? ""` handling below. Go's own `(i + // *ApplyIssue) UnmarshalJSON` is deliberately just as permissive about ABSENT fields, + // while still rejecting a MISTYPED one for the whole payload — see + // `legacyIsValidApplyIssueElement`'s own doc comment. + // + // `| null` on each of `id`/`sql`/`statementClass` (not just `?`): these are plain, + // non-pointer `string` fields on Go's `ApplyStatement`, which has no custom + // `UnmarshalJSON` of its own — so they decode via the default `encoding/json`, which + // (verified empirically) accepts a JSON `null` for a non-pointer field with NO error and + // leaves the zero value (`""`), the same "null means absent" rule as every other scalar + // on this interface — see {@link LegacyPgDeltaApplyIssue.code}'s doc comment. + readonly id?: string | null; + readonly sql?: string | null; + readonly statementClass?: string | null; + // `| null` (not just `?`): Go's `Statement *ApplyStatement` is a pointer, so a JSON + // `"statement":null` entry (e.g. `{"statement":null,"message":"failed"}`) unmarshals to a + // nil pointer — `formatApplyIssue`'s `issue.Statement == nil` (`apply.go:202`) treats that + // identically to a missing field. `legacyFormatApplyIssue`'s guard below must check for + // `null` as well as `undefined`, or a `JSON.parse`'d `null` reaches `issue.statement.*` and + // throws a `TypeError` instead of rendering the message. + } | null; + // `| null` on every scalar below (not just `?`): `ApplyIssue`'s non-`Statement` fields + // (`Code`/`Message`/`IsDependencyError`/`Position`/`Detail`/`Hint`) are all plain, + // non-pointer Go types (`string`/`bool`/`int`) decoded via the default `encoding/json` + // inside `(i *ApplyIssue) UnmarshalJSON`'s `json.Unmarshal(trimmed, &parsed)` call + // (`apply.go:135-140`) — verified empirically that unmarshaling a JSON `null` into a + // non-pointer struct field produces NO error and leaves the zero value untouched (Go's + // documented "null means absent" rule applies to any Go type, not just pointers/maps/ + // slices/interfaces). So `{"message":null}` is a valid, Go-accepted `ApplyIssue` element — + // rejecting it here would turn an otherwise-parseable pg-delta payload into a spurious + // "failed to parse pg-delta apply output" instead of rendering `unknown pg-delta issue` + // the way `legacyFormatApplyIssueMessage`'s existing `String(issue.message ?? "")` already + // does once this type (and `legacyIsValidApplyIssueElement`) let a null through. + readonly code?: string | null; + readonly message?: string | null; + readonly isDependencyError?: boolean | null; + readonly position?: number | null; + readonly detail?: string | null; + readonly hint?: string | null; +} + +/** + * Go's `ApplyStatementLocation` (pg-topo's `StatementId` shape). `ApplyStatementLocation` + * has no custom `UnmarshalJSON` of its own, so `filePath`/`statementIndex`/`sourceOffset` + * are plain, non-pointer Go types decoded via the default `encoding/json` — same "null + * means absent" rule as every other scalar in this file (verified empirically, see {@link + * LegacyPgDeltaApplyIssue.code}'s doc comment), hence `| null` on all three. `sourceOffset` + * is never read by {@link legacyFormatStatementLocation} (Go's own `formatStatementLocation` + * doesn't display it either), but it still must be validated in + * {@link legacyNormalizeApplyStatementId}: Go's struct-level `json.Unmarshal` fails the + * WHOLE object the moment any declared field — including this unused one — has the wrong + * type, not just the fields the formatter happens to read. + */ +export interface LegacyPgDeltaApplyStatementLocation { + readonly filePath?: string | null; + readonly statementIndex?: number | null; + readonly sourceOffset?: number | null; +} + +/** Go's `ApplyDiagnosis` — a pg-topo static-analysis diagnostic. */ +export interface LegacyPgDeltaApplyDiagnosis { + // `| null` on `code`/`message`/`suggestedFix` (not just `?`): `(d *ApplyDiagnosis) + // UnmarshalJSON`'s shadow `raw` struct (`apply.go:88-93`) declares these as plain, + // non-pointer `string` fields with no custom unmarshaler of their own, so — same + // empirically-verified "null means absent" `encoding/json` rule as + // {@link LegacyPgDeltaApplyIssue.code} — a JSON `null` for any of them decodes with no + // error and leaves `""`, not a rejected payload. + readonly code?: string | null; + readonly message?: string | null; + // `| null` (not just `?`): Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) + // explicitly maps a JSON `"statementId":null` to a nil `*ApplyStatementLocation`, and + // `formatStatementLocation` (`apply.go:263-274`) returns `""` for a nil pointer — so the TS + // path must accept `null` here as absent too, or a `JSON.parse`'d `null` reaches + // `legacyFormatStatementLocation`'s `resolved.filePath` and throws a `TypeError` instead of + // rendering the rest of the diagnostic. + readonly statementId?: LegacyPgDeltaApplyStatementLocation | string | null; + readonly suggestedFix?: string | null; +} + +/** + * The JSON payload `pgdelta_declarative_apply.ts` prints on stdout. Go's `ApplyResult`. + * + * `| null` on each `total*` counter (not just `?`): `ApplyResult` has no custom + * `UnmarshalJSON` of its own, so these plain, non-pointer `int` fields decode via the + * default `encoding/json`, which — verified empirically, same rule as {@link + * LegacyPgDeltaApplyIssue.code} — accepts a JSON `null` for a non-pointer `int` field with + * NO error and leaves the zero value. So `{"status":"success","totalApplied":null}` is a + * valid, Go-accepted `ApplyResult`, not a parse failure. + * + * `| null` on each array field too (`errors`/`stuckStatements`/`validationErrors`/ + * `diagnostics`): these are plain, non-pointer Go `[]T` slice fields with no custom + * unmarshaler on `ApplyResult` itself, and `encoding/json` accepts a JSON `null` for a + * slice field with NO error, leaving a nil (zero-length) slice — verified empirically: + * `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + * with `r.Errors == nil` (`len(r.Errors) == 0`). `formatApplyFailure`'s `len(result.Errors) + * > 0` guards treat a nil slice identically to an empty one, so `{"status":"error", + * "errors":null}` must be accepted here too, not rejected as a parse failure. + * + * `status?: string | null` (not required non-null `string`): like every other field here, + * `Status` has no custom unmarshaler on `ApplyResult` itself, so an absent key or a JSON + * `null` decodes with NO error and leaves Go's zero value `""` — verified empirically: + * `json.Unmarshal([]byte(\`{}\`), &r)` and the `{"status":null}` variant both return + * `err == nil` with `r.Status == ""`. So `{}`/`{"status":null}` must reach the normal + * failed-apply summary (status rendered as `""`), not a rejected parse failure. + */ +export interface LegacyPgDeltaApplyResult { + readonly status?: string | null; + readonly totalStatements?: number | null; + readonly totalRounds?: number | null; + readonly totalApplied?: number | null; + readonly totalSkipped?: number | null; + readonly errors?: ReadonlyArray | null; + readonly stuckStatements?: ReadonlyArray | null; + readonly validationErrors?: ReadonlyArray | null; + readonly diagnostics?: ReadonlyArray | null; +} + +/** + * Go's `int`-typed fields (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped` on + * `ApplyResult`, `Position` on `ApplyIssue`) reject any JSON number literal containing a decimal + * point or exponent — Go's `json.Unmarshal` parses the literal text via `strconv.ParseInt` + * rather than decoding a `float64` and truncating it, so even a "whole" float like `1.0` fails + * identically to `1.5` (verified empirically: `json.Unmarshal([]byte(\`{"totalApplied":1.0}\`), + * &r)` and the `1.5` variant both return `cannot unmarshal number ... into ... type int`). A + * `JSON.parse`'d `1.0` is already indistinguishable from the integer `1` by the time it reaches + * this guard — `JSON.parse` itself collapses that distinction, so that exact literal-text + * sub-case can't be reproduced post-parse — but `Number.isInteger` still correctly rejects any + * genuinely fractional value like `1.5`, which is the reachable and observable part of this + * parity gap. + * + * The `[-2^63, 2^63)` bound mirrors Go's `int64` range (`strconv.ParseInt`'s target width on + * every build this CLI ships for): `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` + * of that same literal into `int` fails with "value out of range" — so a mistyped/oversized + * numeric field must be rejected here too, not accepted as a (false) match. Residual gap, same + * class as the `1.0`/exponent one above: the exact boundary literal `9223372036854775807` + * (`2^63-1`, the largest valid `int64`) round-trips through `JSON.parse`'s double-precision + * `float64` as `9223372036854775808` (`2^63`) — indistinguishable from the boundary this check + * rejects — so that one exact literal is spuriously rejected where Go would accept it. + */ +function legacyIsGoIntNumber(value: unknown): value is number { + return ( + typeof value === "number" && Number.isInteger(value) && value >= -(2 ** 63) && value < 2 ** 63 + ); +} + +/** + * Go's `(i *ApplyIssue) UnmarshalJSON` (`apply.go:124-142`) accepts `null`, a bare string, or + * an object whose PRESENT fields each match `ApplyIssue`'s declared JSON types — anything else + * (a number, boolean, array, or an object with a mistyped field) fails Go's `json.Unmarshal` + * for the WHOLE `ApplyResult`, not just that element. Verified empirically against Go's real + * struct definitions: `{"errors":[123]}` returns `cannot unmarshal number into Go struct field + * ApplyResult.errors of type main.alias`, and `{"errors":[{"message":123}]}` returns `cannot + * unmarshal number into Go struct field ApplyResult.errors.message of type string` — both abort + * the ENTIRE parse rather than degrading that one element, so a payload like + * `{"status":"success","errors":[123]}` must be rejected here too, not accepted as a (false) + * success. Nested `statement` is checked the same way, one level deep — Go's `ApplyStatement` + * has no custom `UnmarshalJSON`, so a mistyped `id`/`sql`/`statementClass` fails identically. + * + * A JSON `null` for any INDIVIDUAL scalar field, though — top-level (`code`/`message`/ + * `isDependencyError`/`position`/`detail`/`hint`) or nested under `statement` + * (`id`/`sql`/`statementClass`) — is NOT a mistyped field: every one of these is a plain, + * non-pointer Go type with no custom unmarshaler, and `encoding/json` accepts `null` for those + * with no error, leaving the zero value (verified empirically — see + * {@link LegacyPgDeltaApplyIssue.code}'s doc comment). So `null` is tolerated alongside each + * field's declared type below, matching Go exactly instead of rejecting an otherwise + * Go-compatible payload like `{"message":null}`. + */ +function legacyIsValidApplyIssueElement(value: unknown): boolean { + if (value === null || typeof value === "string") return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("statement" in value) { + const statement = value.statement; + if (statement !== null && statement !== undefined) { + if (typeof statement !== "object" || Array.isArray(statement)) return false; + if ("id" in statement && statement.id !== null && typeof statement.id !== "string") { + return false; + } + if ("sql" in statement && statement.sql !== null && typeof statement.sql !== "string") { + return false; + } + if ( + "statementClass" in statement && + statement.statementClass !== null && + typeof statement.statementClass !== "string" + ) { + return false; + } + } + } + if ("code" in value && value.code !== null && typeof value.code !== "string") return false; + if ("message" in value && value.message !== null && typeof value.message !== "string") { + return false; + } + if ( + "isDependencyError" in value && + value.isDependencyError !== null && + typeof value.isDependencyError !== "boolean" + ) { + return false; + } + if ("position" in value && value.position !== null && !legacyIsGoIntNumber(value.position)) { + return false; + } + if ("detail" in value && value.detail !== null && typeof value.detail !== "string") return false; + if ("hint" in value && value.hint !== null && typeof value.hint !== "string") return false; + return true; +} + +/** + * Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-116`) — unlike `ApplyIssue`, there is + * NO bare-string acceptance branch, so only `null` or an object is valid; a bare + * string/number/boolean/array element fails the whole `ApplyResult` unmarshal. Verified + * empirically: `{"diagnostics":["boom"]}` returns `cannot unmarshal string into Go struct field + * ApplyResult.diagnostics of type struct {...}`. `statementId` is deliberately NOT type-checked + * here: Go decodes it into a `json.RawMessage` first (accepts any valid JSON value), then tries + * `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil if BOTH + * fail — it never propagates an error for a mistyped `statementId` (verified empirically: + * `{"statementId":42}` and `{"statementId":{"filePath":123}}` both unmarshal with `err: `), + * so `legacyNormalizeApplyDiagnosis`/`legacyFormatStatementLocation`'s existing defensive + * handling is the correct (and only) place that degrades gracefully. + * + * Same "null tolerated on a scalar field" rule as {@link legacyIsValidApplyIssueElement} + * applies to `code`/`message`/`suggestedFix` here too: `UnmarshalJSON`'s shadow `raw` struct + * (`apply.go:88-93`) decodes them via the default `encoding/json`, which accepts a JSON + * `null` for a plain `string` field with no error (verified empirically). + */ +function legacyIsValidApplyDiagnosisElement(value: unknown): boolean { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("code" in value && value.code !== null && typeof value.code !== "string") return false; + if ("message" in value && value.message !== null && typeof value.message !== "string") { + return false; + } + if ( + "suggestedFix" in value && + value.suggestedFix !== null && + typeof value.suggestedFix !== "string" + ) { + return false; + } + return true; +} + +/** + * Structural guard for Go's `ApplyResult` JSON shape, applied to an untrusted + * `JSON.parse` of the pg-delta subprocess's stdout. A syntactically valid but non-object + * payload — an array, a bare string/number/bool (e.g. a future pg-delta release that + * changes its output shape) — must fail typed as {@link LegacyPgDeltaDeclarativeApplyError}, not + * crash `parsed.status` with an unhandled `TypeError`. A top-level `null` is NOT one of + * these: `json.Unmarshal([]byte("null"), &result)` into Go's zero-valued (non-pointer) + * `ApplyResult` struct is a no-op that returns no error (verified empirically), unlike the + * array/string/number/bool cases, which genuinely fail with an `UnmarshalTypeError` — so the + * caller normalizes a top-level `null` to `{}` before this guard ever sees it (review: + * PRRT_kwDOErm0O86W8ZYo), and this function only needs to reject the cases Go actually + * rejects. + * + * Every field `ApplyResult` itself declares a type for is checked when present — Go's + * `json.Unmarshal` rejects the whole payload with an `UnmarshalTypeError` the moment any of + * these doesn't match its struct field's declared type (`Errors []ApplyIssue`, `TotalApplied + * int`, etc., `apps/cli-go/internal/pgdelta/apply.go:27-40`), so e.g. an `errors` field that + * arrives as an object (`{"length":1}`) instead of an array must fail here too, not reach + * `legacyFormatApplyFailure`'s `for (const issue of errors)` and throw an unhandled + * `TypeError` defect. Each ARRAY field's elements are also validated ({@link + * legacyIsValidApplyIssueElement}/{@link legacyIsValidApplyDiagnosisElement}) since Go's own + * per-element `UnmarshalJSON` implementations reject a malformed element by failing the WHOLE + * `ApplyResult` decode, not by skipping just that element — see those functions' own doc + * comments for the empirical verification. This is also the AGENTS.md-mandated way to narrow + * `unknown` without an `as` cast. + * + * Each array field also tolerates a JSON `null` (not just an absent key): `ApplyResult`'s + * `[]ApplyIssue`/`[]ApplyDiagnosis` fields have no custom unmarshaler of their own, and + * Go's `encoding/json` accepts `null` for a slice field with no error, leaving a nil + * (zero-length) slice — verified empirically, see {@link LegacyPgDeltaApplyResult}'s own + * doc comment. So `{"status":"error","errors":null}` is a valid, Go-accepted payload, not + * a rejected one. + * + * `status` is checked the same "null/absent tolerated" way as every other field, NOT + * required to be present and non-null: an absent key or `"status":null` is Go's zero + * value `""`, not a parse failure — see {@link LegacyPgDeltaApplyResult}'s own doc comment + * for the empirical verification. + */ +function legacyIsPgDeltaApplyResult(value: unknown): value is LegacyPgDeltaApplyResult { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + ("status" in value && value.status !== null && typeof value.status !== "string") + ) { + return false; + } + if ( + "totalStatements" in value && + value.totalStatements !== null && + !legacyIsGoIntNumber(value.totalStatements) + ) { + return false; + } + if ( + "totalRounds" in value && + value.totalRounds !== null && + !legacyIsGoIntNumber(value.totalRounds) + ) { + return false; + } + if ( + "totalApplied" in value && + value.totalApplied !== null && + !legacyIsGoIntNumber(value.totalApplied) + ) { + return false; + } + if ( + "totalSkipped" in value && + value.totalSkipped !== null && + !legacyIsGoIntNumber(value.totalSkipped) + ) { + return false; + } + if ("errors" in value && value.errors !== null) { + if (!Array.isArray(value.errors) || !value.errors.every(legacyIsValidApplyIssueElement)) { + return false; + } + } + if ("stuckStatements" in value && value.stuckStatements !== null) { + if ( + !Array.isArray(value.stuckStatements) || + !value.stuckStatements.every(legacyIsValidApplyIssueElement) + ) { + return false; + } + } + if ("validationErrors" in value && value.validationErrors !== null) { + if ( + !Array.isArray(value.validationErrors) || + !value.validationErrors.every(legacyIsValidApplyIssueElement) + ) { + return false; + } + } + if ("diagnostics" in value && value.diagnostics !== null) { + if ( + !Array.isArray(value.diagnostics) || + !value.diagnostics.every(legacyIsValidApplyDiagnosisElement) + ) { + return false; + } + } + return true; +} + +/** Go's `(i *ApplyIssue) UnmarshalJSON` string/object dual shape, applied post-`JSON.parse`. */ +function legacyNormalizeApplyIssue( + raw: LegacyPgDeltaApplyIssue | string | null | undefined, +): LegacyPgDeltaApplyIssue { + if (raw === null || raw === undefined) return {}; + if (typeof raw === "string") return { message: raw }; + return raw; +} + +/** + * Go's `(d *ApplyDiagnosis) UnmarshalJSON` three-way `statementId` fallback + * (`apply.go:100-115`): decode into `ApplyStatementLocation` first — an object whose + * PRESENT `filePath`/`statementIndex` fields each match the declared type (`null` + * tolerated per field, same rule as {@link legacyIsValidApplyIssueElement}) — and if + * that fails (a non-object, or an object with a mistyped field), fall back to a bare + * string; if BOTH fail, Go silently leaves `StatementID` nil rather than erroring the + * whole `ApplyResult` parse. Verified empirically: `{"statementId":{"filePath":123, + * "statementIndex":1}}` decodes with `StatementID == nil` in Go — the object-shape + * unmarshal fails on the mistyped `filePath`, and the string fallback also fails since + * the value is an object, not a string. `legacyIsValidApplyDiagnosisElement` deliberately + * does NOT check `statementId`'s shape (see its own doc comment — Go defers this into a + * `json.RawMessage` that never fails the outer parse), so this is the only place that can + * drop a malformed location instead of `legacyFormatStatementLocation`'s `String(...)` + * coercion rendering a bogus location (e.g. `123#1`) Go would never have shown. + * + * `sourceOffset` is validated here too, even though {@link legacyFormatStatementLocation} + * never reads it: Go's struct-level unmarshal (`apply.go:105`) fails on ANY declared field + * with the wrong type, not just the ones a later formatter happens to display. Verified + * empirically: `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` + * returns a non-nil `UnmarshalTypeError` even though `filePath` itself is well-typed, so + * the object-shape decode fails, the string fallback also fails (the value is an object), + * and Go leaves `StatementID` nil — dropping the location entirely rather than keeping a + * `{filePath:"x.sql"}` that misattributes the diagnostic to the wrong file. + */ +function legacyNormalizeApplyStatementId( + raw: LegacyPgDeltaApplyStatementLocation | string | null | undefined, +): LegacyPgDeltaApplyStatementLocation | undefined { + if (raw === null || raw === undefined) return undefined; + if (typeof raw === "string") return { filePath: raw }; + if (typeof raw !== "object" || Array.isArray(raw)) return undefined; + const filePathOk = + !("filePath" in raw) || raw.filePath === null || typeof raw.filePath === "string"; + const indexOk = + !("statementIndex" in raw) || + raw.statementIndex === null || + legacyIsGoIntNumber(raw.statementIndex); + const sourceOffsetOk = + !("sourceOffset" in raw) || raw.sourceOffset === null || legacyIsGoIntNumber(raw.sourceOffset); + if (filePathOk && indexOk && sourceOffsetOk) return raw; + return undefined; +} + +/** Go's `(d *ApplyDiagnosis) UnmarshalJSON` defensive `statementId` handling. */ +function legacyNormalizeApplyDiagnosis( + raw: LegacyPgDeltaApplyDiagnosis | null | undefined, +): LegacyPgDeltaApplyDiagnosis { + if (raw === null || raw === undefined) return {}; + return { ...raw, statementId: legacyNormalizeApplyStatementId(raw.statementId) }; +} + +/** + * Go's `formatStatementLocation` (`apply.go:262-275`). `String(... ?? "")` rather than a bare + * `?? ""` before `.trim()`: `filePath` is typed as `string | undefined`, but this whole module + * types an untrusted `JSON.parse` of subprocess output, so a malformed payload can hand this a + * non-string value (e.g. a number) at runtime — `?? ""` alone only substitutes `null`/ + * `undefined`, so a non-string, non-nullish value would still reach `.trim()` and throw. The + * `resolved === null` check (not just `undefined`) is the same shape: Go's `StatementID + * *ApplyStatementLocation` is a pointer, so `"statementId":null` unmarshals to `nil` and + * `formatStatementLocation`'s own `loc == nil` (`apply.go:264`) treats it as absent — checking + * only `undefined` here would fall through to `resolved.filePath` on a `null` and throw a + * `TypeError` instead of rendering the rest of the diagnostic. + */ +function legacyFormatStatementLocation( + loc: LegacyPgDeltaApplyStatementLocation | string | null | undefined, +): string { + const resolved = typeof loc === "string" ? { filePath: loc } : loc; + if (resolved === null || resolved === undefined) return ""; + const path = legacyTrimGoSpace(String(resolved.filePath ?? "")); + if (path.length === 0) return ""; + if ((resolved.statementIndex ?? 0) > 0) return `${path}#${resolved.statementIndex}`; + return path; +} + +/** + * Go's `formatStatementSQL` (`apply.go:277-283`): collapse whitespace, then truncate at 120 + * UTF-8 bytes — not JS UTF-16 code units. Go's `len(normalized)` and `normalized[:maxLen-3]` + * both count/slice raw bytes, so a statement with multibyte (e.g. non-ASCII identifier) + * characters can be far longer in bytes than in UTF-16 units — a `.length`/`.slice()` guard + * would under-truncate (or not truncate at all) relative to Go's 120-byte limit, changing the + * legacy stderr contract for an already-failed apply. + * + * `\p{White_Space}+`, not `\s+`: `sql` is a user-authored SQL statement pulled verbatim from + * `supabase/declarative`, so — unlike this file's JSON envelope, whose key/shape is controlled + * by the embedded producer script — it can genuinely contain any Unicode code point a user's + * editor wrote, including NEL (code point 0x85) or a BOM (code point 0xFEFF) pasted into a + * comment or string literal. Go's `strings.Fields`/`unicode.IsSpace` and ECMAScript's `\s` + * disagree on both: verified empirically — Go's `unicode.IsSpace(rune(0x85))` (NEL) is `true` + * (`strings.Fields` collapses it, splitting `"a"+NEL+"b"` into two fields) while + * `unicode.IsSpace(rune(0xFEFF))` (BOM) is `false` (`strings.Fields` preserves it inside one + * field); ECMAScript's `\s` is the exact opposite (`/\s/u.test(String.fromCodePoint(0x85))` is + * `false`, `/\s/u.test(String.fromCodePoint(0xfeff))` is `true`). `\p{White_Space}` matches the + * Unicode `White_Space` property Go's `unicode.IsSpace` is itself built from (confirmed + * empirically against the same two code points, plus NBSP `0xA0` and ideographic space + * `0x3000`), so it reproduces Go's classification instead of ECMAScript's — both the rendered + * SQL text and, for a statement long enough to need it, the 120-byte truncation boundary now + * line up with Go's. + * + * Returns a `Buffer`, not a `string`: Go's `[:maxLen-3]` is a raw byte slice with no regard + * for codepoint boundaries, so a multibyte (e.g. non-ASCII identifier) character straddling + * byte 117 is cut mid-sequence, leaving an intentionally INVALID trailing UTF-8 fragment — + * exactly what Go writes to stderr, unvalidated. `Buffer#toString("utf-8")` on that same + * fragment does NOT reproduce it: Node's UTF-8 decoder substitutes U+FFFD for the incomplete + * sequence, and re-encoding that string back to bytes for output yields a DIFFERENT (and + * differently-sized) byte sequence than Go's raw slice — verified empirically: slicing Go's + * own `formatStatementSQL` at a non-boundary-aligned cut produces a 120-byte, deliberately + * invalid-UTF-8 result (`utf8.ValidString` reports `false`), while + * `Buffer.from(sql,"utf-8").subarray(...).toString("utf-8")` on that exact byte range + * decodes+re-encodes to a 121-byte result containing U+FFFD instead. Keeping this a `Buffer` + * all the way to `output.rawBytes` (see {@link legacyFormatApplyFailure}) avoids that + * lossy string round-trip and reproduces Go's bytes exactly, valid or not. + */ +function legacyFormatStatementSql(sql: string): Buffer { + const normalized = sql + .split(/\p{White_Space}+/u) + .filter((part) => part.length > 0) + .join(" "); + const maxLen = 120; + const normalizedBytes = Buffer.from(normalized, "utf-8"); + if (normalizedBytes.byteLength <= maxLen) return normalizedBytes; + return Buffer.concat([normalizedBytes.subarray(0, maxLen - 3), Buffer.from("...", "utf-8")]); +} + +/** + * Joins Buffer "lines" with `\n` — a Buffer-safe equivalent of `Array#join("\n")`, used so + * {@link legacyFormatApplyIssue}/{@link legacyFormatApplyFailure} can embed + * {@link legacyFormatStatementSql}'s raw (possibly invalid-UTF-8) bytes without ever + * decoding them back into a JS string. + */ +function legacyJoinLines(lines: ReadonlyArray): Buffer { + const newline = Buffer.from("\n", "utf-8"); + const parts: Array = []; + lines.forEach((line, index) => { + if (index > 0) parts.push(newline); + parts.push(line); + }); + return Buffer.concat(parts); +} + +/** + * Go's `json.Indent` (`encoding/json/indent.go`): re-flows compact/pretty JSON by inserting + * whitespace between tokens ONLY — every token (string, number, `true`/`false`/`null`) is + * copied byte-for-byte from `src`, never decoded into a value and re-encoded. This is NOT the + * same as `JSON.parse` + `JSON.stringify`: parsing a number decodes it into a JS `float64`, + * which silently loses precision for an integer literal beyond + * `Number.MAX_SAFE_INTEGER` (e.g. a snowflake-style id), and re-stringifying a string + * re-escapes it using `JSON.stringify`'s own rules, which can change an existing escape's + * representation (e.g. `\/` becomes a literal `/`) — both would corrupt the exact debug + * payload users are asked to attach to bug reports. `legacyGoJsonIndentTokens` instead scans + * `src` as a token stream (only tracking string boundaries, via backslash-escape skipping, to + * avoid misreading punctuation inside a string as structural) and reproduces Go's exact + * spacing rules: verified empirically against `encoding/json.Indent` for nested objects/ + * arrays, empty `{}`/`[]` (no inserted newline), a `\/`-escaped string, an emoji (multi-UTF-16 + * code point) string, and an integer literal beyond `Number.MAX_SAFE_INTEGER` — all byte- + * identical to Go's own output. Caller ({@link legacyFormatDebugJson}) is responsible for + * validating `src` is well-formed JSON first; this function assumes it and does not itself + * detect malformed input. + */ +function legacyGoJsonIndentTokens(src: string): string { + let out = ""; + let depth = 0; + let needIndent = false; + let i = 0; + const n = src.length; + const newline = (): void => { + out += `\n${" ".repeat(depth)}`; + }; + const openIndentIfNeeded = (): void => { + if (!needIndent) return; + needIndent = false; + depth++; + newline(); + }; + while (i < n) { + const c = src[i]; + if (c === " " || c === "\t" || c === "\r" || c === "\n") { + i++; + continue; + } + if (c === '"') { + const start = i; + i++; + while (i < n) { + if (src[i] === "\\") { + i += 2; + continue; + } + if (src[i] === '"') { + i++; + break; + } + i++; + } + openIndentIfNeeded(); + out += src.slice(start, i); + continue; + } + if (c === "{" || c === "[") { + openIndentIfNeeded(); + out += c; + needIndent = true; + i++; + continue; + } + if (c === "}" || c === "]") { + if (needIndent) { + needIndent = false; + } else { + depth--; + newline(); + } + out += c; + i++; + continue; + } + if (c === ",") { + openIndentIfNeeded(); + out += c; + newline(); + i++; + continue; + } + if (c === ":") { + openIndentIfNeeded(); + out += ": "; + i++; + continue; + } + openIndentIfNeeded(); + out += c; + i++; + } + return out; +} + +/** + * Go's `formatDebugJSON` (`apply.go:286-296`): pretty-print if parseable, else the trimmed raw + * bytes. `JSON.parse` here is used ONLY as a well-formedness check (its result is discarded); + * the actual reformatting goes through {@link legacyGoJsonIndentTokens} so token values are + * never decoded and re-encoded — see that function's own doc comment for why + * `JSON.stringify(JSON.parse(...))` would corrupt the payload Go's `json.Indent` preserves. + */ +export function legacyFormatDebugJson(raw: string): string { + const trimmed = legacyTrimGoSpace(raw); + if (trimmed.length === 0) return ""; + try { + JSON.parse(trimmed); + } catch { + return trimmed; + } + return legacyGoJsonIndentTokens(trimmed); +} + +/** Go's `formatApplyIssueMessage` (`apply.go:223-242`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ +function legacyFormatApplyIssueMessage(issue: LegacyPgDeltaApplyIssue): string { + const trimmed = legacyTrimGoSpace(String(issue.message ?? "")); + const message = trimmed.length > 0 ? trimmed : "unknown pg-delta issue"; + const metadata: Array = []; + const code = String(issue.code ?? ""); + if (code.length > 0) metadata.push(`SQLSTATE ${code}`); + if ((issue.position ?? 0) > 0) metadata.push(`position ${issue.position}`); + if (issue.isDependencyError === true) metadata.push("dependency error"); + if (metadata.length === 0) return message; + return `${message} (${metadata.join(", ")})`; +} + +/** + * Go's `formatApplyIssue` (`apply.go:202-221`). Every `issue.statement.*`/`issue.*` field is + * defaulted with `String(x ?? "")` before use — not a bare `?? ""`: a malformed subprocess + * payload (e.g. a pg-delta release that reports `detail`/`hint`/`sql` as a number) can hand any + * of these a non-string value, which `?? ""` alone does not catch (it only substitutes + * `null`/`undefined`), and the very next call on several of these fields is a string-only + * method (`.trim()`, `legacyFormatStatementSql`'s `.split()`) that throws a `TypeError` on + * anything else — turning an actionable SQL error into an unhandled defect, the worst place for + * a rendering bug to exist, since this only ever runs on an ALREADY-FAILED apply. + * + * The no-statement guard checks both `undefined` and `null`: Go's `Statement *ApplyStatement` + * is a pointer, so `{"statement":null,...}` unmarshals to `nil` and `issue.Statement == nil` + * (`apply.go:202`) treats it exactly like a missing field. A `JSON.parse`'d `null` is not + * `=== undefined`, so checking only `undefined` would fall through to `issue.statement.*` and + * throw a `TypeError` instead of rendering the message. + * + * Returns a `Buffer`, not a `string`: the `SQL: ` line embeds {@link legacyFormatStatementSql}'s + * raw bytes directly (via {@link legacyJoinLines}) rather than interpolating them into a + * template string, so a truncation that lands mid-codepoint reaches `output.rawBytes` + * unmodified instead of being silently corrupted by a UTF-8 decode/re-encode round-trip. + */ +function legacyFormatApplyIssue(rawIssue: LegacyPgDeltaApplyIssue | string | null): Buffer { + const issue = legacyNormalizeApplyIssue(rawIssue); + if (issue.statement === undefined || issue.statement === null) { + return Buffer.from(`- ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"); + } + const statementClass = String(issue.statement.statementClass ?? ""); + const classSuffix = statementClass.length > 0 ? ` [${statementClass}]` : ""; + const lines: Array = [ + Buffer.from(`- ${String(issue.statement.id ?? "")}${classSuffix}`, "utf-8"), + Buffer.from(` ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"), + ]; + const detail = legacyTrimGoSpace(String(issue.detail ?? "")); + if (detail.length > 0) lines.push(Buffer.from(` Detail: ${detail}`, "utf-8")); + const hint = legacyTrimGoSpace(String(issue.hint ?? "")); + if (hint.length > 0) lines.push(Buffer.from(` Hint: ${hint}`, "utf-8")); + const sql = legacyFormatStatementSql(String(issue.statement.sql ?? "")); + if (sql.byteLength > 0) { + lines.push(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), sql])); + } + return legacyJoinLines(lines); +} + +/** Go's `formatApplyDiagnosis` (`apply.go:244-261`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ +function legacyFormatApplyDiagnosis(rawDiagnosis: LegacyPgDeltaApplyDiagnosis | null): string { + const diagnosis = legacyNormalizeApplyDiagnosis(rawDiagnosis); + const trimmed = legacyTrimGoSpace(String(diagnosis.message ?? "")); + const message = trimmed.length > 0 ? trimmed : "unknown pg-delta diagnostic"; + let out = "- "; + const code = legacyTrimGoSpace(String(diagnosis.code ?? "")); + if (code.length > 0) out += `[${code}] `; + out += message; + const loc = legacyFormatStatementLocation(diagnosis.statementId); + if (loc.length > 0) out += ` (${loc})`; + const fix = legacyTrimGoSpace(String(diagnosis.suggestedFix ?? "")); + if (fix.length > 0) out += `\n Suggested fix: ${fix}`; + return out; +} + +/** + * Port of Go's `formatApplyFailure` (`apply.go:150-199`): a human-readable summary of an + * unsuccessful pg-delta apply, rendered on failure regardless of `--debug`. `verbose` + * (Go's `viper.GetBool("DEBUG")`) only expands pg-topo diagnostics inline — collapsed to a + * one-line count by default since a large schema can produce hundreds of them. + * + * Returns a `Buffer`, not a `string` — see {@link legacyFormatStatementSql}'s doc comment: + * an embedded truncated SQL statement can be intentionally invalid UTF-8 (matching Go's raw + * byte slice), and only a `Buffer` carried through to `output.rawBytes` reproduces those + * exact bytes instead of a lossy decode/re-encode round-trip. Callers that only need the + * text for display/assertions (this module's own unit tests) can `.toString("utf-8")` it — + * safe for every case except the one pathological truncation this return type exists to + * preserve exactly. + */ +export function legacyFormatApplyFailure( + result: LegacyPgDeltaApplyResult, + verbose: boolean, +): Buffer { + const errors = result.errors ?? []; + const stuckStatements = result.stuckStatements ?? []; + const validationErrors = result.validationErrors ?? []; + const diagnostics = result.diagnostics ?? []; + + let totalStatements = result.totalStatements ?? 0; + if (totalStatements === 0) { + totalStatements = + (result.totalApplied ?? 0) + (result.totalSkipped ?? 0) + stuckStatements.length; + } + + const lines: Array = [ + // Go renders the status with `%q` (`apply.go:156`) — plain quotes diverge the + // moment a malformed payload puts a quote/control char in `status`. + Buffer.from( + `pg-delta apply returned status ${legacyGoQuote( + Buffer.from(String(result.status ?? ""), "utf-8"), + )}.`, + "utf-8", + ), + Buffer.from( + `${result.totalApplied ?? 0}/${totalStatements} statements applied in ${ + result.totalRounds ?? 0 + } round(s); ${result.totalSkipped ?? 0} skipped.`, + "utf-8", + ), + ]; + if (errors.length > 0) { + lines.push(Buffer.from("Errors:", "utf-8")); + for (const issue of errors) lines.push(legacyFormatApplyIssue(issue)); + } + if (stuckStatements.length > 0) { + lines.push(Buffer.from("Stuck statements:", "utf-8")); + for (const issue of stuckStatements) lines.push(legacyFormatApplyIssue(issue)); + } + if (validationErrors.length > 0) { + lines.push(Buffer.from("Validation errors (from check_function_bodies=on pass):", "utf-8")); + for (const issue of validationErrors) lines.push(legacyFormatApplyIssue(issue)); + } + if (diagnostics.length > 0) { + if (verbose) { + lines.push(Buffer.from("Diagnostics:", "utf-8")); + for (const diagnosis of diagnostics) { + lines.push(Buffer.from(legacyFormatApplyDiagnosis(diagnosis), "utf-8")); + } + } else { + lines.push( + Buffer.from( + `${diagnostics.length} pg-topo diagnostic(s) omitted (re-run with --debug to view).`, + "utf-8", + ), + ); + } + } + // pg-delta may report status "error" without populating any issue arrays (e.g. an internal + // assertion in a future pg-delta release) — point the user at how to get more information + // rather than leaving them with just the bare status line. + if (errors.length === 0 && stuckStatements.length === 0 && validationErrors.length === 0) { + lines.push( + Buffer.from( + [ + "No per-statement diagnostics were reported by pg-delta.", + "Re-run with --debug to print the raw pg-delta payload, or open an issue at", + "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", + ].join("\n"), + "utf-8", + ), + ); + } + return legacyJoinLines(lines); +} + +/** + * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`): + * applies `declarativeDirAbs` to `target` (the shadow's `contrib_regression` override + * database) via pg-delta's declarative apply engine. Unlike the diff/export/catalog scripts + * (`legacy-pgdelta.ts`), this binds the declarative directory itself read-only at + * `/declarative` rather than mounting the whole project at `/workspace` — Go's own + * `ApplyDeclarative` never needs the wider project tree, only the schema files. `target` is + * always a LOCAL shadow connection (never a remote/Supabase-hosted endpoint), so — unlike + * `legacyDiffPgDelta`'s SOURCE/TARGET — no SSL/CA-bundle preparation applies here, matching + * Go's own plain `"TARGET="+utils.ToPostgresURL(config)` (no TLS handling at all). + */ +export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( + ctx: LegacyPgDeltaContext, + params: { + readonly fs: FileSystem.FileSystem; + /** Absolute host path to the declarative schema directory (stat/bind use this). */ + readonly declarativeDirAbs: string; + /** + * Go's `utils.GetDeclarativeDir()` (`apply.go:304`) — the config value verbatim + * (already `supabase/`-prefixed when relative) or the relative `supabase/database` + * default. Used ONLY in the not-found error message below: Go interpolates this + * relative value, never the `filepath.Abs`-resolved `absDir` it separately computes + * for the bind. + */ + readonly declarativeDirRel: string; + /** The shadow override database's Postgres URL. */ + readonly target: string; + }, +) { + const exists = yield* params.fs + .exists(params.declarativeDirAbs) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* Effect.fail( + new LegacyPgDeltaDeclarativeApplyError({ + message: `declarative schema directory not found: ${params.declarativeDirRel}`, + reason: "missing_schema_dir", + }), + ); + } + + const output = yield* Output; + const edgeRuntime = yield* LegacyEdgeRuntimeScript; + // Go's `pgdelta.ApplyDeclarative` reads `viper.GetBool("DEBUG")` (`apply.go:332,342`), which + // falls back to `SUPABASE_DEBUG` via `AutomaticEnv` when `--debug` itself is unset — + // `legacyResolveDebugWithProjectEnv` (not the bare `LegacyDebugFlag`) reproduces that (review: + // PRRT_kwDOErm0O86XDr4V). By the time either `db diff`/`db pull` reaches here, + // `ParseDatabaseConfig` has already run `Config.Load` -> `loadNestedEnv`, which really + // `os.Setenv`s the merged project `supabase/.env` into the process (`godotenv.Load`, + // `godotenv@v1.5.1/godotenv.go:184-200`) — unlike this port's own `legacyLoadProjectEnv`, + // which is deliberately pure — so a `SUPABASE_DEBUG` set only in `supabase/.env` is visible + // to Go's `viper.GetBool("DEBUG")` here. `legacyResolveDebugWithProjectEnv` reproduces that + // with `ctx.projectEnv` (`legacyReadDbToml`'s merged map, threaded by both `db diff` and + // `db pull`, review: PRRT_kwDOErm0O86XL_oz). + const debug = yield* legacyResolveDebugWithProjectEnv(ctx.projectEnv); + + yield* output.raw("Applying declarative schemas via pg-delta...\n", "stderr"); + + const env: Record = { + SCHEMA_PATH: LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH, + TARGET: params.target, + }; + const binds = [ + `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, + `${params.declarativeDirAbs}:${LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH}:ro`, + ]; + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const result = yield* edgeRuntime + .run({ + script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), + env, + binds, + errPrefix: "error running pg-delta script", + extraFiles: npm.extraFiles, + extraEnv: npm.extraEnv, + denoVersion: ctx.denoVersion, + workdir: ctx.cwd, + }) + .pipe( + Effect.mapError( + (cause) => + new LegacyPgDeltaDeclarativeApplyError({ message: cause.message, reason: cause.docker }), + ), + ); + + const parsed = yield* Effect.try({ + try: () => { + const raw: unknown = JSON.parse(result.stdout); + // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer + // `ApplyResult` destination and leaves it zero-valued, with no error (verified + // empirically) — so a `null` payload must fall through to the normal + // `status !== "success"` failure path below, not be misclassified as a parse + // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. + const normalized: unknown = raw === null ? {} : raw; + if (!legacyIsPgDeltaApplyResult(normalized)) { + throw new Error("pg-delta apply output was not a JSON object"); + } + return normalized; + }, + catch: (cause) => + new LegacyPgDeltaDeclarativeApplyError({ + message: debug + ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` + : `failed to parse pg-delta apply output: ${errMessage(cause)}`, + reason: "output_parse", + }), + }); + + if (parsed.status !== "success") { + // `output.rawBytes`, not `output.raw`: `legacyFormatApplyFailure` returns a `Buffer` that + // may contain intentionally-invalid trailing UTF-8 bytes (a truncated SQL statement cut + // mid-codepoint, matching Go's raw byte slice) — decoding it into a string here would + // corrupt exactly the bytes that Buffer exists to preserve. See its own doc comment. + yield* output.rawBytes( + Buffer.concat([legacyFormatApplyFailure(parsed, debug), Buffer.from("\n", "utf-8")]), + "stderr", + ); + if (debug) { + const debugJson = legacyFormatDebugJson(result.stdout); + if (debugJson.length > 0) { + yield* output.raw("pg-delta apply result:\n", "stderr"); + yield* output.raw(`${debugJson}\n`, "stderr"); + } + } + return yield* Effect.fail( + new LegacyPgDeltaDeclarativeApplyError({ + message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, + }), + ); + } + yield* output.raw( + `Applied ${parsed.totalApplied ?? 0} statements in ${parsed.totalRounds ?? 0} round(s).\n`, + "stderr", + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts new file mode 100644 index 0000000000..409eeac15f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyFormatApplyFailure, + legacyFormatDebugJson, + type LegacyPgDeltaApplyDiagnosis, + type LegacyPgDeltaApplyIssue, + type LegacyPgDeltaApplyResult, + type LegacyPgDeltaApplyStatementLocation, +} from "./legacy-pgdelta.apply.ts"; + +describe("legacyFormatApplyFailure", () => { + test("renders the status + counts summary line, with no per-statement sections when there are no issues", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalStatements: 4, + totalRounds: 2, + totalApplied: 3, + totalSkipped: 1, + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain('pg-delta apply returned status "error".'); + expect(message).toContain("3/4 statements applied in 2 round(s); 1 skipped."); + expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); + expect(message).toContain("https://github.com/supabase/pg-toolbelt/issues"); + }); + + test("derives totalStatements from applied + skipped + stuck when omitted", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalRounds: 1, + totalApplied: 2, + totalSkipped: 1, + stuckStatements: ["stuck one"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("2/4 statements applied in 1 round(s); 1 skipped."); + }); + + test("renders a structured issue with no `statement` field as its message, with SQLSTATE/position/dependency metadata appended", () => { + const issue: LegacyPgDeltaApplyIssue = { + message: "relation already exists", + code: "42P07", + position: 15, + isDependencyError: true, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Errors:"); + expect(message).toContain( + "- relation already exists (SQLSTATE 42P07, position 15, dependency error)", + ); + }); + + test("renders a genuine bare string issue (Go's ApplyIssue string-arm) as its own message", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["relation already exists"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Errors:\n- relation already exists"); + }); + + test("renders a structured issue with its statement id/class, detail, hint, and truncated SQL", () => { + const issue: LegacyPgDeltaApplyIssue = { + message: "column does not exist", + statement: { + id: "001_add_column", + statementClass: "alter_table", + sql: "alter table t add column c int;", + }, + detail: "Column c was dropped earlier in this plan.", + hint: "Check the plan ordering.", + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("- 001_add_column [alter_table]"); + expect(message).toContain(" column does not exist"); + expect(message).toContain(" Detail: Column c was dropped earlier in this plan."); + expect(message).toContain(" Hint: Check the plan ordering."); + expect(message).toContain(" SQL: alter table t add column c int;"); + }); + + test("truncates a multibyte SQL statement by UTF-8 bytes, not UTF-16 code units", () => { + // Go's `formatStatementSQL` (`apply.go:277-283`) truncates via `len(normalized)` and + // `normalized[:maxLen-3]`, both of which count/slice raw UTF-8 bytes. 70 repetitions of a + // single 3-byte CJK character is only 70 JS UTF-16 code units (well under the 120-char + // threshold a naive `.length`/`.slice()` guard would use — it would never truncate at all), + // but 210 UTF-8 bytes — well over Go's 120-byte limit. `117 / 3 === 39` lands the byte cut + // exactly on a codepoint boundary, so the expected output is unambiguous. + const sql = "字".repeat(70); + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(sql.length).toBeLessThanOrEqual(120); + expect(Buffer.byteLength(sql, "utf-8")).toBe(210); + expect(message).toContain(` SQL: ${"字".repeat(39)}...`); + expect(message).not.toContain(sql); + }); + + test("collapses a NEL (U+0085) as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { + // Go's `formatStatementSQL` (`apply.go:277-283`) normalizes via `strings.Fields`, which + // splits on `unicode.IsSpace` — and `unicode.IsSpace(0x85)` (NEL) is `true` (verified + // empirically), so a NEL embedded in a user's SQL statement is collapsed like any other + // run of whitespace. ECMAScript's `\s` does NOT match NEL, so a naive `.split(/\s+/u)` + // would preserve it verbatim instead of collapsing it. + const nel = String.fromCodePoint(0x85); + const sql = `select${nel}1;`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain(" SQL: select 1;"); + expect(message).not.toContain(nel); + }); + + test("preserves a BOM (U+FEFF) instead of treating it as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { + // The opposite gap from the NEL case above: `unicode.IsSpace(0xFEFF)` (BOM) is `false` + // (verified empirically), so Go's `strings.Fields` keeps a BOM embedded mid-statement as + // part of the surrounding "word" rather than treating it as a separator. ECMAScript's `\s` + // DOES match a BOM, so a naive `.split(/\s+/u)` would incorrectly split on it. + const bom = String.fromCodePoint(0xfeff); + const sql = `select${bom}1;`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain(` SQL: select${bom}1;`); + }); + + test("preserves Go's exact (possibly invalid-UTF-8) truncated bytes when the byte cut lands mid-codepoint", () => { + // Unlike the boundary-aligned CJK-repeat case above, a single leading ASCII byte shifts + // every subsequent 3-byte CJK character by one, so the byte-117 cut now lands ONE byte + // into a character instead of exactly on a boundary — reproducing the pathological case + // where Go's raw `normalized[:117]` slice is intentionally invalid UTF-8. Verified against + // Go's own `formatStatementSQL` (`apply.go:277-283`): slicing this exact byte range + // produces a 120-byte result that `unicode/utf8.ValidString` reports as `false`. A naive + // `Buffer#toString("utf-8")` truncation would instead substitute U+FFFD for the incomplete + // trailing sequence, corrupting the byte-exact stderr contract. + const sql = `a${"字".repeat(60)}`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false); + const normalizedBytes = Buffer.from(sql, "utf-8"); + const expectedTruncatedTail = Buffer.concat([ + normalizedBytes.subarray(0, 117), + Buffer.from("...", "utf-8"), + ]); + expect(expectedTruncatedTail.byteLength).toBe(120); + expect( + message.includes(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), expectedTruncatedTail])), + ).toBe(true); + // No replacement character (the tell-tale sign of a lossy UTF-8 decode/re-encode + // round-trip) should ever appear in the output. + expect(message.includes(Buffer.from("�", "utf-8"))).toBe(false); + }); + + test("treats a null errors/stuckStatements/validationErrors/diagnostics array as empty, matching Go's nil-slice decode", () => { + // Go's `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, + // leaving a nil (zero-length) slice — verified empirically: + // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + // with `len(r.Errors) == 0`. `legacyFormatApplyFailure` itself already treats a JS `null`/ + // `undefined` array as empty via `?? []`; this exercises that the TYPE also tolerates it + // (the earlier structural-guard bug — `legacyIsPgDeltaApplyResult` — is covered by the + // integration test in `legacy-pgdelta.apply.integration.test.ts`, since it isn't exported). + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: null, + stuckStatements: null, + validationErrors: null, + diagnostics: null, + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); + expect(message).not.toContain("Errors:"); + expect(message).not.toContain("Stuck statements:"); + }); + + test("stuck statements and validation errors get their own labeled sections", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + stuckStatements: ["still stuck"], + validationErrors: ["bad function body"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Stuck statements:\n- still stuck"); + expect(message).toContain( + "Validation errors (from check_function_bodies=on pass):\n- bad function body", + ); + }); + + test("diagnostics collapse to a one-line count unless verbose", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 1, + totalRounds: 1, + totalSkipped: 0, + errors: ["some error"], + diagnostics: [{ message: "unused index" }, { message: "missing default" }], + }; + const collapsed = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(collapsed).toContain("2 pg-topo diagnostic(s) omitted (re-run with --debug to view)."); + expect(collapsed).not.toContain("unused index"); + + const verbose = legacyFormatApplyFailure(result, true).toString("utf-8"); + expect(verbose).toContain("Diagnostics:"); + expect(verbose).toContain("- unused index"); + expect(verbose).toContain("- missing default"); + }); + + test("renders a partially-populated statement (missing sql/statementClass) without throwing", () => { + // Reproduces feeding a real pg-delta subprocess's malformed stdout + // (`{"errors":[{"message":"boom","statement":{"id":"s1"}}]}`) through + // `legacyApplyDeclarativePgDelta` — that function only validates the top-level shape + // (`{status: string}`), not nested fields, and this only ever runs on an + // ALREADY-FAILED apply, so a formatter crash here would turn an actionable SQL error + // into an unhandled defect. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1"}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("- s1"); + expect(message).toContain(" boom"); + expect(message).not.toContain("undefined"); + }); + + test("renders an issue with a null `statement` field as its message, without throwing", () => { + // Reproduces feeding a real pg-delta subprocess's stdout + // (`{"errors":[{"statement":null,"message":"failed"}]}`) through + // `legacyApplyDeclarativePgDelta` — Go's `Statement *ApplyStatement` is a pointer, so + // `"statement":null` unmarshals to `nil` and `formatApplyIssue`'s `issue.Statement == nil` + // (`apply.go:202`) treats it identically to a missing field. A no-statement guard that only + // checks `=== undefined` would fall through to `issue.statement.statementClass` on `null` + // and throw a `TypeError` instead of rendering the message. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"statement":null,"message":"failed"}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("Errors:\n- failed"); + }); + + test("renders an issue whose detail/hint/sql/statementClass arrived as non-strings without throwing", () => { + // A malformed pg-delta payload can hand any of these fields a non-string value (e.g. a + // future release that reports a numeric `detail`) — a bare `?? ""` guard (rather than + // `String(x ?? "")`) would still pass the number straight to `.trim()`/`.split()` and throw. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1","statementClass":42,"sql":7},"detail":123,"hint":456}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("- s1 [42]"); + expect(message).toContain(" Detail: 123"); + expect(message).toContain(" Hint: 456"); + expect(message).toContain(" SQL: 7"); + }); + + test("renders a diagnosis whose message/code/suggestedFix arrived as non-strings without throwing", () => { + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":123,"code":456,"suggestedFix":789}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("[456] 123"); + expect(message).toContain("Suggested fix: 789"); + }); + + test("drops a diagnosis's statementId when a nested field is mistyped, matching Go's nil fallback", () => { + // Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) tries decoding `statementId` + // as an `ApplyStatementLocation` object first; a mistyped `filePath` (a number, not a + // string) fails that decode, and its bare-string fallback ALSO fails since the value is an + // object, not a string — so Go silently leaves `StatementID` nil, never erroring the whole + // `ApplyResult` parse. Verified empirically against Go's real struct + fallback chain: + // `{"statementId":{"filePath":123,"statementIndex":1}}` decodes with `StatementID == nil`. + // Rendering the raw object anyway (coercing `filePath` via `String(123)`) would show a + // bogus `(123#1)` location Go never emits. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":123,"statementIndex":1}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- d"); + expect(message).not.toContain("123#1"); + expect(message).not.toContain("(123"); + }); + + test("drops a diagnosis's statementId when sourceOffset is mistyped, even though the location renderer never reads it", () => { + // Go's struct-level `json.Unmarshal` into `ApplyStatementLocation` (`apply.go:73-77`) + // fails the moment ANY declared field has the wrong type — including `sourceOffset`, + // which `legacyFormatStatementLocation`/Go's own `formatStatementLocation` never + // display. Verified empirically against Go's real struct: + // `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` returns a + // non-nil error even though `filePath` itself is well-typed, so the object-shape decode + // fails, the bare-string fallback also fails (the value is an object, not a string), and + // Go leaves `StatementID` nil — the location must be dropped, not rendered as `(x.sql)`, + // which would misattribute the diagnostic to a file Go never resolved. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":"x.sql","sourceOffset":"bad"}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- d"); + expect(message).not.toContain("x.sql"); + }); + + test("renders a diagnosis with a null statementId as having no location, without throwing", () => { + // Reproduces a real pg-delta subprocess emitting + // `{"diagnostics":[{"message":"failed","statementId":null}]}` — Go's + // `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) explicitly maps a JSON + // `"statementId":null` to a nil `*ApplyStatementLocation`, and `formatStatementLocation` + // (`apply.go:263-274`) returns `""` for a nil pointer. A guard that only checked + // `resolved === undefined` (not `null`) would fall through to + // `legacyFormatStatementLocation`'s `resolved.filePath` and dereference a `null`, throwing a + // `TypeError` instead of rendering the rest of the diagnostic. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"failed","statementId":null}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- failed"); + expect(message).not.toContain("undefined"); + }); + + test("a diagnosis with a statementId location and suggestedFix renders both", () => { + const statementId: LegacyPgDeltaApplyStatementLocation = { + filePath: "001_a.sql", + statementIndex: 2, + }; + const diagnosis: LegacyPgDeltaApplyDiagnosis = { + code: "PGT001", + message: "circular dependency", + statementId, + suggestedFix: "Split the statement across two files.", + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 1, + totalRounds: 1, + totalSkipped: 0, + errors: ["some error"], + diagnostics: [diagnosis], + }; + const message = legacyFormatApplyFailure(result, true).toString("utf-8"); + expect(message).toContain("- [PGT001] circular dependency (001_a.sql#2)"); + expect(message).toContain("Suggested fix: Split the statement across two files."); + }); +}); + +describe("legacyFormatDebugJson", () => { + test("pretty-prints valid JSON", () => { + expect(legacyFormatDebugJson('{"status":"error","totalApplied":1}')).toBe( + JSON.stringify({ status: "error", totalApplied: 1 }, null, 2), + ); + }); + + test("returns the trimmed raw string when it isn't valid JSON", () => { + expect(legacyFormatDebugJson(" not json ")).toBe("not json"); + }); + + test("returns empty for blank input", () => { + expect(legacyFormatDebugJson(" ")).toBe(""); + }); + + test("preserves an integer literal beyond Number.MAX_SAFE_INTEGER byte-for-byte", () => { + // Go's `json.Indent` (`encoding/json/indent.go`) only inserts whitespace between existing + // tokens — it never decodes a number into a value and re-encodes it. `JSON.parse` would + // decode this literal into a `float64`-backed JS number, silently rounding it (verified: + // `JSON.parse("9007199254740993").toString()` is `"9007199254740992"`), and + // `JSON.stringify` would then re-emit the ROUNDED value — corrupting the exact debug + // payload users are asked to attach to bug reports. + const raw = '{"id":9007199254740993}'; + expect(legacyFormatDebugJson(raw)).toBe('{\n "id": 9007199254740993\n}'); + }); + + test("preserves an existing string escape's exact representation (e.g. an escaped forward slash)", () => { + // Go's `json.Indent` copies string tokens byte-for-byte, so an existing `\/` escape stays + // `\/`. `JSON.stringify(JSON.parse(...))` would instead re-escape the decoded `/` using its + // own (unescaped) convention, changing the payload's exact bytes. + const raw = '{"path":"a\\/b"}'; + expect(legacyFormatDebugJson(raw)).toBe('{\n "path": "a\\/b"\n}'); + }); + + test("matches Go's json.Indent shape for nested objects/arrays, including empty ones", () => { + const raw = '{"a":1,"b":{"c":2,"d":[1,{"e":3}]},"empty":{},"emptyArr":[]}'; + expect(legacyFormatDebugJson(raw)).toBe( + [ + "{", + ' "a": 1,', + ' "b": {', + ' "c": 2,', + ' "d": [', + " 1,", + " {", + ' "e": 3', + " }", + " ]", + " },", + ' "empty": {},', + ' "emptyArr": []', + "}", + ].join("\n"), + ); + }); +}); 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 6e12afd324..70e2a37062 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 @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The pg-delta edge-runtime script failed. Byte-matches Go's * `": :\n"` wrapping in `RunEdgeRuntimeScript` @@ -11,7 +17,21 @@ export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( "LegacyDeclarativeEdgeRuntimeError", )<{ readonly message: string; -}> {} + readonly docker?: "daemon" | "inspect" | "pull"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.docker === "daemon") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (this.docker === "pull") { + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } + if (this.docker === "inspect") { + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + } + return actionability.dbFinding; + } +} /** * Setting up / connecting to / migrating the throwaway shadow database failed. @@ -23,7 +43,14 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( "LegacyDeclarativeShadowDbError", )<{ readonly message: string; -}> {} + readonly docker?: "daemon"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.docker === "daemon" + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : actionability.startStack; + } +} /** * Exporting declarative schema produced no output. Byte-matches Go's @@ -35,7 +62,11 @@ export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( "LegacyDeclarativeEmptyOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} /** * Parsing the declarative export envelope failed. Byte-matches Go's @@ -46,7 +77,11 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( "LegacyDeclarativeParseOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} /** * Parsing the pg-delta diff envelope failed. Byte-matches Go's @@ -55,7 +90,11 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( */ export class LegacyPgDeltaDiffParseError extends Data.TaggedError("LegacyPgDeltaDiffParseError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} /** * Materializing the declarative export on disk failed. Byte-matches Go's @@ -66,4 +105,8 @@ export class LegacyPgDeltaDiffParseError extends Data.TaggedError("LegacyPgDelta */ export class LegacyDeclarativeWriteError extends Data.TaggedError("LegacyDeclarativeWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts new file mode 100644 index 0000000000..a0158f4d43 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { + LegacyDeclarativeEdgeRuntimeError, + LegacyDeclarativeShadowDbError, +} from "./legacy-pgdelta.errors.ts"; + +describe("pg-delta error actionability", () => { + it.each([ + ["daemon", "user_actionable", "docker_not_running", "docker_not_running"], + ["pull", "external_service", "network", "registry_pull"], + ["inspect", "user_actionable", "invalid_config", "image_inspect"], + ] as const)("classifies edge-runtime docker %s failures", (docker, kind, category, suffix) => { + const result = classifyCliErrorActionability( + new LegacyDeclarativeEdgeRuntimeError({ message: "redacted", docker }), + ); + expect(result.error_kind).toBe(kind); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(`tag:LegacyDeclarativeEdgeRuntimeError:${suffix}`); + }); + + it("keeps non-docker edge-runtime failures in the database family", () => { + const result = classifyCliErrorActionability( + new LegacyDeclarativeEdgeRuntimeError({ message: "redacted" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:LegacyDeclarativeEdgeRuntimeError"); + }); + + it("distinguishes an unreachable Docker daemon from a missing shadow stack", () => { + const daemon = classifyCliErrorActionability( + new LegacyDeclarativeShadowDbError({ message: "redacted", docker: "daemon" }), + ); + expect(daemon.error_category).toBe("docker_not_running"); + expect(daemon.error_fingerprint).toBe("tag:LegacyDeclarativeShadowDbError:docker_not_running"); + + const missingStack = classifyCliErrorActionability( + new LegacyDeclarativeShadowDbError({ message: "redacted" }), + ); + expect(missingStack.error_category).toBe("invalid_config"); + expect(missingStack.suggested_command).toBe("supabase start"); + expect(missingStack.error_fingerprint).toBe("tag:LegacyDeclarativeShadowDbError"); + }); +}); 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 9e97f7105e..20a19da966 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 @@ -5,17 +5,22 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; 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 { 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 { legacyIsDockerDaemonUnreachable } from "../../../shared/legacy-docker-suggest.ts"; import { legacyResolveLocalProjectId, localDbContainerId, } from "../../../shared/legacy-docker-ids.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -import { LegacyDeclarativeSeam, type LegacyShadowSource } from "./legacy-pgdelta.seam.service.ts"; -import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +const legacyShadowDockerCause = ( + stderr: string, +): { readonly docker: "daemon" } | Record => + legacyIsDockerDaemonUnreachable(stderr) ? { docker: "daemon" } : {}; /** * Real `LegacyDeclarativeSeam`: runs the bundled `supabase-go`'s hidden @@ -80,7 +85,9 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // calls `flags.LoadConfig` directly without `LoadProjectRef`, so the // env (read only by LoadProjectRef) never reaches the merge — the Go // command seeds `flags.ProjectRef` from `--project-ref` before - // LoadConfig instead (mirrors `db __shadow`). + // LoadConfig instead (the same trick the Go `db __shadow` hidden + // command used to use, before CLI-1956 removed it in favor of a + // native shadow-provisioning port). ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), ...profileArgs, ]; @@ -154,7 +161,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( extendEnv: true, }).pipe( Effect.mapError( - () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect service" }), + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect service", + docker: "daemon", + }), ), ); const stderrChunks: Array = []; @@ -164,13 +175,21 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ).pipe( Effect.mapError( - () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect service" }), + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect service", + docker: "daemon", + }), ), ); const inspectExit = yield* child.exitCode.pipe( Effect.map(Number), Effect.mapError( - () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect service" }), + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect service", + docker: "daemon", + }), ), ); if (inspectExit === 0) return; // already running @@ -200,6 +219,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( stderr.length > 0 ? `failed to inspect service: ${stderr}` : "failed to inspect service", + ...legacyShadowDockerCause(stderr), }), ); } @@ -284,6 +304,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect local Postgres container.", + docker: "daemon", }), ), ); @@ -298,6 +319,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect local Postgres container.", + docker: "daemon", }), ), ); @@ -310,6 +332,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect local Postgres container.", + docker: "daemon", }), ), ); @@ -319,6 +342,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( () => new LegacyDeclarativeShadowDbError({ message: "failed to inspect local Postgres container.", + docker: "daemon", }), ), ); @@ -342,6 +366,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( stderr.length > 0 ? `failed to inspect local Postgres container: ${stderr}` : "failed to inspect local Postgres container.", + ...legacyShadowDockerCause(stderr), }), ); } @@ -359,128 +384,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( ); }), ), - provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => - Effect.scoped( - Effect.gen(function* () { - if (!("found" in resolved)) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: - "Could not find the supabase-go binary required to provision the shadow database.", - }), - ); - } - const args = [ - "db", - "__shadow", - "--mode", - mode, - ...(targetLocal ? ["--target-local"] : []), - ...(usePgDelta ? ["--use-pg-delta"] : []), - ...(schema.length > 0 ? ["--schema", schema.join(",")] : []), - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - // Linked path only: pass the resolved ref so the hidden `db __shadow` - // child's LoadConfig merges the matching `[remotes.]` override - // into the shadow baseline (db.major_version, service enables, vault), - // matching the Go monolith which builds the shadow from the - // remote-merged config. A flag (not env) keeps the Go-proxy channel - // parity and avoids over-merging on local/db-url shadows. - ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), - ...profileArgs, - ]; - const command = ChildProcess.make(resolved.found, args, { - cwd: cliConfig.workdir, - stdin: "inherit", - stdout: "pipe", - stderr: "inherit", - extendEnv: true, - // Disable the child's telemetry so the hidden `db __shadow` seam - // doesn't record its own `cli_command_executed` (and run Go post-run - // work) on top of the user's TS command, matching the explicit - // LegacyGoProxy delegates which set the same env. - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - detached: false, - }); - const handle = yield* spawner.spawn(command).pipe( - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to run the shadow-database provisioner (supabase-go).", - }), - ), - ); - const chunks: Array = []; - yield* Stream.runForEach(handle.stdout, (chunk) => - Effect.sync(() => { - chunks.push(chunk); - }), - ).pipe(Effect.mapError(() => failure())); - const exitCode = yield* handle.exitCode.pipe(Effect.mapError(() => failure())); - if (exitCode !== 0) { - return yield* Effect.fail(failure(exitCode)); - } - 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; - } - // stdout is three newline-separated lines: container id, source URL, - // and an optional target-override URL (empty unless the local-target - // declarative branch redirected the target to a second shadow db). - // The URLs arrive WITHOUT a password — the Go seam prints them via - // ToPostgresURLWithoutPassword so it never logs a credential to stdout - // (CWE-312). The shadow uses the local Postgres password, so we re-inject - // the password resolved from config.toml before handing the URLs to the - // differ / sql-pg connection. On the linked path the child built the - // shadow from the remote-merged config (via --project-ref), so re-read - // with the same ref to pick up a `[remotes.].db.password` override — - // otherwise the injected password wouldn't match the shadow's and the - // connection would fail auth. Absent (local/db-url) → base config. - const lines = new TextDecoder().decode(bytes).split(/\r?\n/u); - const container = (lines[0] ?? "").trim(); - const sourceUrl = (lines[1] ?? "").trim(); - const targetOverride = (lines[2] ?? "").trim(); - if (container.length === 0 || sourceUrl.length === 0) { - return yield* Effect.fail(failure()); - } - const password = yield* legacyReadDbToml(fs, path, cliConfig.workdir, projectRef).pipe( - Effect.map((toml) => toml.password), - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: - "failed to read the local database password from config.toml to connect to the shadow database.", - }), - ), - ); - return { - container, - sourceUrl: legacyInjectPostgresPassword(sourceUrl, password), - targetUrlOverride: - targetOverride.length > 0 - ? legacyInjectPostgresPassword(targetOverride, password) - : undefined, - } satisfies LegacyShadowSource; - }), - ), - removeShadowContainer: (container) => - Effect.gen(function* () { - if (container.length === 0) return; - // Remove the shadow left running by provisionShadow. Best-effort — a - // failure here must never mask the diff result. `-v` removes the - // Postgres anonymous data volume too, matching Go's `DockerRemove` - // (`RemoveOptions{RemoveVolumes: true, Force: true}`, - // `internal/utils/docker.go:330`); without it every shadow leaves a - // dangling volume behind. - yield* containerCliExitCode(spawner, ["rm", "-f", "-v", container], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - extendEnv: true, - }).pipe(Effect.ignore); - }), }); }), ); 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 4f5409c3a6..d77be3545c 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 @@ -9,40 +9,22 @@ import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts" * that used it (`db diff`'s explicit `--from/--to migrations`, and * `db schema declarative sync`'s migrations-catalog diff source) now resolve * natively — see `legacy-pgdelta.cache.ts`'s `legacyResolveMigrationsCatalogRef` - * and `legacyGetMigrationsCatalogRef` respectively. `"baseline"` and + * and `legacyGetMigrationsCatalogRef` respectively; CLI-1956 then ported the + * shadow those two functions provision off the Go seam too (see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog`), so no TS-side caller + * routes shadow provisioning through this seam any more. `"baseline"` and * `"declarative"` remain seam-backed because they need a shadow provisioned with - * ONLY the platform baseline (no migrations) or with declarative files applied — - * neither has a native TS equivalent yet (`start.SetupDatabase` against an - * arbitrary shadow, and `pgdelta.ApplyDeclarative`), and porting either - * overlaps with CLI-1956's in-progress native shadow-provisioning work. CLI-1823 - * (native pg-delta lib) and CLI-1956 are the tracked follow-ups for retiring the - * rest of this seam. + * ONLY the platform baseline (no migrations) or with declarative files applied, + * and the Go subprocess still provisions that shadow itself. The underlying + * primitives ARE natively ported now (`legacySetupDatabase`, + * `shared/db-bootstrap/db-setup.ts`, and `legacyApplyDeclarativePgDelta`, + * `legacy-pgdelta.apply.ts` — both CLI-1956); what's left is composing the + * baseline/declarative catalog export on top of them. CLI-1823 (native + * pg-delta lib) and the remaining `db schema declarative` porting work are the + * tracked next steps for retiring the rest of this seam. */ export type LegacyCatalogMode = "baseline" | "declarative"; -/** - * Which live shadow database the Go seam should provision and leave running: - * - `diff`: platform baseline + local migrations (the `db diff` / migration-style - * `db pull` diff source), plus the local-target declarative branch. - * - `declarative`: a bare shadow with no baseline/migrations (the `db pull - * --declarative` empty export source). - */ -type LegacyShadowMode = "diff" | "declarative"; - -/** A live shadow database left running for the caller to diff against and remove. */ -export interface LegacyShadowSource { - /** Container id; the caller removes it via `removeShadowContainer` when done. */ - readonly container: string; - /** The diff source Postgres URL (the provisioned shadow). */ - readonly sourceUrl: string; - /** - * When set, replaces the diff target with a second shadow database - * (`contrib_regression` with declarative schemas applied). Mirrors Go's - * local-target declarative branch, where the user's local DB is not diffed. - */ - readonly targetUrlOverride: string | undefined; -} - interface LegacyDeclarativeSeamShape { /** * Provisions the shadow-database platform baseline (and, for `declarative`, @@ -51,10 +33,13 @@ interface LegacyDeclarativeSeamShape { * path of the exported pg-delta catalog (cached under `supabase/.temp/pgdelta/`). * Go's progress is teed to stderr; only the catalog path is captured from stdout. * - * This is the seam for `start.SetupDatabase` (the auth/storage/realtime service - * migrations) run against an arbitrary shadow, and for `pgdelta.ApplyDeclarative` - * (the `declarative` mode), neither of which is yet ported to TypeScript - * (CLI-1959/CLI-1956/CLI-1823 — see {@link LegacyCatalogMode}'s doc comment). + * The shadow-database provisioning this needs (`start.SetupDatabase`, the + * auth/storage/realtime service migrations) IS now natively ported + * (`legacySetupDatabase`, `shared/db-bootstrap/db-setup.ts`, CLI-1956) — `db diff`/ + * `db pull` no longer go through this Go seam for their own shadow at all (see + * `commands/db/shared/legacy-shadow-source.ts`). This method stays Go-delegated + * only because `db schema declarative generate`/`sync` haven't been natively + * ported yet, not because the underlying shadow primitive is missing. */ readonly exportCatalog: (opts: { readonly mode: LegacyCatalogMode; @@ -93,33 +78,6 @@ interface LegacyDeclarativeSeamShape { 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 — - * the caller must call `removeShadowContainer` when the diff completes). This - * is the diff "source" that both the migra and pg-delta engines run against in - * `db diff` / `db pull`, mirroring Go's `DiffDatabase` (`differ(shadow, target)`). - * Go's shadow-provisioning progress is teed to stderr. - */ - readonly provisionShadow: (opts: { - readonly mode: LegacyShadowMode; - readonly targetLocal: boolean; - readonly usePgDelta: boolean; - readonly schema: ReadonlyArray; - /** - * Resolved linked project ref, passed ONLY on the `--linked` path so the - * shadow merges the matching `[remotes.]` config override (Go builds the - * shadow from the already-remote-merged global config on the linked path). - * Omitted for local/db-url shadows, which Go never remote-merges. - */ - readonly projectRef?: string; - }) => Effect.Effect; - /** - * Removes a shadow database container left running by `provisionShadow` - * (`docker rm -f `). Best-effort: a failure to remove is swallowed so it - * never masks the underlying diff result. - */ - readonly removeShadowContainer: (container: string) => Effect.Effect; } export class LegacyDeclarativeSeam extends Context.Service< diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts deleted file mode 100644 index 644586df5d..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Injects the Postgres password into a connection URL that the Go `db __shadow` - * seam emitted WITHOUT one. - * - * The Go seam prints the shadow source/target URLs via - * `ToPostgresURLWithoutPassword` so it never writes a credential to stdout - * (CWE-312). The shadow database always uses the local Postgres password - * (`utils.Config.Db.Password`), which the TS caller resolves independently from - * `config.toml` (`legacyReadDbToml().password`) — so we re-attach it here before - * the URL is handed to the differ (migra / pg-delta) or a sql-pg connection. - * - * The host, port, database, and query params are left exactly as the Go seam - * produced them (Go remains the authority for IPv6 bracketing, `connect_timeout`, - * and runtime params); only the userinfo password is set. The `URL` setter - * percent-encodes the password, matching Go's `url.UserPassword` encoding, and - * the pg driver decodes it back to the same secret. - */ -export function legacyInjectPostgresPassword(connectionUrl: string, password: string): string { - const url = new URL(connectionUrl); - url.password = password; - return url.toString(); -} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts deleted file mode 100644 index f8298aa30d..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; - -describe("legacyInjectPostgresPassword", () => { - it("injects the password into a password-less IPv4 shadow URL", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres@127.0.0.1:54320/postgres?connect_timeout=10", - "postgres", - ), - ).toBe("postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10"); - }); - - it("preserves IPv6 bracketing, the database name, and query params", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres@[::1]:54320/contrib_regression?connect_timeout=10&options=test", - "postgres", - ), - ).toBe( - "postgresql://postgres:postgres@[::1]:54320/contrib_regression?connect_timeout=10&options=test", - ); - }); - - it("percent-encodes a password with special characters so it round-trips", () => { - const injected = legacyInjectPostgresPassword( - "postgresql://postgres@127.0.0.1:54320/postgres?connect_timeout=10", - "p@ss:w/rd", - ); - expect(injected).toBe( - "postgresql://postgres:p%40ss%3Aw%2Frd@127.0.0.1:54320/postgres?connect_timeout=10", - ); - // The pg driver decodes the userinfo back to the original secret. - expect(decodeURIComponent(new URL(injected).password)).toBe("p@ss:w/rd"); - }); - - it("overwrites any existing userinfo password", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres:stale@127.0.0.1:54320/postgres?connect_timeout=10", - "fresh", - ), - ).toBe("postgresql://postgres:fresh@127.0.0.1:54320/postgres?connect_timeout=10"); - }); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts new file mode 100644 index 0000000000..2726492757 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -0,0 +1,642 @@ +/** + * The composed shadow-database shapes `db diff`/`db pull` actually call — Go's + * `PrepareShadowSource`/`PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go`), built + * on top of `shared/db-bootstrap/shadow-database.ts`'s lower-level primitives plus the + * `--target-local` declarative-schema branch (Go's `loadDeclaredSchemas`/ + * `shouldApplyDeclarativeWithPgDelta`/`migrateBaseDatabase`, `internal/db/diff/diff.go:52-115, + * 261-274`) and pg-delta's declarative apply engine (`legacy-pgdelta.apply.ts`). + * + * Go's `PrepareShadowSource(ctx, schema []string, targetLocal, usePgDelta bool, fsys, + * options...)` takes a `schema` parameter that is NEVER referenced anywhere in the function + * body (verified by reading the whole function) — dead code in Go itself, making the `--schema` + * flag the now-removed `db __shadow` hidden seam used to forward here a no-op even before + * CLI-1956 deleted that seam in favor of this native port. Deliberately NOT ported here: there + * is nothing to port. + */ + +import { Effect, Result, type FileSystem, type Path } from "effect"; +import type { GlobalFlag } from "effect/unstable/cli"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import type { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import type { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyResolveDeclarativeDir, + legacyResolveSeedSqlPath, + type LegacyPgDeltaTomlConfig, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveUnderWorkdir, + legacyGlobPattern, + legacyWalkSqlFiles, + legacyCompareUtf8Bytes, +} from "../../../shared/legacy-glob.ts"; +import type { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import type { LegacyImagePrepullError } from "../../../shared/db-bootstrap/image-prepull.ts"; +import type { LegacyHealthCheckTimeoutError } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacySeedGlobals } from "../../../shared/legacy-migration-apply.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { + legacyMigrateShadowDatabase, + LegacyShadowDbError, + type LegacyShadowDatabaseHandle, + type LegacyShadowSetupInput, + type LegacyShadowSourceResult, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import type { LegacyStartSetupLocalDatabaseError } from "../../../shared/db-bootstrap/db-setup.ts"; +import { + LegacyPgDeltaDeclarativeApplyError, + legacyApplyDeclarativePgDelta, +} from "./legacy-pgdelta.apply.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +export type { LegacyShadowSourceResult }; + +// `legacyShadowRunInputFromLocalContainerInputs` used to be re-exported here (promoted to +// `shared/db-bootstrap/shadow-database.ts` when `migration squash` became this builder's +// THIRD consumer, CLI-1969). All three call sites (`diff.handler.ts`, `pull.handler.ts`, +// `legacy-pgdelta.cache.ts`) now import it from there directly — no re-export shim needed. + +export interface LegacyPrepareShadowSourceInput extends LegacyShadowSetupInput { + /** Go's `utils.IsLocalDatabase(config)` — the only target-derived input the shadow prep needs. */ + readonly targetLocal: boolean; + /** Selects the declarative-apply engine for the local-declared branch, matching `DiffDatabase`. */ + readonly usePgDelta: boolean; + /** `db.migrations.schema_paths`, RAW (unresolved) — Go's `Config.Db.Migrations.SchemaPaths` pre-`config.go:976-979`-resolution form. */ + readonly schemaPaths: ReadonlyArray; + readonly pgDelta: LegacyPgDeltaTomlConfig; + /** Ambient pg-delta edge-runtime context, only read on the pg-delta declarative-apply sub-branch. */ + readonly ctx: LegacyPgDeltaContext; +} + +/** Every failure {@link legacyPrepareShadowSource} can produce, beyond its own `E` (JWKS resolution). */ +export type LegacyPrepareShadowSourceError = + | LegacyShadowDbError + | LegacyDeclarativeShadowDbError + | LegacyHealthCheckTimeoutError + | LegacyStartSetupLocalDatabaseError + | LegacyImagePrepullError + | LegacyPgDeltaDeclarativeApplyError; + +/** + * Port of Go's `PrepareShadowSource` (`apps/cli-go/internal/db/diff/shadow.go:37-91`): + * health-wait against an already-`legacyCreateShadowDatabase`-created shadow -> + * `MigrateShadowDatabase` (platform baseline + local migrations + the `contrib_regression` + * template database) -> build the diff-source config -> when `targetLocal`, the + * declarative-schema override branch. + * + * Deliberately does NOT call `legacyCreateShadowDatabase` (`shadow-database.ts`) itself, and + * no longer wraps its own body in `Effect.onError` cleanup — the caller does both, structuring + * this function as the `use` phase of an `Effect.acquireUseRelease` whose `acquire` is + * `legacyCreateShadowDatabase` and whose `release` is `legacyRemoveShadowDatabase` (see + * `diff.handler.ts`/`pull.handler.ts`'s call sites). An earlier shape passed THIS WHOLE + * function (create -> health-wait -> migrate -> declarative-apply) as `acquire` instead — + * matching Go's `ok`-sentinel + `defer` pattern for the "remove on any failure after + * creation" case, but Effect's `acquireUseRelease` runs `acquire` inside an + * `uninterruptibleMask` with no `restore` (`uninterruptibleMask(restore => + * flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`), so passing all of this + * function as `acquire` made the ENTIRE health-wait/migration-replay/declarative-apply + * sequence uninterruptible too — a SIGINT during any of it (each of which can run for + * seconds to minutes) was silently swallowed until the whole sequence finished on its own, + * unlike Go, which threads one cancellable `ctx` through every one of these calls. Moving + * creation out to the (brief, Docker-API-bound) `acquire` and keeping this sequence as the + * `use` phase restores that parity: a SIGINT here now interrupts immediately, same as Go's + * ctx cancellation, while `legacyRemoveShadowDatabase` still runs as the `release` finalizer + * regardless of how `use` exits — success, a typed failure, or an interrupt (review: + * PRRT_kwDOErm0O86XMrID). + */ +export const legacyPrepareShadowSource = ( + spawner: Spawner, + handle: LegacyShadowDatabaseHandle, + input: LegacyPrepareShadowSourceInput, +): Effect.Effect< + LegacyShadowSourceResult, + LegacyPrepareShadowSourceError | E, + | Output + | LegacyDockerRun + | RuntimeInfo + | HttpClient.HttpClient + | LegacyDbConnection + | LegacyEdgeRuntimeScript + | GlobalFlag.Setting.Identifier<"debug"> + // `legacyApplyDeclarativePgDelta`'s own `legacyResolveDebugWithProjectEnv` (viper + // `AutomaticEnv` `SUPABASE_DEBUG` fallback, plus the project `.env` Go's `loadNestedEnv` + // has already `os.Setenv`'d into the process by this point, review: PRRT_kwDOErm0O86XDr4V, + // PRRT_kwDOErm0O86XL_oz) needs `CliArgs` to detect an explicit `--debug=false`, same as + // `legacyResolveYes`/`legacyResolveExperimental`. + | CliArgs +> => + Effect.gen(function* () { + const { containerId } = handle; + + yield* legacyWaitForHealthyServices(spawner, [containerId], { + timeoutSeconds: input.healthTimeoutSeconds, + }); + + const connConfig: LegacyPgConnInput = { + host: input.hostname, + port: input.shadowPort, + user: "postgres", + password: input.password, + database: "postgres", + }; + yield* legacyMigrateShadowDatabase(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + container: containerId, + networkId: input.networkId, + connConfig, + setup: input.setup, + }); + + const sourceUrl = legacyToPostgresURL(connConfig); + + let targetUrlOverride: string | undefined; + if (input.targetLocal) { + const declared = yield* legacyLoadDeclaredSchemas( + input.fs, + input.path, + input.workdir, + input.schemaPaths, + input.pgDelta, + ); + if (declared.length > 0) { + const overrideConn: LegacyPgConnInput = { ...connConfig, database: "contrib_regression" }; + const useDeclarativePgDelta = legacyShouldApplyDeclarativeWithPgDelta( + input.path, + input.usePgDelta, + input.schemaPaths, + input.pgDelta, + ); + let appliedViaPgDelta = false; + if (useDeclarativePgDelta) { + const declDirRel = legacyResolveDeclarativeDir(input.path, input.pgDelta); + const declDirAbs = legacyResolveUnderWorkdir(input.path, input.workdir, declDirRel); + // Go's `afero.DirExists` (`shadow.go:72`) — a non-directory path is treated as + // absent here too, same reasoning as `legacyLoadDeclaredSchemas` below. + const declDirExists = yield* input.fs.stat(declDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (declDirExists) { + yield* legacyApplyDeclarativePgDelta(input.ctx, { + fs: input.fs, + declarativeDirAbs: declDirAbs, + declarativeDirRel: declDirRel, + target: legacyToPostgresURL(overrideConn), + }); + appliedViaPgDelta = true; + } + } + if (!appliedViaPgDelta) { + yield* legacyMigrateBaseDatabase( + input.fs, + input.path, + input.workdir, + overrideConn, + declared, + ); + } + targetUrlOverride = legacyToPostgresURL(overrideConn); + } + } + + return { + container: containerId, + sourceUrl, + targetUrlOverride, + } satisfies LegacyShadowSourceResult; + }); + +/** Go's `pkg/config.hasGlobMeta` (`config.go:211-213`) — `*?[` only, NOT `io/fs.hasMeta`'s broader set (which also counts `\`). */ +function legacyHasConfigGlobMeta(pattern: string): boolean { + return /[*?[]/u.test(pattern); +} + +/** + * Port of Go's `Glob.SQLFiles(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs())` + * (`apps/cli-go/pkg/config/config.go:119-192`), the exact option combination + * `loadDeclaredSchemas`'s `schema_paths` branch uses. Deliberately separate from + * `legacy-migrate-and-seed.ts`'s `legacyResolveSchemaPathFiles` (Go's SAME `Glob.SQLFiles` + * with ZERO options, `applySchemaFiles`) — the two option sets are genuinely different: a + * per-pattern "no files matched" is unconditionally an error here UNLESS the pattern + * contains a glob metacharacter (`skipEmptyGlobs`), in which case it's only converted back + * into an error when EVERY pattern ended up skipped and the combined result is still empty + * (`errorOnAllSkippedGlobs`) — and, unlike `applySchemaFiles`'s caller (which swallows any + * collected errors once `len(declared) > 0`), `loadDeclaredSchemas`'s caller propagates + * ANY error unconditionally, regardless of whether other patterns matched. + */ +function legacyGlobDeclaredSchemaPaths( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + patterns: ReadonlyArray, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + const seen = new Set(); + const result: Array = []; + const problems: Array = []; + const skipped: Array = []; + + for (const rawPattern of patterns) { + // Go's `config.go:976-979`: a non-empty, non-absolute `schema_paths` entry is resolved + // under `supabase/` (via `path.Join`, which also cleans the result) at config-load + // time — `legacyResolveSeedSqlPath` already implements the identical resolution Go + // applies to `[db.seed] sql_paths`, the same shape. Go's `Glob.files` then normalizes + // to forward slashes immediately before globbing (`fs.Glob(fsys, + // filepath.ToSlash(pattern))`, `config.go:145`) — an absolute Windows entry such as + // `C:\repo\schema.sql` must become `C:/repo/schema.sql` before `legacyPathMatch`/ + // `legacyGlobPattern` (which only recognize `/` as a segment separator) ever see it. + // Mirrors `legacy-seed-ops.ts`'s identical `toSlash` step for `[db.seed] sql_paths`. + // + // Gated on `path.sep !== "/"`, mirroring BOTH `legacyCleanSchemaPath` below AND + // `legacyGlobPattern`'s own internal `path.sep === "/" ? pattern : ...` normalization + // (`legacy-glob.ts:68`) — `filepath.ToSlash` is a byte-for-byte no-op on POSIX (only + // Windows's `filepath.Separator` is `\`), so converting unconditionally here previously + // fed `legacyGlobPattern` an already-slashed pattern on POSIX too, silently discarding + // any `\` a caller wrote as a `path.Match` escape. Verified empirically with a scratch + // `path.Match` probe on darwin: `path.Match("foo\\*.sql", "foo*.sql")` (Go's real, + // unconverted-on-POSIX behavior) is `true` — a literal `\*` escapes the metacharacter, + // matching a file literally named `foo*.sql` — while this file's OLD unconditional + // `.replaceAll("\\", "/")` turned the same pattern into `foo/*.sql`, which instead + // searches a `foo/` subdirectory and never matches the literal `foo*.sql` file Go finds. + // The same probe also caught a second-order bug: unconditionally rewriting `\[` (a valid + // escaped literal `[`) into `/[` turns it into an unterminated character class, so a + // pattern that is well-formed for Go's `path.Match` was spuriously rejected as malformed + // here. Leaving `\` untouched on POSIX lets `legacyPathMatch`'s own escape handling (used + // by both this and `legacyGlobPattern`) reproduce Go's semantics directly — no gap in + // that shared module needs fixing first. + const rawResolved = legacyResolveSeedSqlPath(path, rawPattern); + // Go's `Glob.files` (`config.go:145`) only ever ToSlashes the pattern for the internal + // `fs.Glob` CALL itself — `hasGlobMeta`, the `skipped` slice, and both "no files matched + // pattern" error sites all keep using the loop's own `pattern` variable, which is NEVER + // ToSlash'd (`config.go:143-154`). So on Windows, an absolute entry like + // `C:\schemas\*.sql` must glob-match as `C:/schemas/*.sql` but still ERROR/report as + // `C:\schemas\*.sql` — `matchPattern` (slashed) feeds `legacyPathMatch`/`legacyGlobPattern` + // below; `rawResolved` (untouched) feeds every diagnostic (`skipped`/`problems`) so stderr + // stays byte-compatible with Go's un-ToSlash'd `pattern`. + const matchPattern = path.sep === "/" ? rawResolved : rawResolved.replaceAll("\\", "/"); + if (legacyPathMatch(matchPattern, "").badPattern) { + problems.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + // Go's `io/fs.Glob` never matches an empty pattern: its literal (no-metacharacter) + // branch calls `Stat(fsys, "")`, which fails on a real OS filesystem (there is no file + // whose path is the empty string), so `Glob` returns zero matches — verified empirically + // against the real `config.Glob.SQLFiles` (`apps/cli-go/pkg/config/config.go:119-133`) + // fed pattern `""` against an `afero.NewOsFs()`: it reports `no files matched pattern: `, + // the same as any other non-matching literal pattern. `legacyGlobPattern`'s own + // literal-pattern branch, however, resolves an empty pattern to the WORKDIR itself + // (`legacyResolveUnderWorkdir(path, workdir, "")` is the workdir, which always exists), + // so without this guard an empty `schema_paths` entry would recurse into and collect + // every `.sql` file in the entire project instead of matching nothing. Short-circuit + // before calling it, rather than fixing `legacyGlobPattern` itself, since that shared + // helper (`legacy-glob.ts`) also backs `[db.seed] sql_paths` (`legacy-seed.ts`) and + // `legacy-migrate-and-seed.ts`, both out of scope for this PR. + // Go's `sort.Strings(matches)` (`config.go:154`) — byte order, not JS's default UTF-16 + // code-unit order; see `legacyCompareUtf8Bytes`'s own doc comment. + const matches = + matchPattern.length === 0 + ? [] + : [...(yield* legacyGlobPattern(fs, path, workdir, matchPattern))].sort( + legacyCompareUtf8Bytes, + ); + if (matches.length === 0) { + if (legacyHasConfigGlobMeta(rawResolved)) { + skipped.push(rawResolved); + continue; + } + // Go always resolves `SchemaPaths` (`config.go:976-979`) before this error can fire + // (resolution happens at config-load time, ahead of any glob), so the error must show + // the RESOLVED, `supabase/`-prefixed pattern, matching the all-skipped-globs branch + // below — not the raw, caller-supplied one. Still `rawResolved`, not `matchPattern`: + // see this loop's own doc comment above on why Go's error text is never ToSlash'd. + problems.push(`no files matched pattern: ${rawResolved}`); + continue; + } + for (const match of matches) { + const absMatch = legacyResolveUnderWorkdir(path, workdir, match); + const statResult = yield* fs.stat(absMatch).pipe(Effect.result); + if (Result.isFailure(statResult)) { + problems.push(`failed to stat matched file: ${statResult.failure.message}`); + continue; + } + if (statResult.success.type !== "Directory") { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + continue; + } + // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` + // error (e.g. a permission-denied or I/O-erroring subdirectory) as `failed to walk + // matched directory: ` — it does NOT treat an unreadable directory as an empty + // match set, since silently doing so can omit declared schemas and compare a + // local-target diff against the wrong target. `legacyWalkSqlFiles` (`legacy-glob.ts`) + // also matches Go's byte-sorted, no-follow-symlink walk semantics — see its own doc + // comment. + const sqlRelativeResult = yield* legacyWalkSqlFiles(fs, absMatch, "").pipe(Effect.result); + if (Result.isFailure(sqlRelativeResult)) { + problems.push(`failed to walk matched directory: ${sqlRelativeResult.failure.message}`); + continue; + } + for (const relative of sqlRelativeResult.success) { + // `io/fs.WalkDir`'s own path.Join(dir, entry.Name()) (`io/fs/walk.go`'s `walkDir`) + // cleans redundant separators before `walkMatchedDir`'s callback ever records the + // child path — so a `match` that retains a trailing separator (e.g. a directory + // `schema_paths` entry configured as `"supabase/schemas/"`) never reaches Go's dedup + // `set` as a double-slashed key. A raw template join skips that implicit clean and + // can let the same file be recorded twice — once here, once via a literal + // `schema_paths` entry for the file itself — bypassing `seen` and double-applying the + // SQL. `legacyCleanSchemaPath` (below) performs the equivalent slash-segment + // collapsing and is reused here rather than duplicated (review: PRRT_kwDOErm0O86XAlIr). + const relativeToWorkdir = legacyCleanSchemaPath(`${match}/${relative}`); + if (!seen.has(relativeToWorkdir)) { + seen.add(relativeToWorkdir); + result.push(relativeToWorkdir); + } + } + } + } + + if (result.length === 0 && skipped.length > 0) { + for (const pattern of skipped) problems.push(`no files matched pattern: ${pattern}`); + } + if (problems.length > 0) { + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ message: problems.join("\n") }), + ); + } + return result; + }); +} + +/** + * Port of Go's `afero.Walk` + regular-`.sql`-file filter + `sort.Strings` (the shared tail of + * both `loadDeclaredSchemas`'s pg-delta-declarative-dir and `SchemasDir` branches, + * `apps/cli-go/internal/db/diff/diff.go:65-76,86-96`). `legacyWalkSqlFiles` (`legacy-glob.ts`) + * also matches Go's byte-sorted, no-follow-symlink walk semantics — see its own doc comment. + * + * The walk ROOT itself is checked for being a symlink here, unlike `legacyGlobDeclaredSchemaPaths`'s + * directory branch (Go's `fs.WalkDir`, whose own doc comment says "if root itself is a symbolic + * link, its target will be walked" — so a symlinked `schema_paths` match is deliberately followed, + * matching `legacyWalkSqlFiles`'s existing never-checks-its-own-root behavior). `afero.Walk` is the + * opposite: its `Walk(fs, root, walkFn)` entry point `Lstat`s the root BEFORE ever calling + * `walkFn`, so a symlinked root is treated as a non-directory and produces zero files silently, + * never descending into the target — verified against `afero`'s own source (`path.go`'s + * `Walk`/`lstatIfPossible`). The PRECEDING `fs.stat`-based existence check in + * `legacyLoadDeclaredSchemas` (which follows symlinks, matching Go's `afero.DirExists` — also + * `fs.Stat`-based) can't substitute for this: existence and walkability are different checks in + * Go, and only the latter uses `Lstat`. + * + * Paths are joined with the injected `Path` service (not a literal `/` template) so a symlink-free + * result matches Go's own `filepath.Join`-built path on every platform — on Windows this yields + * native backslashes (Go's `afero.Walk` never calls `filepath.ToSlash` on this branch, unlike + * `walkMatchedDir`'s `schema_paths` branch, which does), and `path.join` normalizes ANY `/` + * `legacyWalkSqlFiles`'s own relative-path construction produced internally, not just the outer + * `dirRel`/`relative` join (verified: `path.win32.join("supabase/database", + * "sub/dir/file.sql")` returns `"supabase\\database\\sub\\dir\\file.sql"`, not a mixed-separator + * string) — on POSIX this is a no-op (`path.posix.join` is byte-identical to the old template). + * + * `errorPrefix` lets the two callers preserve Go's own DIFFERENT wrapping messages for the same + * walk failure: the pg-delta declarative-dir branch reports `"failed to walk declarative dir: + * %w"` while the `supabase/schemas` fallback reports `"failed to walk dir: %w"` + * (`apps/cli-go/internal/db/diff/diff.go:65-76,86-96` — same walk, genuinely different prefix + * per source), so stderr still identifies which configured source failed. + */ +function legacyWalkSqlFilesSorted( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dirRel: string, + errorPrefix: string, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + const dirAbs = legacyResolveUnderWorkdir(path, workdir, dirRel); + const isSymlinkRoot = yield* fs.readLink(dirAbs).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (isSymlinkRoot) return []; + const sqlRelative = yield* legacyWalkSqlFiles(fs, dirAbs, "").pipe( + Effect.mapError( + (cause) => + new LegacyDeclarativeShadowDbError({ message: `${errorPrefix}: ${cause.message}` }), + ), + ); + return sqlRelative.map((relative) => path.join(dirRel, relative)); + }); +} + +/** + * Port of Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`): a + * three-source priority ladder — `db.migrations.schema_paths` (when non-empty) -> + * pg-delta's declarative dir (when `[experimental.pgdelta] enabled` AND the dir exists) -> + * `supabase/schemas` (when it exists) -> `[]`. Each source is `sort.Strings`-ordered. + */ +export function legacyLoadDeclaredSchemas( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + schemaPaths: ReadonlyArray, + pgDelta: LegacyPgDeltaTomlConfig, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + if (schemaPaths.length > 0) { + return yield* legacyGlobDeclaredSchemaPaths(fs, path, workdir, schemaPaths); + } + if (pgDelta.enabled) { + const declDirRel = legacyResolveDeclarativeDir(path, pgDelta); + const declDirAbs = legacyResolveUnderWorkdir(path, workdir, declDirRel); + // Go's `afero.DirExists` (`diff.go:63`) — a path that exists but is a regular file is + // "not a directory" (`err == nil && exists` is false), not an error, so it falls through + // to the `supabase/schemas` source below rather than being walked as a directory. + const isDeclDir = yield* fs.stat(declDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (isDeclDir) { + return yield* legacyWalkSqlFilesSorted( + fs, + path, + workdir, + declDirRel, + "failed to walk declarative dir", + ); + } + } + const schemasDirRel = "supabase/schemas"; + const schemasDirAbs = legacyResolveUnderWorkdir(path, workdir, schemasDirRel); + // Same `afero.DirExists` semantics as above (`diff.go:80`): a missing path or a path that + // exists but isn't a directory both resolve to "no declared schemas" (`[]`), not an error — + // only a genuine stat failure (permission denied, I/O error) propagates. + const isSchemasDir = yield* fs.stat(schemasDirAbs).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(false) + : Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: `failed to check schemas: ${cause.message}`, + }), + ), + onSuccess: (info) => Effect.succeed(info.type === "Directory"), + }), + ); + if (!isSchemasDir) return []; + return yield* legacyWalkSqlFilesSorted(fs, path, workdir, schemasDirRel, "failed to walk dir"); + }); +} + +/** + * Windows-only sibling of {@link legacyCleanSchemaPath}'s segment cleaner: the length of the + * leading "volume" a Windows path can carry, mirroring Go's `volumeNameLen` + * (`internal/filepathlite/path_windows.go`) for the two shapes realistic in a `schema_paths` + * config value — a drive letter (`C:...`, length 2) and a UNC share (`//host/share`, length + * through the second separator, Go's `uncLen`). Deliberately does NOT port Go's `\\.\`/`\\?\`/ + * `\??\` device-path branches (`\\.\C:\...`, Root Local Device paths) — not realistic values + * for this field, and porting them would add meaningful complexity for no reachable parity + * benefit. `path` is already backslash-normalized to `/` by the caller. + */ +function legacyWindowsVolumeLen(path: string): number { + if (path.length >= 2 && path[1] === ":") return 2; + if (path.length < 2 || path[0] !== "/" || path[1] !== "/") return 0; + let separators = 0; + for (let i = 2; i < path.length; i++) { + if (path[i] === "/") { + separators++; + if (separators === 2) return i; + } + } + return path.length; +} + +/** + * Go's `cleanSchemaPath` (`apps/cli-go/internal/db/diff/diff.go:117-119`): + * `filepath.ToSlash(filepath.Clean(path))`. `filepath.Clean`/`ToSlash` only treat `\` as a path + * separator on the Windows build of the Go CLI (`filepath.Separator == '\\'` there) — on every + * POSIX build (darwin/linux, what this TS binary stands in for on those hosts) a backslash is + * just a literal filename character that survives untouched. Verified empirically: + * `filepath.ToSlash(filepath.Clean(\`supabase/foo\bar\`))` compiled for `GOOS=darwin` returns + * `supabase/foo\bar`, not `supabase/foo/bar`. Gate the separator-normalization on the host + * platform so this matches whichever Go build this TS binary is standing in for. + * + * On Windows, `filepath.Clean` never cleans INTO a leading volume (`internal/filepathlite/ + * path_windows.go`'s `volumeNameLen`/`Clean`) — a UNC host+share (or a drive letter) survives + * verbatim, including its doubled leading separator for UNC, through `ToSlash`. Split it off + * with {@link legacyWindowsVolumeLen} before the segment-cleanup loop below, which would + * otherwise treat a UNC path's two leading empty segments the same as any other redundant + * separator and collapse `//host/share` down to `/host/share` — verified empirically against + * a standalone extraction of Go's own windows `Clean`/`ToSlash` source, run natively (review: + * PRRT_kwDOErm0O86W2tRk): `filepath.ToSlash(filepath.Clean(\`\\server\share\schemas\`))` + * compiled for `GOOS=windows` returns `//server/share/schemas`, not `/server/share/schemas`. + */ +export function legacyCleanSchemaPath( + rawPath: string, + platform: NodeJS.Platform = process.platform, +): string { + const normalized = platform === "win32" ? rawPath.replaceAll("\\", "/") : rawPath; + const volumeLen = platform === "win32" ? legacyWindowsVolumeLen(normalized) : 0; + const volume = normalized.slice(0, volumeLen); + const remainder = normalized.slice(volumeLen); + // A bare volume with nothing after it (`\\server\share`, or `C:`) — Go's Clean leaves it + // untouched rather than falling into the segment-cleanup loop below (which would otherwise + // turn "no path left" into a bare "." and lose the volume). + if (volumeLen > 0 && remainder === "") return volume; + const isAbsolute = remainder.startsWith("/"); + const out: Array = []; + for (const segment of remainder.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); + else if (!isAbsolute) out.push(".."); + } else { + out.push(segment); + } + } + const joined = out.join("/"); + if (joined.length === 0) return volume + (isAbsolute ? "/" : "."); + return volume + (isAbsolute ? "/" : "") + joined; +} + +/** + * Port of Go's `shouldApplyDeclarativeWithPgDelta` (`apps/cli-go/internal/db/diff/diff.go: + * 103-115`): `usePgDelta` false -> false; zero `schema_paths` -> true; more than one + * `schema_paths` entry -> false; exactly one entry -> true only when it resolves (Go's + * `config.go:976-979` resolution, matching `legacyResolveSeedSqlPath`) to the SAME cleaned + * path as the effective declarative dir. + */ +export function legacyShouldApplyDeclarativeWithPgDelta( + path: Path.Path, + usePgDelta: boolean, + schemaPaths: ReadonlyArray, + pgDelta: LegacyPgDeltaTomlConfig, + platform: NodeJS.Platform = process.platform, +): boolean { + if (!usePgDelta) return false; + if (schemaPaths.length === 0) return true; + if (schemaPaths.length !== 1) return false; + const resolvedSchema = legacyCleanSchemaPath( + legacyResolveSeedSqlPath(path, schemaPaths[0]!), + platform, + ); + const declDir = legacyCleanSchemaPath(legacyResolveDeclarativeDir(path, pgDelta), platform); + return resolvedSchema === declDir; +} + +/** + * Port of Go's `migrateBaseDatabase` (`apps/cli-go/internal/db/diff/diff.go:261-274`): prints + * the declarative-schema file list, connects to `config` (the shadow's `contrib_regression` + * override), then seeds `migrations` as globals (Go's `migration.SeedGlobals` — no history + * row, no history table, WITHOUT the migra-engine schema files' own transactional/seed + * distinctions {@link legacySeedGlobals} already reproduces for every other caller of it). + */ +function legacyMigrateBaseDatabase( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + config: LegacyPgConnInput, + migrations: ReadonlyArray, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw("Creating local database from declarative schemas:\n", "stderr"); + const msg = migrations.map((m) => ` • ${legacyBold(m)}`).join("\n"); + yield* output.raw(`${msg}\n`, "stderr"); + + const dbConnection = yield* LegacyDbConnection; + const session = yield* dbConnection + .connect(config, { isLocal: true, dnsResolver: "native" }) + .pipe( + Effect.mapError( + (cause) => new LegacyDeclarativeShadowDbError({ message: cause.message }), + ), + ); + + const absolutePaths = migrations.map((m) => legacyResolveUnderWorkdir(path, workdir, m)); + yield* legacySeedGlobals( + session, + fs, + path, + absolutePaths, + (message) => new LegacyDeclarativeShadowDbError({ message }), + ); + }), + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts new file mode 100644 index 0000000000..3291e51fc9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts @@ -0,0 +1,776 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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, Layer, Option, Path, PlatformError } from "effect"; + +import { + legacyCleanSchemaPath, + legacyLoadDeclaredSchemas, + legacyShouldApplyDeclarativeWithPgDelta, +} from "./legacy-shadow-source.ts"; +import type { LegacyPgDeltaTomlConfig } from "../../../shared/legacy-db-config.toml-read.ts"; + +function pgDelta(overrides: Partial = {}): LegacyPgDeltaTomlConfig { + return { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + npmVersion: Option.none(), + ...overrides, + }; +} + +function makeWorkdir(): string { + return mkdtempSync(join(tmpdir(), "legacy-shadow-source-")); +} + +// Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. +const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + +describe("legacyShouldApplyDeclarativeWithPgDelta", () => { + it.effect("is false whenever usePgDelta is false, regardless of schema_paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, false, [], pgDelta())).toBe(false); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, false, ["schemas/x.sql"], pgDelta()), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is true when usePgDelta and zero schema_paths are configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, [], pgDelta())).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is false when more than one schema_paths entry is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["a.sql", "b.sql"], pgDelta()), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "is true when exactly one schema_paths entry resolves to the effective declarative dir", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["database"], pgDelta())).toBe( + true, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is false when the single schema_paths entry does not match the declarative dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["schemas"], pgDelta())).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("matches a configured (non-default) declarative_schema_path the same way", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/custom-decl") }); + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["custom-decl"], configured)).toBe( + true, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "on POSIX, a backslash in schema_paths is a literal character, not a path separator", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + // Go's `filepath.Clean`/`ToSlash` only treat `\` as a separator on a Windows build — + // on darwin/linux it's untouched, so a `foo\bar` schema_paths entry (which + // `legacyResolveSeedSqlPath` joins under `supabase/` unresolved) must NOT be treated + // as equivalent to the slash-separated declarative dir `supabase/foo/bar`. + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "darwin"), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("on win32, a backslash in schema_paths normalizes as a path separator", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "win32"), + ).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); + +describe("legacyCleanSchemaPath", () => { + // Go's `filepath.Clean` (windows build) never cleans INTO a leading UNC volume — verified + // empirically against a standalone extraction of Go's own windows `internal/filepathlite` + // Clean/ToSlash/volumeNameLen source, run natively: `filepath.ToSlash(filepath.Clean( + // \`\\server\share\schemas\`))` compiled for `GOOS=windows` returns `//server/share/schemas` + // (review: PRRT_kwDOErm0O86W2tRk) — the doubled leading separator is part of the UNC host+ + // share, not a redundant separator to collapse to one. + it("preserves a UNC host+share prefix on win32, matching Go's Clean", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\schemas", "win32")).toBe( + "//server/share/schemas", + ); + }); + + it("cleans `.`/`..` segments AFTER a UNC prefix without touching the prefix itself", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\a\\.\\b\\..\\c", "win32")).toBe( + "//server/share/a/c", + ); + }); + + it("drops a leading `..` past a UNC share root instead of climbing above it", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\..\\schemas", "win32")).toBe( + "//server/share/schemas", + ); + }); + + it("leaves a bare UNC share (no subpath) unchanged", () => { + expect(legacyCleanSchemaPath("\\\\server\\share", "win32")).toBe("//server/share"); + }); + + it("does not confuse a UNC path with the distinct root-relative path of the same tail", () => { + // The bug this guards against: collapsing `//server/share/schemas` down to + // `/server/share/schemas` would make a UNC `schema_paths` entry compare equal to an + // unrelated root-relative declarative dir. + expect(legacyCleanSchemaPath("\\\\server\\share\\schemas", "win32")).not.toBe( + legacyCleanSchemaPath("/server/share/schemas", "win32"), + ); + }); + + it("still cleans a drive-letter path correctly", () => { + expect(legacyCleanSchemaPath("C:\\foo\\..\\bar", "win32")).toBe("C:/bar"); + }); + + it("does not treat a doubled separator as a UNC volume off win32", () => { + // POSIX has no UNC concept — Go's non-Windows `filepath.Clean` collapses redundant + // separators uniformly, same as this function's pre-existing POSIX behavior. + expect(legacyCleanSchemaPath("//server/share/schemas", "darwin")).toBe("/server/share/schemas"); + }); +}); + +describe("legacyLoadDeclaredSchemas", () => { + it.effect( + "returns [] when neither schema_paths, an enabled pg-delta dir, nor supabase/schemas exist", + () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "falls back to sorted supabase/schemas/*.sql when no schema_paths/pg-delta dir apply", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "b.sql"), "select 2;\n"); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/a.sql", "supabase/schemas/b.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "prefers the pg-delta declarative dir over supabase/schemas when pg-delta is enabled and the dir exists", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database", "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "unused.sql"), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/database/t.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "prefers db.migrations.schema_paths over both the pg-delta dir and supabase/schemas", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database", "unused.sql"), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom/*.sql"], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/custom/x.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("fails when a literal (non-glob) schema_paths entry matches nothing", () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["missing.sql"], + pgDelta(), + ).pipe(Effect.exit); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + 'an empty schema_paths entry matches nothing, not the entire project (Go\'s fs.Glob(""))', + () => { + // Go's `io/fs.Glob` never matches an empty pattern — its literal-pattern branch calls + // `Stat(fsys, "")`, which fails on a real OS filesystem, so `Glob.SQLFiles` reports + // `no files matched pattern: ` for it (verified empirically against the real + // `config.Glob.SQLFiles` fed `""` over an `afero.NewOsFs()`). Without this guard, + // `legacyGlobPattern`'s literal-pattern branch resolves `""` to the workdir itself + // (which always exists) and recursively collects every `.sql` file in the project, + // including files well outside any declared schema path. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "migrations", "001_init.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [""], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("a glob schema_paths entry matching nothing is silently skipped, not an error", () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom/*.sql", "empty-glob/*.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/custom/x.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "on POSIX, a backslash in a schema_paths entry is a path.Match escape, not a separator (review: PRRT_kwDOErm0O86W7n90)", + () => { + // Go's `filepath.ToSlash` (`fs.Glob(fsys, filepath.ToSlash(pattern))`, + // `pkg/config/config.go:145`) is a byte-for-byte no-op on POSIX — only Windows's + // `filepath.Separator` is `\`. `path.Match` (what `fs.Glob` compiles down to) then + // treats an un-converted `\` as an escape metacharacter: `custom\x.sql` escapes the + // literal `x`, matching a FILE literally named `customx.sql` directly under + // `supabase/`, never the path-separated `supabase/custom/x.sql`. Verified empirically: + // `path.Match("custom\\x.sql", "customx.sql")` is `true` on darwin, while + // `path.Match("custom\\x.sql", "custom/x.sql")` never even reaches that filename (the + // pattern has no `/`, so it only lists `supabase/`, never descends into `custom/`). + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "customx.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom\\x.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/customx.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "on POSIX, a backslash-escaped glob metacharacter in schema_paths matches the literal filename (review: PRRT_kwDOErm0O86W7n90)", + () => { + // The specific case the review thread flagged: `path.Match("foo\\*.sql", "foo*.sql")` + // is `true` on darwin — the escaped `*` is a literal asterisk, matching a file named + // `foo*.sql`, not a glob that searches a `foo/` subdirectory. Before this fix, + // `legacyGlobDeclaredSchemaPaths` unconditionally rewrote the pattern to `foo/*.sql` + // ahead of globbing, which searches `foo/` instead and would report "no files matched" + // for this exact, valid Go config. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "foo*.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["foo\\*.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/foo*.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "dedupes a directory schema_paths entry with a trailing separator against a literal-file entry for the same file (review: PRRT_kwDOErm0O86XAlIr)", + () => { + // A RELATIVE trailing-slash entry gets `path.Join`-cleaned away by + // `legacyResolveSeedSqlPath` before it ever reaches the glob, matching Go's own + // `path.Join(builder.SupabaseDirPath, pattern)` resolution — so the bug is only + // reachable via an ABSOLUTE entry, which `legacyResolveSeedSqlPath` returns verbatim + // (Go's `Glob.files` never resolves an absolute entry either). Without the fix, the + // directory branch recorded the walked file as `/custom//x.sql` (raw template + // concatenation), which never matches the literal entry's `/custom/x.sql` in + // `seen`, so both were appended to `result` and the declarative apply would run the + // same file's SQL twice. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + const absDirWithTrailingSlash = `${join(workdir, "supabase", "custom")}/`; + const absFile = join(workdir, "supabase", "custom", "x.sql"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [absDirWithTrailingSlash, absFile], + pgDelta(), + ); + expect(result).toEqual([absFile]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "excludes a symlinked .sql file from a recursively-matched schema_paths directory", + () => { + // Go's `entry.Type().IsRegular()` (`config.go:127`) is a no-follow check — a symlink + // is never "regular", so `walkMatchedDir` excludes it even when it resolves to a real + // `.sql` file. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); + const secretTarget = join(workdir, "outside.sql"); + writeFileSync(secretTarget, "select 2;\n"); + symlinkSync(secretTarget, join(workdir, "supabase", "custom", "linked.sql")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("excludes a symlinked .sql file from the supabase/schemas fallback walk", () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); + const secretTarget = join(workdir, "outside.sql"); + writeFileSync(secretTarget, "select 2;\n"); + symlinkSync(secretTarget, join(workdir, "supabase", "schemas", "linked.sql")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "does not follow a symlinked subdirectory in a recursively-matched schema_paths directory", + () => { + // Go's `fs.WalkDir` (`walkMatchedDir`, `config.go:194-211`) is `Lstat`-based and never + // descends into a symlinked directory (`io/fs.WalkDir` doc: "WalkDir does not follow + // symbolic links found in directories") — a schema dir symlinking OUT of the configured + // schema tree must not leak the linked directory's files into the diff/pull target. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); + const outsideDir = join(workdir, "outside"); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); + symlinkSync(outsideDir, join(workdir, "supabase", "custom", "linked-dir"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "does not follow a symlinked subdirectory in the supabase/schemas fallback walk", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); + const outsideDir = join(workdir, "outside"); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); + symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "linked-dir"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "falls back to supabase/schemas when the pg-delta declarative path exists but is a regular file", + () => { + // Go's `afero.DirExists` (`apps/cli-go/internal/db/diff/diff.go:63`) treats a non-directory + // path as absent, not present-but-unwalkable — a stray `supabase/database` FILE (e.g. left + // over from a previous config) must fall through to `supabase/schemas`, not make + // `legacyWalkSqlFilesSorted` try (and fail) to read a file as a directory. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database"), "not a directory"); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/schemas/a.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "returns [] when supabase/schemas exists but is a regular file, not a directory", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas"), "not a directory"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "returns [] (does not follow) when the pg-delta declarative dir itself is a symlink", + () => { + // Go's `afero.Walk(fsys, declDir, ...)` Lstat's the ROOT before ever calling `walkFn` + // (`afero`'s own `Walk`/`lstatIfPossible`) — a symlinked root is treated as a + // non-directory and produces zero files, silently, never descending into the target. + // The PRECEDING `afero.DirExists`-equivalent existence check (which follows symlinks, + // matching Go's own `fs.Stat`-based `DirExists`) reports the symlinked dir as present, so + // only the WALK itself (not the existence check) must reject it. + const workdir = makeWorkdir(); + const realDir = join(workdir, "real-database"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + symlinkSync(realDir, join(workdir, "supabase", "database"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("returns [] (does not follow) when supabase/schemas itself is a symlink", () => { + const workdir = makeWorkdir(); + const realDir = join(workdir, "real-schemas"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + symlinkSync(realDir, join(workdir, "supabase", "schemas"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "sorts declared schema paths by UTF-8 byte order, not JS's default UTF-16 code-unit order", + () => { + // A supplementary-plane character (U+1F600, a surrogate pair in UTF-16) alongside a BMP + // private-use character (U+E000) is the textbook case where JS's default `.sort()` + // (UTF-16 code units) disagrees with Go's `sort.Strings` (UTF-8 bytes, which preserves + // codepoint order): JS ranks the surrogate pair first (0xD800 < 0xE000), Go ranks the + // supplementary-plane codepoint last (it's numerically > U+FFFF). Verified empirically + // against `Buffer.compare` on the two filenames' UTF-8 encodings. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + const supplementary = "a\u{1F600}.sql"; + const privateUse = "a.sql"; + writeFileSync(join(workdir, "supabase", "schemas", supplementary), "select 1;\n"); + writeFileSync(join(workdir, "supabase", "schemas", privateUse), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([ + `supabase/schemas/${privateUse}`, + `supabase/schemas/${supplementary}`, + ]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "propagates (rather than silently drops) a per-entry stat failure during the pg-delta/schemas walk", + () => { + // Both Go walkers (`afero.Walk`, `fs.WalkDir`) pass a per-entry stat/lstat error to their + // callback, which returns it and aborts the whole walk — an entry that can't be statted + // after its parent was listed (permissions, I/O error, a concurrent filesystem change) + // must not be silently omitted, which could build an incomplete declarative target. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + const brokenAbs = join(workdir, "supabase", "schemas", "broken.sql"); + writeFileSync(brokenAbs, "select 2;\n"); + const statFs = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => ({ + ...real, + stat: (statPath: string) => + statPath === brokenAbs + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "stat", + description: "simulated stat failure", + pathOrDescriptor: statPath, + }), + ) + : real.stat(statPath), + })), + ).pipe(Layer.provideMerge(BunServices.layer)); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(statFs)); + }, + ); + + it.effect.skipIf(isRoot)( + "fails (rather than silently treating as empty) when a matched schema directory can't be read, and keeps the underlying cause in the message", + () => { + // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` + // error as `failed to walk matched directory: ` — an unreadable directory must + // surface as a failure, not silently contribute zero files (which could compare a + // local-target diff against the wrong target or generate an incomplete migration), and + // the reported message must carry the real underlying error (permission denied, here), + // not just the directory name — otherwise a user can't tell WHY the walk failed. + const workdir = makeWorkdir(); + const locked = join(workdir, "supabase", "locked"); + mkdirSync(locked, { recursive: true }); + chmodSync(locked, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["locked"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk matched directory:"); + expect(errorJson).not.toContain("failed to walk matched directory: locked"); + } + chmodSync(locked, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "visits sibling directories in UTF-8 byte order, not JS's default UTF-16 order, so the reported failure matches Go's (review: PRRT_kwDOErm0O86XAlIo)", + () => { + // `["dir\u{1F600}", "dir\u{E000}"].sort()` (JS default, UTF-16 code-unit order) puts the + // supplementary-plane name FIRST — its lead surrogate (0xD83D) is less than the + // private-use code unit (0xE000). Byte order (Go's `sort.Strings`/`bytealg.CompareString`, + // what `legacyCompareUtf8Bytes` reproduces) disagrees: U+1F600 encodes to a LARGER first + // UTF-8 byte (0xF0) than U+E000 (0xEE), so the private-use name sorts first instead. + // Both subdirectories are unreadable, so whichever the walk visits FIRST is the one whose + // `EACCES` failure aborts the whole walk (Effect.gen never reaches the second entry) — + // its path, not the other one's, must appear in the resulting error. + const workdir = makeWorkdir(); + const matched = join(workdir, "supabase", "custom"); + const utf16First = join(matched, "dir\u{1F600}"); + const byteOrderFirst = join(matched, "dir\u{E000}"); + mkdirSync(utf16First, { recursive: true }); + mkdirSync(byteOrderFirst, { recursive: true }); + chmodSync(utf16First, 0o000); + chmodSync(byteOrderFirst, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain(byteOrderFirst); + expect(errorJson).not.toContain(utf16First); + } + chmodSync(utf16First, 0o755); + chmodSync(byteOrderFirst, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "reports the pg-delta declarative dir walk failure as 'failed to walk declarative dir', not the generic 'failed to walk dir'", + () => { + // Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`) wraps the + // SAME `afero.Walk` failure with a DIFFERENT prefix per source: the pg-delta declarative + // dir branch reports `failed to walk declarative dir: %w`, while the `supabase/schemas` + // fallback (covered by the sibling test below) reports `failed to walk dir: %w` — both + // walks share `legacyWalkSqlFilesSorted`, which must be told which source it's walking. + const workdir = makeWorkdir(); + const declDir = join(workdir, "supabase", "database"); + mkdirSync(declDir, { recursive: true }); + chmodSync(declDir, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk declarative dir:"); + expect(errorJson).not.toContain("failed to walk dir:"); + } + chmodSync(declDir, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "reports the supabase/schemas fallback walk failure as 'failed to walk dir', not the declarative-dir prefix", + () => { + const workdir = makeWorkdir(); + const schemasDir = join(workdir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + chmodSync(schemasDir, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk dir:"); + expect(errorJson).not.toContain("failed to walk declarative dir:"); + } + chmodSync(schemasDir, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index 866993c135..e032cde37b 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -3,21 +3,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - LegacyDebugFlag, - LegacyNetworkIdFlag, - legacyResolveExperimentalWithProjectEnv, -} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyIsBitbucketPipeline } from "../../../shared/legacy-bitbucket-pipeline.ts"; import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; -import { - legacyCliProjectFilterValue, - legacyResolveNetworkId, - localDbContainerId, -} from "../../../shared/legacy-docker-ids.ts"; import { legacyEnvOverride, legacyEnvOverrideApiMaxRows, @@ -34,7 +24,6 @@ import { legacyResolveAuthEmail, legacyResolveAuthEmailSmtp, legacyResolveAuthExternalProviders, - legacyResolveAuthExternalUrl, legacyResolveAuthHooks, legacyResolveAuthMfa, legacyResolveAuthSms, @@ -45,7 +34,6 @@ import { legacyResolveGotrueSessions, legacyResolveGotrueWeb3, legacyResolveLocalConfigValues, - legacyResolveLocalJwks, legacyResolveThirdPartyProviders, } from "../../../shared/legacy-local-config-values.ts"; import { @@ -55,12 +43,11 @@ import { import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyGoUrlParse } from "../../../shared/legacy-storage-url.ts"; import { legacyLoadLocalProjectContext } from "../../../shared/legacy-local-project-context.ts"; -import { legacyResolveDbBootstrapConfig } from "../../../shared/db-bootstrap/bootstrap-config.ts"; -import { legacyEnsureImagesCached } from "../../../shared/db-bootstrap/image-prepull.ts"; +import { legacyCliProjectFilterValue } from "../../../shared/legacy-docker-ids.ts"; +import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyRollbackStart } from "../../../shared/db-bootstrap/rollback.ts"; import { legacyStartDatabase } from "../../../shared/db-bootstrap/start-database.ts"; -import type { LegacyContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; function asRecord(value: unknown): Record | undefined { @@ -125,6 +112,12 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega const runtimeInfo = yield* RuntimeInfo; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* LegacyNetworkIdFlag; + // Threaded into `legacyRollbackStart`'s own `legacyDockerRemoveAll` teardown — Go's + // `--debug` gates that function's `Pruned …:` stderr reports (`docker.go:123-143`, + // `viper.GetBool("DEBUG")`), matching `supabase start`'s own handler — and into + // `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume + // Realtime/Storage/Auth migrate job tees its own stderr (`db-setup.ts`'s + // `legacyRunStartMigrateJob`). const debug = yield* LegacyDebugFlag; const body = Effect.gen(function* () { @@ -168,7 +161,17 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega cliConfig.workdir, (message) => new LegacyDbConfigLoadError({ message }), ); - const { config, projectEnvValues, loaded, hostname, projectId } = context; + // `projectId`/`hostname` are NOT destructured under their bare names here — the not-running + // branch below passes this SAME `context` into `legacyBuildLocalDbContainerInputs` as its + // `preloadedContext` param (reused, not reloaded — a second `legacyLoadLocalProjectContext` + // call would run `@supabase/config`'s `loadProjectConfig` again, which unconditionally + // prints deprecated-config-section WARN lines to stderr, doubling them for one invocation), + // and that function returns the SAME context back verbatim as `inputs.context`, later + // destructured under `context.projectId`/`context.hostname` — re-declaring those same bare + // names here, in this same function scope, would still collide with that later + // destructuring. `hostnameForValidation` is still needed here, for the eager, discarded + // `legacyResolveLocalConfigValues` call further down. + const { config, projectEnvValues, loaded, hostname: hostnameForValidation } = context; // Go decodes every `time.Duration` config field — including these 5 — in the same single, // unconditional `Config.Load` pass (`mapstructure.StringToTimeDurationHookFunc()`, @@ -883,18 +886,15 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega } // Closes an entire recurring class of gaps in the battery above, rather than adding another - // one-off field check: `legacyResolveLocalConfigValues` (below) is the SAME resolver this - // handler already calls, unconditionally, to build `values` for the not-running branch — it - // was simply called too LATE, after the already-running shortcut, so every Go `Config.Load`/ - // `Validate` step it performs internally (not just the ones this battery separately - // hand-duplicates above) was skipped whenever Postgres was already up. Moving the SAME call - // here — before the shortcut, matching every other check in this battery — closes 6 review - // findings at once, because they're all steps this one resolver already performs internally: - // `auth.captcha` decode (`legacyResolveAuthCaptcha`, review: PRRT_kwDOErm0O86WYMj_), - // `auth.jwt_secret` length validation (`resolveJwtSecret`/`generateAPIKeys`, review: - // PRRT_kwDOErm0O86WYMkJ), `auth.signing_keys_path` file read - // (`legacyResolveConfiguredSigningKeys`, review: PRRT_kwDOErm0O86WYMkM), `api.tls` cert/key - // path validation + file reads (`readApiTlsFiles`, review: PRRT_kwDOErm0O86WYMkP), + // one-off field check: `legacyResolveLocalConfigValues` is the SAME resolver + // `legacyBuildLocalDbContainerInputs` calls again below, in the not-running branch, to build + // the REAL `values` the container bring-up needs — calling it EAGERLY here too, before the + // already-running shortcut, closes 6 review findings at once, because they're all steps this + // one resolver already performs internally: `auth.captcha` decode (`legacyResolveAuthCaptcha`, + // review: PRRT_kwDOErm0O86WYMj_), `auth.jwt_secret` length validation + // (`resolveJwtSecret`/`generateAPIKeys`, review: PRRT_kwDOErm0O86WYMkJ), `auth.signing_keys_path` + // file read (`legacyResolveConfiguredSigningKeys`, review: PRRT_kwDOErm0O86WYMkM), `api.tls` + // cert/key path validation + file reads (`readApiTlsFiles`, review: PRRT_kwDOErm0O86WYMkP), // `auth.external.*` required-field validation (`validateAuthExternalProviders`, review: // PRRT_kwDOErm0O86WYMkT), and `auth.email`/notification template content reads // (`readAuthEmailTemplateContent`, review: PRRT_kwDOErm0O86WYMkW) — all genuinely unconditional @@ -906,17 +906,15 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // `db.ssl_enforcement.enabled`, `db.health_timeout`, `storage.{enabled,file_size_limit}`, // Mailpit/Logflare's non-primary ports) — `status`/`stop` never read those either, so // `legacyResolveLocalConfigValues` never decodes them, and they still need their own eager - // check the same way they always have. It's harmless (not incorrect) that this also - // re-validates a handful of fields the battery above already covers one-by-one (e.g. - // `studio.port`/`local_smtp.port`, `api.tls.enabled`) — same "resolve once, still call again - // to force the decode" precedent `db.settings`/`realtime.*` already use elsewhere in this - // battery — removing those now-redundant individual checks is a separate cleanup, not required - // to close the gaps above. - const values = yield* Effect.try({ + // check the same way they always have. Its result is discarded here — only the fail-fast + // behavior matters — and `legacyBuildLocalDbContainerInputs` below re-resolves the REAL + // `values`, same "resolve once, still call again to force the decode" precedent + // `db.settings`/`realtime.*` already use elsewhere in this battery. + yield* Effect.try({ try: () => legacyResolveLocalConfigValues( config, - hostname, + hostnameForValidation, cliConfig.workdir, projectEnvValues, loaded?.document, @@ -967,49 +965,36 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // resolver) plus the shared `legacyResolveDbBootstrapConfig` derivation `supabase // start` also uses — deliberately narrower than `supabase start`'s own prelude: no // `--exclude`, no image pre-pull for any other service, no JWT/JWKS/image resolution - // beyond what Postgres and its own fresh-volume setup jobs need. - // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep - // inside `legacyStartDatabase`'s fresh-volume setup pipeline — resolved here (project `.env` - // aware, like `db reset`'s identical gate) so it can be threaded straight through. - const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); - - // `values` was already resolved above, before the already-running shortcut (see that call's - // own doc comment) — reused here rather than calling `legacyResolveLocalConfigValues` a - // second time. - const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( - fs, - path, - { config, projectEnvValues, workdir: cliConfig.workdir }, - (message) => new LegacyDbConfigLoadError({ message }), - ); - - // Go's `DockerStart` forces every container's network mode (and the network it creates) - // to `--network-id` when set, ahead of the generated `supabase_network_` fallback - // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` - // shell/project-dotenv env var when the flag itself is omitted, via the same - // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: - // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT - // the same freeze-at-package-init shape as `utils.Config.Hostname`). - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), - projectId, - projectEnvValues, - ); - // Go's `DockerStart` unconditionally appends the Linux-only - // `host.docker.internal:host-gateway` extra host for every container it starts - // (`docker_linux.go`; empty on darwin/windows, where Docker Desktop already resolves that - // hostname). - const extraHosts = - runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - const isBitbucketPipeline = legacyIsBitbucketPipeline(); - const startOpts: LegacyContainerOpts = { - projectId, - isBitbucketPipeline, - workdir: cliConfig.workdir, - extraHosts, - }; + // beyond what Postgres and its own fresh-volume setup jobs need. Shared with `db reset`'s + // own identical prelude — see `legacyBuildLocalDbContainerInputs`'s own header for why + // `fromBackup`/rollback tracking stay here instead of moving into it. `context` (loaded + // eagerly above) is threaded through as `preloadedContext` — no `projectRef`/ + // `remoteOverrideKeys` (`db start` never has either) — so this call reuses it instead of + // calling `legacyLoadLocalProjectContext` a second time, which would otherwise double-print + // any deprecated-config-section stderr warning for this single invocation (see + // `preloadedContext`'s own doc comment). + const inputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + undefined, + undefined, + context, + ); + const { + context: { projectId, hostname }, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + setup, + } = inputs; - const dbContainerId = localDbContainerId(projectId); const filterValue = legacyCliProjectFilterValue(projectId); // Go's `utils.NoBackupVolume` package var — assigned by `legacyStartDatabase`'s own @@ -1033,96 +1018,25 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega hostname, dbContainerId, dbPort: values.dbPort, - containerOpts: startOpts, - postgresSpec: { - db: { - ...config.db, - port: values.dbPort, - major_version: bootstrapConfig.majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - }, - experimental: { - ...config.experimental, - orioledb_version: bootstrapConfig.orioledbVersion, - s3_host: bootstrapConfig.s3Host, - s3_region: bootstrapConfig.s3Region, - s3_access_key: bootstrapConfig.s3AccessKey, - s3_secret_key: bootstrapConfig.s3SecretKey, - }, - jwtSecret: values.jwtSecret, - jwtExpiry: values.authJwtExpiry, - projectId, - networkId, - configImage: bootstrapConfig.postgresImage, - rootKey: values.rootKey, - fromBackup, - }, + containerOpts, + // `fromBackup` (if set) drives BOTH the restore-entrypoint variant and + // `legacyStartDatabase`'s own backup-volume-exists guard — `db reset` has no + // `fromBackup` concept at all, so `postgresSpecBase` omits it. + postgresSpec: { ...postgresSpecBase, fromBackup }, // Go's `db start` never pre-pulls any OTHER service's image (it has no // `ensureImagesCached`-equivalent pre-pull pass at all — `internal/start/start.go`'s own // pre-pull is top-level-`start`-only) — only the `db` container's own image, resolved // lazily, right where Go's `DockerStart` would resolve it internally // (`DockerResolveImageIfNotCached`, `internal/utils/docker.go:363-365`). - resolvePostgresImage: legacyEnsureImagesCached( - spawner, - [bootstrapConfig.postgresImage], - projectEnvValues, - ).pipe( - Effect.map( - (resolved) => - resolved.get(bootstrapConfig.postgresImage) ?? bootstrapConfig.postgresImage, - ), - ), + resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, - setup: { - majorVersion: bootstrapConfig.majorVersion, - experimental, - config: { - ...config, - realtime: { - ...config.realtime, - enabled: bootstrapConfig.realtimeEnabledForSetup, - ip_version: bootstrapConfig.realtimeIpVersion, - max_header_length: bootstrapConfig.realtimeMaxHeaderLength, - }, - storage: { - ...config.storage, - enabled: bootstrapConfig.storageEnabledForSetup, - file_size_limit: bootstrapConfig.storageFileSizeLimit, - }, - auth: { - ...config.auth, - enabled: bootstrapConfig.authEnabledForSetup, - }, - }, - dbUrl: values.dbUrl, - jwtSecret: values.jwtSecret, - // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on - // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase - // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the - // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). - // `legacyStartDatabase` only evaluates this Effect when reached AND - // `realtimeEnabledForSetup` — see its own header for why this is lazy. - jwks: Effect.tryPromise({ - try: () => - legacyResolveLocalJwks(config, cliConfig.workdir, values.jwtSecret, projectEnvValues), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }), - apiUrl: values.apiUrl, - authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), - siteUrl: values.authSiteUrl, - anonKey: values.anonKey, - serviceRoleKey: values.serviceRoleKey, - storageTargetMigration: bootstrapConfig.storageTargetMigration, - realtimeEnabledForSetup: bootstrapConfig.realtimeEnabledForSetup, - storageEnabledForSetup: bootstrapConfig.storageEnabledForSetup, - authEnabledForSetup: bootstrapConfig.authEnabledForSetup, - serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, - projectEnvValues, - debug, - }, + // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on + // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase + // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the + // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). + // `legacyStartDatabase` only evaluates this Effect when reached AND + // `realtimeEnabledForSetup` — see its own header for why this is lazy. + setup, onFreshVolumeResolved: (resolved) => { isFreshVolume = resolved; }, diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 506dde00a6..b84f27bcf7 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -7,6 +7,7 @@ import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { vi } from "vitest"; import { mockOutput, @@ -25,6 +26,7 @@ import { LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession, @@ -154,6 +156,7 @@ function defaultRoute(opts: { readonly neverHealthy?: boolean } = {}) { const created = new Set(); return (args: ReadonlyArray): RouteResult => { if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "inspect") return { exitCode: 1 }; if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; if (args[0] === "volume" && args[1] === "inspect") return { exitCode: 0 }; if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; @@ -275,6 +278,10 @@ interface SetupOpts { readonly catalogStdout?: string; /** Fails the mocked catalog-export call with this message instead of succeeding. */ readonly catalogExportFailWith?: string; + /** Number of initial `LegacyDbConnection.connect` attempts that fail before succeeding. */ + readonly connectFailures?: number; + /** Whether the mocked connect failures are dial-level (`retryable`). Defaults to `true`. */ + readonly connectFailuresRetryable?: boolean; } function setup(opts: SetupOpts = {}) { @@ -311,6 +318,25 @@ function setup(opts: SetupOpts = {}) { requireSslForHost: () => Effect.succeed(false), }); + let connectAttempts = 0; + const connectFailures = opts.connectFailures ?? 0; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.suspend(() => { + connectAttempts += 1; + if (connectAttempts <= connectFailures) { + return Effect.fail( + new LegacyDbConnectError({ + message: + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: connect ECONNREFUSED 127.0.0.1:54322", + ...(opts.connectFailuresRetryable === false ? {} : { retryable: true }), + }), + ); + } + return Effect.succeed(dbSession.session); + }), + }); + const layer = Layer.mergeAll( BunServices.layer, out.layer, @@ -318,7 +344,7 @@ function setup(opts: SetupOpts = {}) { telemetry.layer, child.layer, alwaysReadyHttpClientLayer, - Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed(dbSession.session) }), + dbConnection, legacyDockerRunLayer.pipe( Layer.provide(child.layer), Layer.provide(mockProcessControl().layer), @@ -335,7 +361,17 @@ function setup(opts: SetupOpts = {}) { edgeRuntime, sslProbe, ); - return { layer, out, telemetry, child, dbSession, edgeRunCalls }; + return { + layer, + out, + telemetry, + child, + dbSession, + edgeRunCalls, + get connectAttempts() { + return connectAttempts; + }, + }; } const currentBranchPath = (workdir: string) => @@ -378,6 +414,32 @@ describe("legacy db start", () => { }, ); + it.live( + "fresh volume: retries the host connect while the published port is not yet reachable (#6136)", + () => { + const s = setup({ route: freshVolumeRoute(defaultRoute()), connectFailures: 2 }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(s.layer)); + expect(s.connectAttempts).toBe(3); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }); + }, + 15_000, + ); + + it.live("fresh volume: a non-dial connect failure is not retried", () => { + const s = setup({ + route: freshVolumeRoute(defaultRoute()), + connectFailures: 1, + connectFailuresRetryable: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(s.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.connectAttempts).toBe(1); + }); + }); + it.live( "PG <= 14 on a fresh volume: execs schema/globals SQL directly instead of the PG15+ one-shot migrate jobs", () => { @@ -736,6 +798,31 @@ describe("legacy db start", () => { }); }); + it.live( + "an explicitly empty --network-id falls back to the generated network name, not a literal empty override", + () => { + // Go's gate is `len(viper.GetString("network-id")) > 0` (docker.go:379-383), not merely + // "the flag was passed" — an empty override (e.g. a shell expanding an unset var to "") + // must fall through to the generated `supabase_network_` name, not produce a + // literal `--network ""` on the `docker create` call. + const { layer, child } = setup({ + route: freshVolumeRoute(defaultRoute()), + networkId: "", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + child.spawned.some( + (s) => s.args[0] === "network" && s.args.at(-1) === "supabase_network_test", + ), + ).toBe(true); + const args = createArgs(child.spawned); + const networkIndex = args?.indexOf("--network") ?? -1; + expect(args?.[networkIndex + 1]).toBe("supabase_network_test"); + }); + }, + ); + it.live( "fails with a typed config error on a malformed SUPABASE_DB_HEALTH_TIMEOUT, before any container is created", () => { @@ -1457,6 +1544,40 @@ describe("legacy db start", () => { }); }); + it.live( + "prints @supabase/config's deprecated-[inbucket]-section WARN only once on a fresh, not-already-running start", + () => { + // `legacyLoadLocalProjectContext` wraps `@supabase/config`'s `loadProjectConfig`, which + // unconditionally `Console.error`s a deprecation WARN for a legacy `[inbucket]` section + // (`packages/config/src/io.ts`'s `normalizeDeprecatedSMTPSections`, pinned to the real + // console — not this file's `Output` service, so it must be observed with a raw + // `console.error` spy, same idiom as `stop`/`status`'s own identical deprecated-provider + // tests). This handler used to load that context TWICE on the not-running path: once + // eagerly here (ahead of the already-running short-circuit), and again inside + // `legacyBuildLocalDbContainerInputs`'s own, now-removed, internal reload — doubling this + // WARN for one invocation, unlike Go's single `flags.LoadConfig` call + // (`internal/db/start/start.go:45`). Threading the eagerly-loaded context through as + // `legacyBuildLocalDbContainerInputs`'s `preloadedContext` fixes this. + const { layer } = setup({ + configContents: 'project_id = "test"\n[inbucket]\n', + route: freshVolumeRoute(defaultRoute()), + }); + const warnings: Array = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const inbucketWarnings = warnings.filter((m) => + m.includes( + "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", + ), + ); + expect(inbucketWarnings).toHaveLength(1); + }).pipe(Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); + }, + ); + it.live("fails on a malformed auth duration field even when the db is already running", () => { // Go's `flags.LoadConfig` (and therefore this eager duration validation) runs before // `AssertSupabaseDbIsRunning` in `start.Run` (`internal/db/start/start.go:45-47`) — a diff --git a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts index c3e2028cf4..abdaa1fe55 100644 --- a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts @@ -210,6 +210,7 @@ const flags = () => ({ dbUrl: Option.none(), linked: false, local: true, + projectRef: Option.none(), }); describe("legacy db test (alias) integration", () => { diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts index 833fe6414c..d7a471e188 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -11,6 +11,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** + * Internal discriminated failure for the CNAME verification pipeline: + * `transport: true` for resolver failures (fetch error, non-200, timeout), + * `transport: false` for a genuine finding about the user's DNS records + * (no CNAME answer). Consumed exclusively by {@link verifyLegacyCname}, which + * folds it into `LegacyDomainsCnameError` so telemetry can tell a Cloudflare + * DoH outage apart from a misconfigured record. + */ +export interface LegacyCnameFailure { + readonly transport: boolean; + readonly detail: string; +} + /** * Extract the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS JSON * response. Mirrors Go's `utils.ResolveCNAME` @@ -20,7 +33,10 @@ function isRecord(value: unknown): value is Record { * answers instead of Go's actual (uncapped, `%+v`-on-`[]byte`) dump — see the * NOTE at the failure site below for why those don't byte-match. */ -export function parseFirstCname(payload: unknown, host: string): Effect.Effect { +export function parseFirstCname( + payload: unknown, + host: string, +): Effect.Effect { const answers = isRecord(payload) && Array.isArray(payload["Answer"]) ? payload["Answer"] : []; for (const answer of answers) { if (isRecord(answer) && answer["type"] === CNAME_TYPE && typeof answer["data"] === "string") { @@ -38,15 +54,16 @@ export function parseFirstCname(payload: unknown, host: string): Effect.Effect 1024 ? `${dump.slice(0, 1024)}…` : dump; - return Effect.fail( - new Error(`failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`), - ); + return Effect.fail({ + transport: false, + detail: `failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`, + }); } /** * Render the `%w`-wrapped cause string for the "failed to resolve" CNAME error. - * Transport / timeout / parse failures and the locate error all flow through - * here so the outer message stays Go-shaped without leaking object internals. + * Transport / timeout / parse failures all flow through here so the outer + * message stays Go-shaped without leaking object internals. */ export function formatCnameCause(cause: unknown): string { if (cause instanceof Error) return cause.message; @@ -54,6 +71,11 @@ export function formatCnameCause(cause: unknown): string { return String(cause); } +const transportFailure = (cause: unknown): LegacyCnameFailure => ({ + transport: true, + detail: formatCnameCause(cause), +}); + /** * Verify that `customHostname` has a CNAME record pointing at the project's * Supabase subdomain before initializing a custom hostname. Mirrors @@ -77,20 +99,29 @@ export const verifyLegacyCname = Effect.fnUntraced(function* (args: { ); const resolved = yield* Effect.gen(function* () { - const response = yield* args.httpClient.execute(request); + const response = yield* args.httpClient + .execute(request) + .pipe(Effect.mapError(transportFailure)); if (response.status !== 200) { - return yield* Effect.fail(new Error(`unexpected DNS query status ${response.status}`)); + return yield* Effect.fail({ + transport: true, + detail: `unexpected DNS query status ${response.status}`, + }); } - const payload = yield* response.json; + const payload = yield* response.json.pipe(Effect.mapError(transportFailure)); return yield* parseFirstCname(payload, args.customHostname); }).pipe( Effect.timeout("10 seconds"), - Effect.mapError( - (cause) => - new LegacyDomainsCnameError({ - message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${formatCnameCause(cause)}`, - }), - ), + Effect.mapError((cause) => { + const failure: LegacyCnameFailure = + typeof cause === "object" && cause !== null && "transport" in cause + ? cause + : transportFailure(cause); + return new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${failure.detail}`, + transport: failure.transport, + }); + }), ); if (resolved !== expected) { diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts index c6304af928..fdbc439de6 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts @@ -33,11 +33,12 @@ describe("parseFirstCname", () => { expect(Exit.isFailure(exit)).toBe(true); }); - it("fails with a locate error when no CNAME answer is present", () => { - const error = Effect.runSync( + it("fails with a non-transport locate failure when no CNAME answer is present", () => { + const failure = Effect.runSync( Effect.flip(parseFirstCname({ Answer: [{ type: 1, data: "1.2.3.4" }] }, "host.example.com")), ); - expect(error.message).toContain( + expect(failure.transport).toBe(false); + expect(failure.detail).toContain( "failed to locate appropriate CNAME record for host.example.com", ); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 11b68b3b65..bf6326c1e5 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -1,28 +1,48 @@ import { Data } from "effect"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Transport-level failure talking to the Management API custom-hostname * endpoints. Mirrors Go's `errors.Errorf("failed to custom hostname: %w", err)` * (`apps/cli-go/internal/hostnames/*`). */ -class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ +export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * The custom-hostname endpoint returned a status the Go CLI does not treat as * success (201 for create/reverify/activate, 200 for get/delete). Mirrors Go's * `errors.Errorf("unexpected hostname status %d: %s", code, body)`. */ -class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( +export class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( "LegacyDomainsUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The gated create/get/activate/reverify wrappers currently do not retain + // the entitlement check's boolean on this shared error. Keep 404 on the + // conservative API-status policy until that typed signal is threaded. + return statusCodeActionability(this.status); + } +} /** * The CNAME pre-check in `domains create` failed — either the DNS lookup did @@ -31,7 +51,20 @@ class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( */ export class LegacyDomainsCnameError extends Data.TaggedError("LegacyDomainsCnameError")<{ readonly message: string; -}> {} + /** + * Set when the DNS-over-HTTPS resolver call itself failed (timeout, + * non-200, or fetch failure against the 1.1.1.1 resolver) rather than the + * CNAME being missing or pointing at the wrong host. + */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return actionability.invalidConfig; + } +} /** * Build the network/status error mapper for a custom-hostname subcommand. The diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts index cf57170f97..7a54880c81 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; /** @@ -7,22 +13,33 @@ import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; * Mirrors Go's `errors.Errorf("failed to pgsodium config: %w", err)` * (`apps/cli-go/internal/encryption/{get,update}`). */ -class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ +export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * The pgsodium endpoint returned a status the Go CLI does not treat as success * (it only accepts `JSON200`). Mirrors Go's * `errors.Errorf("unexpected pgsodium config status %d: %s", code, body)`. */ -class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( +export class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( "LegacyEncryptionUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** * Build the network/status error mapper for an encryption subcommand. Go uses diff --git a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md index 88402efeed..f83de01dfa 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/deploy/SIDE_EFFECTS.md @@ -2,16 +2,18 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ----------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions | -| `/supabase/functions//index.ts` | TypeScript | function source to deploy | -| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | -| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | -| configured static files | any | when `static_files` patterns match local files | -| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | -| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past ``; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally, matching Go's `Config.Load` | +| `/supabase/functions//index.ts` | TypeScript | function source to deploy | +| `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | +| imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | +| configured static files | any | when `static_files` patterns match local files | +| `package.json` next to function entrypoint | JSON | Docker bundling package discovery | +| `/supabase/functions/import_map.json` | JSON | deprecated fallback import map discovery | ## Files Written @@ -22,10 +24,12 @@ ## Subprocesses -| Command | When | -| ------------- | ------------------------------------------------------------------- | -| `docker info` | to detect whether explicitly selected local Docker bundling can run | -| `docker run` | when Docker bundling is selected/available | +| Command | When | +| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `docker info` | to detect whether explicitly selected local Docker bundling can run | +| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker bundling: check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | +| `docker pull ` | Docker bundling, cache miss on a candidate: pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= ...` | when Docker bundling is selected/available; labeled so orphaned containers can be associated with the project (Go: `DockerStart`) | Docker bundling may pull or run the configured edge-runtime image and uses the `supabase_edge_runtime_` Deno cache volume. @@ -43,13 +47,17 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | optional project ref fallback | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry | no | -| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | -| `DEBUG` | enables verbose Docker bundle output when `true` | no | +| Variable | Purpose | Required? | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading, matching Go's `loadNestedEnv` | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded, matching Go; `NPM_AUTH_TOKEN` is not) | no | +| `DEBUG` | enables verbose Docker bundle output when `true` | no | ## Exit Codes @@ -93,3 +101,11 @@ Legacy `--output` / `-o` does not change deploy output, matching the Go command. - `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes. - `--prune` deletes deployed Functions that are not present locally after a confirmation prompt; global `--yes` skips the prompt. +- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled + 2026-08-12):** the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, + shared with `functions serve` and `start`'s Edge Runtime bring-up) matches import-map keys + per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only + for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` + (`pkg/function/deno.go:150-155`). Upload sets may shrink vs the Go CLI for maps that relied + on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a + file) is skipped with a `WARN` instead of aborting the deploy. diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index 00bb20b7ba..7ff6481ad3 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -1,9 +1,9 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; -import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -29,12 +29,8 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi const runtimeInfo = yield* RuntimeInfo; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; - const edgeRuntimeVersion = yield* Effect.tryPromise(() => - readFile(join(cliConfig.workdir, "supabase", ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((version) => version.trim()), - Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_VERSIONS["edge-runtime"]), + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( + join(cliConfig.workdir, "supabase"), ); let resolvedProjectRef = Option.none(); @@ -45,7 +41,7 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi projectRoot: cliConfig.workdir, supabaseDir: join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), - goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, yes, rawArgs, edgeRuntimeVersion, @@ -65,6 +61,10 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi // and the no-functions error dir (`deploy.go:35`, rendered on stderr) — // both stderr-bound, matching `legacyBold`'s default TTY gate. styleEmphasis: (text) => legacyBold(text), + // Go: `utils.Yellow` on the `WARNING:` token before "Docker is not + // running" (`deploy.go:60`, stderr) — matches `legacyYellow`'s default + // TTY gate. + styleWarning: (text) => legacyYellow(text), }).pipe( Effect.ensuring( Effect.suspend(() => diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 2f1952f292..6dbb187af6 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -19,8 +20,11 @@ import { deployFunctions, shouldChmodBundleOutputDirectory, } from "../../../../shared/functions/deploy.ts"; +import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { ConflictingFunctionDeployFlagsError, + InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "../../../../shared/functions/deploy.errors.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -63,6 +67,37 @@ async function writeLocalFunction( // eslint-disable-next-line no-control-regex const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +function resolveDockerOutputPath(args: ReadonlyArray): string { + const outputIndex = args.indexOf("--output"); + if (outputIndex < 0 || args[outputIndex + 1] === undefined) { + throw new Error("missing docker bundle output flag"); + } + return args[outputIndex + 1]!; +} + +/** + * Every `docker image inspect` call is a cache hit (exit 0) — no real pull, + * no real registry candidate fallback (that path has its own coverage in + * `functions/download`'s integration tests) — and every `docker run` + * synthesizes the eszip the bundler container would otherwise have produced, + * so `bundleFunctionWithDocker` can read it back and complete the deploy. + */ +function mockDockerBundleSpawner() { + const spawnerOpts: { + exitCode?: number; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if (record.command !== "docker" || record.args[0] !== "run") { + return; + } + const outputPath = resolveDockerOutputPath(record.args); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, "eszip-test-output"); + }; + return mockChildProcessSpawner(spawnerOpts); +} + describe("legacy functions deploy", () => { it.live("deploys a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -1167,7 +1202,7 @@ describe("legacy functions deploy", () => { projectRoot: tempRoot.current, supabaseDir: join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", - goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, yes: false, rawArgs: ["functions", "deploy"], edgeRuntimeVersion: "1.69.12", @@ -1220,4 +1255,545 @@ describe("legacy functions deploy", () => { ); }); }); + + describe("Config.Validate parity (CLI-1963)", () => { + it.live( + "fails before any Docker/API work when config.toml has an explicit empty project_id", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig( + tempRoot.current, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "reports a Config.Validate failure before an invalid slug's format error, matching Go's flags.LoadConfig-before-slug-validation order (deploy.go:22-28)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); + + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + functionNames: ["1-invalid-slug"], + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live("still rejects an invalid slug once the config itself is valid", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + + const error = yield* legacyFunctionsDeploy({ + ...baseFlags, + functionNames: ["1-invalid-slug"], + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDeploySlugError); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); + + describe("Docker bundling path Go-parity config/env wiring (CLI-1963)", () => { + function mockFunctionCreateApi() { + return mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + }), + ); + }, + }); + } + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + // `docker info` is spawned[0]; the bundler's first image-inspect + // candidate (a cache hit here) is spawned[1]. + expect(child.spawned[1]).toEqual({ + command: "docker", + args: ["image", "inspect", "public.ecr.aws/supabase/edge-runtime:v1.68.4"], + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the bundler's docker network when no --network-id flag is passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "env-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("env-network"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "prefers an explicit --network-id flag over SUPABASE_NETWORK_ID for the bundler container", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "deploy", + "hello-world", + "--use-api=false", + "--network-id", + "flag-network", + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "flag-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("flag-network"); + expect(runCommand?.args).not.toContain("env-network"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "labels the bundler container with the resolved project id (Go parity: docker.go:349-386)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig(tempRoot.current, 'project_id = "test-project"\n'), + ); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toEqual( + expect.arrayContaining([ + "--label", + "com.supabase.cli.project=test-project", + "--label", + "com.docker.compose.project=test-project", + ]), + ); + // Adjacent pairs, not merely present anywhere in argv — + // `buildFunctionsDockerRunArgs` emits the two `--label KEY=VALUE` + // pairs back-to-back, immediately before the image. + const cliLabelIndex = runCommand?.args.indexOf("--label") ?? -1; + expect(runCommand?.args.slice(cliLabelIndex, cliLabelIndex + 4)).toEqual([ + "--label", + "com.supabase.cli.project=test-project", + "--label", + "com.docker.compose.project=test-project", + ]); + // `-w ` — Go's bundler sets WorkingDir to + // the post-ChangeWorkDir cwd (`bundle.go:79`), which + // `deploy.ts`/`deploy.handler.ts` resolve to `cliConfig.workdir`, + // i.e. `tempRoot.current` in this test. + const workingDirIndex = runCommand?.args.indexOf("-w") ?? -1; + expect(runCommand?.args.slice(workingDirIndex, workingDirIndex + 2)).toEqual([ + "-w", + toDockerPath(tempRoot.current), + ]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + + it.live( + "does not climb to an ancestor project's config.toml for the Docker bundling path", + () => { + // Go's `flags.LoadConfig` only ever resolves `supabase/config.toml` + // from the already-resolved workdir, with no ancestor climb + // (`NewPathBuilder`, `pkg/config/utils.go:43-48`) — mirrored by + // `loadFunctionsProjectConfig`'s `search: false` (a real behavior + // change: deploy did NOT have this before CLI-1963, unlike download). + const nestedWorkdir = join(tempRoot.current, "nested"); + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: nestedWorkdir }), + runtimeInfo: mockRuntimeInfo({ cwd: nestedWorkdir }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeProjectConfig(tempRoot.current, 'project_id = "ancestor-project"\n'), + ); + yield* Effect.tryPromise(() => writeLocalFunction(nestedWorkdir, "hello-world")); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "supabase_network_abcdefghijklmnopqrst"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("supabase_network_abcdefghijklmnopqrst"); + expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }, + ); + }); + + describe("docker-not-running warning styling (Go parity: deploy.go:60; only WARNING: is styled)", () => { + it.live("wraps only the WARNING token, not the rest of the fallback line", () => { + // Calls the shared `deployFunctions` with a marker `styleWarning` instead + // of going through `legacyFunctionsDeploy`: the real hook (`legacyYellow`) + // is TTY-gated and therefore inert under vitest, so only an injected + // marker can deterministically observe styling scope — same pattern as + // the "no-functions error styling" block above. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed(legacyJsonResponse(request, 200, [])); + } + return Effect.succeed( + legacyJsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: false, + entrypoint_path: "functions/hello-world/index.ts", + }), + ); + }, + }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + + const platformApi = yield* LegacyPlatformApi; + yield* deployFunctions( + { ...baseFlags, useApi: false, useDocker: true }, + { + api: platformApi, + cwd: tempRoot.current, + flagCwd: tempRoot.current, + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + dashboardUrl: "https://supabase.com/dashboard", + goConfigCompat: legacyFunctionsGoConfigCompat, + yes: false, + rawArgs: ["functions", "deploy", "hello-world", "--use-api=false"], + edgeRuntimeVersion: "1.69.12", + resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), + styleWarning: (text) => `${text}`, + }, + ); + + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index 9c9c6a1845..84465d7676 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -2,66 +2,84 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | -| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | +| `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadProjectConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadProjectConfig` callers. Matches Go's `flags.LoadConfig` running unconditionally at the top of `Run` (`download.go:131-138`): a malformed config now fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads, matching Go's `flags.LoadConfig` -> `Config.Validate`. | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents, matching Go's `Config.Load` doing the same I/O unconditionally | +| `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| --------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API (`--use-api`, or the Docker-unbundle fallback when Docker isn't running) | +| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | +| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ----------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (`--use-api` path only) | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | `--use-api`: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses -| Command | When | Purpose | -| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------- | -| `supabase-go functions download ...` | `--use-docker` (default) or `--legacy-bundle`, unless `--use-api` | preserve hidden compatibility modes | - -The delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` so the Go child's -own `cli_command_executed` doesn't double-count on top of this command's own -telemetry (mirrors `db pull`/`db diff`'s delegated-call pattern). In -`--output-format json|stream-json`, the child's stdout is captured and -discarded instead of inherited (`LegacyGoProxy.execCapture`) — the raw text -never reaches the terminal, and this command emits the `Output` envelope -itself once the child exits successfully. +| Command | When | Purpose | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker info` | `--use-docker` (default), unless `--use-api` | check whether Docker is running before choosing the Docker-unbundle downloader | +| `docker image inspect ` (ECR, then GHCR, then Docker Hub) | Docker-unbundle path, when Docker is running | check whether the edge-runtime image is already cached locally, tried in registry order, before the network/volume ensure | +| `docker pull ` | Docker-unbundle path, cache miss on a candidate | pull with 2 retries (4s/8s backoff) before falling through to the next registry candidate | +| `docker network inspect` / `network create` / `volume create` | Docker-unbundle path, when Docker is running | ensure the shared per-project network/named volume exist (same primitives as `functions deploy`'s Docker bundler) | +| `docker run --rm ... --label com.supabase.cli.project= --label com.docker.compose.project= unbundle --eszip ... --output ...` | Docker-unbundle path, when Docker is running | extract the downloaded eszip into `supabase/functions//...`; labeled so orphaned containers can be associated with the project (Go: `DockerStart`) | +| `supabase-go functions download ... --legacy-bundle` | `--legacy-bundle` only | preserve the hidden, deprecated pre-1.120.0 bundling fallback (native TS port tracked separately, CLI-1963) | + +The `--legacy-bundle` delegated call runs with `SUPABASE_TELEMETRY_DISABLED=1` +so the Go child's own `cli_command_executed` doesn't double-count on top of +this command's own telemetry (mirrors `db pull`/`db diff`'s delegated-call +pattern). In `--output-format json|stream-json`, the child's stdout is +captured and discarded instead of inherited (`LegacyGoProxy.execCapture`) — +the raw text never reaches the terminal, and this command emits the `Output` +envelope itself once the child exits successfully. The Docker-unbundle path's +own container stdout is routed the same way: to the real stdout in text mode, +to stderr in machine-output modes (CLI-1546). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | -| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | -| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | -| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref`) | -| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| Variable | Purpose | Required? | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `/access-token`) | +| `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | +| `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | +| `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `/supabase/.temp/project-ref`) | +| `SUPABASE_WORKDIR` | sets `` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` -> cwd) | +| `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when set, skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the registry the edge-runtime unbundle image is pulled from (`legacyGetRegistryImageUrl`); read from the ambient shell **or** project dotenv (Docker-unbundle path); unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL — also consumed on the `--use-api` invocation even though it never pulls an image | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | Docker-unbundle path: overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | Docker-unbundle path: overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | network / connection failure | -| `1` | invalid function slug or flag conflict | +| Code | Condition | +| ---- | ---------------------------------------------------------------------- | +| `0` | success | +| `1` | API error (non-2xx response) | +| `1` | authentication error (no token found) | +| `1` | network / connection failure | +| `1` | invalid function slug or flag conflict | +| `1` | Docker-unbundle container exited non-zero (suggests `--legacy-bundle`) | ## Telemetry Events Fired @@ -73,25 +91,50 @@ itself once the child exits successfully. ### `--output-format text` (Go CLI compatible) -Prints progress and success messages as functions are downloaded. +Prints progress and success messages as functions are downloaded. The Docker-unbundle path prints +`Downloading function: ` (lowercase "function", unlike the `--use-api` path's "Downloading +Function:") and does **not** print a final "Downloaded Function ... from project ..." line — that +line only appears on the `--use-api` and `--legacy-bundle` paths (Go parity, `download.go`). ### `--output-format json` Prints a structured success result with the downloaded function slugs and project ref. On the -Docker/legacy-bundle proxy path, the Go child's stdout is captured/discarded (never inherited) so -it can't corrupt the envelope; the slug list is resolved independently for the payload. +`--legacy-bundle` proxy path, the Go child's stdout is captured/discarded (never inherited) so it +can't corrupt the envelope; the slug list is resolved independently for the payload. On the +Docker-unbundle path, the `unbundle` container's own stdout is routed to stderr instead of stdout +for the same reason. ### `--output-format stream-json` -Same envelope as `json` above (including on the proxy path). +Same envelope as `json` above (including on the proxy and Docker-unbundle paths). ## Notes - If no function name is provided, downloads all functions. - Requires a linked project (`--project-ref` or linked project config). -- Native downloads reject path traversal and symlink escapes before writing source files. -- `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`. -- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` proxies to the Go binary's Docker-based unbundler unless `--use-api` resolves to `true`, which forces the native server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = false }` reads the resolved flag value, not presence — `--use-api=false` still proxies). -- If Docker is not running, the Go binary itself prints `WARNING: Docker is not running` to stderr and falls back to its own server-side unbundler — the command still exits `0` without Docker installed or running. -- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. The Go proxy call itself also only ever forwards one of `--use-docker`/`--legacy-bundle`, never both, even though `--use-docker` defaults to `true`. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. +- The `--use-api` path rejects path traversal and symlink escapes before writing source files + (`resolveDownloadDestination`/`ensureContainedPath`) — the Docker-unbundle path has no equivalent + check of its own; it delegates the actual file writes to the `unbundle` subcommand running inside + the edge-runtime container, through the `supabase/functions` bind mount, matching Go's own + `extractOne` (which has no path-containment check either — this is a pre-existing, not + CLI-1963-introduced, gap shared with the Go CLI). Slugs sourced from the Management API's function + list (downloading-all) are validated against the same pattern as user-supplied slugs, on both + paths, before any per-slug download runs (CLI-1891 parity). +- `--legacy-bundle` is a hidden flag forwarded to the Go binary for backward compatibility — it + requires installing a real Deno binary on the host (`InstallOrUpgradeDeno`) and is a pre-1.120.0 + compatibility fallback; native TS port tracked separately (CLI-1963). `--use-docker` is a hidden + flag but now runs natively. +- `--use-docker`, `--use-api`, and `--legacy-bundle` are mutually exclusive. +- `--use-docker` defaults to `true` (Go parity), so a bare `supabase functions download` runs the + native Docker-unbundle downloader unless `--use-api` resolves to `true`, which forces the native + server-side download path instead (`apps/cli-go/cmd/functions.go:51-53`: `if useApi { useDocker = +false }` reads the resolved flag value, not presence — `--use-api=false` still runs Docker-unbundle). +- If Docker is not running, this command itself prints `WARNING: Docker is not running` to stderr + and falls back to the native server-side unbundler — the command still exits `0` without Docker + installed or running. +- The mutual-exclusivity check only counts flags the user explicitly passed on the command line, + not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" + error. The `--legacy-bundle` Go proxy call itself only ever forwards `--legacy-bundle`, never + `--use-docker` alongside it, even though `--use-docker` defaults to `true`. +- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a + project ref. diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 4b666da7f6..27f6ca2330 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,9 +1,13 @@ +import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { downloadFunctions, - makeGoProxyDownloadArgs, + makeGoProxyLegacyBundleArgs, } from "../../../../shared/functions/download.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -22,12 +26,28 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu const proxy = yield* LegacyGoProxy; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( + join(cliConfig.workdir, "supabase"), + ); let resolvedProjectRef = Option.none(); yield* downloadFunctions(flags, { api, projectRoot: cliConfig.workdir, rawArgs, + goConfigCompat: legacyFunctionsGoConfigCompat, + edgeRuntimeVersion, + // Go: `utils.Bold` on the `Downloading function:` slug (`downloadOne`, + // `download.go:219`, stderr) — matches `legacyBold`'s default TTY gate. + styleEmphasis: (text) => legacyBold(text), + // Go: `utils.Aqua` on the suggested `--legacy-bundle` command + // (`suggestLegacyBundle`, `download.go:315`, stderr) — matches + // `legacyAqua`'s default TTY gate. + styleAqua: (text) => legacyAqua(text), + // Go: `utils.Yellow` on the `WARNING:` token before "Docker is not + // running" (`download.go:146`, stderr) — matches `legacyYellow`'s default + // TTY gate. + styleWarning: (text) => legacyYellow(text), resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( Effect.tap((ref) => @@ -47,11 +67,13 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu // pattern for the CLI-1546 "stdout is payload-only in machine mode" // invariant — `downloadFunctions` emits the `Output` envelope itself. proxyDownload: (proxyFlags, projectRef, captureOutput) => { - const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const args = makeGoProxyLegacyBundleArgs(proxyFlags.functionName, projectRef); const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; return captureOutput - ? Effect.asVoid(proxy.execCapture(args, { env, stdin: "ignore" })) - : proxy.exec(args, { env }); + ? Effect.asVoid( + proxy.execCapture(args, { env, stdin: "ignore", suppressChildTelemetry: true }), + ) + : proxy.exec(args, { env, suppressChildTelemetry: true }); }, }).pipe( Effect.ensuring( diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index af37969828..a5e5546b32 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; @@ -17,12 +20,110 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { legacyContainerRuntimeNotFoundMessage } from "../../../shared/legacy-container-cli.ts"; +import { downloadFunctions } from "../../../../shared/functions/download.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; +const PROJECT_ID = "abcdefghijklmnopqrst"; + +/** + * Mutates the shared spawner options object from inside `onSpawn`, scoped to + * the `docker run ... unbundle` invocation specifically — every earlier + * Docker call (`info`, `network inspect`, `volume create`) in the same test + * already resolved by the time this fires, since `download.ts` awaits each + * child process sequentially, so this only ever affects the unbundle step's + * own exit code/stdio. + */ +function mockDockerUnbundle( + opts: { + readonly runExitCode?: number; + readonly runStdout?: ReadonlyArray; + readonly runStderr?: ReadonlyArray; + } = {}, +) { + const spawnerOpts: { + exitCode?: number; + stdout?: string[]; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if (record.command === "docker" && record.args[0] === "run") { + spawnerOpts.exitCode = opts.runExitCode ?? 0; + spawnerOpts.stdout = opts.runStdout === undefined ? [] : [...opts.runStdout]; + spawnerOpts.stderr = opts.runStderr === undefined ? [] : [...opts.runStderr]; + } + }; + return mockChildProcessSpawner(spawnerOpts); +} + +/** + * A real ENOENT-style spawn failure for the `docker run ... unbundle` step + * specifically — distinct from `mockDockerUnbundle`'s non-zero exit code, + * which models the container starting but the `unbundle` binary itself + * failing. This models `child_process.spawn` (or the container runtime + * binary) never starting at all, which `runChildProcess` surfaces as an + * `unknown` cause rather than an `{ exitCode, stdout, stderr }` result. + * Mirrors `legacy-container-cli.unit.test.ts`'s `mockSpawner({ bothMissing: + * true })`: failing both the `docker` and `podman` fallback attempts for the + * `run` step is what makes `spawnContainerCli` surface + * `legacyContainerRuntimeNotFoundMessage` instead of retrying indefinitely. + * Every other Docker call (`info`, `network inspect`, `volume create`) + * succeeds with exit code 0, so only the unbundle step itself fails. + */ +function mockDockerRunSpawnFailure() { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const cmd = command._tag === "StandardCommand" ? command.command : ""; + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ command: cmd, args }); + + if (args[0] === "run") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${cmd} not found`, + }), + ); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + get spawned() { + return spawned; + }, + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + }; +} + const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); // `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through @@ -157,49 +258,70 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); - it.live("proxies to Docker by default (Go parity), with no flags passed", () => { - const out = mockOutput({ format: "text" }); - const api = mockLegacyPlatformApi(); - const proxy = mockProxy(); - const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - }), - proxy.layer, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), - ); + it.live( + "runs the native Docker unbundle path by default (Go parity), with no flags passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Non-empty stdout/stderr on the `docker run` step exercises both the + // text-mode stdout routing branch and the always-to-stderr container + // stderr branch in `downloadWithDockerUnbundle`. + const child = mockDockerUnbundle({ + runStdout: ["unbundle: wrote index.ts"], + runStderr: ["unbundle: warning about deno.json"], + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); - return Effect.gen(function* () { - // `useDocker: true` mirrors what the CLI parser now resolves to by - // default (CLI-1862) — no `--use-docker` flag appears in argv above. - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + return Effect.gen(function* () { + // `useDocker: true` mirrors what the CLI parser now resolves to by + // default (CLI-1862) — no `--use-docker` flag appears in argv above. + // CLI-1963: this now runs the native Docker-unbundle path instead of + // delegating to the Go proxy. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - expect(api.requests).toEqual([]); - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ], - ]); - // The delegated Go binary must not also fire its own - // `cli_command_executed` on top of this command's own instrumentation. - expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - }).pipe(Effect.provide(layer)); - }); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect(api.requests.some((request) => request.url.endsWith("/hello-world/body"))).toBe( + true, + ); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + expect(out.stdoutText).toContain("unbundle: wrote index.ts\n"); + expect(out.stderrText).toContain("unbundle: warning about deno.json\n"); + // Go parity finding (CLI-1963 audit): unlike the server-side and + // `--legacy-bundle` paths, `downloadWithDockerUnbundle` never prints + // a "Downloaded Function ... from project ..." success line — + // guarded here against a future accidental regression. + expect(out.stderrText).not.toContain("Downloaded Function"); + // No `--debug` — the temp eszip file is removed after the run. + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); it.live( "does not treat the --use-docker default as conflicting with an explicit --use-api", @@ -251,10 +373,186 @@ describe("legacy functions download", () => { }, ); - it.live("still proxies to Docker when --use-api=false is passed explicitly", () => { + it.live( + "still runs the native Docker unbundle path when --use-api=false is passed explicitly", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api=false", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + // Go's override is value-based (`if useApi { useDocker = false }`, + // apps/cli-go/cmd/functions.go:51-53), not presence-based. An + // explicit `--use-api=false` must not be treated like `--use-api` — + // it should leave the `--use-docker` default (true) in effect and + // still run the native Docker path (CLI-1963). + yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "emits a JSON success envelope when running the native Docker path in machine-output mode", + () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Non-empty container stdout exercises the machine-mode branch that + // routes it to stderr instead of stdout (CLI-1546: stdout stays + // payload-only in json/stream-json modes). + const child = mockDockerUnbundle({ runStdout: ["unbundle: wrote index.ts"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path; + // this asserts the JSON envelope this command emits itself still + // shows up correctly, with no delegated Go child's stdout to worry + // about capturing. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toContain("unbundle: wrote index.ts\n"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { function_slugs: ["hello-world"], project_ref: PROJECT_ID }, + }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("lists remote functions and downloads each natively via Docker in machine mode", () => { + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed( + legacyJsonResponse(request, 200, [ + { slug: "hello-world" }, + { slug: "goodbye-world" }, + ]), + ) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + PROJECT_ID, + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.filter( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toHaveLength(2); + // The edge-runtime image is resolved/pulled once for the whole + // invocation, not once per function — see `PulledEdgeRuntimeImage`'s + // doc comment in `download.ts`. + expect( + child.spawned.filter( + (spawned) => + spawned.command === "docker" && + spawned.args[0] === "image" && + spawned.args[1] === "inspect", + ), + ).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + function_slugs: ["hello-world", "goodbye-world"], + project_ref: PROJECT_ID, + }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs docker with the expected binds, network, and unbundle command", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -262,44 +560,857 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", "hello-world", - "--use-api=false", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + // Go: `extractOne` (`download.go:260-266`) — bind order and network + // reuse the same primitives `deploy.ts`'s own Docker-bundling path + // already uses. + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + expect(child.spawned.find((spawned) => spawned.args[0] === "volume")).toEqual({ + command: "docker", + args: [ + "volume", + "create", + "--label", + `com.supabase.cli.project=${PROJECT_ID}`, + "--label", + `com.docker.compose.project=${PROJECT_ID}`, + `supabase_edge_runtime_${PROJECT_ID}`, + ], + }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + const functionsDir = resolve(tempRoot.current, "supabase", "functions"); + expect(runCommand?.args).toContain( + `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, + ); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, + ); + expect(runCommand?.args).toContain(`${functionsDir}:/home/deno:rw`); + expect(runCommand?.args).toContain("--network"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + // The unbundle tail is always the LAST 6 args regardless of whether + // `--add-host` (Linux-only) was inserted before it. + expect(runCommand?.args.slice(-6)).toEqual([ + `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, + "unbundle", + "--eszip", + "/root/eszips/output_hello-world.eszip", + "--output", + "/home/deno/hello-world", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits the named Deno cache volume bind on Bitbucket", () => { + // Go's `DockerStart` drops the named-volume bind entirely on Bitbucket + // (`internal/utils/docker.go:400-405`) rather than just skipping its + // explicit creation — `docker run -v :...` would otherwise still + // implicitly create the named volume, which Bitbucket's restricted Docker + // environment doesn't allow (review round on CLI-1963's `functions + // download` port; `deploy.ts`'s `buildDockerBinds` already applies this + // same carve-out). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, ]), }), ); - return Effect.gen(function* () { - // Go's override is value-based (`if useApi { useDocker = false }`, - // apps/cli-go/cmd/functions.go:51-53), not presence-based. An explicit - // `--use-api=false` must not be treated like `--use-api` — it should - // leave the `--use-docker` default (true) in effect and still proxy. - yield* legacyFunctionsDownload({ ...baseFlags, useApi: false, useDocker: true }); + const previousBitbucketCloneDir = process.env["BITBUCKET_CLONE_DIR"]; + process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).not.toContain( + `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, + ); + const hostEszipPath = resolve( + tempRoot.current, + "supabase", + ".temp", + "output_hello-world.eszip", + ); + expect(runCommand?.args).toContain( + `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, + ); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousBitbucketCloneDir === undefined) { + delete process.env["BITBUCKET_CLONE_DIR"]; + } else { + process.env["BITBUCKET_CLONE_DIR"] = previousBitbucketCloneDir; + } + }), + ), + ); + }); + + it.live("requests the raw eszip body instead of a negotiated JSON response", () => { + // `v1GetAFunctionBody`'s generated contract marks its response + // `kind: "json"`, so `executeRaw` would otherwise default to + // `Accept: application/json` (`buildRequest`'s unconditional `acceptJson` + // for json-kind operations) and risk a negotiated JSON response instead + // of the raw eszip body Go's un-overridden request receives (review round + // on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const bodyRequest = api.requests.find((request) => request.url.endsWith("/hello-world/body")); + expect(bodyRequest?.headers["accept"]).toBe("*/*"); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses an explicit --network-id override instead of the derived network name", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "custom-network", + ]), + }), + ); + + return Effect.gen(function* () { + // `--network-id` is a persistent root flag (`cmd/root.go:328`), not + // registered on `functions download` itself — `lastExplicitLongFlagValue` + // scans the whole argv unscoped. + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "custom-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("custom-network"); + expect(runCommand?.args).not.toContain(`supabase_network_${PROJECT_ID}`); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "falls back to the generated network name when --network-id is passed with an empty value", + () => { + // Go only overrides the network when `len(viper.GetString("network-id")) > 0` + // (`internal/utils/docker.go:379-382`) — an explicit-but-empty + // `--network-id=` must fall through to the generated network name just + // like an omitted flag (review round on CLI-1963's `functions download` + // port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id=", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("honors the final occurrence of a repeated --network-id flag", () => { + // pflag/viper string flags are shared-variable, last-`Set()`-wins + // (confirmed empirically: `pflag.FlagSet.Parse` on + // `--network-id old --network-id custom-network` resolves to + // `custom-network`) — `lastExplicitLongFlagValue` must keep scanning past the + // first match instead of returning early (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "old-network", + "--network-id", + "custom-network", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("custom-network"); + expect(runCommand?.args).not.toContain("old-network"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "falls back to the generated network name when the final --network-id occurrence is empty", + () => { + // Same last-wins rule as above, applied to Go's `len(networkId) > 0` + // gate: a non-empty default followed by an explicit-but-empty override + // must fall through to the generated network name, not the earlier + // non-empty value. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "custom-network", + "--network-id=", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).not.toContain("custom-network"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not climb to an ancestor project's config.toml for the Docker download path", + () => { + // Go's `flags.LoadConfig` only ever resolves `supabase/config.toml` from + // the already-resolved workdir, with no ancestor climb + // (`NewPathBuilder`, `pkg/config/utils.go:43-48`) — mirrored here by + // `resolveEdgeRuntimeImage`'s `search: false` (review round on + // CLI-1963's `functions download` port). A nested workdir with no + // `supabase/config.toml` of its own must fall back to `--project-ref` + // for network/volume naming, not an ancestor project's configured + // `project_id`, even though `cliConfig.workdir` sits right inside one. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const nestedWorkdir = join(tempRoot.current, "nested"); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: nestedWorkdir }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "ancestor-project"', ""].join("\n"), + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); + expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("prefers config.toml over a stray config.json for the Docker download path", () => { + // Go's `NewPathBuilder`/`Config.Load` (`pkg/config/utils.go:43-48`) has + // no concept of a JSON project config file — it always resolves + // `supabase/config.toml`, mirrored here by `resolveEdgeRuntimeImage`'s + // `tomlOnly: true` (review round on CLI-1963's `functions download` + // port). A workdir with both files must resolve `project_id` from + // `config.toml`, not prefer the JSON file as the package loader + // otherwise would. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "toml-project"', ""].join("\n"), + ), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.json"), + JSON.stringify({ project_id: "json-project" }), + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("supabase_network_toml-project"); + expect(runCommand?.args).not.toContain("supabase_network_json-project"); + }).pipe(Effect.provide(layer)); + }); + + it.live("skips network creation for a container: network mode", () => { + // Go's `container.NetworkMode.IsUserDefined()` + // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly + // excludes `IsContainer()` — `--network-id container:redis` attaches to + // another container's network stack, so `DockerNetworkCreateIfNotExists` + // never inspects or creates a network for it, and the mode is passed + // straight through to `docker run --network` (review round on + // CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "container:redis", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toBeUndefined(); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("container:redis"); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not double-prefix an already v-prefixed edge-runtime-version pin", () => { + // Go's `replaceImageTag` (`pkg/config/utils.go:81-84`) appends the pin + // file's raw content verbatim after the image's `:`, so a pin already + // carrying its own `v` prefix (a legitimate form — see + // `legacy-edge-runtime-image.unit.test.ts`'s own `"v9.9.9"` fixture) must + // not be prepended with a second `v` (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase", ".temp"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe("public.ecr.aws/supabase/edge-runtime:v9.9.9"); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps the temporary eszip file when --debug is passed", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--debug", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "removes the temporary eszip file when --debug=false overrides the flag's own presence", + () => { + // Go gates this on `viper.GetBool("DEBUG")`, so an explicit + // `--debug=false` resolves to `false` (cleanup runs) — a presence-only + // check would get this backwards and treat `--debug=false` like + // `--debug` (review round on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--debug=false", + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails on an invalid project config before falling back when Docker is not running", + () => { + // Go's `Run` calls `flags.LoadConfig(fsys)` unconditionally at the very + // top, before checking whether Docker is running (`download.go:135-138`) + // — an invalid `supabase/config.toml` must fail up front instead of + // silently falling through to the server-side path's API/filesystem + // side effects (review round on CLI-1963's `functions download` port). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + // Every docker command (including the `docker info` probe) fails, + // modeling Docker not running. + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 3", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + describe("docker unbundle container failures", () => { + it.live("fails with the legacy-bundle suggestion when the container exits non-zero", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["boom"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("error running container: exit 1"); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "prepends the deno v2 suggestion when deno_version is 1 and the container reports an invalid eszip", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ + runExitCode: 1, + // Go's scanner requires a full-line, case-insensitive match + // (`strings.EqualFold(line, "invalid eszip v2")`, `download.go:295`) + // — a line merely containing the phrase as a substring (e.g. + // "error: invalid eszip v2 header") does not fire the suggestion. + runStderr: ["invalid eszip v2"], + }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("error running container: exit 1"); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "Please use deno v2 in supabase/config.toml to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n" + + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "does not prepend the deno v2 suggestion when deno_version is 1 but the container's error is unrelated", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockDockerUnbundle({ runExitCode: 1, runStderr: ["permission denied"] }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); - expect(api.requests).toEqual([]); - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ], - ]); - expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - }).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + }).pipe(Effect.provide(layer)); + }, + ); }); - it.live("emits a JSON success envelope when proxying to Docker in machine-output mode", () => { - const out = mockOutput({ format: "json" }); + it.live("fails when ensureDockerNetwork can't create a missing network", () => { + const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); const proxy = mockProxy(); + const spawnerOpts: { + exitCode?: number; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + spawnerOpts.exitCode = record.command === "docker" && record.args[0] === "network" ? 1 : 0; + spawnerOpts.stderr = ["permission denied"]; + }; + const child = mockChildProcessSpawner(spawnerOpts); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -307,63 +1418,47 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", "hello-world", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", - "--output-format", - "json", + PROJECT_ID, ]), }), ); return Effect.gen(function* () { - // CLI-1546: stdout is payload-only in machine mode, so the Go child's - // raw output must be captured/discarded (not inherited) and this - // command must emit the `Output` envelope itself, matching the native - // path's shape. - yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); - expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - [ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ], - ]); - expect(proxy.captureEnvs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - data: { function_slugs: ["hello-world"], project_ref: "abcdefghijklmnopqrst" }, - }), + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to create docker network: supabase_network_${PROJECT_ID}`, ); + expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); + expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); + // Go parity fix (CLI-1963 review): `Effect.ensuring` wraps the whole + // Docker-extraction sequence, so the temp eszip written just before it + // is still cleaned up even though the failure happened before Docker + // ever ran — not only after a successful `runChildProcess` call. + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); }).pipe(Effect.provide(layer)); }); it.live( - "lists remote functions before delegating when no function name is given in machine mode", + "fails with the docker-step prefix when the unbundle container itself cannot be spawned", () => { - const out = mockOutput({ format: "json" }); - const api = mockLegacyPlatformApi({ - handler: (request) => - request.url.endsWith("/functions") - ? Effect.succeed( - legacyJsonResponse(request, 200, [ - { slug: "hello-world" }, - { slug: "goodbye-world" }, - ]), - ) - : Effect.succeed(legacyJsonResponse(request, 200, {})), - }); + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); const proxy = mockProxy(); + const child = mockDockerRunSpawnFailure(); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -371,38 +1466,40 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", "download", + "hello-world", + "--use-docker", "--project-ref", - "abcdefghijklmnopqrst", - "--output-format", - "json", + PROJECT_ID, ]), }), ); return Effect.gen(function* () { - yield* legacyFunctionsDownload({ - ...baseFlags, - functionName: Option.none(), - useDocker: true, - }); + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); - expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], - ]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - data: { - function_slugs: ["hello-world", "goodbye-world"], - project_ref: "abcdefghijklmnopqrst", - }, - }), + // Distinct from `ensureDockerNetwork`/`ensureDockerNamedVolume` + // failures (asserted above), which already self-describe and must + // NOT gain this prefix — a bare spawn/runtime-not-found failure from + // `runChildProcess` itself carries no context of its own about which + // command was running, so `withDockerStepFailure` adds one. + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to run the edge-runtime unbundle container: ${legacyContainerRuntimeNotFoundMessage}`, ); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + "\nIf your function is deployed using CLI < 1.120.0, trying running supabase functions download --legacy-bundle hello-world instead.", + ); + expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(true); + expect( + existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + ).toBe(false); }).pipe(Effect.provide(layer)); }, ); @@ -418,6 +1515,11 @@ describe("legacy functions download", () => { : Effect.succeed(legacyJsonResponse(request, 200, {})), }); const proxy = mockProxy(); + // Deterministic stand-in for `emptyEnv()`'s real `ChildProcessSpawner` + // (via `BunServices`, pulled in by `buildLegacyTestRuntime`) — `useDocker: + // true` still probes `docker info` even though this project has no + // functions to download, so this must not spawn a real `docker` process. + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -425,6 +1527,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -470,6 +1573,7 @@ describe("legacy functions download", () => { : Effect.succeed(legacyJsonResponse(request, 200, {})), }); const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, @@ -477,6 +1581,7 @@ describe("legacy functions download", () => { cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), }), proxy.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -506,6 +1611,59 @@ describe("legacy functions download", () => { }).pipe(Effect.provide(layer)); }); + it.live("fails loudly instead of silently dropping a malformed function-list entry", () => { + // Go: `FunctionResponse.Slug` (`pkg/api/types.gen.go:6465`) is a + // required, non-pointer `string` — a list entry with no "slug" key + // decodes to the zero value "" and then fails `ValidateFunctionSlug` + // in `downloadAll` (`download.go:182-188`), rather than being dropped + // from the list. A malicious/compromised API response returning + // `[{}]` must surface an error here too, never "No functions found." + // nor a silent partial download (review round on CLI-1963's + // `functions download` port). + const out = mockOutput({ format: "json" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/functions") + ? Effect.succeed(legacyJsonResponse(request, 200, [{}])) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "--project-ref", + "abcdefghijklmnopqrst", + "--output-format", + "json", + ]), + }), + ); + + return Effect.gen(function* () { + const exit = yield* legacyFunctionsDownload({ + ...baseFlags, + functionName: Option.none(), + useDocker: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(proxy.calls).toEqual([]); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ type: "success", message: "No functions found." }), + ); + }).pipe(Effect.provide(layer)); + }); + it.live("forwards only --legacy-bundle to the Go proxy, not the --use-docker default too", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi(); @@ -664,4 +1822,487 @@ describe("legacy functions download", () => { expect(proxy.calls).toEqual([]); }).pipe(Effect.provide(layer)); }); + + describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { + it.live( + "fails before any Docker/API work when config.toml has an explicit empty project_id", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "config.toml"), 'project_id = ""\n'), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Missing required field in config: project_id"); + expect(api.requests).toEqual([]); + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + + const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + expect(api.requests).toEqual([]); + expect(child.spawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + "public.ecr.aws/supabase/edge-runtime:v1.68.4", + ); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "env-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("env-network"); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }, + ); + + it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + "--network-id", + "flag-network", + ]), + }), + ); + + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ + command: "docker", + args: ["network", "inspect", "flag-network"], + }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toContain("flag-network"); + expect(runCommand?.args).not.toContain("env-network"); + }) + .pipe(Effect.provide(layer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ), + ); + }); + + it.live( + "resolves a registry override configured only via project dotenv, not the ambient shell", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io\n", + ), + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + ); + expect( + child.spawned.filter( + (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", + ), + ).toHaveLength(1); + // Proves the registry came from the project dotenv file, not the + // ambient shell environment. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("falls back to the GHCR candidate when the ECR image cannot be inspected", () => { + // Simulates a registry-candidate fallback with no real pull/retry: the + // ECR `docker image inspect` MISSes cleanly (non-zero exit, "not found" + // stderr), so `hasLocalImage` moves straight to the next candidate + // instead of ever entering the sleeping pull-retry loop. + const spawnerOpts: { + exitCode?: number; + stderr?: string[]; + onSpawn?: (record: { command: string; args: ReadonlyArray }) => void; + } = { exitCode: 0 }; + spawnerOpts.onSpawn = (record) => { + if ( + record.command === "docker" && + record.args[0] === "image" && + record.args[1] === "inspect" + ) { + const image = record.args[2] ?? ""; + const isEcrCandidate = image.startsWith("public.ecr.aws/"); + spawnerOpts.exitCode = isEcrCandidate ? 1 : 0; + spawnerOpts.stderr = isEcrCandidate ? [`Error: No such image: ${image}`] : []; + return; + } + spawnerOpts.exitCode = 0; + spawnerOpts.stderr = []; + }; + const child = mockChildProcessSpawner(spawnerOpts); + + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect( + child.spawned.filter( + (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", + ), + ).toHaveLength(2); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args.slice(-6)[0]).toBe( + `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "labels the unbundle container with the resolved project id (Go parity: docker.go:349-386)", + () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).toEqual( + expect.arrayContaining([ + "--label", + `com.supabase.cli.project=${PROJECT_ID}`, + "--label", + `com.docker.compose.project=${PROJECT_ID}`, + ]), + ); + }).pipe(Effect.provide(layer)); + }, + ); + }); + + describe("docker-not-running warning styling (Go parity: download.go:146; only WARNING: is styled)", () => { + it.live("wraps only the WARNING token, not the rest of the fallback line", () => { + // Calls the shared `downloadFunctions` with a marker `styleWarning` + // instead of going through `legacyFunctionsDownload`: the real hook + // (`legacyYellow`) is TTY-gated and therefore inert under vitest, so + // only an injected marker can deterministically observe styling scope. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const platformApi = yield* LegacyPlatformApi; + + yield* downloadFunctions( + { ...baseFlags, useDocker: true }, + { + api: platformApi, + projectRoot: tempRoot.current, + rawArgs: ["functions", "download", "hello-world", "--project-ref", PROJECT_ID], + goConfigCompat: legacyFunctionsGoConfigCompat, + edgeRuntimeVersion: "1.69.12", + resolveProjectRef: () => Effect.succeed(PROJECT_ID), + proxyDownload: () => Effect.die("unexpected proxy invocation"), + styleWarning: (text) => `${text}`, + }, + ); + + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + }).pipe(Effect.provide(layer)); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index b4348baf5c..47c7799fa4 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsListNetworkError extends Data.TaggedError( "LegacyFunctionsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( "LegacyFunctionsListUnexpectedStatusError", @@ -12,10 +25,18 @@ export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyFunctionsEnvNotSupportedError extends Data.TaggedError( "LegacyFunctionsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.unit.test.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.unit.test.ts new file mode 100644 index 0000000000..6fbb798a20 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; +import { LegacyFunctionsListUnexpectedStatusError } from "./list.errors.ts"; + +describe("LegacyFunctionsListUnexpectedStatusError actionability", () => { + it("keeps collection-level 404s on the API-status policy", () => { + expect( + classifyCliErrorActionability( + new LegacyFunctionsListUnexpectedStatusError({ + status: 404, + body: "not found", + message: "unexpected list functions status 404: not found", + }), + ), + ).toMatchObject({ + error_kind: "external_service", + error_category: "api_status", + error_fingerprint: "tag:LegacyFunctionsListUnexpectedStatusError:api_status", + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index bf86350057..e4a669f7e8 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -88,6 +88,7 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* yield* fetching?.fail() ?? Effect.void; return yield* new LegacyFunctionsListNetworkError({ message: decodedFunctions.message, + decode: true, }); } yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/functions/new/new.errors.ts b/apps/cli/src/legacy/commands/functions/new/new.errors.ts index ac34daf516..2d5916b53c 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.errors.ts @@ -1,11 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsNewInvalidSlugError extends Data.TaggedError( "LegacyFunctionsNewInvalidSlugError", )<{ readonly message: string; readonly detail: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( "LegacyFunctionsNewFileExistsError", @@ -13,12 +22,20 @@ export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( readonly path: string; readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFunctionsNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Maps an arbitrary thrown cause from a filesystem write to a typed diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index e7382f532d..fd1aac8776 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -2,16 +2,18 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — Go-parity project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally, matching Go's `Config.Load` — read even though `serve` doesn't otherwise use their contents | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | ## Files Written @@ -42,14 +44,17 @@ validation is performed on the discovered URLs, also matching the Go CLI. ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror | no (defaults to `public.ecr.aws`) | +| Variable | Purpose | Required? | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | +| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | +| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | +| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | +| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | +| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | +| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | +| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading, matching Go's `loadNestedEnv` | no | ## Exit Codes @@ -57,7 +62,7 @@ validation is performed on the discovered URLs, also matching the Go CLI. | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | | `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | -| `1` | invalid inspect flag combination or invalid project/auth config | +| `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | | `1` | env file, signing key, import map, or function bind resolution failure | | `1` | edge-runtime container startup, log streaming, or restart loop failure | @@ -96,6 +101,9 @@ Long-running raw log / error events only; there is no terminal `result` event on - named volume: `supabase_edge_runtime_` - network: `supabase_network_` unless `--network-id` overrides it - Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`, matching the Go serve path. -- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not mutate `process.env` or move/hide any project files. +- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`, matching Go) and passed into `loadProjectConfig`. The command does not move/hide any project files. One `process.env` mutation exists: the Go-parity config pipeline (`legacyLoadLocalProjectContext`, shared with `deploy`/`download`/`start`) installs a project-dotenv-only `BITBUCKET_CLONE_DIR` into `process.env`, matching Go's `loadNestedEnv` `os.Setenv` behavior. +- Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. +- Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook), matching Go's `flags.LoadConfig` -> `Config.Validate`. - A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container. The Go CLI never auto-restarts a crashed container. - The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. +- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 86b51f387b..9f66fe7a34 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { join } from "node:path"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -34,5 +35,6 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function networkId, projectIdOverride: cliConfig.projectId, goViperCompat: true, + goConfigCompat: legacyFunctionsGoConfigCompat, }).pipe(Effect.ensuring(telemetryState.flush)); }); 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 f7820c3313..4198b91fdb 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 @@ -14,7 +14,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { toDockerPath } from "../../../../shared/functions/deploy.ts"; +import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; import { mockOutput, mockProcessControl, @@ -74,11 +74,12 @@ const deployMockState = vi.hoisted(() => ({ }, })); -vi.mock("../../../../shared/functions/deploy.ts", async () => { - const actual = await vi.importActual( - "../../../../shared/functions/deploy.ts", - ); +vi.mock("../../../../shared/functions/functions-docker.ts", async () => { + const actual = await vi.importActual< + typeof import("../../../../shared/functions/functions-docker.ts") + >("../../../../shared/functions/functions-docker.ts"); const { Effect } = await import("effect"); + const { legacyGetRegistryImageUrl } = await import("../../../shared/legacy-docker-registry.ts"); return { ...actual, @@ -90,6 +91,18 @@ vi.mock("../../../../shared/functions/deploy.ts", async () => { Effect.sync(() => { deployMockState.volumeCalls.push({ volumeName, projectId }); }), + // Stubbed to the pure registry-mapping step only, skipping the actual + // cache-check/pull: the real implementation + // (`legacyMakeDockerImageResolver`) does `docker image inspect`/`docker + // pull` via the real `ChildProcessSpawner` directly (not through this + // file's mocked `runChildProcess` below), so leaving it real here would + // insert un-mocked spawns — and real 4s/8s retry backoffs on a miss — + // into every test that reaches container start. Registry + // resolution/retry has its own coverage in `functions-docker.unit.test.ts`. + resolveFunctionsDockerImage: ( + image: string, + projectEnvValues?: Readonly>, + ) => Effect.sync(() => legacyGetRegistryImageUrl(image, projectEnvValues)), runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => Effect.suspend(() => { const envFile = args.flatMap((value, index) => @@ -488,7 +501,10 @@ describe("legacy functions serve integration", () => { expect(dockerRun.args).toContain("supabase_network_test-project"); 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"); + // The pin's content is applied VERBATIM as the tag (Go's + // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays + // bare, no `v` synthesized. + expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); expect( extractFlagValues(dockerRun.args, "-v").some((value) => value.endsWith(":/root/index.ts:ro,Z"), @@ -2752,6 +2768,242 @@ describe("legacy functions serve integration", () => { }); }); + describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { + it.live( + "fails before any Docker work when config.toml has an explicit empty project_id", + () => { + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig('project_id = ""\n')); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe("Missing required field in config: project_id"); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); + }); + }, + ); + + it.live( + "fails before any Docker work on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + // Proves the WHOLE resolved config is validated, not just `project_id` + // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` + // branch (`config.go:1034-1062`). + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe( + "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", + ); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.networkCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); + }); + }, + ); + + it.live( + "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun).toBeDefined(); + if (dockerRun === undefined) { + throw new Error("expected docker run call"); + } + expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); + }); + }, + ); + + it.live( + "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", + () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + expect(deployMockState.networkCalls).toEqual([ + { networkMode: "env-network", projectId: "test-project" }, + ]); + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun?.args).toContain("env-network"); + }); + }, + ); + + it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "run") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_NETWORK_ID"]; + } else { + process.env["SUPABASE_NETWORK_ID"] = previous; + } + }), + ); + + yield* Effect.promise(() => + writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + ); + yield* Effect.promise(() => + writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + ); + yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + + const { layer } = setupServe({ childSpawner, networkId: Option.some("flag-network") }); + yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); + + expect(deployMockState.networkCalls).toEqual([ + { networkMode: "flag-network", projectId: "test-project" }, + ]); + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "run", + ); + expect(dockerRun?.args).toContain("flag-network"); + expect(dockerRun?.args).not.toContain("env-network"); + }); + }); + }); + it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { return Effect.gen(function* () { yield* Effect.promise(() => diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts index d2522fa035..8ae4c96c68 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Extracts a display message from a thrown `cause`. Every `Effect.try` catch in this * command's handler/signing-key resolver wraps a function that only ever throws a real @@ -28,26 +34,42 @@ export class LegacyGenBearerJwtRoleRequiredError extends Data.TaggedError( "LegacyGenBearerJwtRoleRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `supabase/config.toml` itself is malformed. Mirrors `gen signing-key`'s own error shape. */ export class LegacyGenBearerJwtConfigParseError extends Data.TaggedError( "LegacyGenBearerJwtConfigParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** `[auth].signing_keys_path` is configured but the file could not be read. */ export class LegacyGenBearerJwtReadError extends Data.TaggedError("LegacyGenBearerJwtReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** `[auth].signing_keys_path`'s file is not valid JSON / not a JWK array. */ export class LegacyGenBearerJwtDecodeError extends Data.TaggedError( "LegacyGenBearerJwtDecodeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Go's `getSigningKey` Branch A (`bearerjwt.go:46-50`): the pasted stdin JWK is not @@ -57,7 +79,11 @@ export class LegacyGenBearerJwtKeyParseError extends Data.TaggedError( "LegacyGenBearerJwtKeyParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Go's `getSigningKey` Branch B (`bearerjwt.go:67`): the entered kid matched no @@ -67,7 +93,11 @@ export class LegacyGenBearerJwtKeyNotFoundError extends Data.TaggedError( "LegacyGenBearerJwtKeyNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Go's `getSigningKey` Branch C (`bearerjwt.go:70-79`): the TTY key picker @@ -80,7 +110,11 @@ export class LegacyGenBearerJwtKeyPickerAbortedError extends Data.TaggedError( "LegacyGenBearerJwtKeyPickerAbortedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** * Go's `parseClaims` payload merge (`cmd/gen.go:209-211`). Byte-matches @@ -90,7 +124,11 @@ export class LegacyGenBearerJwtPayloadError extends Data.TaggedError( "LegacyGenBearerJwtPayloadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Go's `config.GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`) — unsupported @@ -100,4 +138,8 @@ export class LegacyGenBearerJwtPayloadError extends Data.TaggedError( */ export class LegacyGenBearerJwtSignError extends Data.TaggedError("LegacyGenBearerJwtSignError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts index cf53a22656..10e806e398 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts @@ -1,35 +1,64 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenSigningKeyConfigParseError extends Data.TaggedError( "LegacyGenSigningKeyConfigParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyGenerateError extends Data.TaggedError( "LegacyGenSigningKeyGenerateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} export class LegacyGenSigningKeyReadError extends Data.TaggedError("LegacyGenSigningKeyReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyDecodeError extends Data.TaggedError( "LegacyGenSigningKeyDecodeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyWriteError extends Data.TaggedError( "LegacyGenSigningKeyWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyCancelledError extends Data.TaggedError( "LegacyGenSigningKeyCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 5dde3f3fef..1285e3da84 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -1,8 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenTypesNetworkError extends Data.TaggedError("LegacyGenTypesNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( "LegacyGenTypesUnexpectedStatusError", @@ -10,16 +23,28 @@ export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( "LegacyInvalidGenTypesDurationError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( "LegacyInvalidGenTypesDatabaseUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts index 4b6bd8eb16..66d6948a2b 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts @@ -18,15 +18,16 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, mockTelemetryRuntime, mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -44,6 +45,8 @@ import { LegacyIdentityStitch } from "../../../shared/legacy-identity-stitch.ts" import { legacyGenTypesRuntimeLayer } from "./types.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-gen-types-layers-"); + /** * Stub layer satisfying every external service required by * `legacyGenTypesRuntimeLayer` from the root runtime. Services under test are @@ -77,7 +80,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. + legacyIsolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, analytics.layer, diff --git a/apps/cli/src/legacy/commands/init/init.errors.ts b/apps/cli/src/legacy/commands/init/init.errors.ts index 93854b4a04..abcdc38da8 100644 --- a/apps/cli/src/legacy/commands/init/init.errors.ts +++ b/apps/cli/src/legacy/commands/init/init.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * `supabase/config.toml` already exists and `--force` was not set. Reproduces * Go's wrapped `O_EXCL` open error from `utils.InitConfig` @@ -16,7 +22,11 @@ import { Data } from "effect"; export class LegacyInitConfigExistsError extends Data.TaggedError("LegacyInitConfigExistsError")<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--use-orioledb` without `--experimental`. Reproduces cobra's @@ -30,4 +40,8 @@ export class LegacyInitExperimentalRequiredError extends Data.TaggedError( "LegacyInitExperimentalRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md index 7bfc41e7a1..4c1baef3d3 100644 --- a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md @@ -36,12 +36,12 @@ no new config reads. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------------------------- | --------------------------------- | --------------------------------------- | -| `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | -| `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | -| `SUPABASE_PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | -| libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | +| Variable | Purpose | Required? | +| ---------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------- | +| `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | +| `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | +| `SUPABASE_PROJECT_ID` | project ref fallback (linked), superseded by `--project-ref` when set | no (config resolution fallback) | +| libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | ## Database Queries @@ -73,10 +73,11 @@ Deprecated aliases run an active subcommand's query: `cache-hit`→db-stats; ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------ | -| `0` | success | -| `1` | mutually-exclusive flags, resolution, connection, or query failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | mutually-exclusive flags, resolution, connection, or query failure | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Telemetry Events Fired @@ -119,3 +120,11 @@ Emit one extra stderr line before the table: from the absence of `--db-url` / `--local` while keeping the mutual-exclusivity check keyed off explicitly-set flags. - All queries are read-only `SELECT`s; the command performs no writes to the database. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution `LegacyDbConfigResolver` performs + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies + `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard + error rather than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). Shared verbatim by every `inspect db` subcommand via + `LEGACY_INSPECT_DB_FLAGS`. diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts index 73feddc40f..b72d108628 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts @@ -20,6 +20,11 @@ export const LEGACY_INSPECT_DB_FLAGS = { ), linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; /** @@ -34,7 +39,15 @@ export function legacyInspectDbCommandHandler( return (flags: LegacyInspectConnectionFlags) => handler(flags).pipe( withLegacyCommandInstrumentation({ - flags: { "db-url": flags.dbUrl, linked: flags.linked, local: flags.local }, + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "project-ref": flags.projectRef, + }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), withJsonErrorHandling, ); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts index d5863b58b1..82df4cda84 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts @@ -84,6 +84,7 @@ const flags: LegacyInspectConnectionFlags = { dbUrl: Option.none(), linked: false, local: true, + projectRef: Option.none(), }; // All deprecated-alias handlers share the same factory-produced type. diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts index d3cd34a7ed..84f6eac267 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts @@ -179,6 +179,7 @@ const flags = (over: Partial = {}): LegacyInspectC dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), }); describe("legacy inspect db query runner", () => { @@ -371,6 +372,44 @@ describe("legacy inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); + it.live("inspects the project given via --project-ref on the default linked path", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags({ projectRef: Option.some(FLAG_REF) })); + // `inspect db` never caches the ref — the resolver call it threads the flag + // into is the strongest observable this harness offers. + expect(resolver.resolveInput?.connType).toBe("linked"); + expect(resolver.resolveInput?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyInspectDbDbStats(flags({ local: true, projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + const error = failure.value; + expect(error).toBeInstanceOf(LegacyInspectMutuallyExclusiveFlagsError); + if (error instanceof LegacyInspectMutuallyExclusiveFlagsError) { + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + } + } + // The guard fires before any connection resolution. + expect(resolver.resolveInput).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("surfaces a query failure", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts index fddd4a3d9d..408103e61c 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -3,6 +3,11 @@ import { Data, Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; @@ -21,6 +26,8 @@ export interface LegacyInspectConnectionFlags { readonly dbUrl: Option.Option; readonly linked: boolean; readonly local: boolean; + // TS-only override of the linked project ref — see push.command.ts (db push). + readonly projectRef: Option.Option; } /** @@ -60,7 +67,11 @@ export interface LegacyInspectQuerySpec { */ export class LegacyInspectMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyInspectMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // --------------------------------------------------------------------------- // Cell formatters — pure, exported, unit-tested. Each reproduces a Go `fmt` @@ -196,10 +207,23 @@ export const legacyRunInspectQuery = Effect.fnUntraced(function* ( // so deriving the connType here does not re-trigger it. const connType = target.connType ?? "linked"; + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyInspectMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, + linkedProjectRef: flags.projectRef, }); const rows = yield* Effect.scoped( diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts index 983b9dcc41..1f8f33f2c2 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts @@ -75,7 +75,12 @@ function setup(rows: ReadonlyArray>) { }; } -const localFlags = { dbUrl: Option.none(), linked: false, local: true }; +const localFlags = { + dbUrl: Option.none(), + linked: false, + local: true, + projectRef: Option.none(), +}; type ParamKind = "none" | "schemas1" | "schemas2"; diff --git a/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts b/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts index fd125b66b7..e57a2c3b4f 100644 --- a/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts @@ -18,15 +18,16 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, mockTelemetryRuntime, mockTty, } from "../../../../tests/helpers/mocks.ts"; import { + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -44,6 +45,8 @@ import { LegacyIdentityStitch } from "../../shared/legacy-identity-stitch.ts"; import { legacyInspectBaseLayer } from "./inspect.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-inspect-layers-"); + /** * Stub layer satisfying every external service required by * `legacyInspectBaseLayer` from the root runtime. Services under test are @@ -75,7 +78,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. + legacyIsolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, analytics.layer, diff --git a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md index 4152af8961..10fe43236b 100644 --- a/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/inspect/report/SIDE_EFFECTS.md @@ -85,6 +85,7 @@ resolve the connection (via `LegacyDbConfigResolver`). | `1` | COPY failure (`failed to copy output`) / file-write failure (`failed to create output file`) | | `1` | malformed `config.toml` | | `1` | more than one of `--db-url` / `--linked` / `--local` | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | A **per-rule** csvq evaluation error does **not** fail the command — it becomes the rule's STATUS cell, matching Go. @@ -121,3 +122,13 @@ instead a structured result is emitted: ```json { "outputDir": "", "files": [{ "name": "locks", "path": "..." }, ...], "rules": [{ "name": "...", "status": "...", "matches": "..." }, ...] } ``` + +## Notes + +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution `LegacyDbConfigResolver` performs + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies + `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard + error rather than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). diff --git a/apps/cli/src/legacy/commands/inspect/report/report.command.ts b/apps/cli/src/legacy/commands/inspect/report/report.command.ts index e9d2f0abde..6d699f50b4 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.command.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.command.ts @@ -15,6 +15,11 @@ const config = { ), linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), outputDir: Flag.string("output-dir").pipe( Flag.withDescription("Path to save CSV files in."), Flag.withDefault("."), @@ -33,8 +38,12 @@ export const legacyInspectReportCommand = Command.make("report", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, "output-dir": flags.outputDir, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts index 38eece5203..d42f6823f7 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts @@ -1,4 +1,10 @@ import { Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A bounded, hand-written evaluator for the subset of the csvq SQL dialect that @@ -47,7 +53,12 @@ import { Option } from "effect"; /** Thrown for grammar or evaluation outside the supported csvq subset. */ export class LegacyInspectCsvqError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInspectCsvqError"; override readonly name = "LegacyInspectCsvqError"; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } } // --------------------------------------------------------------------------- diff --git a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts index 57ef7a82e4..32f043e77c 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the dated `//` directory failed. Mirrors Go's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyInspectReportMkdirError extends Data.TaggedError( "LegacyInspectReportMkdirError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Writing one of the report CSV files failed. Mirrors Go's `copyToCSV` @@ -18,4 +27,8 @@ export class LegacyInspectReportMkdirError extends Data.TaggedError( */ export class LegacyInspectReportWriteError extends Data.TaggedError( "LegacyInspectReportWriteError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts index 53280d800d..3bf5cc3aec 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, FileSystem, Path } from "effect"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -96,10 +96,24 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( // Go's `--linked` defaults to true, so absence of the others resolves to linked. const connType = target.connType ?? "linked"; + + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyInspectMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, + linkedProjectRef: flags.projectRef, }); // `outDir = /`, resolved against the process CWD when relative diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts index cb24a9d9f8..3953043531 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -162,6 +162,7 @@ const flags = (over: Partial = {}): LegacyInspectRepor dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), outputDir: over.outputDir ?? ".", }); @@ -306,6 +307,44 @@ describe("legacy inspect report", () => { }).pipe(Effect.provide(layer)); }); + it.live("reports on the project given via --project-ref on the default linked path", () => { + const FLAG_REF = "flagflagflagflagflag"; + const base = tempDir("supabase-report-out-"); + const { layer, resolver } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + yield* legacyInspectReport(flags({ outputDir: base, projectRef: Option.some(FLAG_REF) })); + // `inspect report` never caches the ref — the resolver call it threads the + // flag into is the strongest observable this harness offers. + const resolveInput = resolver.resolveInput as { + connType: string; + linkedProjectRef: Option.Option; + }; + expect(resolveInput.connType).toBe("linked"); + expect(resolveInput.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver } = setupLegacyReport({ + csvs: DEFAULT_RULE_CSVS, + cliArgs: ["--local"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyInspectReport(flags({ local: true, projectRef: Option.some(FLAG_REF) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution. + expect(resolver.resolveInput).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live( "prints connect + running + saved progress to stderr and the rules table to stdout", () => { diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 504965d46a..efdcd8c977 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -1,11 +1,32 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + CliErrorCategory, + CliErrorKind, + CliSuggestionType, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; -/** Transport failure while fetching `GET /v1/projects/{ref}`. */ +/** Transport (or response-decode) failure while fetching `GET /v1/projects/{ref}`. */ export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( "LegacyLinkProjectStatusNetworkError", )<{ readonly message: string; -}> {} + /** + * Set when the failure was the generated client rejecting the response body + * (`SchemaError`) rather than a transport failure — an API response problem + * instead of a network one. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's @@ -15,7 +36,11 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` @@ -25,14 +50,32 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPausedError")<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The rendered remediation is "unpause it from the Supabase dashboard" — + // remote project state, not local config and not an entitlement failure. + return { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectPaused, + has_suggestion: true, + suggestion_type: CliSuggestionType.OpenDashboard, + }; + } +} /** Transport failure while fetching `GET /v1/projects/{ref}/api-keys`. */ export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( "LegacyLinkApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's @@ -43,7 +86,14 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The shared mapper wraps any non-200 in this tag; the status policy maps + // 401 → re-login, 404 → user-supplied ref not found, everything else → + // API status. + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** * The api-keys response contained no usable anon/service-role key. Byte-matches @@ -51,4 +101,8 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo */ export class LegacyLinkMissingKeyError extends Data.TaggedError("LegacyLinkMissingKeyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} diff --git a/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts b/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts new file mode 100644 index 0000000000..2a0b8bdcdc --- /dev/null +++ b/apps/cli/src/legacy/commands/link/link.errors.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { + LegacyLinkAuthTokenError, + LegacyLinkMissingKeyError, + LegacyLinkProjectStatusError, + LegacyLinkProjectStatusNetworkError, +} from "./link.errors.ts"; + +describe("LegacyLinkProjectStatusNetworkError actionability", () => { + it("classifies a body-decode failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LegacyLinkProjectStatusNetworkError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLinkProjectStatusNetworkError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyLinkProjectStatusNetworkError({ message: "boom" }), + ); + expect(result.error_category).toBe("network"); + }); +}); + +describe("link response actionability", () => { + it("classifies a missing selected project from the api-keys request as invalid input", () => { + const result = classifyCliErrorActionability( + new LegacyLinkAuthTokenError({ status: 404, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LegacyLinkAuthTokenError:not_found"); + }); + + it("keeps the project-status fallback 404 on the API-status policy", () => { + const result = classifyCliErrorActionability( + new LegacyLinkProjectStatusError({ status: 404, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLinkProjectStatusError:api_status"); + }); + + it("classifies a successful api-keys response missing both keys as an API response failure", () => { + const result = classifyCliErrorActionability( + new LegacyLinkMissingKeyError({ message: "Anon key not found." }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLinkMissingKeyError:api_response"); + }); +}); diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index f1cca7c3e5..c4ffa72d11 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -65,9 +65,13 @@ const classifyProjectError = ( ), ); } + // Everything else: a transport `HttpClientError` (no response) is a network + // failure; a non-`HttpClientError` (the generated client's `SchemaError` + // rejecting the response body) is an API response problem. return Effect.fail( new LegacyLinkProjectStatusNetworkError({ message: `failed to retrieve remote project status: ${String(cause)}`, + decode: !HttpClientError.isHttpClientError(cause), }), ); }; diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts index 65b9c5aab1..089c0f543c 100644 --- a/apps/cli/src/legacy/commands/login/login.errors.ts +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Go's `ErrMissingToken` (`apps/cli-go/cmd/login.go:16`). Go Aqua-styles the * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the legacy port renders @@ -9,15 +15,29 @@ export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = `Cannot use automatic login flow inside non-TTY environments. ` + `Please provide --token flag or set the SUPABASE_ACCESS_TOKEN environment variable.`; -/** Token-path save failure — Go's `cannot save provided token: %w` (`login.go:171`). */ +/** + * Token-path save failure — Go's `cannot save provided token: %w` + * (`login.go:171`). Only ever constructed on the provided-token paths (`--token` + * / `SUPABASE_ACCESS_TOKEN` / piped stdin); the browser flow saves via the raw + * `credentials.saveAccessToken`. A malformed provided token is not fixable by + * `supabase login`, so the remediation is to correct that input. + */ export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} /** Non-TTY environment with no token supplied (`login.go:34-35`). */ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginMissingTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} /** * A single login-session poll/parse failure. Carries the underlying message so @@ -27,19 +47,68 @@ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginM */ export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ readonly message: string; -}> {} + /** HTTP status of a non-200 poll response, when one was received. */ + readonly statusCode?: number; + /** Set when the poll failed at the transport layer (connection/timeout). */ + readonly network?: boolean; + /** + * Set when the poll response arrived but its body could not be decoded — an + * API response problem rather than a transport (network) one. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} -/** All verification retries exhausted (`login.go:214-216`). */ +/** + * All verification retries exhausted (`login.go:214-216`). Carries the LAST + * poll failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (the endpoint keeps returning a pending 4xx, or + * no signal) from a genuine platform problem (5xx / transport). See the Go + * poll protocol: `pollForAccessToken` treats every non-200 as a retryable + * error (`login.go:132-157`, `pkg/fetcher/http.go:102-113`). + */ export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ readonly message: string; -}> {} + readonly statusCode?: number; + readonly network?: boolean; + /** + * Set when the last poll response arrived but its body could not be decoded — + * an API response problem rather than a transport (network) one or an + * incomplete browser flow. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return actionability.authLogin; + } +} /** ECDH / AES-GCM decryption failure — Go's `cannot decrypt access token` (`login.go:47`). */ export class LegacyLoginDecryptError extends Data.TaggedError("LegacyLoginDecryptError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** ECDH keypair generation failure — Go's `cannot generate crypto keys` (`login.go:66`). */ export class LegacyLoginCryptoError extends Data.TaggedError("LegacyLoginCryptoError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} diff --git a/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts b/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts new file mode 100644 index 0000000000..faef326043 --- /dev/null +++ b/apps/cli/src/legacy/commands/login/login.errors.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LegacyLoginFailedError, LegacyLoginSaveTokenError } from "./login.errors.ts"; + +describe("LegacyLoginFailedError actionability", () => { + it("classifies a decoded-body poll failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_response"); + }); + + it("prefers the decode signal over a transport one", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", network: true, decode: true }), + ); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", network: true }), + ); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:network"); + }); + + it("classifies a 5xx poll status as an API status problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", statusCode: 503 }), + ); + expect(result.error_fingerprint).toBe("tag:LegacyLoginFailedError:api_status"); + }); + + it("treats an incomplete browser flow (no signal / pending 4xx) as auth login", () => { + expect( + classifyCliErrorActionability(new LegacyLoginFailedError({ message: "boom" })).error_category, + ).toBe("auth"); + expect( + classifyCliErrorActionability( + new LegacyLoginFailedError({ message: "boom", statusCode: 400 }), + ).error_category, + ).toBe("auth"); + }); +}); + +describe("LegacyLoginSaveTokenError actionability", () => { + it("classifies a provided-token save failure as a token problem", () => { + const result = classifyCliErrorActionability( + new LegacyLoginSaveTokenError({ message: "cannot save provided token: bad" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + }); +}); diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts index a7ced0612c..5e4234021f 100644 --- a/apps/cli/src/legacy/commands/logout/logout.errors.ts +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Raised when the user declines the logout confirmation prompt. Go returns @@ -12,4 +17,8 @@ import { Data } from "effect"; */ export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} 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 cdb4c5f903..a712b116e5 100644 --- a/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md @@ -2,10 +2,11 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/migrations/` | directory | always, to read migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | always, to read migration files | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.temp/project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -28,11 +29,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ----------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -63,6 +65,13 @@ Same structured result delivered as an NDJSON `result` event. - `--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. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). - Takes no positional arguments. - Skips Go's best-effort `pgcache.TryCacheMigrationsCatalog` (documented divergence). - Dotenvx-encrypted (`encrypted:`) `[db.vault]` values are decrypted during config 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 dd6be72039..8989841033 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.command.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.command.ts @@ -37,6 +37,11 @@ const config = { // Go: `downFlags.Bool("local", true, …)`. Flag.withDefault(true), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; export type LegacyMigrationDownFlags = CliCommand.Command.Config.Infer; @@ -52,7 +57,11 @@ export const legacyMigrationDownCommand = Command.make("down", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/migration/down/down.errors.ts b/apps/cli/src/legacy/commands/migration/down/down.errors.ts index a243205914..d17e86a0b6 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.errors.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.errors.ts @@ -1,9 +1,18 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** `--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; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--last` >= the number of applied migrations. Byte-matches Go's @@ -15,4 +24,8 @@ export class LegacyMigrationLastTooLargeError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} 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 5c386e6ef5..accb6935fa 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -63,6 +63,18 @@ const runDown = Effect.fnUntraced(function* ( const connType = target.connType ?? "local"; // down defaults to `--local` (Go: `Bool("local", true)`). + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + // 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 @@ -71,6 +83,7 @@ const runDown = Effect.fnUntraced(function* ( dbUrl: flags.dbUrl, connType, dnsResolver, + linkedProjectRef: flags.projectRef, }); // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), @@ -89,7 +102,7 @@ const runDown = Effect.fnUntraced(function* ( ? yield* Effect.gen(function* () { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const linkedRef = yield* projectRef.loadProjectRef(Option.none()); + const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef); return linkedProjectCache.cache(linkedRef); }) : undefined; 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 index b39174673c..f8e9cc468c 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -114,11 +114,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the hardcoded `LEGACY_VALID_REF` fallback) drives the linked ref. 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), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : LEGACY_VALID_REF, + ), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -142,7 +148,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - return { layer, out, telemetry, execs, queries }; + return { layer, out, telemetry, execs, queries, cache }; } const flags = (over: Partial = {}): LegacyMigrationDownFlags => ({ @@ -150,6 +156,7 @@ const flags = (over: Partial = {}): LegacyMigrationDow dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? true, + projectRef: over.projectRef ?? Option.none(), }); const seed = (workdir: string, name: string, body = "create table a;\n") => { @@ -224,6 +231,58 @@ describe("legacy migration down", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "reverts on the project given via --project-ref --linked, overriding the linked ref", + () => { + // down defaults to local; only with --linked does the flag's ref get + // cached. The fake resolver's own fallback (LEGACY_VALID_REF) represents + // whatever the workdir would resolve to absent the flag. + const FLAG_REF = "flagflagflagflagflag"; + seed(tmp.current, "20240101000000_a.sql"); + const { layer, cache } = setup(tmp.current, { + args: ["--linked"], + confirm: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* legacyMigrationDown( + flags({ last: 1, linked: true, local: false, projectRef: Option.some(FLAG_REF) }), + ); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects --project-ref on the default local target", () => { + // down defaults to local when no target flag is set — the guard must fire + // from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, execs, queries, cache } = setup(tmp.current, { + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationDown( + flags({ last: 1, projectRef: Option.some(FLAG_REF) }), + ).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( + "LegacyMigrationTargetFlagsError", + ); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or cache write. + expect(execs).toEqual([]); + expect(queries).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("cancels on a declined prompt", () => { seed(tmp.current, "20240101000000_a.sql"); const { layer, execs } = setup(tmp.current, { 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 398edd9745..9eefea3a54 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,10 +2,11 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | +| Path | Format | When | +| --------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -28,11 +29,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to write migration files | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to write migration files | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -63,6 +65,13 @@ Same structured `files` result delivered as an NDJSON `result` event. ## Notes - `--linked` (default true), `--local`, and `--db-url` are mutually exclusive. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). - 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 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 7f80918625..fc210e1bae 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts @@ -21,6 +21,11 @@ const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Fetches migration history from the local database."), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; export type LegacyMigrationFetchFlags = CliCommand.Command.Config.Infer; @@ -35,7 +40,11 @@ export const legacyMigrationFetchCommand = Command.make("fetch", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts index 3cc8289327..6418ad4af0 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Writing a fetched migration file failed. Byte-matches Go's * `failed to write migration: %w` (`internal/migration/fetch/fetch.go:38`). @@ -8,4 +14,8 @@ export class LegacyMigrationFetchWriteError extends Data.TaggedError( "LegacyMigrationFetchWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} 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 e5bc6f29c2..e663285b47 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -50,6 +50,18 @@ const runFetch = Effect.fnUntraced(function* ( const connType = target.connType ?? "linked"; // fetch defaults to `--linked` (Go: `Bool("linked", true)`). + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + // 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` @@ -60,6 +72,7 @@ const runFetch = Effect.fnUntraced(function* ( dbUrl: flags.dbUrl, connType, dnsResolver, + linkedProjectRef: flags.projectRef, }); // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), @@ -79,7 +92,7 @@ const runFetch = Effect.fnUntraced(function* ( ? yield* Effect.gen(function* () { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.projectRef); return linkedProjectCache.cache(ref); }) : undefined; 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 index b4c408487d..8476b66ea2 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -93,11 +93,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the hardcoded `LEGACY_VALID_REF` fallback) drives the linked ref. 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), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : LEGACY_VALID_REF, + ), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -121,13 +127,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - return { layer, out, telemetry }; + return { layer, out, telemetry, cache }; } const flags = (over: Partial = {}): LegacyMigrationFetchFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? true, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), }); const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); @@ -418,4 +425,46 @@ describe("legacy migration fetch", () => { }).pipe(Effect.provide(layer)); }, ); + + it.live( + "fetches from the project given via --project-ref, overriding the default linked ref", + () => { + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag — the flag must win over it + // and drive the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup(tmp.current, { rows: [] }); + return Effect.gen(function* () { + yield* legacyMigrationFetch(flags({ projectRef: Option.some(FLAG_REF) })); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, out, cache } = setup(tmp.current, { cliArgs: ["--local"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationFetch( + flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), + ).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( + "LegacyMigrationTargetFlagsError", + ); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution, filesystem write, or + // cache write. + expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(out.promptConfirmCalls.length).toBe(0); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); }); 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 dd463ab3a5..dc12086b28 100644 --- a/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/list/SIDE_EFFECTS.md @@ -2,10 +2,11 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list local migration files | +| Path | Format | When | +| -------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/migrations/` | directory | always, to list local migration files | ## Files Written @@ -28,11 +29,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to open migrations directory | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to open migrations directory | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -59,3 +61,10 @@ Same structured `migrations` result delivered as an NDJSON `result` event. - `--db-url` targets a specific database URL directly. - `--password` / `-p` sets the DB password (also reads `DB_PASSWORD` env var). - `--db-url`, `--linked`, and `--local` are mutually exclusive. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). 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 1fb1bbc919..d9aa2f32a2 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.command.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.command.ts @@ -21,6 +21,11 @@ const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Lists migrations applied to the local database."), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -40,9 +45,13 @@ export const legacyMigrationListCommand = Command.make("list", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/legacy/commands/migration/list/list.handler.ts b/apps/cli/src/legacy/commands/migration/list/list.handler.ts index b6758179ba..d110c794bc 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.handler.ts @@ -55,6 +55,18 @@ const runList = Effect.fnUntraced(function* ( ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && (target.connType ?? "linked") !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const listBody = Effect.gen(function* () { // list defaults to `--linked` (Go: `Bool("linked", true)`). const cfg = yield* resolver.resolve({ @@ -62,6 +74,7 @@ const runList = Effect.fnUntraced(function* ( connType: target.connType ?? "linked", dnsResolver, password: flags.password, + linkedProjectRef: flags.projectRef, }); const remote = yield* Effect.scoped( @@ -100,7 +113,7 @@ const runList = Effect.fnUntraced(function* ( if ((target.connType ?? "linked") === "linked") { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.projectRef); return yield* listBody.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); } return yield* listBody; diff --git a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts index fb9a9c3907..66350f46be 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts @@ -80,11 +80,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the hardcoded `LEGACY_VALID_REF` fallback) drives the linked ref. 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), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : LEGACY_VALID_REF, + ), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -113,6 +119,7 @@ const flags = (over: Partial = {}): LegacyMigrationLis dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? true, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), }); @@ -177,6 +184,44 @@ describe("legacy migration list", () => { }).pipe(Effect.provide(layer)); }); + it.live("lists the project given via --project-ref, overriding the default linked ref", () => { + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag — the flag must win over it + // and drive the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + seedMigrations(tmp.current, ["20240101000000_a.sql"]); + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* legacyMigrationList(flags({ projectRef: Option.some(FLAG_REF) })); + expect(ctx.cache.cachedRef).toBe(FLAG_REF); + expect(ctx.cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + seedMigrations(tmp.current, ["20240101000000_a.sql"]); + const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, remote: [] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationList( + flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), + ).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( + "LegacyMigrationTargetFlagsError", + ); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or cache write. + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.cache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(ctx.layer)); + }); + it.live("targets the local database with --local and skips the linked cache", () => { seedMigrations(tmp.current, ["20240101000000_a.sql"]); const ctx = setup(tmp.current, { diff --git a/apps/cli/src/legacy/commands/migration/migration.errors.ts b/apps/cli/src/legacy/commands/migration/migration.errors.ts index 09d3f96bb8..af8d0ad977 100644 --- a/apps/cli/src/legacy/commands/migration/migration.errors.ts +++ b/apps/cli/src/legacy/commands/migration/migration.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -10,7 +16,11 @@ export class LegacyMigrationTargetFlagsError extends Data.TaggedError( "LegacyMigrationTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--db-url` combined with `--password`/`-p`. Reproduces cobra's @@ -20,7 +30,11 @@ export class LegacyMigrationPasswordFlagsError extends Data.TaggedError( "LegacyMigrationPasswordFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A positional version argument is not a valid integer. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyMigrationInvalidVersionError extends Data.TaggedError( "LegacyMigrationInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No local migration file matched the requested version glob. Byte-matches Go's @@ -41,7 +59,11 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( "LegacyMigrationFileNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The user declined a confirmation prompt (overwrite / repair-all / revert). @@ -50,4 +72,8 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( */ export class LegacyOperationCanceledError extends Data.TaggedError("LegacyOperationCanceledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts index 886224c0c2..dc67ea14fa 100644 --- a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -1,43 +1,41 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; import { legacyMigrationCommand } from "./migration.command.ts"; -function mockLegacyGoProxy() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push([...args]); - }), - execCapture: () => Effect.succeed(""), - }); - - return { layer, calls }; -} - +// `withGlobalFlags` must come AFTER `withSubcommands` — see +// `start.string-slice-flags.integration.test.ts`'s identical comment. const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyMigrationCommand]), + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), ); describe("legacy migration command integration", () => { it.live("accepts the Go-compatible plural migrations alias", () => { - // Routes through `squash`, which stays a deliberate Go-proxy delegate (a - // native pg-delta squash would diverge from Go's pg_dump output — see the - // porting-status doc), so this also asserts the proxy path still works while - // the other six subcommands are now native. - const proxy = mockLegacyGoProxy(); + // After CLI-1969, `squash` is native and no `migration` subcommand is proxied + // any more — so the plural alias is now proven at the PARSER instead: a + // `migrations squash --nope` must fail with squash's own unknown-flag error, + // which never builds the command's `Command.provide` runtime layer. const run = Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["migrations", "squash"]); - - expect(proxy.calls).toEqual([["migration", "squash"]]); - }).pipe(Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter())))); + const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "migrations", + "squash", + "--nope", + ]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeJson = JSON.stringify(exit.cause); + // The alias resolved: the parse error is scoped to the squash LEAF, not the root. + expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); + expect(causeJson).not.toContain('"subcommand":"migrations"'); + } + }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); // Command.runWith's Environment type is retained even though this path only needs CliOutput - // and the mocked proxy at runtime. + // at runtime. return run as Effect.Effect; }); }); diff --git a/apps/cli/src/legacy/commands/migration/migration.layers.ts b/apps/cli/src/legacy/commands/migration/migration.layers.ts index 906d8e3477..d2dc850e00 100644 --- a/apps/cli/src/legacy/commands/migration/migration.layers.ts +++ b/apps/cli/src/legacy/commands/migration/migration.layers.ts @@ -1,11 +1,13 @@ import { Layer } from "effect"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; import { legacyLinkedDbResolverRuntimeLayer } from "../../shared/legacy-management-api-runtime.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; @@ -58,3 +60,20 @@ export const legacyMigrationDbRuntimeLayer = (commandPath: ReadonlyArray legacyLinkedDbResolverRuntimeLayer(commandPath).pipe(Layer.provide(legacyIdentityStitchLayer)), commandRuntimeLayer(commandPath), ); + +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +/** + * Runtime layer for `supabase migration squash` — `legacyMigrationDbRuntimeLayer`'s bundle + * plus the three services only squash needs: `LegacyDockerRun` (the `pg_dump` one-shot + * container + the shadow's PG15+ one-shot setup jobs), `HttpClient` (the native shadow's + * health-check wait), and `LegacyDebugLogger` (Go's `GetDebugLogger()` on the + * `LoadLocalVersions` fallback). `ChildProcessSpawner`/`RuntimeInfo`/`Tty`/`FileSystem`/ + * `Path` come from the root layer, same as `db diff`. + */ +export const legacyMigrationSquashRuntimeLayer = Layer.mergeAll( + legacyMigrationDbRuntimeLayer(["migration", "squash"]), + legacyDockerRunLayer, + httpClient, + legacyDebugLoggerLayer, +); diff --git a/apps/cli/src/legacy/commands/migration/new/new.errors.ts b/apps/cli/src/legacy/commands/migration/new/new.errors.ts index a6f8d07cc1..fb866e0e45 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the migrations directory or writing the new migration file failed. @@ -7,4 +12,8 @@ import { Data } from "effect"; */ export class LegacyMigrationNewWriteError extends Data.TaggedError("LegacyMigrationNewWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/repair/SIDE_EFFECTS.md index 3dd87b7f81..04ef3e0726 100644 --- a/apps/cli/src/legacy/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/repair/SIDE_EFFECTS.md @@ -2,9 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| -------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -27,11 +28,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | invalid or missing `--status` flag | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | invalid or missing `--status` flag | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -77,3 +79,10 @@ migration history table to match local migration files?` (default **NO**). for the name + statements; a missing file exits non-zero. - `--linked` (default true), `--local`, and `--db-url` are mutually exclusive, as are `--db-url` and `--password`/`-p`. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts index 0b6859a907..947e839cb3 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts @@ -27,6 +27,11 @@ const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Repairs the migration history of the local database."), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -44,6 +49,7 @@ export const legacyMigrationRepairCommand = Command.make("repair", config).pipe( dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, + projectRef: flags.projectRef, password: flags.password, }).pipe( withLegacyCommandInstrumentation({ @@ -52,11 +58,16 @@ export const legacyMigrationRepairCommand = Command.make("repair", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, // --status is Flag.choice and is auto-detected as safe via `config` - // below (Go's isEnumFlag, cmd/root_analytics.go:110-116); password stays redacted. + // below (Go's isEnumFlag, cmd/root_analytics.go:110-116); password stays + // redacted. --project-ref is a TS-only flag with no Go telemetry-safety + // baseline either; Go's nearest --project-ref registrations + // (cmd/pgdelta_catalog.go:44 and most others) are unmarked, so it stays + // redacted too. config, aliases: { p: "password" }, }), diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts index 7b85614e33..55e050110d 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Applying the repair batch (TRUNCATE / UPSERT / DELETE) failed. Byte-matches @@ -9,4 +14,8 @@ export class LegacyMigrationRepairUpdateError extends Data.TaggedError( "LegacyMigrationRepairUpdateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts index 3f6d5578a5..7b889f408d 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts @@ -46,6 +46,7 @@ export interface LegacyMigrationRepairInput { readonly dbUrl: Option.Option; readonly linked: boolean; readonly local: boolean; + readonly projectRef: Option.Option; readonly password: Option.Option; } @@ -142,6 +143,18 @@ const runRepair = Effect.fnUntraced(function* ( const repairAll = input.versions.length === 0; const connType = target.connType ?? "linked"; // repair defaults to `--linked` (Go: `Bool("linked", true)`). + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(input.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + // Resolve the DB config (and, for the linked default, the project ref) BEFORE the // version parse and any prompt — mirroring Go's cobra order: root `PersistentPreRunE` // runs `ParseDatabaseConfig` (`apps/cli-go/cmd/root.go:118`) before `repair.Run`'s @@ -153,6 +166,7 @@ const runRepair = Effect.fnUntraced(function* ( connType, dnsResolver, password: input.password, + linkedProjectRef: input.projectRef, }); // Go loads the project .env via loadNestedEnv INSIDE ParseDatabaseConfig (config.go:701), @@ -173,7 +187,7 @@ const runRepair = Effect.fnUntraced(function* ( ? yield* Effect.gen(function* () { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(input.projectRef); return linkedProjectCache.cache(ref); }) : undefined; diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts index d00cb96677..76217a8c5e 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts @@ -94,11 +94,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the hardcoded `LEGACY_VALID_REF` fallback) drives the linked ref. 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), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : LEGACY_VALID_REF, + ), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -131,6 +137,7 @@ const input = (over: Partial = {}): LegacyMigrationR dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? true, local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), }); @@ -461,6 +468,57 @@ describe("legacy migration repair", () => { }).pipe(Effect.provide(layer)); }); + it.live("repairs the project given via --project-ref, overriding the default linked ref", () => { + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag — the flag must win over it + // and drive the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + const { layer, cache } = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyMigrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + projectRef: Option.some(FLAG_REF), + }), + ); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-ref combined with an explicit --local target", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, execs, queries, cache } = setup(tmp.current, { args: ["--local"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + linked: false, + local: true, + projectRef: Option.some(FLAG_REF), + }), + ).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( + "LegacyMigrationTargetFlagsError", + ); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or cache write. + expect(execs).toEqual([]); + expect(queries).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a structured result in json mode", () => { const { layer, out } = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md index 0f2143dca9..e78195bc0f 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -1,55 +1,160 @@ # `supabase migration squash` +Native Effect port (CLI-1969). Squashes every local migration up to (optionally) +`--version` into the last one — diffing a natively-provisioned shadow database's +`auth`/`storage` schemas before and after applying every migration, dumping the +full schema into the target file, and deleting the merged files — then either +suggests `migration repair` (local target) or prompts to baseline the remote +migration-history table to match. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/migrations/` | directory | always, to read migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| ----------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | +| `/supabase/migrations/` | directory | always | +| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | +| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | +| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | +| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written -| Path | Format | When | -| -------------------------------------- | -------- | --------------------------------- | -| `/supabase/migrations/` files | SQL text | always — squashes migration files | +| Path | Format | When | +| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/.sql` | SQL text | ≥2 migrations squash — **truncated** (0644) then rewritten as the full dump + separator + `auth`/`storage` line diff | +| `/supabase/migrations/.sql` (×N) | — | **deleted** — every earlier merged migration; a per-file failure is non-fatal (printed, not raised) | +| scoped temp dir | SQL | shadow's `initSchema`/`ApplyApiPrivileges` SQL (PG≤14) — removed when the scope closes | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` (post-run cache, even when the command itself fails) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | + +## Docker + +- Network ensure (`legacyEnsureNetwork`, same as `db diff`/`db pull`). +- Shadow Postgres container: no `--name`, no network alias, `--publish :5432`, + `-c max_worker_processes=0`, `--rm`, PG≤14 tmpfs on `/docker-entrypoint-initdb.d` — created, + started, health-polled (`container inspect`), then removed (`rm -f -v`) once squash finishes, + success or failure. +- PG15+ one-shot realtime/storage/auth migrate jobs (`initSchema15`), dialed at the shadow + container's own 12-char short id as `DB_HOST` (no name/alias needed — see + `shared/db-bootstrap/shadow-database.ts`'s own header for why that host still resolves). +- **Three** one-shot `pg_dump` containers, each a fresh `docker run` on **host** networking + (or the named `--network-id` network when set) — `["bash","-c", , "--"]`, + `PGHOST= PGPORT= PGUSER=postgres PGPASSWORD= PGDATABASE=postgres`, + the config Postgres image: + 1. before-migration `auth`/`storage` dump — `EXTRA_FLAGS=--schema=auth|storage`, `EXTRA_SED=/^--/d` + 2. after-migration `auth`/`storage` dump — identical env + 3. the final full dump (no schema filter) — `EXCLUDED_SCHEMAS=`, `EXTRA_SED=/^--/d`, streamed straight into the truncated target file + + Unlike `db diff`/`db pull`, the shadow only ever gets Go's `SetupDatabase` (platform + baseline + roles.sql) — **no** `CREATE DATABASE contrib_regression` template database, since + squash calls `start.SetupDatabase` directly rather than going through `setupShadowConn`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Purpose | +| ---------- | ---------------------------------- | ------ | ---------------------------------------------------------- | +| — | — | — | local target: none | +| POST | `/v1/projects/{ref}/roles` | Bearer | `--linked`: temp login role when no password | +| GET | `/v1/projects/{ref}/pooler/config` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | +| GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +`SUPABASE_YES`, `DB_PASSWORD`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, +`DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, +`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, +`SUPABASE_EXPERIMENTAL`. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to read migrations directory | +| Code | Condition | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — **including** the single-migration no-op **and** a declined remote-baseline prompt | +| `1` | invalid `--version`; `--version` file not found; `version not found`; migrations-dir read failure; shadow create/health/setup/apply failure; `pg_dump` non-zero exit; migration-file open/write failure; baseline connect/batch failure; flag-group conflicts | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `130` | SIGINT | ## Output ### `--output-format text` (Go CLI compatible) -Prints "Finished `supabase migration squash`." on success. - -### `--output-format json` - -Not applicable. - -### `--output-format stream-json` - -Not applicable. +stderr, in order (path-dependent): + +``` +Loading config override: [remotes.] (only when --linked resolves a [remotes.] block) +Initialising schema... +Seeding globals from roles.sql... (unconditional — printed even when roles.sql is absent) +Applying migration ... (once per migration applied to the shadow) + is already the earliest migration. (single-migration no-op) + -- or -- +Squashed local migrations to + (per merged-file removal failure, non-fatal) +Failed to remove container: (shadow cleanup failure, non-fatal) +Update remote migration history table? [Y/n] (remote target only) +Baselining migration history to (remote target, prompt confirmed — BEFORE connecting) +Connecting to remote database... +``` + +stdout: only `Finished supabase migration squash.` (aqua), printed inline by the +handler itself — matching Go's per-command `PostRun` (there is no shared +group-level epilogue that prints it). Local target additionally prints `Run +supabase migration repair --status applied to update your remote migration +history table.` to stderr, after the stdout line. + +### `--output-format json` / `stream-json` + +Progress lines stay on stderr (including the confirmation prompt — Go's +`Console` ignores `--output`/`--output-format` entirely); stdout carries +`output.success("Migrations squashed", { squashedInto, removed, removeFailures, +alreadyEarliest, isLocal, baselinedVersion })` instead of the `Finished …` line +and (for the local target) the repair suggestion — both suppressed in machine +mode, matching `migration repair`/`migration up`. `removed` and `removeFailures` +partition every merged file between them: `removed` is the workdir-relative +paths that were successfully deleted, `removeFailures` is +`{ path, message }` for every merged file whose removal failed (`message` is +the same relativized text the text-mode stderr line prints) — a removal failure +is always non-fatal, so `removeFailures` being non-empty never changes the exit +code or the rest of the payload. ## Notes -- `--version` squashes up to the specified migration version. -- `--local` (default true), `--linked`, and `--db-url` are mutually exclusive. -- `--password` / `-p` sets the DB password. +- `--local` defaults **true** (Go: `Bool("local", true)`); `[db-url linked local]` and + `[db-url password]` are the two mutually-exclusive flag groups. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). +- The shadow gets `SetupDatabase` only — **no** `CREATE DATABASE contrib_regression` (unlike + `db diff`/`db pull`). +- `--version` is compared **lexically** against zero-padded timestamps (Go's `v <= version`). +- The baseline version is re-derived from the local migrations directory listing taken + **after** the merged-file removals — so a removal that failed non-fatally causes the + baseline to target the surviving **older** version, not the original squash target. +- A failed full-schema dump leaves the target migration truncated (Go's own behaviour, not + recoverable — the file was already truncated before the dump began). +- A declined "Update remote migration history table?" prompt is a **success** path (exit 0, + no baseline query, `Finished …` still prints) — the opposite of `migration repair`/`fetch`/ + `down`, which treat a decline as a cancellation. +- **Atomicity note:** Go sends the baseline `DELETE`/`INSERT` via `pgx.Batch` (a pipeline, not + an explicit transaction) — a partial failure could leave the DELETE applied without the + INSERT. The TS port wraps both statements in an explicit `BEGIN`/`COMMIT` with `ROLLBACK` on + error (matching `migration repair`'s own equivalent divergence); the success path is + byte-identical to Go. +- **Documented divergences** (neither reproduced, both judged strictly worse to replicate): + (a) `bufio.Scanner`'s 64 KiB `MaxScanTokenSize` silently truncates `lineByLineDiff`'s output + when a single dumped line exceeds it (`scanner.Err()` is never checked in Go) — not + reproduced (`squash.diff.ts`); (b) Go's own separator-comment write (`fmt.Fprint`, + `squash.go:130`) discards its error return, while the auth/storage diff write right after it + is checked — this port combines both into one write, so a hypothetical failure isolated to + just the separator bytes would (unlike Go) surface as `failed to write line: …`; not + realistically triggerable on a real filesystem for a single already-open file descriptor. +- `Initialising schema...` is printed by the shared setup prelude just before + `SetupDatabase` rather than from inside it — inherited from CLI-1956, shared with `db +diff`/`db pull`'s identical shadow-provisioning prelude. diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts index b4429ae73a..202a08763a 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.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 { legacyMigrationSquashRuntimeLayer } from "../migration.layers.ts"; import { legacyMigrationSquash } from "./squash.handler.ts"; const config = { @@ -18,12 +22,19 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Squashes the migration history of the local database."), + // Go: `squashFlags.Bool("local", true, …)`. + Flag.withDefault(true), ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), Flag.optional, ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; export type LegacyMigrationSquashFlags = CliCommand.Command.Config.Infer; @@ -31,5 +42,26 @@ export type LegacyMigrationSquashFlags = CliCommand.Command.Config.Infer legacyMigrationSquash(flags)), + Command.withHandler((flags) => + legacyMigrationSquash(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + version: flags.version, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + "project-ref": flags.projectRef, + }, + // Go's `markFlagTelemetrySafe(migration.go:134)` — only `--version`'s value is + // recorded verbatim. `--project-ref` is TS-only with no Go telemetry-safety baseline; + // Go's nearest --project-ref registrations are unmarked, so it stays redacted. + safeFlags: ["version"], + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyMigrationSquashRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts b/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts new file mode 100644 index 0000000000..3fab835169 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts @@ -0,0 +1,68 @@ +/** + * Pure port of Go's `separatorComment` constant and `lineByLineDiff` + * (`apps/cli-go/internal/migration/squash/squash.go:134-157`). No Effect, no + * services — just string in, string out — so this stays tightly unit-testable in + * isolation from the Docker/shadow-database machinery `squash.handler.ts` composes. + */ + +/** + * Go's `separatorComment` (`squash.go:134-139`) — a raw string literal that opens + * immediately with a newline, so the exact bytes carry a LEADING `\n`, not just the + * trailing blank line one might expect from the source layout. + */ +export const LEGACY_SQUASH_SEPARATOR_COMMENT = + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n"; + +/** + * Go's `bufio.NewScanner(...).Split(bufio.ScanLines)` tokens for `text` + * (`bufio.ScanLines`): splits on `\n`, drops the trailing empty token a final `\n` + * would otherwise produce (a `\n`-terminated final line yields no extra token; a + * final line WITHOUT a trailing `\n` still yields a token), then strips exactly one + * trailing `\r` from every token — including the final, EOF-flushed one, since Go's + * `dropCR` runs on that branch too. An empty `text` yields zero tokens, matching + * `Scan()` returning `false` immediately on an empty reader. + * + * Deliberate divergence (documented in `SIDE_EFFECTS.md`): Go's `bufio.Scanner` also + * enforces `bufio.MaxScanTokenSize` (64 KiB) and silently truncates the scan when a + * single line exceeds it (`scanner.Err()` is never checked by `lineByLineDiff`) — not + * reproduced here, since replicating a silent-data-loss quirk would only make this + * port worse for users for no observable benefit on any realistic `auth`/`storage` + * dump line. + */ +export function legacySquashScanLines(text: string): ReadonlyArray { + if (text.length === 0) return []; + const lines = text.split("\n"); + if (text.endsWith("\n")) lines.pop(); + return lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); +} + +/** + * Go's `lineByLineDiff(before, after io.Reader, f io.Writer) error` + * (`squash.go:141-157`): a single forward pass over `after`'s lines, advancing an + * "anchor" cursor into `before`'s lines whenever the current `after` line matches it + * — emitting every `after` line that DOESN'T match, each with a trailing `\n` + * (`fmt.Fprintln`). Assumes `before` is a subset of `after` (true for a + * schema-only auth/storage dump before vs. after a migration apply — entities in + * those managed schemas are never altered by user migrations). + * + * `anchorText` reproduces Go's exhausted-scanner sentinel exactly: once every + * `before` token has been consumed, `anchor.Text()` returns `""` forever (Go's + * `bufio.Scanner` resets its last token to `nil` on the final, unsuccessful `Scan()` + * call) — so every subsequent blank line in `after` is silently swallowed rather than + * emitted, matching Go byte-for-byte. + */ +export function legacySquashLineByLineDiff(before: string, after: string): string { + const beforeTokens = legacySquashScanLines(before); + const afterTokens = legacySquashScanLines(after); + let anchorIndex = 0; + let out = ""; + for (const line of afterTokens) { + const anchorText = anchorIndex < beforeTokens.length ? beforeTokens[anchorIndex]! : ""; + if (line === anchorText) { + anchorIndex++; + continue; + } + out += `${line}\n`; + } + return out; +} diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts new file mode 100644 index 0000000000..dd5d9d1a89 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + LEGACY_SQUASH_SEPARATOR_COMMENT, + legacySquashLineByLineDiff, + legacySquashScanLines, +} from "./squash.diff.ts"; + +/** + * Ports Go's `TestLineByLine` (`apps/cli-go/internal/migration/squash/squash_test.go`). + * The `before.sql`/`after.sql`/`diff.sql` fixtures are read directly from the Go oracle's + * own `testdata/` directory (same pattern as `legacy-pg-dump.env.unit.test.ts`'s + * `goScriptsDir`) rather than hand-transcribed as template literals: `apps/cli` still + * gains no new `testdata/` fixtures directory of its own, but a byte-for-byte copy of + * 90+109+19 lines of real `pg_dump` output is exactly the kind of content a manual + * transcription would silently corrupt (trailing whitespace, blank lines, quoting). + */ +const goTestdataDir = fileURLToPath( + new URL("../../../../../../cli-go/internal/migration/squash/testdata/", import.meta.url), +); +const readGoFixture = (name: string) => readFileSync(`${goTestdataDir}${name}`, "utf8"); + +describe("legacySquashLineByLineDiff", () => { + it("diffs real pg_dump output into Go's exact diff.sql bytes", () => { + const before = readGoFixture("before.sql"); + const after = readGoFixture("after.sql"); + const expected = readGoFixture("diff.sql"); + expect(legacySquashLineByLineDiff(before, after)).toBe(expected); + }); + + it("keeps only after-only lines when before is shorter", () => { + const before = "select 1;"; + const after = "select 0;\nselect 1;\nselect 2;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 0;\nselect 2;\n"); + }); + + it("emits nothing when after is shorter", () => { + const before = "select 1;\nselect 2;"; + const after = "select 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe(""); + }); + + it("emits the single after line when nothing matches", () => { + const before = "select 0;\nselect 1;"; + const after = "select 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 1;\n"); + }); + + it('swallows every subsequent after line once before is exhausted (the anchor.Text() === "" sentinel)', () => { + // Once `before` runs out of tokens, Go's `anchor.Text()` returns `""` forever, so a + // blank line in `after` matches that sentinel and is silently dropped — NOT emitted + // as if it were an unmatched line. `before` has a single non-blank token; every + // remaining `after` line (including two literal blank lines) must vanish. + const before = "create schema test;"; + const after = "create schema test;\n\n\nselect 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 1;\n"); + }); + + it("strips one trailing \\r per line like bufio.ScanLines (CRLF before, LF after)", () => { + const before = "select 1;\r\nselect 2;\r\n"; + const after = "select 1;\nselect 2;\n"; + // After stripping the trailing \r from each `before` token, every `after` line + // matches its anchor — the diff is empty. + expect(legacySquashLineByLineDiff(before, after)).toBe(""); + }); + + it("treats a final line without a trailing newline as a token, and a trailing newline as no extra empty token", () => { + // `before` has no trailing newline (one token, "a"); `after` DOES (two tokens: "a", + // "b"), so only "b" is unmatched — a final "\n" must not manufacture a phantom empty + // token that would otherwise consume the "b" match or emit an extra blank line. + const before = "a"; + const after = "a\nb\n"; + expect(legacySquashLineByLineDiff(before, after)).toBe("b\n"); + }); +}); + +describe("legacySquashScanLines", () => { + it("yields zero tokens for an empty string", () => { + expect(legacySquashScanLines("")).toEqual([]); + }); + + it("yields one token for a single line with no trailing newline", () => { + expect(legacySquashScanLines("select 1;")).toEqual(["select 1;"]); + }); + + it("drops the trailing empty token a final newline would otherwise produce", () => { + expect(legacySquashScanLines("a\nb\n")).toEqual(["a", "b"]); + }); + + it("keeps an interior blank line as its own empty-string token", () => { + expect(legacySquashScanLines("a\n\nb")).toEqual(["a", "", "b"]); + }); + + it("strips exactly one trailing \\r from every token, including the final EOF-flushed one", () => { + expect(legacySquashScanLines("a\r\nb\r")).toEqual(["a", "b"]); + }); + + it("does not strip more than one trailing \\r", () => { + expect(legacySquashScanLines("a\r\r\n")).toEqual(["a\r"]); + }); + + it("treats a lone \\r with no following \\n as part of the final token, then strips it", () => { + expect(legacySquashScanLines("only-cr\r")).toEqual(["only-cr"]); + }); +}); + +describe("LEGACY_SQUASH_SEPARATOR_COMMENT", () => { + it("carries Go's leading newline before the dashed comment banner", () => { + expect(LEGACY_SQUASH_SEPARATOR_COMMENT).toBe( + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n", + ); + }); + + it("starts with \\n, not with the comment banner itself", () => { + expect(LEGACY_SQUASH_SEPARATOR_COMMENT.startsWith("\n--")).toBe(true); + expect(LEGACY_SQUASH_SEPARATOR_COMMENT.startsWith("--")).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts new file mode 100644 index 0000000000..4e84c0d317 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts @@ -0,0 +1,102 @@ +import { Effect } from "effect"; + +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyBuildSchemaDumpEnv, + type LegacyDumpOptions, +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; +import { LegacyMigrationSquashDumpError } from "./squash.errors.ts"; + +/** + * Input to {@link legacySquashDumpSchema} — squash's own thin wrapper over one + * `migration.DumpSchema` call (`pkg/migration/dump.go`). + */ +export interface LegacySquashDumpParams { + /** + * `utils.Config.Db.Image` — the pin-resolved (not yet registry-mapped) Postgres + * image (`localInputs.bootstrapConfig.postgresImage`); {@link legacyStreamPgDump} + * applies the registry mirror itself, mirroring Go's `DockerStart` -> + * `GetRegistryImageUrl`. + */ + readonly image: string; + /** The shadow's own connect target (host / shadow port / `postgres` / password / `postgres`). */ + readonly conn: LegacyPgConnInput; + /** `["auth","storage"]` for the before/after diff dumps, `[]` for the unrestricted full dump. */ + readonly schema: ReadonlyArray; + /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + /** Loaded project `supabase/.env` map — forwarded to {@link legacyStreamPgDump}'s own `SUPABASE_NETWORK_ID` fallback. */ + readonly projectEnvValues?: Readonly>; +} + +/** + * Port of Go's `migration.DumpSchema(ctx, cfg, w, dump.DockerExec, opts...)` + * (`pkg/migration/dump.go`): a schema-only `pg_dump`, streamed to `onStdout` at + * constant memory. `squashMigrations` calls this exactly three times + * (`apps/cli-go/internal/migration/squash/squash.go:109,116,126`): before/after + * with `WithSchema("auth","storage")`, and a third, unrestricted call for the final + * full dump written straight to the target migration file. + */ +export const legacySquashDumpSchema = Effect.fnUntraced(function* ( + params: LegacySquashDumpParams, +) { + const opt: LegacyDumpOptions = { + schema: params.schema, + keepComments: false, + excludeTable: [], + columnInsert: false, + }; + const result = yield* legacyStreamPgDump({ + image: params.image, + script: legacyDumpSchemaScript, + env: legacyBuildSchemaDumpEnv(params.conn, opt), + onStdout: params.onStdout, + projectEnvValues: params.projectEnvValues, + }); + if (result.exitCode !== 0) { + return yield* Effect.fail( + new LegacyMigrationSquashDumpError({ + message: `error running container: exit ${result.exitCode}`, + }), + ); + } +}); + +/** Concatenates stdout chunks into one buffer, mirroring Go's `bytes.Buffer` sink. */ +const concatChunks = (chunks: ReadonlyArray): Uint8Array => { + 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 bytes; +}; + +/** + * Buffered convenience over {@link legacySquashDumpSchema} for the before/after + * diff dumps — mirrors Go's own `bytes.Buffer` sink (`squash.go:108`), which is + * inherently in-memory too: an `auth`/`storage` schema-only dump is tens of KB, not + * a streaming-scale payload. The FULL dump never goes through this — it streams + * straight to the target migration file's own handle at constant memory + * (`squash.handler.ts`'s `squashMigrations`). + */ +export const legacySquashDumpSchemaToString = Effect.fnUntraced(function* (params: { + readonly image: string; + readonly conn: LegacyPgConnInput; + readonly schema: ReadonlyArray; + readonly projectEnvValues?: Readonly>; +}) { + const chunks: Array = []; + yield* legacySquashDumpSchema({ + image: params.image, + conn: params.conn, + schema: params.schema, + onStdout: (chunk) => Effect.sync(() => chunks.push(chunk)), + projectEnvValues: params.projectEnvValues, + }); + return new TextDecoder().decode(concatChunks(chunks)); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts new file mode 100644 index 0000000000..c57ebdf446 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts @@ -0,0 +1,77 @@ +import { mkdirSync, mkdtempSync, 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, stripAnsi } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase migration squash (legacy)", () => { + let workdir: string; + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "sb-mig-squash-e2e-")); + mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + }); + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + // Real-subprocess guard for the production layer graph: `--version 0_init` is + // not a valid integer, so `squash.Run`'s bare `invalid version number` message + // (no repair-style `failed to parse :` prefix) must surface — proving the + // real `legacyMigrationSquashRuntimeLayer` builds end to end, without ever + // touching Docker/Postgres. This is the same class of missing-service bug the + // `migration fetch` e2e exists to catch. Unlike a declined confirmation prompt + // (Go's `context.Canceled`), this is a genuine validation error, so the usual + // `--debug` troubleshooting hint still follows it (`output.layer.ts`'s + // `CONTEXT_CANCELED_MESSAGE` guard does not apply here). + test( + "rejects a non-numeric --version with the bare Go message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase( + ["migration", "squash", "--version", "0_init"], + { + entrypoint: "legacy", + cwd: workdir, + }, + ); + + expect(exitCode).toBe(1); + const text = stripAnsi(stderr); + expect(text).toContain("invalid version number"); + expect(text).not.toContain("failed to parse"); + expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); + }, + ); + + // Golden path with no Docker required: a single local migration short-circuits + // `squashToVersion` before any shadow-database work, so this proves the whole + // local no-op + `--local` suggestion path end to end. + test( + "no-ops on a single local migration and suggests migration repair", + { timeout: E2E_TIMEOUT_MS }, + async () => { + writeFileSync( + join(workdir, "supabase", "migrations", "20240101000000_init.sql"), + "select 1;\n", + ); + + const { exitCode, stdout, stderr } = await runSupabase(["migration", "squash", "--local"], { + entrypoint: "legacy", + cwd: workdir, + }); + + expect(exitCode).toBe(0); + expect(stripAnsi(stderr)).toContain( + "supabase/migrations/20240101000000_init.sql is already the earliest migration.", + ); + expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); + expect(stripAnsi(stderr)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts new file mode 100644 index 0000000000..d8d17f134c --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts @@ -0,0 +1,71 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +/** + * `squashToVersion` found no local migrations to squash — either the migrations + * directory is empty, or `--version` filtered out every file. Byte-matches Go's + * `ErrMissingVersion` (`squash.go:26`, `errors.New("version not found")`). + */ +export class LegacyMigrationSquashMissingVersionError extends Data.TaggedError( + "LegacyMigrationSquashMissingVersionError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * One of squash's three `pg_dump` containers exited non-zero. Byte-matches Go's + * `"error running container: exit " + code` (`DockerStreamLogs`, reached via + * `migration.DumpSchema` -> `dump.DockerExec`). + */ +export class LegacyMigrationSquashDumpError extends Data.TaggedError( + "LegacyMigrationSquashDumpError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} + +/** + * The target migration file could not be truncated/opened for writing, or a chunk + * of the full dump/separator/diff could not be appended to it. Byte-matches Go's + * `"failed to open migration file: " + err` (`squash.go:123`) / `"failed to write + * line: " + err` (`squash.go:153`). + */ +export class LegacyMigrationSquashWriteError extends Data.TaggedError( + "LegacyMigrationSquashWriteError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * `baselineMigrations`'s history-table batch (`LEGACY_DELETE_MIGRATION_BEFORE` + + * `INSERT_MIGRATION_VERSION`) failed to send/commit. Byte-matches Go's `"failed to + * update migration history: " + err` (`squash.go:187`). Classified `dbConnection`, + * matching `migration repair`'s `LegacyMigrationRepairUpdateError` + * (`repair.errors.ts:19`) — both wrap the identical history-table batch-send + * failure shape. + */ +export class LegacyMigrationSquashBaselineError extends Data.TaggedError( + "LegacyMigrationSquashBaselineError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index c5a459f089..e2275f3137 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -1,16 +1,624 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; + +import { cobraMutuallyExclusiveErrorMessage } from "../../../../shared/cli/cobra-flag-groups.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { + legacyResolveDbSetupPrelude, + legacySetupDatabase, +} from "../../../shared/db-bootstrap/db-setup.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { + legacyBuildShadowSetupDatabaseInput, + legacyConnectShadowDatabase, + legacyCreateShadowDatabase, + legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyApplyProjectEnv, + legacyLoadProjectEnv, + legacyReadDbToml, + type LegacyDbTomlValues, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + legacyErrorMessage, + legacyRelativizeErrorMessage, +} from "../../../shared/legacy-error-message.ts"; +import { + legacyApplyMigrations, + LegacyMigrationApplyError, +} from "../../../shared/legacy-migration-apply.ts"; +import { + INSERT_MIGRATION_VERSION, + LEGACY_DELETE_MIGRATION_BEFORE, + legacyCreateMigrationTable, + legacyLoadLocalVersions, + legacyLoadPartialMigrations, + legacyReadMigrationFile, + legacyResolveMigrationFile, +} from "../../../shared/legacy-migration-history.ts"; +import { legacyParseMigrationVersion } from "../../../shared/legacy-migration-timestamp.format.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacyMigrationFileNotFoundError, + LegacyMigrationInvalidVersionError, + LegacyMigrationPasswordFlagsError, + LegacyMigrationTargetFlagsError, +} from "../migration.errors.ts"; +import { legacyMigrationConfirm } from "../migration.prompt.ts"; import type { LegacyMigrationSquashFlags } from "./squash.command.ts"; +import { LEGACY_SQUASH_SEPARATOR_COMMENT, legacySquashLineByLineDiff } from "./squash.diff.ts"; +import { legacySquashDumpSchema, legacySquashDumpSchemaToString } from "./squash.dump.ts"; +import { + LegacyMigrationSquashBaselineError, + LegacyMigrationSquashMissingVersionError, + LegacyMigrationSquashWriteError, +} from "./squash.errors.ts"; + +type Spawner = ChildProcessSpawnerType["Service"]; + +/** + * Port of Go's `squashMigrations` (`apps/cli-go/internal/migration/squash/squash.go:81-132`): + * shadow create -> health-wait -> connect -> `start.SetupDatabase` DIRECTLY (Go's `squash.go:96` + * — NOT `setupShadowConn`, so NO `CREATE DATABASE contrib_regression` template) -> dump the + * auth/storage schema before migrating -> apply every migration -> dump auth/storage again -> + * write the target file as the FULL (unrestricted) dump + the separator + the auth/storage + * line diff. `acquire` is only shadow creation (brief, Docker-API-bound); the health-wait/ + * connect/setup/dump/apply sequence runs in the interruptible `use` phase, matching the CLI-1956 + * review ruling `shadow-database.ts`/`diff.handler.ts` already established (a SIGINT during the + * health-wait must land immediately, same as Go's single cancellable `ctx`). + */ +const squashMigrations = Effect.fnUntraced(function* ( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + migrations: ReadonlyArray, + localInputs: LegacyLocalDbContainerInputs, + toml: LegacyDbTomlValues, +) { + const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const shadowInput = legacyShadowRunInputFromLocalContainerInputs( + localInputs, + resolvedShadowImage, + toml, + fs, + path, + ); + const connConfig: LegacyPgConnInput = { + host: localInputs.context.hostname, + port: toml.shadowPort, + user: "postgres", + password: toml.password, + database: "postgres", + }; + // Go's `utils.Config.Db.Image` — the pin-resolved (not yet registry-mapped) image every + // `pg_dump` container below uses; `legacySquashDumpSchema` applies the registry mirror itself. + const image = localInputs.bootstrapConfig.postgresImage; + + yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.scoped( + Effect.gen(function* () { + yield* legacyWaitForHealthyServices(spawner, [handle.containerId], { + timeoutSeconds: shadowInput.healthTimeoutSeconds, + }); + const session = yield* legacyConnectShadowDatabase(connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(shadowInput.setup); + yield* legacySetupDatabase( + spawner, + legacyBuildShadowSetupDatabaseInput( + { + fs: shadowInput.fs, + path: shadowInput.path, + workdir: shadowInput.workdir, + projectId: shadowInput.projectId, + container: handle.containerId, + networkId: shadowInput.networkId, + connConfig, + setup: shadowInput.setup, + }, + session, + resolved, + ), + ); + + const before = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + yield* legacyApplyMigrations( + session, + fs, + path, + migrations, + (message) => new LegacyMigrationApplyError({ message }), + ); + const after = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + + const targetPath = migrations[migrations.length - 1]!; + const targetRel = path.relative(workdir, targetPath); + yield* Effect.scoped( + Effect.gen(function* () { + // Go's `OpenFile(path, O_WRONLY|O_CREATE|O_TRUNC, 0644)` (`squash.go:121`) — ONE + // call that both truncates (or creates) the target file AND opens it for the + // writes below, matching `new.handler.ts:87`'s identical `{ flag: "w" }` precedent. + // There is no separate truncate-then-reopen step to diverge from Go's single + // `OpenFile`. + const file = yield* fs.open(targetPath, { flag: "w", mode: 0o644 }).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to open migration file: ${legacyRelativizeErrorMessage(legacyErrorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + // The full dump — NO schema restriction (Go's `migration.DumpSchema(ctx, config, + // f, dump.DockerExec)`, no `opt`, `squash.go:126`) — streamed straight into the + // already-truncated file at constant memory. Go's underlying failure here is + // `stdcopy.StdCopy`'s own write into `f` (`DockerStreamLogs`, `docker.go:574-576`), + // not `lineByLineDiff`'s own writer below, so it byte-matches "failed to copy + // docker logs:" rather than "failed to write line:". + yield* legacySquashDumpSchema({ + image, + conn: connConfig, + schema: [], + projectEnvValues: localInputs.context.projectEnvValues, + onStdout: (chunk) => + file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to copy docker logs: ${legacyErrorMessage(cause)}`, + }), + ), + ), + }); + // Go writes the separator (`fmt.Fprint`, `squash.go:130` — its error return is + // discarded, unchecked) then the auth/storage line diff (`lineByLineDiff`, + // `squash.go:131`) sequentially to the SAME handle, with nothing observable + // between the two writes — combined into one `writeAll` here. + const tail = + LEGACY_SQUASH_SEPARATOR_COMMENT + legacySquashLineByLineDiff(before, after); + yield* file.writeAll(new TextEncoder().encode(tail)).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to write line: ${legacyRelativizeErrorMessage(legacyErrorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + }), + ); + }), + ), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); +}); + +/** Outcome of {@link squashToVersion} — feeds the machine-mode payload. */ +interface LegacySquashToVersionResult { + readonly alreadyEarliest: boolean; + /** Workdir-relative path of the migration everything squashed into (Go's bold `local`). */ + readonly target: string; + /** Workdir-relative paths of the merged files that were successfully removed. */ + readonly removed: ReadonlyArray; + /** The rest: merged files whose removal failed — non-fatal, so `removed`/`removeFailures` always partition every merged file between them. */ + readonly removeFailures: ReadonlyArray<{ readonly path: string; readonly message: string }>; +} + +/** + * Port of Go's `squashToVersion` (`apps/cli-go/internal/migration/squash/squash.go:54-79`): + * loads the local migrations up to `version` (all when empty), squashes every one but the + * last into the shadow-produced dump, then removes the merged files — a removal failure is + * NON-FATAL (Go only prints it to stderr and continues). + */ +const squashToVersion = Effect.fnUntraced(function* ( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + migrationsDir: string, + version: string, + localInputs: LegacyLocalDbContainerInputs, + toml: LegacyDbTomlValues, +) { + const output = yield* Output; + const migrations = yield* legacyLoadPartialMigrations(fs, path, migrationsDir, version); + if (migrations.length === 0) { + return yield* Effect.fail( + new LegacyMigrationSquashMissingVersionError({ message: "version not found" }), + ); + } + + const local = migrations[migrations.length - 1]!; + const rel = path.relative(workdir, local); + if (migrations.length === 1) { + yield* output.raw(`${legacyBold(rel)} is already the earliest migration.\n`, "stderr"); + return { + alreadyEarliest: true, + target: rel, + removed: [], + removeFailures: [], + } satisfies LegacySquashToVersionResult; + } + + yield* squashMigrations(spawner, fs, path, workdir, migrations, localInputs, toml); + yield* output.raw(`Squashed local migrations to ${legacyBold(rel)}\n`, "stderr"); + + const removed: Array = []; + const removeFailures: Array<{ readonly path: string; readonly message: string }> = []; + for (const merged of migrations.slice(0, -1)) { + const mergedRel = path.relative(workdir, merged); + yield* fs.remove(merged).pipe( + Effect.matchEffect({ + onFailure: (cause) => { + const message = legacyRelativizeErrorMessage( + legacyErrorMessage(cause), + merged, + mergedRel, + ); + removeFailures.push({ path: mergedRel, message }); + return output.raw(`${message}\n`, "stderr"); + }, + onSuccess: () => + Effect.sync(() => { + removed.push(mergedRel); + }), + }), + ); + } + return { + alreadyEarliest: false, + target: rel, + removed, + removeFailures, + } satisfies LegacySquashToVersionResult; +}); + +/** + * Port of Go's `baselineMigrations` (`apps/cli-go/internal/migration/squash/squash.go:159-190`): + * re-derives an empty `version` from the (POST-file-removal) local version listing, prints the + * "Baselining…" banner BEFORE connecting, then deletes every history row `<= version` and + * inserts the target migration's row in one transaction. + * + * The re-list runs AFTER `squashToVersion`'s file removals (this function is only ever called + * once that has fully completed) — so when a merged-file removal failed non-fatally, this + * baselines to the surviving OLDER version, not the squash target. Do not "optimise" this by + * passing the already-known target version through instead; that would silently diverge from + * Go on exactly that path. + */ +const baselineMigrations = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + migrationsDir: string, + cfg: LegacyResolvedDbConfig, + dnsResolver: "native" | "https", + version: string, +) { + const output = yield* Output; + const connection = yield* LegacyDbConnection; + const debugLogger = yield* LegacyDebugLogger; + + let resolvedVersion = version; + if (resolvedVersion.length === 0) { + // Go's `list.LoadLocalVersions` — a read failure only logs via `utils.GetDebugLogger()` + // and leaves `version` empty; it never aborts the baseline. + const local = yield* legacyLoadLocalVersions(fs, path, migrationsDir).pipe( + Effect.catch((cause) => + debugLogger.debug(cause.message).pipe(Effect.as>([])), + ), + ); + if (local.length > 0) resolvedVersion = local[0]!; + } + + // Go prints this BEFORE connecting (`squash.go:165`, ahead of `utils.ConnectByConfig` at + // `squash.go:166`) — the opposite order from every other prompting migration subcommand. + yield* output.raw(`Baselining migration history to ${resolvedVersion}\n`, "stderr"); + + yield* Effect.scoped( + Effect.gen(function* () { + // Always remote: `runSquash` already returned on the local target (step 9) before + // `baselineMigrations` is ever called, so `cfg.isLocal` is necessarily `false` here — + // matching Go's own unconditional "Connecting to remote database..." on this path + // (`ConnectByConfigStream`, `connect.go:331-336`; the `IsLocalDatabase` branch right + // above it is unreachable from `baselineMigrations`'s only caller). + yield* output.raw("Connecting to remote database...\n", "stderr"); + const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + yield* legacyCreateMigrationTable(session); + + const resolvedFile = yield* legacyResolveMigrationFile( + fs, + path, + migrationsDir, + resolvedVersion, + ); + if (Option.isNone(resolvedFile)) { + return yield* Effect.fail( + new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, + }), + ); + } + const m = yield* legacyReadMigrationFile(fs, path, resolvedFile.value); + + // Go's `pgx.Batch` (`squash.go:183-186`) — data statements only, no schema mutation, so + // (matching `migration repair`'s own `updateMigrationTable`) wrapped in an explicit + // transaction for atomicity between the DELETE and the INSERT. + const txn = Effect.gen(function* () { + yield* session.exec("BEGIN"); + yield* session.query(LEGACY_DELETE_MIGRATION_BEFORE, [m.version]); + yield* session.query(INSERT_MIGRATION_VERSION, [m.version, m.name, m.statements]); + yield* session.exec("COMMIT"); + }); + yield* txn.pipe( + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), + Effect.mapError( + (cause) => + new LegacyMigrationSquashBaselineError({ + message: `failed to update migration history: ${legacyErrorMessage(cause)}`, + }), + ), + ); + }), + ); + + return resolvedVersion; +}); + +const runSquash = Effect.fnUntraced(function* ( + flags: LegacyMigrationSquashFlags, + target: ReturnType, +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const cliConfig = yield* LegacyCliConfig; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + + // Resolved linked ref, captured so the post-run finalizer caches the project + // (GET /v1/projects/{ref}) — Go's `ensureProjectGroupsCached` (cmd/root.go:214). + let linkedRefForCache: string | undefined; + + yield* Effect.gen(function* () { + // 1. Flag groups — cobra's parse-time `MarkFlagsMutuallyExclusive`, ahead of the root + // `PersistentPreRunE` (`apps/cli-go/cmd/migration.go:66-75`). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: cobraMutuallyExclusiveErrorMessage( + ["db-url", "linked", "local"], + target.setFlags, + ), + }), + ); + } + if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { + return yield* Effect.fail( + new LegacyMigrationPasswordFlagsError({ + message: cobraMutuallyExclusiveErrorMessage( + ["db-url", "password"], + ["db-url", "password"], + ), + }), + ); + } + + const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); + // squash defaults to `--local` (Go: `Bool("local", true)`), same as `up`/`down`. + const connType = target.connType ?? "local"; + + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + + // 2/3. Linked pre-resolution (mirrors `db diff --linked`, `diff.handler.ts:400-430`): + // resolve + cache the project ref, and read the remote-merged config, BEFORE + // `resolver.resolve()` below — matching Go's stateful pre-run (`LoadProjectRef` -> the + // remote-merged `LoadConfig` -> only THEN `NewDbConfigWithPassword`'s actual connection + // work). Read unconditionally (base config when not linked) since the shadow is provisioned + // locally regardless of the remote/local target. + let linkedRef: string | undefined; + if (connType === "linked") { + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); + linkedRefForCache = linkedRef; + } + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + + // 4. The shadow's own container spec — always built, and built BEFORE `resolver.resolve()` + // below, matching Go's config-load-then-connect ordering (`diff.handler.ts`'s identical + // rationale: all config load/validation happens ahead of `NewDbConfigWithPassword`). + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + connType === "linked" ? linkedRef : undefined, + toml.remoteOverrideKeys, + ); + + // 5. Resolve the target connection — the resolver owns `--password`/`DB_PASSWORD`/ + // temp-login-role/IPv6 handling for `--linked`, so squash needs no bespoke password prompt. + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + linkedProjectRef: flags.projectRef, + }); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + } + if (linkedRef !== undefined) linkedRefForCache = linkedRef; + + // 6. Go loads the project `.env` via `loadNestedEnv` INSIDE `ParseDatabaseConfig`, after the + // flag-group validation above — so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms + // the remote-baseline prompt, but a flag conflict still surfaces before any `.env` read. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader in `legacyGetRegistryImageUrl`, reverted + // when this scope closes. Go's `loadNestedEnv` `os.Setenv`s the project `.env` + // (config.go:789) before any container starts, and each of squash's three + // pg_dump containers resolves its image through `DockerStart` -> + // `GetRegistryImageUrl`/`GetRegistryImageUrls` (docker.go:221-246,326-348, + // 363-371) — so a dotenv-only mirror override reaches all three dumps below. + yield* legacyApplyProjectEnv(projectEnv); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + + // 7. `--version` validation — inside Go's `squash.Run`, i.e. AFTER db-config resolution. + const version = Option.getOrElse(flags.version, () => ""); + if (version.length > 0) { + if (legacyParseMigrationVersion(version) === undefined) { + // Bare message — squash does NOT inherit repair's "failed to parse : " prefix + // (`squash.go:30` is `errors.New(repair.ErrInvalidVersion)`, no `Errorf` wrap). + return yield* Effect.fail( + new LegacyMigrationInvalidVersionError({ message: "invalid version number" }), + ); + } + const versionFile = yield* legacyResolveMigrationFile(fs, path, migrationsDir, version); + if (Option.isNone(versionFile)) { + return yield* Effect.fail( + new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }), + ); + } + } + + // 8. Squash local migrations. + const squashResult = yield* squashToVersion( + spawner, + fs, + path, + cliConfig.workdir, + migrationsDir, + version, + localInputs, + toml, + ); + + // 9. Local target: suggest `migration repair` instead of touching the remote history. + if (cfg.isLocal) { + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase migration squash")}.\n`); + yield* output.raw( + `Run ${legacyAqua("supabase migration repair --status applied")} to update your remote migration history table.\n`, + "stderr", + ); + } else { + yield* output.success("Migrations squashed", { + squashedInto: squashResult.target, + removed: squashResult.removed, + removeFailures: squashResult.removeFailures, + alreadyEarliest: squashResult.alreadyEarliest, + isLocal: true, + baselinedVersion: null, + }); + } + return; + } + + // 10. Remote target: prompt before touching the remote history table. A DECLINED prompt is + // still a SUCCESS path in Go (`squash.go:47` returns `nil`, not `context.Canceled`) — unlike + // repair/fetch/down, so this never raises `LegacyOperationCanceledError`. + const confirmed = yield* legacyMigrationConfirm("Update remote migration history table?", { + defaultValue: true, + yes, + }); + let baselinedVersion: string | null = null; + if (confirmed) { + baselinedVersion = yield* baselineMigrations( + fs, + path, + migrationsDir, + cfg, + dnsResolver, + version, + ); + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase migration squash")}.\n`); + } else { + yield* output.success("Migrations squashed", { + squashedInto: squashResult.target, + removed: squashResult.removed, + removeFailures: squashResult.removeFailures, + alreadyEarliest: squashResult.alreadyEarliest, + isLocal: false, + baselinedVersion, + }); + } + }).pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + ), + ), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply above to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, + ); +}); export const legacyMigrationSquash = Effect.fn("legacy.migration.squash")(function* ( flags: LegacyMigrationSquashFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["migration", "squash"]; - if (Option.isSome(flags.version)) args.push("--version", flags.version.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"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const telemetryState = yield* LegacyTelemetryState; + const cliArgs = yield* CliArgs; + const target = resolveLegacyDbTargetFlags(cliArgs.args); + yield* runSquash(flags, target).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts new file mode 100644 index 0000000000..3d1193350c --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts @@ -0,0 +1,1585 @@ +import { existsSync, mkdirSync, 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, FileSystem, Layer, Option } from "effect"; +import { PlatformError, SystemError } from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; +import { + LEGACY_FAKE_SHADOW_CONTAINER_ID, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LEGACY_INTERNAL_SCHEMAS } from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.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, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import type { LegacyMigrationSquashFlags } from "./squash.command.ts"; +import { legacyMigrationSquash } from "./squash.handler.ts"; + +// --------------------------------------------------------------------------- +// A fake `LegacyDockerRun` that distinguishes squash's own three one-shot +// `pg_dump` containers from the shadow's PG15+ platform-baseline setup jobs +// purely by their env matrix: only a `pg_dump` invocation ever carries +// `PGDATABASE` (`legacyToDumpEnv`) — none of the realtime/storage/auth +// one-shot jobs do (`db-setup.ts`). Among the dump calls, the first two +// sharing `EXTRA_FLAGS=--schema=auth|storage` are the before/after diff dumps +// (in that call order — `squashMigrations` dumps `before` strictly before +// applying migrations, `after` strictly after); a dump call with no +// `EXTRA_FLAGS` at all is the final, unrestricted full dump. +// --------------------------------------------------------------------------- + +function mockSquashDockerRun( + opts: { + readonly beforeSql?: string; + readonly afterSql?: string; + readonly fullSql?: string; + readonly failDump?: "before" | "after" | "full"; + readonly failSetupJob?: boolean; + } = {}, +) { + const dumpCalls: Array = []; + const setupJobCalls: Array = []; + let authStorageCalls = 0; + + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("LegacyDockerRun.run is unused by migration squash"), + runCapture: () => Effect.die("LegacyDockerRun.runCapture is unused by migration squash"), + runStream: (dockerOpts, streamOpts) => { + const isDump = dockerOpts.env["PGDATABASE"] !== undefined; + if (!isDump) { + setupJobCalls.push(dockerOpts); + return Effect.succeed({ exitCode: opts.failSetupJob === true ? 1 : 0, stderr: "" }); + } + dumpCalls.push(dockerOpts); + const isAuthStorage = dockerOpts.env["EXTRA_FLAGS"] === "--schema=auth|storage"; + let kind: "before" | "after" | "full"; + let sql: string; + if (isAuthStorage) { + authStorageCalls += 1; + kind = authStorageCalls === 1 ? "before" : "after"; + sql = kind === "before" ? (opts.beforeSql ?? "") : (opts.afterSql ?? ""); + } else { + kind = "full"; + sql = opts.fullSql ?? ""; + } + const exitCode = opts.failDump === kind ? 1 : 0; + return streamOpts + .onStdout(new TextEncoder().encode(sql)) + .pipe(Effect.as({ exitCode, stderr: "" })); + }, + }); + + return { layer, dumpCalls, setupJobCalls }; +} + +// --------------------------------------------------------------------------- +// Filesystem fault injection — a single wrapper layer covering every +// filesystem failure squash's own scenarios need, keyed by exact absolute +// path so unrelated reads/writes elsewhere in the setup pipeline are +// unaffected. Follows `tests/helpers/legacy-mocks.ts`'s own +// `legacyFailWriteStringOnNthCallFsLayer` pattern. +// --------------------------------------------------------------------------- + +const simulatedFsError = (path: string, method: string) => + new PlatformError( + new SystemError({ + _tag: "Unknown", + module: "FileSystem", + method, + pathOrDescriptor: path, + description: "simulated failure", + }), + ); + +interface FsFaultOpts { + /** + * Makes `fs.open(path, { flag: "w" })` itself fail — squash's SINGLE target-file open + * call (CLI-1969 review: collapsed from a truncate-then-reopen two-step into one + * `O_TRUNC`-equivalent open, matching `new.handler.ts:87`'s precedent). + */ + readonly failOpenPath?: string; + /** + * Lets the Nth+ `writeAll` call on the open handle for `path` fail (1-indexed), + * succeeding on every earlier call — so the full-dump stream's own `writeAll` (call 1) + * and the separator/diff tail's `writeAll` (call 2) can be failed independently, + * exercising both of squash's distinct write-failure call sites. + */ + readonly failWriteAllFromCall?: { readonly path: string; readonly fromCall: number }; + readonly failRemovePath?: string; + readonly failReadDirectoryAtCall?: { readonly path: string; readonly atCall: number }; +} + +function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { + return Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => { + let readDirCallsForPath = 0; + return FileSystem.FileSystem.of({ + ...real, + remove: (path, removeOpts) => + opts.failRemovePath !== undefined && path === opts.failRemovePath + ? Effect.fail(simulatedFsError(path, "remove")) + : real.remove(path, removeOpts), + readDirectory: (path, readOpts) => { + if ( + opts.failReadDirectoryAtCall !== undefined && + path === opts.failReadDirectoryAtCall.path + ) { + readDirCallsForPath += 1; + if (readDirCallsForPath === opts.failReadDirectoryAtCall.atCall) { + return Effect.fail(simulatedFsError(path, "readDirectory")); + } + } + return real.readDirectory(path, readOpts); + }, + open: (path, openOpts) => { + if ( + opts.failOpenPath !== undefined && + path === opts.failOpenPath && + openOpts?.flag === "w" + ) { + return Effect.fail(simulatedFsError(path, "open")); + } + return real.open(path, openOpts).pipe( + Effect.map((file) => { + if ( + opts.failWriteAllFromCall === undefined || + path !== opts.failWriteAllFromCall.path + ) { + return file; + } + let writeAllCalls = 0; + return { + ...file, + writeAll: (buffer: Uint8Array) => { + writeAllCalls += 1; + return writeAllCalls >= opts.failWriteAllFromCall!.fromCall + ? Effect.fail(simulatedFsError(path, "writeAll")) + : file.writeAll(buffer); + }, + }; + }), + ); + }, + }); + }), + ).pipe(Layer.provide(BunServices.layer)); +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +interface SetupOpts { + readonly format?: OutputFormat; + readonly isTTY?: boolean; + readonly pipedInput?: string; + readonly yes?: boolean; + readonly confirm?: boolean; + readonly args?: ReadonlyArray; + readonly isLocal?: boolean; + readonly linkedRef?: string; + /** Omits `ref` entirely from the resolved config, matching the real resolver's own `--local`/`--db-url` shape (`ref` is an optional field, not always `None` — see `legacy-db-config.types.ts`). */ + readonly omitRef?: boolean; + readonly failResolve?: boolean; + readonly failSql?: string; + readonly networkId?: string; + readonly neverHealthyShadow?: boolean; + readonly failCreateShadow?: boolean; + readonly failRemoveShadow?: boolean; + readonly failSetupJob?: boolean; + readonly beforeDumpSql?: string; + readonly afterDumpSql?: string; + readonly fullDumpSql?: string; + readonly failDumpKind?: "before" | "after" | "full"; + readonly fsFaults?: FsFaultOpts; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + + const spawner = mockLegacyShadowContainerCliSpawner({ + neverHealthy: opts.neverHealthyShadow ?? false, + failCreate: opts.failCreateShadow ?? false, + failRemove: opts.failRemoveShadow ?? false, + }); + const docker = mockSquashDockerRun({ + beforeSql: opts.beforeDumpSql, + afterSql: opts.afterDumpSql, + fullSql: opts.fullDumpSql, + failDump: opts.failDumpKind, + failSetupJob: opts.failSetupJob, + }); + + const execs: Array = []; + const queries: Array<{ readonly sql: string; readonly params?: ReadonlyArray }> = []; + // Every `exec`/`query` call, in ONE combined call-order log — `execs`/`queries` above + // can't prove statement ORDER (`.toContain`/`.find` are order-blind), so a swapped + // DELETE/INSERT in the baseline transaction would ship green against them alone + // (CLI-1969 review item #7). + const statements: Array<{ readonly sql: string; readonly params?: ReadonlyArray }> = []; + const connectedDatabases: Array = []; + const connection = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: (sql: string) => + Effect.suspend(() => { + execs.push(sql); + statements.push({ sql }); + return opts.failSql !== undefined && sql.includes(opts.failSql) + ? Effect.fail(new LegacyDbExecError({ message: "boom" })) + : Effect.void; + }), + query: (sql: string, params?: ReadonlyArray) => + Effect.suspend(() => { + queries.push({ sql, params }); + statements.push({ sql, params }); + return opts.failSql !== undefined && sql.includes(opts.failSql) + ? Effect.fail(new LegacyDbExecError({ message: "boom" })) + : Effect.succeed>>([]); + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; + }), + }); + + const resolverCalls: Array = []; + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags: LegacyDbConfigFlags) => { + resolverCalls.push(flags); + if (opts.failResolve === true) { + return Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ); + } + return Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: opts.isLocal ?? true, + // A real `--local`/`--db-url` resolution can genuinely omit `ref` altogether + // (it's an optional field, not always `None`) — `omitRef` reproduces that + // shape so `runSquash`'s `cfg.ref ?? Option.none()` fallback stays exercised. + ...(opts.omitRef === true + ? {} + : { ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none() }), + } satisfies LegacyResolvedDbConfig); + }, + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the `opts.linkedRef`/`LEGACY_VALID_REF` fallback) drives the linked ref. + const projectRef = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? LEGACY_VALID_REF)), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 + ? flagValue.value + : (opts.linkedRef ?? LEGACY_VALID_REF), + ), + promptProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + }); + + const debugLogs: Array = []; + const debugLogger = Layer.succeed(LegacyDebugLogger, { + debug: (message: string) => + Effect.sync(() => { + debugLogs.push(message); + }), + http: () => Effect.void, + }); + + const baseLayer = Layer.mergeAll( + // Listed first so every fake service layer below overrides its real + // implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `diff.integration.test.ts`'s own established ordering. + BunServices.layer, + out.layer, + telemetry.layer, + cache.layer, + resolver, + connection, + projectRef, + spawner.layer, + docker.layer, + debugLogger, + alwaysReadyHttpClientLayer, + mockLegacyCliConfig({ workdir }), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), + mockTty({ stdinIsTty: opts.isTTY ?? true }), + mockStdin( + opts.isTTY ?? true, + opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), + ), + mockRuntimeInfo(), + ); + + const layer = + opts.fsFaults === undefined ? baseLayer : Layer.merge(baseLayer, faultyFsLayer(opts.fsFaults)); + + return { + layer, + out, + telemetry, + cache, + execs, + queries, + statements, + connectedDatabases, + resolverCalls, + debugLogs, + shadowSpawned: spawner.spawned, + dumpCalls: docker.dumpCalls, + setupJobCalls: docker.setupJobCalls, + }; +} + +const flags = (over: Partial = {}): LegacyMigrationSquashFlags => ({ + version: over.version ?? Option.none(), + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? true, + password: over.password ?? Option.none(), + projectRef: over.projectRef ?? Option.none(), +}); + +const seedMigration = (workdir: string, name: string, body = "create table t (id int);\n") => { + const dir = join(workdir, "supabase", "migrations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), body); +}; + +const stdout = (out: ReturnType) => stripAnsi(out.stdoutText); +const stderr = (out: ReturnType) => stripAnsi(out.stderrText); + +const failureTag = (exit: Exit.Exit): string | undefined => { + if (!Exit.isFailure(exit)) return undefined; + const failure = Cause.findErrorOption(exit.cause); + return Option.isSome(failure) ? (failure.value as { readonly _tag?: string })._tag : undefined; +}; + +const tmp = useLegacyTempWorkdir(); + +describe("legacy migration squash", () => { + // ------------------------------------------------------------------------- + // Flag surface & ordering + // ------------------------------------------------------------------------- + + describe("flag surface & ordering", () => { + it.effect("rejects --linked combined with --local", () => { + const s = setup(tmp.current, { args: ["--linked", "--local"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ linked: true, local: true })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationTargetFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --db-url combined with --password", () => { + const s = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--password", "y"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationPasswordFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --project-ref on the default local target", () => { + // No target flag given at all — squash defaults to `--local`, so the + // guard must fire from the flag alone, with no explicit --local needed. + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ projectRef: Option.some(LEGACY_VALID_REF) }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationTargetFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any resolver call, shadow/dump work, or cache write. + expect(s.resolverCalls).toEqual([]); + expect(s.shadowSpawned).toEqual([]); + expect(s.dumpCalls).toEqual([]); + expect(s.setupJobCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --project-ref combined with an explicit --db-url target", () => { + const s = setup(tmp.current, { + args: ["--db-url", "postgresql://x", "--project-ref", LEGACY_VALID_REF], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ + dbUrl: Option.some("postgresql://x"), + projectRef: Option.some(LEGACY_VALID_REF), + }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationTargetFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any resolver call, shadow/dump work, or cache write. + expect(s.resolverCalls).toEqual([]); + expect(s.shadowSpawned).toEqual([]); + expect(s.dumpCalls).toEqual([]); + expect(s.setupJobCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "rejects a non-numeric --version with the bare Go message (no 'failed to parse' prefix)", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ version: Option.some("0_init") })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "invalid version number", + ); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("rejects an out-of-int64-range --version with the same bare message", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ version: Option.some("99999999999999999999") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "invalid version number", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails with a glob not-found error when --version matches no local file", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ version: Option.some("9") })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationFileNotFoundError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "glob supabase/migrations/9_*.sql: file does not exist", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("surfaces a db-config resolution failure before validating --version", () => { + // Cobra's pre-run order resolves the DB target before `squash.Run`'s own + // `strconv.Atoi` version check — so an unlinked/invalid target wins over a + // bad version, matching `migration repair`'s identical ordering test. + const s = setup(tmp.current, { failResolve: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ version: Option.some("not-a-number") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyProjectNotLinkedError"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("defaults to the local database when no target flag is given", () => { + seedMigration(tmp.current, "0_init.sql"); + // `omitRef` matches the real resolver's own `--local` shape: no `ref` at all, + // not merely `None` — exercising the `cfg.ref ?? Option.none()` fallback. + const s = setup(tmp.current, { args: [], omitRef: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.resolverCalls[0]?.connType).toBe("local"); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // squashToVersion + // ------------------------------------------------------------------------- + + describe("squashToVersion", () => { + it.effect("fails with 'version not found' when the migrations directory is empty", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashMissingVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "version not found", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'version not found' when the only file is a deprecated <14-digit>_init.sql", + () => { + seedMigration(tmp.current, "20211208000000_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashMissingVersionError"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "surfaces 'failed to read directory' when supabase/migrations is a file, not a directory", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* legacyMigrationSquash(flags()).pipe(Effect.flip); + expect((error as { message: string }).message).toContain("failed to read directory"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "no-ops on a single migration: prints the earliest-migration line, spawns no container, and still finishes", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain( + "supabase/migrations/0_init.sql is already the earliest migration.", + ); + expect(s.shadowSpawned).toEqual([]); + expect(s.dumpCalls).toEqual([]); + // Step 2 still runs on the no-op path (Go falls through to it). + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // Happy path — squashing two-or-more migrations + // ------------------------------------------------------------------------- + + describe("squashing local migrations", () => { + const BEFORE_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nold auth object;\n"; + const AFTER_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nnew auth object;\n"; + const FULL_SQL = "CREATE TABLE t (id int);\n"; + + function setupHappyPath(opts: SetupOpts = {}) { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + return setup(tmp.current, { + beforeDumpSql: BEFORE_SQL, + afterDumpSql: AFTER_SQL, + fullDumpSql: FULL_SQL, + ...opts, + }); + } + + it.effect( + "squashes two migrations into the last file: applies every migration, deletes the earlier one, and prints the summary", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(stderr(s.out)).toContain("Initialising schema..."); + expect(stderr(s.out)).toContain("Applying migration 0_init.sql..."); + expect(stderr(s.out)).toContain("Applying migration 1_target.sql..."); + expect(stderr(s.out)).toContain( + "Squashed local migrations to supabase/migrations/1_target.sql", + ); + + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + + // Hardcoded (not recomputed via `squash.diff.ts`'s own helpers) so a + // regression in the separator constant or the diff algorithm itself + // — not just in how `squashMigrations` wires them together — still + // fails this assertion. + const expectedTail = + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + "new auth object;\n"; + expect(readFileSync(join(migrationsDir, "1_target.sql"), "utf8")).toBe( + FULL_SQL + expectedTail, + ); + + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "runs the before/after dumps scoped to auth|storage and the full dump excluding the internal schemas", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + const [before, after, full] = s.dumpCalls; + expect(before?.env["EXTRA_FLAGS"]).toBe("--schema=auth|storage"); + expect(before?.env["EXCLUDED_SCHEMAS"]).toBeUndefined(); + expect(after?.env["EXTRA_FLAGS"]).toBe("--schema=auth|storage"); + expect(after?.env["EXCLUDED_SCHEMAS"]).toBeUndefined(); + expect(full?.env["EXTRA_FLAGS"]).toBeUndefined(); + expect(full?.env["EXCLUDED_SCHEMAS"]).toBe(LEGACY_INTERNAL_SCHEMAS.join("|")); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "runs every dump container on host networking with the shadow's connection env and the config Postgres image", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.env["PGPORT"]).toBe("54320"); + expect(call.env["PGUSER"]).toBe("postgres"); + expect(call.env["PGDATABASE"]).toBe("postgres"); + expect(call.network).toEqual({ _tag: "host" }); + expect(call.cmd).toEqual(["bash", "-c", legacyDumpSchemaScript, "--"]); + // `legacyStreamPgDump` applies the registry mirror itself (Go's + // `GetRegistryImageUrl`) — the default (no override) registry rewrites + // to the ECR mirror, not the bare Dockerfile-manifest tag. + expect(call.image).toBe(legacyGetRegistryImageUrl(dockerfileServiceImage("pg"))); + } + // Every dump dials the SAME shadow host, whatever this machine's Docker + // context resolves it to (`legacyGetHostname`) — self-consistency avoids + // hardcoding the host-dependent value. + const hosts = new Set(s.dumpCalls.map((c) => c.env["PGHOST"])); + expect(hosts.size).toBe(1); + const [host] = hosts; + expect(host).toBeTruthy(); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "dials the shadow's PG15+ setup jobs at the container's 12-char short id (DB_HOST)", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); + expect(s.setupJobCalls.length).toBeGreaterThan(0); + let sawHost = false; + for (const call of s.setupJobCalls) { + if (call.env["DB_HOST"] !== undefined) { + expect(call.env["DB_HOST"]).toBe(expectedHost); + sawHost = true; + } + for (const value of Object.values(call.env)) { + if (value.includes("@") && value.includes(":")) { + expect(value).toContain(`@${expectedHost}:`); + sawHost = true; + } + } + } + expect(sawHost).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "forwards --network-id to every dump container as a named network instead of host", + () => { + const s = setupHappyPath({ networkId: "custom-net" }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + // Go's `loadNestedEnv` `os.Setenv`s the project `.env` (config.go:789) before any of + // squash's three pg_dump containers start; each one resolves its image through + // `DockerStart` -> `GetRegistryImageUrl`/`GetRegistryImageUrls` (docker.go:221-246, + // 326-348,363-371) — so a registry mirror set only in `supabase/.env` reaches all + // three. The handler mirrors that with `legacyApplyProjectEnv`, scoped to the run + // and reverted when it completes. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + const s = setupHappyPath(); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); + } + // Reverted once the command's own scope closes (`Effect.scoped` on `runSquash`'s + // terminal pipe) — never leaks into a later command in the same process. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + const s = setupHappyPath(); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.network).toEqual({ _tag: "named", name: "dotenv-net" }); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect("squashes only the migrations up to --version, leaving newer ones untouched", () => { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + seedMigration(tmp.current, "2_after.sql", "create table c (id int);\n"); + const s = setup(tmp.current, { + beforeDumpSql: BEFORE_SQL, + afterDumpSql: AFTER_SQL, + fullDumpSql: FULL_SQL, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ version: Option.some("1") })); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + // The newer file was never touched — outside the `--version 1` window. + expect(readFileSync(join(migrationsDir, "2_after.sql"), "utf8")).toBe( + "create table c (id int);\n", + ); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Failure paths — every one leaves the shadow removed (unless creation + // itself is what failed, matching Go's leak-on-create-failure parity). + // ------------------------------------------------------------------------- + + describe("squashMigrations failure paths", () => { + it.effect("fails when the shadow container cannot be created and never attempts a dump", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failCreateShadow: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyShadowDbError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + // Nothing to release — the container was never created (Go's own + // leak-on-create-failure parity, see `legacyCreateShadowDatabase`'s doc). + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toEqual([]); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with a health-check timeout when the shadow never becomes healthy, and removes it", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + // A zero-second health timeout means zero retries after the first failed + // probe — an immediate, deterministic timeout with no real/virtual delay. + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + '[db]\nhealth_timeout = "0s"\n', + ); + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyHealthCheckTimeoutError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails when the shadow's platform-baseline setup job exits non-zero, and removes it", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyDbSetupError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails when applying a migration to the shadow errors, and removes it", () => { + seedMigration(tmp.current, "0_init.sql", "create table boom;\n"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failSql: "create table boom" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationApplyError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'error running container: exit 1' when the before/after dump container exits non-zero", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failDumpKind: "before" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashDumpError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "error running container: exit 1", + ); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'error running container: exit 1' when the full-schema dump exits non-zero, leaving the target file truncated", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "partial output before the container died", + failDumpKind: "full", + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashDumpError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + // Truncated (by the earlier `O_TRUNC`), then only the partial stream the + // dying container managed to write before failing — no separator/diff + // was ever appended, since the whole operation aborted first. + expect(readFileSync(targetPath, "utf8")).toBe("partial output before the container died"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to open migration file' when the target file cannot be truncated/opened", + () => { + // Squash's ONE `O_TRUNC`-equivalent open call (CLI-1969 review: collapsed from a + // truncate-then-reopen two-step into a single `fs.open(path, { flag: "w" })`, + // matching `new.handler.ts:87`'s precedent) — a single failure site, not two. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failOpenPath: targetPath }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + const message = + Option.isSome(failure) && (failure.value as { message: string }).message; + expect(message).toContain("failed to open migration file:"); + // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + expect(message).not.toContain(tmp.current); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to copy docker logs' when streaming the full dump into the target file fails", + () => { + // Go's underlying failure on this path is `stdcopy.StdCopy`'s own write into the + // target file (`DockerStreamLogs`, `docker.go:574-576`), byte-matching "failed to + // copy docker logs:" — NOT `lineByLineDiff`'s own "failed to write line:" below. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 1 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && (failure.value as { message: string }).message, + ).toContain("failed to copy docker logs:"); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to write line' when appending the separator/diff tail fails (the full dump itself wrote fine)", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + // `fromCall: 2` lets the full-dump stream's own `writeAll` (call 1) + // succeed, isolating the separator/diff tail's write (call 2). + fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 2 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + const message = + Option.isSome(failure) && (failure.value as { message: string }).message; + expect(message).toContain("failed to write line:"); + // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + expect(message).not.toContain(tmp.current); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // The full dump itself made it onto disk before the tail write failed. + expect(readFileSync(targetPath, "utf8")).toBe("full;\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("prints a merged-file removal error to stderr non-fatally and still succeeds", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + // Non-fatal: the command still finishes successfully. + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + // The failed removal's relativized error text reached stderr — pinned, not just + // "non-empty", and proves the workdir-relative path (never the absolute one). + expect(stderr(s.out)).toContain("FileSystem.remove (supabase/migrations/0_init.sql)"); + // The file that failed to be removed is still on disk. + expect(existsSync(earlierPath)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "reports the removal failure in the machine-mode payload's removeFailures, leaving removed empty", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { + readonly removed: ReadonlyArray; + readonly removeFailures: ReadonlyArray<{ + readonly path: string; + readonly message: string; + }>; + }; + expect(data.removed).toEqual([]); + expect(data.removeFailures).toHaveLength(1); + expect(data.removeFailures[0]?.path).toBe("supabase/migrations/0_init.sql"); + expect(data.removeFailures[0]?.message).toContain( + "FileSystem.remove (supabase/migrations/0_init.sql)", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("reports a shadow cleanup failure without failing the command", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + failRemoveShadow: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + `Failed to remove container: ${LEGACY_FAKE_SHADOW_CONTAINER_ID}`, + ); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Step 2 — local target + // ------------------------------------------------------------------------- + + describe("local target", () => { + it.effect( + "prints Finished on stdout and the repair suggestion on stderr, and never prompts", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + expect(stderr(s.out)).not.toContain("Update remote migration history table?"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("a --db-url pointing at the local stack also takes the local-suggestion path", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { isLocal: true, args: ["--db-url", "postgresql://local"] }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ dbUrl: Option.some("postgresql://local") })); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Step 2 — remote target + // ------------------------------------------------------------------------- + + describe("remote target", () => { + function setupRemote(opts: SetupOpts = {}) { + seedMigration(tmp.current, "0_init.sql"); + return setup(tmp.current, { isLocal: false, linkedRef: LEGACY_VALID_REF, ...opts }); + } + + it.effect("prompts to update the remote history table and baselines on 'y'", () => { + const s = setupRemote({ confirm: true, args: ["--linked"] }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ linked: true })); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); + expect(s.queries.some((q) => q.sql.includes("DELETE FROM supabase_migrations"))).toBe(true); + expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "prints 'Baselining migration history to ' BEFORE 'Connecting to remote database...'", + () => { + const s = setupRemote({ confirm: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const text = stderr(s.out); + const baseliningAt = text.indexOf("Baselining migration history to 0"); + const connectingAt = text.indexOf("Connecting to remote database..."); + expect(baseliningAt).toBeGreaterThanOrEqual(0); + expect(connectingAt).toBeGreaterThan(baseliningAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "baselines via one transaction: BEGIN, DELETE ... WHERE version <= $1, INSERT ..., COMMIT", + () => { + const s = setupRemote({ confirm: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + // ONE ordered log (not `execs`/`queries` separately — `.toContain`/`.find` are + // order-blind, so an INSERT-before-DELETE regression would ship green against + // them) — the baseline's own transaction is the LAST 4 statements sent, after + // `legacyCreateMigrationTable`'s own (exec-only) setup transaction. + const baseline = s.statements.slice(-4); + expect(baseline.map((entry) => entry.sql)).toEqual([ + "BEGIN", + "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1", + "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)", + "COMMIT", + ]); + expect(baseline[1]?.params).toEqual(["0"]); + expect(baseline[2]?.params).toEqual(["0", "init", ["create table t (id int)"]]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "rolls back and reports a baseline failure when the history-table batch fails", + () => { + const s = setupRemote({ confirm: true, failSql: "INSERT INTO supabase_migrations" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashBaselineError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && (failure.value as { message: string }).message, + ).toContain("failed to update migration history:"); + } + expect(s.execs).toContain("ROLLBACK"); + // Exactly one COMMIT — `legacyCreateMigrationTable`'s own setup transaction, + // which runs (and commits) BEFORE the baseline's own BEGIN/DELETE/INSERT + // batch; the baseline's OWN transaction never reaches COMMIT. + expect(s.execs.filter((e) => e === "COMMIT")).toHaveLength(1); + expect(s.execs.filter((e) => e === "BEGIN")).toHaveLength(2); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "declining the prompt exits 0, runs no baseline query, and still prints Finished", + () => { + const s = setupRemote({ confirm: false }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.execs).not.toContain("BEGIN"); + expect(s.queries).toEqual([]); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--yes auto-confirms by echoing the prompt with 'y' and reads no stdin", () => { + const s = setupRemote({ yes: true, pipedInput: undefined }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] y"); + expect(s.execs).toContain("BEGIN"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("a non-TTY run with no piped answer takes the default (yes) and baselines", () => { + const s = setupRemote({ isTTY: false, pipedInput: undefined }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.execs).toContain("BEGIN"); + expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--version 0 baselines exactly version 0 even though a newer migration survives", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_newer.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ version: Option.some("0") })); + const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); + expect(insert?.params?.[0]).toBe("0"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("baselines the surviving older version when a merged-file removal failed", () => { + // Go re-lists local versions AFTER the file removals — a failed removal + // means the squash TARGET survives on disk (already true), but so does + // the OLDER merged file whose removal failed, and THAT older version is + // what an empty `--version` baselines to, not the squash target. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); + // "0" (the surviving older file), NOT "1" (the squash target). + expect(insert?.params?.[0]).toBe("0"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "debug-logs and baselines with an empty version when the post-squash version reload fails", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + // The FIRST `readDirectory(migrationsDir)` call is `squashToVersion`'s own + // listing (must succeed so the squash itself completes); the SECOND is + // `baselineMigrations`'s post-removal re-list, which this fails — the + // THIRD (inside `legacyResolveMigrationFile`, resolving the now-empty + // version) must succeed again so the scenario isolates the reload failure. + fsFaults: { failReadDirectoryAtCall: { path: migrationsDir, atCall: 2 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(s.debugLogs).toHaveLength(1); + expect(s.debugLogs[0]).toContain("failed to read directory"); + expect(s.debugLogs[0]).toContain("simulated failure"); + expect(stderr(s.out)).toContain("Baselining migration history to \n"); + // `repair.NewMigrationFromVersion("")` finds no match — the empty-version + // glob fails, which surfaces as the baseline's own missing-file error, + // proving `resolvedVersion` genuinely stayed "" rather than falling back + // to the squash target. + expect(failureTag(exit)).toBe("LegacyMigrationFileNotFoundError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "glob supabase/migrations/_*.sql: file does not exist", + ); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--linked caches the linked project ref even when the squash fails", () => { + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + args: ["--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ linked: true, version: Option.some("bad") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--linked --project-ref overrides the workdir's own linked ref for resolution and caching", + () => { + // `opts.linkedRef` (LEGACY_VALID_REF) represents whatever the workdir would + // resolve to absent the flag — the explicit --project-ref flag must win over + // it, both for the resolver call and for what ultimately gets cached. + const FLAG_REF = "flagflagflagflagflag"; + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + args: ["--linked", "--project-ref", FLAG_REF], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ linked: true, projectRef: Option.some(FLAG_REF), version: Option.some("bad") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(FLAG_REF); + expect(s.cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "--linked reads [remotes.] and prints the config-override line before resolving", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.dev]", `project_id = "${LEGACY_VALID_REF}"`, ""].join("\n"), + ); + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ linked: true })); + const text = stderr(s.out); + expect(text).toContain("Loading config override: [remotes.dev]"); + const overrideAt = text.indexOf("Loading config override: [remotes.dev]"); + const promptAt = text.indexOf("Update remote migration history table?"); + expect(promptAt).toBeGreaterThan(overrideAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // Output formats + // ------------------------------------------------------------------------- + + describe("output formats", () => { + it.effect("json emits the squash payload on stdout and keeps progress on stderr", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Migrations squashed", + data: { + squashedInto: "supabase/migrations/0_init.sql", + removed: [], + removeFailures: [], + alreadyEarliest: true, + isLocal: true, + baselinedVersion: null, + }, + }), + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("json suppresses the Finished line and the repair suggestion", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).not.toContain("Finished"); + expect(stderr(s.out)).not.toContain("Run supabase migration repair"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("stream-json emits the result event on stdout with progress lines on stderr", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "stream-json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.out.messages.some((m) => m.type === "success")).toBe(true); + expect(stderr(s.out)).toContain("is already the earliest migration."); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("json still writes the prompt label to stderr and reads the piped answer", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); + expect(s.execs).toContain("BEGIN"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "json on the declined-prompt path reports success with baselinedVersion: null", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: false, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ isLocal: false, baselinedVersion: null }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("json on the remote-confirmed 2-migration path reports the full real payload", () => { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + // "1_target.sql" is the sole surviving local file once "0_init.sql" is removed, so + // the empty-`--version` baseline reload (`legacyLoadLocalVersions`, run AFTER the + // removal) resolves to its own version, "1" — matching `squashedInto` below, NOT + // the removed file's "0". + expect(success?.data).toEqual({ + squashedInto: "supabase/migrations/1_target.sql", + removed: ["supabase/migrations/0_init.sql"], + removeFailures: [], + alreadyEarliest: false, + isLocal: false, + baselinedVersion: "1", + }); + }).pipe(Effect.provide(s.layer)); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 0169f3b55a..67979af38f 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -2,10 +2,11 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/migrations/` | directory | always, to read pending migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | always, to read pending migration files | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/.temp/project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -28,11 +29,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ----------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Output @@ -54,6 +56,13 @@ Same structured `applied` result delivered as an NDJSON `result` event. ## Notes - `--local` (default true), `--linked`, and `--db-url` are mutually exclusive. +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command) + overrides ONLY the linked-ref resolution used for the connection (flag > + `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: + passing it with a resolved `--local`/`--db-url` target is a hard error rather + than a silently discarded flag (deliberately stricter than + `SUPABASE_PROJECT_ID`, which Go's equivalent env var simply leaves unused on + a non-linked target). - `--include-all` applies all migrations not found on the remote history table. - Pipeline-incompatible statements (`CREATE [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`) run standalone outside diff --git a/apps/cli/src/legacy/commands/migration/up/up.command.ts b/apps/cli/src/legacy/commands/migration/up/up.command.ts index 806c29ceb5..100baf969b 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.command.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.command.ts @@ -24,6 +24,11 @@ const config = { // Go: `upFlags.Bool("local", true, …)`. Flag.withDefault(true), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; export type LegacyMigrationUpFlags = CliCommand.Command.Config.Infer; @@ -39,7 +44,11 @@ export const legacyMigrationUpCommand = Command.make("up", config).pipe( "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/legacy/commands/migration/up/up.errors.ts b/apps/cli/src/legacy/commands/migration/up/up.errors.ts index 683c2c8c25..84f37b2297 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.errors.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A remote migration version is not present in the local migrations directory. @@ -10,7 +15,11 @@ export class LegacyMigrationMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Out-of-order local migrations exist before the last remote migration, and @@ -23,4 +32,8 @@ export class LegacyMigrationMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} diff --git a/apps/cli/src/legacy/commands/migration/up/up.handler.ts b/apps/cli/src/legacy/commands/migration/up/up.handler.ts index e45ec9ab7d..98052e919e 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.handler.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.handler.ts @@ -56,6 +56,18 @@ const runUp = Effect.fnUntraced(function* ( ); } + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && (target.connType ?? "local") !== "linked") { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); const upBody = Effect.gen(function* () { @@ -64,6 +76,7 @@ const runUp = Effect.fnUntraced(function* ( dbUrl: flags.dbUrl, connType: target.connType ?? "local", dnsResolver, + linkedProjectRef: flags.projectRef, }); const ref = Option.getOrUndefined(cfg.ref ?? Option.none()); const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref); @@ -144,7 +157,7 @@ const runUp = Effect.fnUntraced(function* ( if ((target.connType ?? "local") === "linked") { const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const linkedRef = yield* projectRef.loadProjectRef(Option.none()); + const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef); return yield* upBody.pipe(Effect.ensuring(linkedProjectCache.cache(linkedRef))); } return yield* upBody; diff --git a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts index abab7670fd..6f05eba361 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts @@ -97,11 +97,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same + // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag + // (not just the hardcoded `LEGACY_VALID_REF` fallback) drives the linked ref. 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), + loadProjectRef: (flagValue: Option.Option) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : LEGACY_VALID_REF, + ), promptProjectRef: () => Effect.succeed(LEGACY_VALID_REF), }); @@ -117,7 +123,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(CliArgs, { args: opts.args ?? [] }), BunServices.layer, ); - return { layer, out, telemetry, execs, queries }; + return { layer, out, telemetry, execs, queries, cache }; } const flags = (over: Partial = {}): LegacyMigrationUpFlags => ({ @@ -125,6 +131,7 @@ const flags = (over: Partial = {}): LegacyMigrationUpFla dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? true, + projectRef: over.projectRef ?? Option.none(), }); const seed = (workdir: string, name: string, body = "create table a;\n") => { @@ -271,6 +278,51 @@ describe("legacy migration up", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "applies on the project given via --project-ref --linked, overriding the linked ref", + () => { + // up defaults to local; only with --linked does the flag's ref get cached. + // The fake resolver's own fallback (LEGACY_VALID_REF) represents whatever + // the workdir would resolve to absent the flag — the flag must win over it. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup(tmp.current, { args: ["--linked"], remote: [] }); + return Effect.gen(function* () { + yield* legacyMigrationUp( + flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) }), + ); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("rejects --project-ref on the default local target", () => { + // up defaults to local when no target flag is set — the guard must fire + // from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationUp(flags({ projectRef: Option.some(FLAG_REF) })).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( + "LegacyMigrationTargetFlagsError", + ); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or cache write. + expect(execs).toEqual([]); + expect(queries).toEqual([]); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("emits a structured result in json", () => { seed(tmp.current, "20240101000000_a.sql"); const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts index 6e9459a1fb..037c0d8baf 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts @@ -1,10 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + export class LegacyNetworkBansGetNetworkError extends Data.TaggedError( "LegacyNetworkBansGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansGetUnexpectedStatusError", @@ -12,13 +26,24 @@ export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyNetworkBansRemoveNetworkError extends Data.TaggedError( "LegacyNetworkBansRemoveNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansRemoveUnexpectedStatusError", @@ -26,13 +51,21 @@ export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyNetworkBansEnvNotSupportedError extends Data.TaggedError( "LegacyNetworkBansEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( "LegacyNetworkBansInvalidIpError", @@ -43,4 +76,8 @@ export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( constructor(args: { readonly input: string }) { super({ input: args.input, message: `invalid IP address: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts index 73ac0b13c2..94c76b288a 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts @@ -5,11 +5,10 @@ import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; -import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; -import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTelemetryRuntime } from "../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyPlatformApi, useLegacyTempWorkdir, @@ -39,42 +38,21 @@ function setup() { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` - // itself), so the real `legacyCredentialsLayer` built inline inside the - // command for the "gate open" case resolves ITS `RuntimeInfo` from this - // layer. Point homeDir at this test's isolated tempRoot so the layer's - // file-based token fallback (`/.supabase/access-token`) can't pick - // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. - runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + // The "gate open" case builds the real `legacyManagementApiRuntimeLayer` + // inline inside the command; its cliConfig/credentials layers read real + // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Isolate both, keeping + // only the keyring kill-switch set. + runtimeInfo: legacyIsolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( runtime, CliOutput.layer(textCliOutputFormatter()), - // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` - // (provided inline inside the command, not by this test's mocked runtime), - // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Wipe process.env - // down to just this and disable the keyring fallback. - processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: `${tempRoot.current}/.supabase`, - tracesDir: `${tempRoot.current}/.supabase/traces`, - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), + mockTelemetryRuntime({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + }), ); return { layer, api }; } diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts index 8e7b0b2bf0..eb75ee5ddf 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyNetworkRestrictionsGetNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsGetUnexpectedStatusError", @@ -12,13 +25,24 @@ export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateUnexpectedStatusError", @@ -26,7 +50,11 @@ export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.T readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( "LegacyNetworkRestrictionsInvalidCidrError", @@ -38,6 +66,10 @@ export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:23`. super({ input: args.input, message: `failed to parse IP: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( @@ -50,4 +82,8 @@ export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:26`. super({ input: args.input, message: `private IP provided: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts index 494060d003..a175e9c6ba 100644 --- a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts +++ b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site @@ -7,7 +13,14 @@ import { Data } from "effect"; export class LegacyOrgsListNetworkError extends Data.TaggedError("LegacyOrgsListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsListUnexpectedStatusError", @@ -15,11 +28,22 @@ export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyOrgsCreateNetworkError extends Data.TaggedError("LegacyOrgsCreateNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsCreateUnexpectedStatusError", @@ -27,7 +51,11 @@ export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path error — `orgs list --output env` is explicitly rejected by the Go @@ -40,4 +68,8 @@ export class LegacyOrgsEnvNotSupportedError extends Data.TaggedError( "LegacyOrgsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts index a15a8c4c5f..a9b2d1128d 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyPostgresConfigGetNetworkError extends Data.TaggedError( "LegacyPostgresConfigGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigGetUnexpectedStatusError", @@ -12,19 +22,33 @@ export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyPostgresConfigGetUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigGetUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} export class LegacyPostgresConfigUpdateNetworkError extends Data.TaggedError( "LegacyPostgresConfigUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnexpectedStatusError", @@ -32,25 +56,43 @@ export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyPostgresConfigUpdateUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} export class LegacyPostgresConfigUpdateSerializeError extends Data.TaggedError( "LegacyPostgresConfigUpdateSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigDeleteNetworkError extends Data.TaggedError( "LegacyPostgresConfigDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnexpectedStatusError", @@ -58,19 +100,33 @@ export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacyPostgresConfigDeleteUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Constructed only after a 200 status check when `parseJsonObject` fails — + // an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} export class LegacyPostgresConfigDeleteSerializeError extends Data.TaggedError( "LegacyPostgresConfigDeleteSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedError( "LegacyPostgresConfigInvalidConfigValueError", @@ -84,4 +140,8 @@ export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedErro message: `expected config value in key:value format, received: '${args.input}'`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts index 1db57151d0..fa6e8eb3d0 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts @@ -5,11 +5,10 @@ import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; -import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; -import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTelemetryRuntime } from "../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyPlatformApi, useLegacyTempWorkdir, @@ -42,42 +41,21 @@ function setup() { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` - // itself), so the real `legacyCredentialsLayer` built inline inside the - // command for the "gate open" case resolves ITS `RuntimeInfo` from this - // layer. Point homeDir at this test's isolated tempRoot so the layer's - // file-based token fallback (`/.supabase/access-token`) can't pick - // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. - runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + // The "gate open" case builds the real `legacyManagementApiRuntimeLayer` + // inline inside the command; its cliConfig/credentials layers read real + // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Isolate both, keeping + // only the keyring kill-switch set. + runtimeInfo: legacyIsolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( runtime, CliOutput.layer(textCliOutputFormatter()), - // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` - // (provided inline inside the command, not by this test's mocked runtime), - // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Wipe process.env - // down to just this and disable the keyring fallback. - processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: `${tempRoot.current}/.supabase`, - tracesDir: `${tempRoot.current}/.supabase/traces`, - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), + mockTelemetryRuntime({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + }), ); return { layer, api }; } diff --git a/apps/cli/src/legacy/commands/projects/list/list.handler.ts b/apps/cli/src/legacy/commands/projects/list/list.handler.ts index 6ac9159fc5..d950ed0195 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.handler.ts @@ -114,6 +114,7 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( status: response.status, body: "", message: `Unexpected error retrieving projects: ${cause}`, + decode: true, }), ), ); @@ -123,6 +124,7 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( status: response.status, body: "", message: "Unexpected error retrieving projects: response was not an array", + decode: true, }); } yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index 791e931075..604340f428 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +16,11 @@ export class LegacyProjectsListNetworkError extends Data.TaggedError( "LegacyProjectsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsListUnexpectedStatusError", @@ -18,13 +28,30 @@ export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + /** + * Set when the failure is a 200 response whose body could not be decoded + * (unparseable JSON / not an array) rather than a genuine non-200 status — + * an API response problem, not a bad status code. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return statusCodeActionability(this.status); + } +} export class LegacyProjectsCreateNetworkError extends Data.TaggedError( "LegacyProjectsCreateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsCreateUnexpectedStatusError", @@ -32,7 +59,11 @@ export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Interactive org list fetched by `create` when `--org-id` is omitted // (`create.go:97-105`). @@ -40,7 +71,14 @@ export class LegacyProjectsOrgsListNetworkError extends Data.TaggedError( "LegacyProjectsOrgsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsOrgsListUnexpectedStatusError", @@ -48,13 +86,24 @@ export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyProjectsDeleteNetworkError extends Data.TaggedError( "LegacyProjectsDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsDeleteUnexpectedStatusError", @@ -62,20 +111,35 @@ export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // 404 branch of `delete.Run` (`delete.go:37-38`): "Project does not exist:". export class LegacyProjectsDeleteNotFoundError extends Data.TaggedError( "LegacyProjectsDeleteNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyProjectsApiKeysNetworkError extends Data.TaggedError( "LegacyProjectsApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsApiKeysUnexpectedStatusError", @@ -83,7 +147,11 @@ export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -94,7 +162,11 @@ export class LegacyProjectsEnvNotSupportedError extends Data.TaggedError( "LegacyProjectsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Non-interactive `create` missing required params — mirrors Go's PreRunE // marking `--org-id`, `--db-password`, `--region` required + ExactArgs(1) @@ -103,14 +175,22 @@ export class LegacyProjectsCreateMissingArgError extends Data.TaggedError( "LegacyProjectsCreateMissingArgError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // Interactive `create` name prompt returned blank (`create.go:94`). export class LegacyProjectsCreateNameEmptyError extends Data.TaggedError( "LegacyProjectsCreateNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `delete` non-interactive with no positional ref — mirrors Go's // `cobra.ExactArgs(1)` on a non-TTY (`projects.go:109-113`). @@ -118,7 +198,11 @@ export class LegacyProjectsDeleteRefRequiredError extends Data.TaggedError( "LegacyProjectsDeleteRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // User declined the delete confirmation prompt (`delete.go:24-25`, // `errors.New(context.Canceled)`). @@ -126,4 +210,8 @@ export class LegacyProjectsDeleteCancelledError extends Data.TaggedError( "LegacyProjectsDeleteCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 385c0911fe..b88f50f13b 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -1,5 +1,12 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + // --------------------------------------------------------------------------- // HTTP-bound errors (network + unexpected-status pairs) // --------------------------------------------------------------------------- @@ -8,7 +15,14 @@ export class LegacySecretsListNetworkError extends Data.TaggedError( "LegacySecretsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( "LegacySecretsListUnexpectedStatusError", @@ -16,11 +30,22 @@ export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacySecretsSetNetworkError extends Data.TaggedError("LegacySecretsSetNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsSetUnexpectedStatusError", @@ -28,13 +53,24 @@ export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacySecretsUnsetNetworkError extends Data.TaggedError( "LegacySecretsUnsetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsUnsetUnexpectedStatusError", @@ -42,7 +78,11 @@ export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, file I/O, user cancellation) @@ -52,33 +92,72 @@ export class LegacySecretsEnvFileOpenError extends Data.TaggedError( "LegacySecretsEnvFileOpenError", )<{ readonly message: string; -}> {} + readonly reason: "not_found" | "permission" | "other"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "not_found") { + return { ...actionability.provideFlags, fingerprint_suffix: "not_found" }; + } + if (this.reason === "permission") { + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + } + return { ...actionability.unknown, fingerprint_suffix: "platform_error" }; + } +} export class LegacySecretsEnvFileParseError extends Data.TaggedError( "LegacySecretsEnvFileParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +export class LegacySecretsSetInputError extends Data.TaggedError("LegacySecretsSetInputError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyInvalidSecretPairError extends Data.TaggedError("LegacyInvalidSecretPairError")<{ readonly pair: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsNoArgumentsError extends Data.TaggedError( "LegacySecretsNoArgumentsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySecretsEnvNotSupportedError extends Data.TaggedError( "LegacySecretsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsUnsetCancelledError extends Data.TaggedError( "LegacySecretsUnsetCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index fed1646688..0d527afb2d 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -23,6 +23,7 @@ import { LegacySecretsEnvFileOpenError, LegacySecretsEnvFileParseError, LegacySecretsNoArgumentsError, + LegacySecretsSetInputError, LegacySecretsSetNetworkError, LegacySecretsSetUnexpectedStatusError, } from "../secrets.errors.ts"; @@ -323,6 +324,12 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( (cause) => new LegacySecretsEnvFileOpenError({ message: `failed to open env file: ${String(cause)}`, + reason: + cause.reason._tag === "NotFound" + ? "not_found" + : cause.reason._tag === "PermissionDenied" + ? "permission" + : "other", }), ), ); @@ -391,13 +398,18 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // cap) before sending any request. Without this, a schema-invalid entry in a // later batch would only surface after earlier batches had already been // uploaded, leaving the project partially updated. Decoding fails with the - // same `SchemaError` `bulkCreateSecrets` raises, so `mapSetError` keeps the - // error surface identical to the previous single-call path. + // same `SchemaError` `bulkCreateSecrets` raises. This validation is wholly + // user-derived, so keep it distinct from response-schema decode failures. yield* Effect.forEach( batches, (batch) => Schema.decodeUnknownEffect(V1BulkCreateSecretsInput)({ ref, body: batch }), { discard: true }, - ).pipe(Effect.catch(mapSetError)); + ).pipe( + Effect.mapError( + (cause) => + new LegacySecretsSetInputError({ message: `failed to set secrets: ${String(cause)}` }), + ), + ); const setting = output.format === "text" ? yield* output.task("Setting secrets...") : undefined; yield* Effect.forEach(batches, (batch) => api.v1.bulkCreateSecrets({ ref, body: batch }), { diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts index 29bbaf3fd0..2c560ad80d 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -1,8 +1,9 @@ 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 { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, PlatformError } from "effect"; import { mockOutput, @@ -17,6 +18,7 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySecretsSet } from "./set.handler.ts"; function mockLegacyDebugLoggerTracked() { @@ -33,6 +35,28 @@ function mockLegacyDebugLoggerTracked() { }; } +function permissionDeniedReadLayer(target: string) { + return Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => + FileSystem.FileSystem.of({ + ...real, + readFileString: (path, encoding) => + path === target + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + }), + ) + : real.readFileString(path, encoding), + }), + ), + ).pipe(Layer.provide(BunServices.layer)); +} + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- @@ -178,6 +202,12 @@ describe("legacy secrets set integration", () => { }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySecretsSetInputError"); + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_kind).toBe("user_actionable"); + expect(classified.error_category).toBe("invalid_input"); + } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }, @@ -423,7 +453,37 @@ FOO = "literal-foo" const errJson = JSON.stringify(exit.cause); expect(errJson).toContain("LegacySecretsEnvFileOpenError"); expect(errJson).toContain("failed to open env file"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + error_fingerprint: "tag:LegacySecretsEnvFileOpenError:not_found", + }); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("classifies an unreadable env file as a permission failure", () => { + const envPath = join(tempRoot.current, "private.env"); + const { layer: baseLayer, api } = setup(); + const layer = Layer.mergeAll(baseLayer, permissionDeniedReadLayer(envPath)); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySecretsSet({ + projectRef: Option.none(), + envFile: Option.some(envPath), + secrets: [], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + suggestion_type: "none", + error_fingerprint: "tag:LegacySecretsEnvFileOpenError:filesystem", + }); } + expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md index e884f1f397..e77d7c2a28 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/seed/buckets/SIDE_EFFECTS.md @@ -13,6 +13,7 @@ stack is used; with `--linked` the remote project is used. | `/supabase//**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/` (Go `config.go:757-759`), an absolute path is used as-is | | `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). | | `/supabase/` | PEM text | local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing (Go `config.go:845-861`) — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. | +| `/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | | `/supabase/.env*`, `/.env*` | dotenv | when no pre-resolved `yes` is passed in (the standalone command; `db reset --local` passes its own), to resolve `SUPABASE_YES` for the overwrite/prune prompts (CLI-1878; Go's `loadNestedEnv`) | ## Files Written @@ -88,6 +89,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `1` | network / connection failure to the Storage gateway | | `1` | malformed list response (a 200 body whose shape doesn't decode, mirroring Go's strict `ParseJSON`) | | `1` | unreadable `objects_path` (filesystem error during walk/upload) | +| `1` | `--project-ref` set without `--linked` (see Notes) | ## Telemetry Events Fired @@ -141,6 +143,11 @@ stdout and a terminal `result`/`error` event is emitted. ## Notes +- **`--project-ref`** (TS-only, no Go equivalent — Go's `seed` defines no + `--project-ref` at all) overrides ONLY the linked-ref resolution used above + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies + `--linked`: passing it without `--linked` (i.e. targeting local) is a hard + error rather than a silently discarded flag. - **Remote (`--linked`) — config override merge.** The project ref is resolved BEFORE config is loaded. `loadProjectConfig` then merges the `[remotes.]` block whose `project_id` equals the resolved ref over the base config (including diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts index 8fe2122f71..7ac22c42d5 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.command.ts @@ -1,5 +1,5 @@ -import { Effect, Layer } from "effect"; -import { Command } from "effect/unstable/cli"; +import { Effect, Layer, type Option } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -10,18 +10,28 @@ import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; +const config = { + // TS-only override of the linked project ref — see push.command.ts (db push). + // No Go equivalent: `seed.go` never registers `--project-ref` on this command. + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +}; + // `--linked`/`--local` are scoped globals on the `seed` group (`seed.flags.ts`), -// so this leaf has no own flags; the handler selects the target from the changed -// argv set, not these parsed values. +// so this leaf only owns `--project-ref` above; the handler selects the target +// from the changed argv set, not these parsed values. export type LegacyBucketsFlags = { readonly linked: boolean; readonly local: boolean; + readonly projectRef: Option.Option; }; -export const legacyBucketsCommand = Command.make("buckets").pipe( +export const legacyBucketsCommand = Command.make("buckets", config).pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), - Command.withHandler(() => + Command.withHandler((leafFlags) => Effect.gen(function* () { // Enforce --local/--linked mutual exclusivity BEFORE instrumentation, so a // flag-validation rejection doesn't emit `cli_command_executed` (Go rejects @@ -33,8 +43,20 @@ export const legacyBucketsCommand = Command.make("buckets").pipe( const flags: LegacyBucketsFlags = { linked: yield* LegacySeedLinkedFlag, local: yield* LegacySeedLocalFlag, + projectRef: leafFlags.projectRef, }; - return yield* legacySeedBuckets(flags).pipe(withLegacyCommandInstrumentation({ flags })); + return yield* legacySeedBuckets(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + linked: flags.linked, + local: flags.local, + "project-ref": flags.projectRef, + }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. + }), + ); }).pipe(withJsonErrorHandling), ), Command.provide( diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts index 2f3e9efa6f..cc3e438670 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Domain errors specific to `supabase seed buckets`. @@ -18,7 +23,11 @@ import { Data } from "effect"; */ export class LegacySeedConfigLoadError extends Data.TaggedError("LegacySeedConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised when `--local` and `--linked` are both passed, reproducing cobra's @@ -28,4 +37,8 @@ export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacySeedMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index f5d04275b7..38da60a520 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; +import { LegacySeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; /** * `supabase seed buckets` — seeds Storage buckets from @@ -19,9 +20,10 @@ import type { LegacyBucketsFlags } from "./buckets.command.ts"; * target-flag resolution and the post-run cache + telemetry side effects. */ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( - // Target is selected from the changed-flag set (Go's flag.Changed), not the - // parsed value, so the flags arg itself is unused here. - _flags: LegacyBucketsFlags, + // Target (linked vs. local) is selected from the changed-flag set (Go's + // flag.Changed), not the parsed `linked`/`local` values — only `projectRef` + // is read directly below. + flags: LegacyBucketsFlags, ) { const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -40,10 +42,22 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // `flag.Changed`, not the flag value: `--linked` is the linked path whenever // it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); + const isLinked = setFlags.includes("linked"); + + // `--project-ref` never implies `--linked` and must not be silently + // discarded on the local target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && !isLinked) { + return yield* Effect.fail( + new LegacySeedMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }), + ); + } + const projectRefResolver = yield* LegacyProjectRefResolver; - const projectRef = setFlags.includes("linked") - ? yield* projectRefResolver.loadProjectRef(Option.none()) - : ""; + const projectRef = isLinked ? yield* projectRefResolver.loadProjectRef(flags.projectRef) : ""; linkedRef = projectRef; yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index a689e82bfb..a87db85e61 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -43,7 +43,7 @@ interface MockRoute { readonly transportDescription?: string; } -const DEFAULT_FLAGS: LegacyBucketsFlags = { linked: false, local: true }; +const DEFAULT_FLAGS: LegacyBucketsFlags = { linked: false, local: true, projectRef: Option.none() }; function setupLegacySeedBuckets( workdir: string, @@ -150,14 +150,19 @@ function setupLegacySeedBuckets( ) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), - loadProjectRef: () => - opts.linkedFails === true - ? Effect.fail( - new LegacyProjectNotLinkedError({ - message: "Cannot find project ref. Have you run supabase link?", - }), - ) - : Effect.succeed(projectRefRef), + // Gives an explicit `--project-ref` flag top precedence, same as Go's + // `flags.LoadProjectRef` — short-circuits BEFORE `linkedFails`, so a test + // can prove the flag resolves a ref even for an "unlinked" workdir. + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(projectRefRef), promptProjectRef: () => Effect.succeed(projectRefRef), }); @@ -654,7 +659,7 @@ describe("legacy seed buckets", () => { // with no config file Go still builds the remote client, fetches the // service-role key, and lists buckets — failures surface instead of a silent // success. With no configured buckets the remote LIST must still happen. - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { projectRef: LEGACY_VALID_REF, apiKeys: [ @@ -977,10 +982,11 @@ describe("legacy seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets({ linked: true, local: false }).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacySeedBuckets({ + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).not.toContain("Another process may be listening"); }); @@ -1560,7 +1566,7 @@ describe("legacy seed buckets", () => { // --------------------------------------------------------------------------- it.live("--linked seeds the remote storage project", () => { - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: LEGACY_VALID_REF, @@ -1589,6 +1595,64 @@ describe("legacy seed buckets", () => { }); }); + it.live( + "--project-ref --linked seeds the project given by the flag, overriding LEGACY_VALID_REF", + () => { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (LEGACY_VALID_REF) — the flag must win over it and drive the storage + // gateway host. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, out, requests, linkedCache } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + args: ["seed", "buckets", "--linked"], + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets({ + linked: true, + local: false, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Creating Storage bucket: test"); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe( + true, + ); + expect(requests.some((r) => r.url.includes(LEGACY_VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }, + ); + + it.live("rejects --project-ref on the default local target", () => { + // seed buckets defaults to local when no target flag is set — the guard + // must fire from the flag alone, with no explicit --local needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacySeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + }); + return Effect.gen(function* () { + const exit = yield* legacySeedBuckets({ + linked: false, + local: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + ); + } + // The guard fires before any HTTP request or cache write. + expect(requests).toEqual([]); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("--linked=false still takes the linked path (Go flag.Changed, not value)", () => { // Go selects the target from flag.Changed: `--linked=false` is still linked. const { layer, requests } = setupLegacySeedBuckets(tmp.current, { @@ -1601,10 +1665,11 @@ describe("legacy seed buckets", () => { ], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets({ linked: false, local: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacySeedBuckets({ + linked: false, + local: true, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); // Remote URL → the linked path ran despite the parsed value being false. expect( @@ -1623,10 +1688,11 @@ describe("legacy seed buckets", () => { ], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets({ linked: false, local: false }).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacySeedBuckets({ + linked: false, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); // Local path (not the remote https host) — `--local` changed selects local. // Asserting "not remote" keeps this independent of the loopback host env. @@ -1645,10 +1711,11 @@ describe("legacy seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets({ linked: true, local: false }).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacySeedBuckets({ + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); // Go's tenant.GetApiKeys → errMissingKey, before NewStorageAPI. expect(JSON.stringify(exit)).toContain("Anon key not found."); @@ -1669,10 +1736,11 @@ describe("legacy seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets({ linked: true, local: false }).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* legacySeedBuckets({ + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); const json = JSON.stringify(exit); expect(json).toContain("LegacyStorageAuthTokenError"); @@ -1704,7 +1772,7 @@ describe("legacy seed buckets", () => { ], }); return Effect.gen(function* () { - yield* legacySeedBuckets({ linked: true, local: false }).pipe( + yield* legacySeedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( Effect.provide(linked.layer), Effect.exit, ); @@ -1719,7 +1787,7 @@ describe("legacy seed buckets", () => { it.live("--linked uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => { const prevKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: LEGACY_VALID_REF, @@ -1747,7 +1815,7 @@ describe("legacy seed buckets", () => { }); it.live("upserts analytics buckets when analytics.enabled and --linked", () => { - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -1798,7 +1866,7 @@ describe("legacy seed buckets", () => { }); it.live("prunes a stale analytics bucket when the prompt is accepted", () => { - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -1837,7 +1905,7 @@ describe("legacy seed buckets", () => { }); it.live("--linked fails when the project is not linked", () => { - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", linkedFails: true, @@ -1950,7 +2018,7 @@ describe("legacy seed buckets", () => { // appear after the merge (Go's mergeRemoteConfig merges subtrees recursively; // it does not wholesale replace [storage.buckets]). const remoteRef = LEGACY_VALID_REF; // "abcdefghijklmnopqrst" - const flags: LegacyBucketsFlags = { linked: true, local: false }; + const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ 'project_id = "test"', diff --git a/apps/cli/src/legacy/commands/services/services.errors.ts b/apps/cli/src/legacy/commands/services/services.errors.ts index 7450caafb2..1eb85785f0 100644 --- a/apps/cli/src/legacy/commands/services/services.errors.ts +++ b/apps/cli/src/legacy/commands/services/services.errors.ts @@ -1,7 +1,16 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyServicesEnvNotSupportedError extends Data.TaggedError( "LegacyServicesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts b/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts index d2e4d0c5b8..6c294535b9 100644 --- a/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts @@ -18,15 +18,16 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, mockTelemetryRuntime, mockTty, } from "../../../../tests/helpers/mocks.ts"; import { + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -42,6 +43,8 @@ import { LegacyIdentityStitch } from "../../shared/legacy-identity-stitch.ts"; import { legacyServicesRuntimeLayer } from "./services.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-services-layers-"); + /** * Stub layer satisfying every external service required by * `legacyServicesRuntimeLayer` from the root runtime. Services under test are @@ -62,7 +65,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. + legacyIsolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, analytics.layer, diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index 26510a13d2..16ff115d62 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -203,6 +203,9 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func (cause) => new LegacySnippetsDownloadNetworkError({ message: `failed to download snippet: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index 486e15a9ce..ec8fd562b9 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -185,6 +185,9 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( (cause) => new LegacySnippetsListNetworkError({ message: `failed to list snippets: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index 3038ca03bc..f0edb9db64 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySnippetsListNetworkError extends Data.TaggedError( "LegacySnippetsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsListUnexpectedStatusError", @@ -12,7 +25,11 @@ export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Mirrors Go's `utils.ErrEnvNotSupported` ("--output env is not supported"), // returned from `list.Run` when `OutputFormat.Value == OutputEnv`. @@ -20,7 +37,11 @@ export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( "LegacySnippetsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Mirrors Go's `utils.EncodeOutput` TOML failure: `snippets list -o toml` // fails whenever a snippet carries a `description`, because BurntSushi @@ -30,19 +51,34 @@ export class LegacySnippetsTomlEncodeError extends Data.TaggedError( "LegacySnippetsTomlEncodeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} // Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's // `invalid snippet ID: ` prefix so callers see the same string. export class LegacySnippetsInvalidIdError extends Data.TaggedError("LegacySnippetsInvalidIdError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( "LegacySnippetsDownloadNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsDownloadUnexpectedStatusError", @@ -50,4 +86,8 @@ export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts index 0ff460b5a1..460c5f220b 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySslEnforcementGetNetworkError extends Data.TaggedError( "LegacySslEnforcementGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementGetUnexpectedStatusError", @@ -12,13 +25,24 @@ export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} export class LegacySslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacySslEnforcementUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementUpdateUnexpectedStatusError", @@ -26,7 +50,11 @@ export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} // Verbatim Go string from `apps/cli-go/internal/ssl_enforcement/update/update.go:27`. export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedError( @@ -37,6 +65,10 @@ export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedErr constructor() { super({ message: "enable/disable not specified" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } // Verbatim cobra string for parity with Go's `MarkFlagsMutuallyExclusive` @@ -53,4 +85,8 @@ export class LegacySslEnforcementMutuallyExclusiveFlagsError extends Data.Tagged "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set none of the others can be", }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts index e76c441eaa..83abe25d01 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts @@ -4,11 +4,10 @@ import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; -import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; -import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTelemetryRuntime } from "../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyPlatformApi, useLegacyTempWorkdir, @@ -41,42 +40,21 @@ function setup() { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - // `RuntimeInfo` is ambient (not provided by `legacyManagementApiRuntimeLayer` - // itself), so the real `legacyCredentialsLayer` built inline inside the - // command for the "gate open" case resolves ITS `RuntimeInfo` from this - // layer. Point homeDir at this test's isolated tempRoot so the layer's - // file-based token fallback (`/.supabase/access-token`) can't pick - // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. - runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), + // The "gate open" case builds the real `legacyManagementApiRuntimeLayer` + // inline inside the command; its cliConfig/credentials layers read real + // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, + // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the + // test would make these assertions non-deterministic. Isolate both, keeping + // only the keyring kill-switch set. + runtimeInfo: legacyIsolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( runtime, CliOutput.layer(textCliOutputFormatter()), - // The "gate open" case reaches the real `legacyManagementApiRuntimeLayer` - // (provided inline inside the command, not by this test's mocked runtime), - // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Wipe process.env - // down to just this and disable the keyring fallback. - processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: `${tempRoot.current}/.supabase`, - tracesDir: `${tempRoot.current}/.supabase/traces`, - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), + mockTelemetryRuntime({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + }), ); return { layer, api }; } diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index 804cda7dfe..a57f24131f 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -60,7 +60,8 @@ const SAML_DISABLED_MESSAGE = const readMetadata = readMetadataFile({ openError: (args) => new LegacySsoAddMetadataFileError(args), - nonUtf8Error: (args) => new LegacySsoAddMetadataFileError({ message: args.message }), + nonUtf8Error: (args) => + new LegacySsoAddMetadataFileError({ message: args.message, reason: "invalid_content" }), }); const readAttributeMapping = readAttributeMappingFile({ @@ -306,6 +307,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy (cause) => new LegacySsoAddMetadataFileError({ message: `${cause.message} Use --skip-url-validation to suppress this error`, + reason: "invalid_url", }), ), ); @@ -370,7 +372,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // mapper uses (`mapLegacyHttpError`) so error output stays bounded and // shell-safe — the raw-HTTP path must not skip these defences. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -383,7 +385,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy yield* creating?.fail() ?? Effect.void; if (response.status === 404) { return yield* Effect.fail( - new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } return yield* Effect.fail( @@ -391,6 +393,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy status: response.status, body: bodyText, message: `Unexpected error adding identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts index 61b037cbc1..7f88b54c2b 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts @@ -18,6 +18,7 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySsoAdd } from "./add.handler.ts"; const RESPONSE_PROVIDER = { @@ -921,6 +922,11 @@ describe("legacy sso add integration", () => { const dump = JSON.stringify(exit.cause); expect(dump).toContain("only HTTPS Metadata URLs are supported"); expect(dump).toContain("Use --skip-url-validation to suppress this error"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + error_fingerprint: "tag:LegacySsoAddMetadataFileError:invalid_url", + }); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 43e8f4f263..3840547f80 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -41,7 +41,7 @@ const handleListError = (ref: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoListUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -49,9 +49,17 @@ const handleListError = (ref: string, cause: SupabaseApiError) => }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } + return yield* Effect.fail( + new LegacySsoListUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index badca247ed..a4338b4fb4 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -38,7 +38,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoRemoveUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -48,9 +48,18 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr return yield* Effect.fail( new LegacySsoRemoveNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoRemoveUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 301852ba1a..23e8f3742f 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -1,4 +1,46 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + planLimitGatedActionability, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; +import type { LegacySsoFileErrorReason } from "./sso.saml.ts"; + +function ssoFileActionability(reason: LegacySsoFileErrorReason): CliErrorActionabilityDeclaration { + if (reason === "not_found") { + return { ...actionability.provideFlags, fingerprint_suffix: "not_found" }; + } + if (reason === "permission") { + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + } + if (reason === "invalid_content") { + return { ...actionability.invalidInput, fingerprint_suffix: "invalid_content" }; + } + if (reason === "invalid_url") { + return { ...actionability.provideFlags, fingerprint_suffix: "invalid_url" }; + } + return { ...actionability.unknown, fingerprint_suffix: "platform_error" }; +} + +/** + * The SAML feature is entitlement-gated: handlers thread the typed result of + * `legacySuggestUpgrade` (`upgradeSuggested`) into these errors so telemetry + * can distinguish plan-gated failures from ordinary API failures without + * sniffing message text. + */ +const samlDisabledActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true + ? planLimitGatedActionability + : { ...actionability.invalidConfig, fingerprint_suffix: "saml_disabled" }; + +const gatedNotFoundActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true ? planLimitGatedActionability : actionability.invalidInput; // Shared across show / update / remove: Go's `uuid.Parse` failure. // Message intentionally diverges from Go's verbose `failed to parse provider ID: invalid UUID …` @@ -7,7 +49,11 @@ import { Data } from "effect"; export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvalidUuidError")<{ readonly providerId: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Shared across list / show: mirrors Go's `utils.EncodeOutput` TOML failure // ("failed to output toml: %w") — reachable when an `attribute_mapping` @@ -15,18 +61,34 @@ export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvali // element). export class LegacySsoTomlEncodeError extends Data.TaggedError("LegacySsoTomlEncodeError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} // `sso list` export class LegacySsoListNetworkError extends Data.TaggedError("LegacySsoListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoListSamlDisabledError extends Data.TaggedError( "LegacySsoListSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( "LegacySsoListUnexpectedStatusError", @@ -34,18 +96,32 @@ export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} // `sso add` export class LegacySsoAddNetworkError extends Data.TaggedError("LegacySsoAddNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoAddSamlDisabledError extends Data.TaggedError( "LegacySsoAddSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( "LegacySsoAddUnexpectedStatusError", @@ -53,23 +129,42 @@ export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacySsoAddMetadataFileError extends Data.TaggedError( "LegacySsoAddMetadataFileError", )<{ readonly message: string; -}> {} + readonly reason: LegacySsoFileErrorReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ssoFileActionability(this.reason); + } +} export class LegacySsoAddAttributeMappingFileError extends Data.TaggedError( "LegacySsoAddAttributeMappingFileError", )<{ readonly message: string; -}> {} + readonly reason: LegacySsoFileErrorReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ssoFileActionability(this.reason); + } +} export class LegacySsoMutexFlagError extends Data.TaggedError("LegacySsoMutexFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // pflag's `ValueRequiredError` (`errors.go:63-78`), emulated for the case the // Effect parser accepts but pflag rejects: a bare value-taking flag as the @@ -81,7 +176,11 @@ export class LegacySsoFlagNeedsArgumentError extends Data.TaggedError( "LegacySsoFlagNeedsArgumentError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // pflag's `InvalidValueError` (`errors.go:32-48`, raised when a flag's // `Value.Set` rejects an occurrence), emulated for values the Effect parser @@ -96,7 +195,11 @@ export class LegacySsoInvalidFlagValueError extends Data.TaggedError( "LegacySsoInvalidFlagValueError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // cobra's `ValidateRequiredFlags` (`command.go:1007`), emulated for the case // the Effect parser cannot see: pflag consumed the required flag's own token @@ -106,35 +209,66 @@ export class LegacySsoAddRequiredFlagError extends Data.TaggedError( "LegacySsoAddRequiredFlagError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // Shared across add + update — metadata URL validation. export class LegacySsoMetadataUrlInvalidError extends Data.TaggedError( "LegacySsoMetadataUrlInvalidError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoMetadataUrlNetworkError extends Data.TaggedError( "LegacySsoMetadataUrlNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Fired only during preflight validation of the USER-SUPPLIED + // `--metadata-url` (a third-party SAML IDP endpoint), never a Supabase + // service — a bad URL that times out / non-200s / is too large is user + // input, like its `MetadataUrlInvalid` / `NonUtf8` siblings. + return actionability.provideFlags; + } +} export class LegacySsoMetadataUrlNonUtf8Error extends Data.TaggedError( "LegacySsoMetadataUrlNonUtf8Error", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `sso show` export class LegacySsoShowNetworkError extends Data.TaggedError("LegacySsoShowNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoShowNotFoundError extends Data.TaggedError("LegacySsoShowNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( "LegacySsoShowUnexpectedStatusError", @@ -142,13 +276,21 @@ export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySsoShowEnvNotSupportedError extends Data.TaggedError( "LegacySsoShowEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // `sso update` // cobra's `ValidateArgs` / `ExactArgs(1)` (`command.go:968`, `cmd/sso.go:87`), @@ -158,15 +300,31 @@ export class LegacySsoShowEnvNotSupportedError extends Data.TaggedError( // (CLI-1982). Message byte-matches cobra's `ExactArgs` template. export class LegacySsoUpdateArityError extends Data.TaggedError("LegacySsoUpdateArityError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoUpdateNetworkError extends Data.TaggedError("LegacySsoUpdateNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoUpdateNotFoundError extends Data.TaggedError("LegacySsoUpdateNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySsoUpdateUnexpectedStatusError", @@ -174,28 +332,58 @@ export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: true, + }); + } +} export class LegacySsoUpdateMetadataFileError extends Data.TaggedError( "LegacySsoUpdateMetadataFileError", )<{ readonly message: string; -}> {} + readonly reason: LegacySsoFileErrorReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ssoFileActionability(this.reason); + } +} export class LegacySsoUpdateAttributeMappingFileError extends Data.TaggedError( "LegacySsoUpdateAttributeMappingFileError", )<{ readonly message: string; -}> {} + readonly reason: LegacySsoFileErrorReason; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return ssoFileActionability(this.reason); + } +} // `sso remove` export class LegacySsoRemoveNetworkError extends Data.TaggedError("LegacySsoRemoveNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoRemoveNotFoundError extends Data.TaggedError("LegacySsoRemoveNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( "LegacySsoRemoveUnexpectedStatusError", @@ -203,7 +391,12 @@ export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} /** * Go's `GetSupabase` token gate (`internal/utils/api.go:119-124`): @@ -213,4 +406,8 @@ export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( */ export class LegacySsoAccessTokenError extends Data.TaggedError("LegacySsoAccessTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts b/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts new file mode 100644 index 0000000000..3086bd71e3 --- /dev/null +++ b/apps/cli/src/legacy/commands/sso/sso.errors.unit.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { + LegacySsoAddAttributeMappingFileError, + LegacySsoAddMetadataFileError, + LegacySsoMetadataUrlNetworkError, + LegacySsoUpdateAttributeMappingFileError, + LegacySsoUpdateMetadataFileError, +} from "./sso.errors.ts"; +import type { LegacySsoFileErrorReason } from "./sso.saml.ts"; + +describe("LegacySsoMetadataUrlNetworkError actionability", () => { + it("classifies a failed user-supplied --metadata-url as a flag input problem", () => { + const result = classifyCliErrorActionability( + new LegacySsoMetadataUrlNetworkError({ message: "failed to fetch metadata url: timeout" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("provide_flags"); + }); +}); + +describe("SSO file error actionability", () => { + const factories: ReadonlyArray<(reason: LegacySsoFileErrorReason) => Error> = [ + (reason) => new LegacySsoAddMetadataFileError({ message: "file error", reason }), + (reason) => new LegacySsoAddAttributeMappingFileError({ message: "file error", reason }), + (reason) => new LegacySsoUpdateMetadataFileError({ message: "file error", reason }), + (reason) => new LegacySsoUpdateAttributeMappingFileError({ message: "file error", reason }), + ]; + + it("distinguishes missing, unreadable, invalid, URL, and ambiguous failures", () => { + for (const makeError of factories) { + expect(classifyCliErrorActionability(makeError("not_found"))).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + error_fingerprint: expect.stringContaining(":not_found"), + }); + expect(classifyCliErrorActionability(makeError("permission"))).toMatchObject({ + error_category: "permission", + suggestion_type: "none", + error_fingerprint: expect.stringContaining(":filesystem"), + }); + expect(classifyCliErrorActionability(makeError("invalid_content"))).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "none", + error_fingerprint: expect.stringContaining(":invalid_content"), + }); + expect(classifyCliErrorActionability(makeError("invalid_url"))).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + error_fingerprint: expect.stringContaining(":invalid_url"), + }); + expect(classifyCliErrorActionability(makeError("other"))).toMatchObject({ + error_kind: "unknown", + error_category: "unknown", + suggestion_type: "none", + error_fingerprint: expect.stringContaining(":platform_error"), + }); + } + }); +}); diff --git a/apps/cli/src/legacy/commands/sso/sso.saml.ts b/apps/cli/src/legacy/commands/sso/sso.saml.ts index 8b3e93a6c0..566134f5bb 100644 --- a/apps/cli/src/legacy/commands/sso/sso.saml.ts +++ b/apps/cli/src/legacy/commands/sso/sso.saml.ts @@ -1,4 +1,18 @@ import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; + +export type LegacySsoFileErrorReason = + | "not_found" + | "permission" + | "invalid_content" + | "invalid_url" + | "other"; + +function fileErrorReason(cause: PlatformError): LegacySsoFileErrorReason { + if (cause.reason._tag === "NotFound") return "not_found"; + if (cause.reason._tag === "PermissionDenied") return "permission"; + return "other"; +} /** * The `--name-id-format` value set, shared by `sso add` and `sso update` @@ -51,7 +65,10 @@ export function validateMetadataXmlBytes( */ export const readMetadataFile = (factory: { - readonly openError: (args: { readonly message: string }) => Eopen; + readonly openError: (args: { + readonly message: string; + readonly reason: LegacySsoFileErrorReason; + }) => Eopen; readonly nonUtf8Error: (args: { readonly source: string; readonly message: string }) => Eutf; }) => (path: string): Effect.Effect => @@ -61,13 +78,14 @@ export const readMetadataFile = // single error branch here (any open / read failure surfaces as // `failed to open metadata file:` to match the externally observable // string for the common case — missing file). - const bytes = yield* fs - .readFile(path) - .pipe( - Effect.mapError((cause) => - factory.openError({ message: `failed to open metadata file: ${String(cause)}` }), - ), - ); + const bytes = yield* fs.readFile(path).pipe( + Effect.mapError((cause) => + factory.openError({ + message: `failed to open metadata file: ${String(cause)}`, + reason: fileErrorReason(cause), + }), + ), + ); yield* validateMetadataXmlBytes(bytes, path, factory.nonUtf8Error); return new TextDecoder("utf-8").decode(bytes); }); @@ -78,21 +96,30 @@ export const readMetadataFile = * `default` that aren't in the generated `attribute_mapping` schema. */ export const readAttributeMappingFile = - (factory: { readonly openError: (args: { readonly message: string }) => E }) => + (factory: { + readonly openError: (args: { + readonly message: string; + readonly reason: LegacySsoFileErrorReason; + }) => E; + }) => (path: string): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const content = yield* fs - .readFileString(path) - .pipe( - Effect.mapError((cause) => - factory.openError({ message: `failed to open attribute mapping: ${String(cause)}` }), - ), - ); + const content = yield* fs.readFileString(path).pipe( + Effect.mapError((cause) => + factory.openError({ + message: `failed to open attribute mapping: ${String(cause)}`, + reason: fileErrorReason(cause), + }), + ), + ); const parsed = yield* Effect.try({ try: () => JSON.parse(content) as unknown, catch: (cause) => - factory.openError({ message: `failed to parse attribute mapping: ${String(cause)}` }), + factory.openError({ + message: `failed to parse attribute mapping: ${String(cause)}`, + reason: "invalid_content", + }), }); return parsed; }); diff --git a/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts b/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts index 64c97d7800..26e54d29e6 100644 --- a/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts @@ -3,16 +3,25 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Data, Effect, Exit } from "effect"; +import { Data, Effect, Exit, FileSystem, PlatformError } from "effect"; import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; import { + LegacySsoAddMetadataFileError, + LegacySsoUpdateAttributeMappingFileError, +} from "./sso.errors.ts"; +import { + type LegacySsoFileErrorReason, readAttributeMappingFile, readMetadataFile, validateMetadataXmlBytes, } from "./sso.saml.ts"; -class TestOpenError extends Data.TaggedError("TestOpenError")<{ readonly message: string }> {} +class TestOpenError extends Data.TaggedError("TestOpenError")<{ + readonly message: string; + readonly reason: LegacySsoFileErrorReason; +}> {} class TestNonUtf8Error extends Data.TaggedError("TestNonUtf8Error")<{ readonly source: string; readonly message: string; @@ -27,6 +36,15 @@ const readAttrMapping = readAttributeMappingFile({ openError: (args) => new TestOpenError(args), }); +function permissionDenied(method: "readFile" | "readFileString") { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor: "/private/file", + }); +} + const tempRoot = useLegacyTempWorkdir("sso-saml-unit-"); describe("readMetadataFile", () => { @@ -50,6 +68,29 @@ describe("readMetadataFile", () => { }).pipe(Effect.provide(BunServices.layer)); }); + it.effect("preserves a metadata file permission failure", () => { + const read = readMetadataFile({ + openError: (args) => new LegacySsoAddMetadataFileError(args), + nonUtf8Error: (args) => + new LegacySsoAddMetadataFileError({ message: args.message, reason: "invalid_content" }), + }); + return Effect.gen(function* () { + const error = yield* read("/private/metadata.xml").pipe(Effect.flip); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + suggestion_type: "none", + error_fingerprint: "tag:LegacySsoAddMetadataFileError:filesystem", + }); + }).pipe( + Effect.provide( + FileSystem.layerNoop({ + readFile: () => Effect.fail(permissionDenied("readFile")), + }), + ), + ); + }); + it.live("fails with TestNonUtf8Error on invalid UTF-8 bytes", () => { const path = join(tempRoot.current, "bad.xml"); writeFileSync(path, Buffer.from([0xff, 0xfe, 0xfd])); @@ -97,6 +138,27 @@ describe("readAttributeMappingFile", () => { } }).pipe(Effect.provide(BunServices.layer)); }); + + it.effect("preserves an attribute mapping permission failure", () => { + const read = readAttributeMappingFile({ + openError: (args) => new LegacySsoUpdateAttributeMappingFileError(args), + }); + return Effect.gen(function* () { + const error = yield* read("/private/mapping.json").pipe(Effect.flip); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + suggestion_type: "none", + error_fingerprint: "tag:LegacySsoUpdateAttributeMappingFileError:filesystem", + }); + }).pipe( + Effect.provide( + FileSystem.layerNoop({ + readFileString: () => Effect.fail(permissionDenied("readFileString")), + }), + ), + ); + }); }); describe("validateMetadataXmlBytes", () => { diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index d02548cdf5..162258098e 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -63,7 +63,8 @@ import type { LegacySsoUpdateFlags } from "./update.command.ts"; const readMetadata = readMetadataFile({ openError: (args) => new LegacySsoUpdateMetadataFileError(args), - nonUtf8Error: (args) => new LegacySsoUpdateMetadataFileError({ message: args.message }), + nonUtf8Error: (args) => + new LegacySsoUpdateMetadataFileError({ message: args.message, reason: "invalid_content" }), }); const readAttributeMapping = readAttributeMappingFile({ @@ -122,7 +123,7 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoUpdateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -132,9 +133,18 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError return yield* Effect.fail( new LegacySsoUpdateNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); @@ -442,6 +452,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( return yield* Effect.fail( new LegacySsoUpdateNetworkError({ message: `failed to get sso provider: ${cause instanceof Error ? cause.message : String(cause)}`, + decode: true, }), ); } @@ -452,7 +463,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // gate check, then 404 / unexpected-status. yield* fetching?.fail() ?? Effect.void; const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -466,6 +477,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( return yield* Effect.fail( new LegacySsoUpdateNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } @@ -474,6 +486,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( status: response.status, body: bodyText, message: `unexpected error fetching identity provider: ${bodyText}`, + upgradeSuggested, }), ); }); @@ -502,6 +515,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( (cause) => new LegacySsoUpdateMetadataFileError({ message: `${cause.message} Use --skip-url-validation to suppress this error.`, + reason: "invalid_url", }), ), ); @@ -569,7 +583,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // Cap + sanitise to match `mapLegacyHttpError`'s defences — see add handler // for the rationale; the raw-HTTP path must not bypass these. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -586,6 +600,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( status: response.status, body: bodyText, message: `unexpected error fetching identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index 4a50feaea4..eb37dca845 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -18,6 +18,7 @@ import { import { LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyIdentityStitch } from "../../../shared/legacy-identity-stitch.ts"; import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySsoUpdate } from "./update.handler.ts"; const VALID_PROVIDER_ID = "b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8"; @@ -1471,6 +1472,11 @@ describe("legacy sso update integration", () => { // Per Go's `update.go:69`: error tail is `… Use --skip-url-validation to suppress this error.` // (trailing period). expect(dump).toContain("Use --skip-url-validation to suppress this error."); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + error_fingerprint: "tag:LegacySsoUpdateMetadataFileError:invalid_url", + }); } }).pipe(Effect.provide(layer)); }); @@ -1835,6 +1841,10 @@ describe("legacy sso update integration", () => { const dump = JSON.stringify(exit.cause); expect(dump).toContain("LegacySsoUpdateNetworkError"); expect(dump).toContain("failed to get sso provider:"); + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_kind).toBe("external_service"); + expect(classified.error_category).toBe("api_status"); + expect(classified.error_fingerprint).toBe("tag:LegacySsoUpdateNetworkError:api_response"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); @@ -1887,6 +1897,12 @@ describe("legacy sso update integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_kind).toBe("user_actionable"); + expect(classified.error_category).toBe("plan_limit"); + expect(classified.suggestion_type).toBe("upgrade_plan"); + } const project = api.requests.find((r) => r.url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`), ); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 6f6236b192..01e501628c 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -56,7 +56,7 @@ documented at its exact Go call site in `start.handler.ts`: Ported: Go's `SetupLocalDatabase` → `initSchema` → `initRealtimeJob`/`initStorageJob`/`initAuthJob` pipeline (`internal/db/start/`). Gated on -`isFreshVolume` (`legacyStartVolumeExists` on the Postgres volume, checked BEFORE the +`isFreshVolume` (`legacyVolumeExists` on the Postgres volume, checked BEFORE the volume is created), matching Go's `NoBackupVolume` — this same check also selects which of `Starting database...`/`Starting database from backup...` prints to stderr immediately before Postgres's container is created (`db/start/start.go:165-175`). Runs immediately @@ -360,3 +360,12 @@ prose, not structured data. - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database initialization, and `start` reports the existing not-running status instead. +- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled + 2026-08-12):** Edge Runtime bind mounts are computed by the same functions import scanner + as `functions deploy`/`functions serve` (`walkImportPaths`/`substituteImportMapValue`, + shared code), which now matches import-map keys per the import-maps spec Deno/edge-runtime + implement (exact match, or prefix match only for a `/`-suffixed key) instead of Go's + any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs + the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target + (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`, matching the same + divergence documented on the `functions deploy`/`functions serve` SIDE_EFFECTS.md. diff --git a/apps/cli/src/legacy/commands/start/start.errors.ts b/apps/cli/src/legacy/commands/start/start.errors.ts index ea029e2b48..e5bf7bafcc 100644 --- a/apps/cli/src/legacy/commands/start/start.errors.ts +++ b/apps/cli/src/legacy/commands/start/start.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a * directory. Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go: @@ -10,12 +16,20 @@ import { Data } from "effect"; */ export class LegacyStartWorkdirError extends Data.TaggedError("LegacyStartWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ export class LegacyStartConfigLoadError extends Data.TaggedError("LegacyStartConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * `config.toml` resolved to a value `Config.Validate` would reject before @@ -26,4 +40,8 @@ export class LegacyStartInvalidConfigError extends Data.TaggedError( "LegacyStartInvalidConfigError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index b7399f37a0..cc8a7b8073 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -55,11 +55,12 @@ import { import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; import { legacyCliProjectFilterValue, - legacyResolveNetworkId, legacyServiceContainerIds, legacyServiceContainerName, localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyInspectContainerState, legacyListContainersByLabel, @@ -920,17 +921,17 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // Go's `DockerStart` forces every container's network mode (and the // network it creates) to `--network-id` when set, ahead of the generated // `supabase_network_` fallback (`docker.go:379-383`) — and `--network-id` falls - // back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var when the flag itself is - // omitted, via the same `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/ + // back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var ONLY when the flag was + // never passed, via the same `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/ // `SUPABASE_EXPERIMENTAL` (review: PRRT_kwDOErm0O86VlqIL). See - // {@link legacyResolveNetworkId}'s doc comment (shared with `db start`, which computes this - // identically). + // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env + // precedence (shared with `db start` and the `functions` Docker paths). const networkIdFlag = yield* LegacyNetworkIdFlag; - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), + const networkId = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(networkIdFlag), + envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, - projectEnvValues, - ); + }); // Go's `DockerStart` unconditionally appends the Linux-only // `host.docker.internal:host-gateway` extra host for every container it // starts (`docker_linux.go`; empty on darwin/windows, where Docker diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index aae4dd9bc9..8073b711cb 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -24,6 +24,7 @@ import { useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { LegacyDebugFlag, LegacyExperimentalFlag, @@ -242,6 +243,7 @@ function defaultRoute(opts: { readonly neverHealthy?: ReadonlySet } = {} const created = new Set(); return (args: ReadonlyArray): RouteResult => { if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "inspect") return { exitCode: 1 }; if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; @@ -1289,6 +1291,11 @@ describe("legacy start integration", () => { const serialized = JSON.stringify(exit.cause); expect(serialized).toContain("LegacyDockerLifecycleInspectError"); expect(serialized).toContain("docker: command not found (podman also not found)"); + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "user_actionable", + error_category: "docker_not_running", + error_fingerprint: "tag:LegacyDockerLifecycleInspectError:docker_not_running", + }); } }).pipe(Effect.provide(layer)); }, @@ -3362,7 +3369,7 @@ content_path = "./templates/custom_notice.html" expect(rollbackWasAttempted(child.spawned)).toBe(false); // Reported by container name, with the recovery advice naming the image // Postgres's own health wait resolved for it. - expect(out.stderrText).toContain("supabase_db_demo: container is not ready"); + expect(out.stderrText).toContain("supabase_db_demo container is not ready"); expect(out.stderrText).toContain("supabase_db_demo's image"); expect(out.stderrText).toContain("image rm -f public.ecr.aws/supabase/postgres:"); // `--ignore-health-check` leaves the stack up, so a bare restart would be a @@ -3468,7 +3475,7 @@ content_path = "./templates/custom_notice.html" expect(rollbackWasAttempted(child.spawned)).toBe(false); // Reported by container name, not `docker create`'s opaque id, and the // advice names the image actually resolved for that container. - expect(out.stderrText).toContain("supabase_auth_demo: container is not ready"); + expect(out.stderrText).toContain("supabase_auth_demo container is not ready"); expect(out.stderrText).toContain("docker image rm -f public.ecr.aws/supabase/gotrue:"); // Go never fires `cli_stack_started` on the ignored-unhealthy // fallthrough (`start.go:1287` sits after the `if err != nil` block) — @@ -3632,6 +3639,29 @@ content_path = "./templates/custom_notice.html" }).pipe(Effect.provide(layer)); }); + it.live("never spawns a create for a pre-created --network-id network", () => { + const base = defaultRoute(); + const route = (args: ReadonlyArray): RouteResult => { + if (args[0] === "network" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "create") { + return { exitCode: 1, stderr: ["error during connect: write: broken pipe"] }; + } + return base(args); + }; + const { layer, child } = setup({ networkId: Option.some("custom-net"), route }); + return Effect.gen(function* () { + yield* legacyStart(flags()); + expect(child.spawned.some((s) => s.args[0] === "network" && s.args[1] === "create")).toBe( + false, + ); + expect( + child.spawned.some( + (s) => s.args[0] === "network" && s.args[1] === "inspect" && s.args[2] === "custom-net", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.live("falls back to SUPABASE_NETWORK_ID when the flag itself is omitted", () => { // Go's `network-id` is a persistent flag bound to viper under `SetEnvPrefix("SUPABASE")` // + `AutomaticEnv()` (`apps/cli-go/cmd/root.go:318-334`), and `DockerStart` reads diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts index 7c7b7090bf..04e77a74df 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.live.test.ts @@ -253,7 +253,7 @@ describeLive("supabase start (live)", () => { expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).not.toBe(0); // Go reports `utils.InbucketId`, not the id `docker create` returns. expect(start.stderr).toContain(`${mailpitContainer} container logs:`); - expect(start.stderr).toContain(`${mailpitContainer}: container is not ready`); + expect(start.stderr).toContain(`${mailpitContainer} container is not ready`); // ...and the advice names that container's actual resolved image. expect(start.stderr).toContain(`${mailpitContainer}'s image ${mailpitImage}`); expect(start.stderr).toContain(`image rm -f ${mailpitImage}`); diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts index ac8d49db97..6b1f78943d 100644 --- a/apps/cli/src/legacy/commands/stop/stop.errors.ts +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,7 +15,11 @@ import { Data } from "effect"; */ export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--project-id` and `--all` were both set. Best-effort match of cobra's @@ -23,12 +32,20 @@ export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( "LegacyStopMutuallyExclusiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Listing containers to stop failed. `stop`-specific wrapper over @@ -37,26 +54,46 @@ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfi */ export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker container prune` failed. */ export class LegacyStopContainerPruneError extends Data.TaggedError( "LegacyStopContainerPruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker network prune` failed. */ export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} diff --git a/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md index b06f4679e2..dbf1df120f 100644 --- a/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/cp/SIDE_EFFECTS.md @@ -40,6 +40,7 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME` — same roles as `storage ls`. +`SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command (Go `root.go:63`): `cp` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -51,6 +52,7 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | invalid/parse url, unsupported operation (local→local), copy-between-buckets, object-not-found (recursive download), file create/read failure, API non-2xx, network, auth, config parse | +| `1` | `--project-ref` set with `--local` (see Notes) | ## Output @@ -81,6 +83,10 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Notes +- **`--project-ref`** (TS-only, no Go equivalent) overrides ONLY the linked-ref + resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). + It never implies `--linked`: passing it with `--local` is a hard error + rather than a silently discarded flag. - Single upload does NOT send `x-upsert`; recursive upload sets it (Go's `Overwrite`). - `--content-type` overrides the sniffed type; an explicit value is still refined when it is a generic `text/plain` (Go's `ParseFileOptions` → `UploadObject`). diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts index 82bf9a3af2..0cb39f865b 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -12,6 +12,7 @@ import { legacyParseUintBase0 } from "../../../shared/legacy-parse-uint.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, + LegacyStorageProjectRefFlagDef, legacyAssertStorageTargetsExclusive, } from "../storage.flags.ts"; import { legacyStorageCp } from "./cp.handler.ts"; @@ -80,6 +81,7 @@ const config = { ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, + projectRef: LegacyStorageProjectRefFlagDef, src: Argument.string("src").pipe(Argument.withDescription("Source path to copy from.")), dst: Argument.string("dst").pipe(Argument.withDescription("Destination path to copy to.")), } as const; @@ -119,8 +121,12 @@ export const legacyStorageCpCommand = Command.make("cp", config).pipe( jobs: flags.jobs, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }; return yield* legacyStorageCp(flags).pipe( + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. withLegacyCommandInstrumentation({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts index 6793d17ad9..cdcaa2699b 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts @@ -35,6 +35,7 @@ import { LegacyStorageConfigError } from "../../../shared/legacy-storage-credent import { LegacyStorageCopyBetweenBucketsError, LegacyStorageFileError, + LegacyStorageMutuallyExclusiveFlagsError, LegacyStorageObjectNotFoundError, LegacyStorageUnsupportedOperationError, LegacyStorageUrlParseError, @@ -82,7 +83,19 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); + // `--project-ref` never implies `--linked` and must not be silently + // discarded on the local target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && flags.local) { + return yield* Effect.fail( + new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }), + ); + } + + const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; const loaded = yield* legacyLoadStorageConfig(cliConfig.workdir, projectRef); if (loaded.appliedRemote !== undefined) { diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts index 4bbc4f2aa7..1303cbb2a6 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts @@ -34,6 +34,7 @@ function cpFlags(opts: { jobs: opts.jobs === undefined ? Option.none() : Option.some(opts.jobs), linked: true, local: opts.local ?? true, + projectRef: Option.none(), }; } @@ -456,6 +457,51 @@ describe("legacy storage cp", () => { }); }); + it.live("uploads to the project given via --project-ref, overriding LEGACY_VALID_REF", () => { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (LEGACY_VALID_REF) — the flag must win over it and drive the gateway host. + const FLAG_REF = "flagflagflagflagflag"; + writeFileSync(join(tmp.current, "readme.md"), "hello world"); + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageCp({ + ...cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + local: false, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); + expect(requests.some((r) => r.url.includes(LEGACY_VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }); + + it.live("rejects --project-ref combined with --local", () => { + const FLAG_REF = "flagflagflagflagflag"; + writeFileSync(join(tmp.current, "readme.md"), "hello world"); + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageCp({ + ...cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + local: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + ); + // The guard fires before any network call or cache write. + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("propagates a non-200 from the gateway on upload", () => { writeFileSync(join(tmp.current, "readme.md"), "hello"); const { layer } = setupLegacyStorage(tmp.current, { diff --git a/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md index 119dc42a81..05dc4bb515 100644 --- a/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/ls/SIDE_EFFECTS.md @@ -31,14 +31,14 @@ Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `s ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | ---------------------------------------------------- | ---------------------------------- | -| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | linked: bypass tenant key fetch; local: explicit key | no | -| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key | no (→ `auth.jwt_secret` → default) | -| `SUPABASE_ACCESS_TOKEN` | linked: Management API auth | no (→ `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked: project-ref resolution | no | -| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host | no (→ Docker host → `127.0.0.1`) | -| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | +| Variable | Purpose | Required? | +| -------------------------------- | ---------------------------------------------------------------------- | ---------------------------------- | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | linked: bypass tenant key fetch; local: explicit key | no | +| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key | no (→ `auth.jwt_secret` → default) | +| `SUPABASE_ACCESS_TOKEN` | linked: Management API auth | no (→ `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked: project-ref resolution, superseded by `--project-ref` when set | no | +| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host | no (→ Docker host → `127.0.0.1`) | +| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | `storage` is an experimental command (Go `root.go:63`): every subcommand requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -50,6 +50,7 @@ Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `s | ---- | --------------------------------------------------------------------------- | | `0` | success | | `1` | invalid URL / url-parse error / API non-2xx / network / auth / config parse | +| `1` | `--project-ref` set with `--local` (see Notes) | ## Output @@ -83,6 +84,10 @@ No custom storage telemetry events (verified against `internal/storage/ls`). - Default path is `ss:///` (all buckets root) → remotePath `/`; recursive file paths then carry a leading slash, while an empty bucket is reported bare as `/`. - `--recursive`/`-r` walks the tree (BFS). +- **`--project-ref`** (TS-only, no Go equivalent) overrides ONLY the linked-ref + resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). + It never implies `--linked`: passing it with `--local` is a hard error + rather than a silently discarded flag. - `--local` / `--linked` are mutually exclusive; `--local` routes to the local stack, otherwise the linked project is used. They are declared **per-leaf** (not as `storage`-group scoped globals) because Effect CLI requires global-flag names to be diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts index 42abfa7da6..52d9e089ca 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts @@ -10,6 +10,7 @@ import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, + LegacyStorageProjectRefFlagDef, legacyAssertStorageTargetsExclusive, } from "../storage.flags.ts"; import { legacyStorageLs } from "./ls.handler.ts"; @@ -25,6 +26,7 @@ const config = { ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, + projectRef: LegacyStorageProjectRefFlagDef, } as const; export type LegacyStorageLsFlags = CliCommand.Command.Config.Infer; @@ -49,8 +51,12 @@ export const legacyStorageLsCommand = Command.make("ls", config).pipe( recursive: flags.recursive, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }; return yield* legacyStorageLs(flags).pipe( + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. withLegacyCommandInstrumentation({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts b/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts index 4152f6bee9..68cd0fd23b 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts @@ -12,6 +12,7 @@ import { legacyParseStorageUrlEffect, } from "../storage.frame.ts"; import type { LegacyStorageLsFlags } from "./ls.command.ts"; +import { LegacyStorageMutuallyExclusiveFlagsError } from "../storage.errors.ts"; /** * `supabase storage ls [path]` — list objects by path prefix. @@ -33,10 +34,22 @@ export const legacyStorageLs = Effect.fn("legacy.storage.ls")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { + // `--project-ref` never implies `--linked` and must not be silently + // discarded on the local target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && flags.local) { + return yield* Effect.fail( + new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }), + ); + } + // Routing reads the `--local` value (Go `storage.go:21-32`): local clears the // ref, otherwise the linked path resolves it. No network — safe before the // url parse below. - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); + const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; // Config is always loaded (Go's `utils.Config`); a `[remotes.*]` match prints diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts index b27d5ac756..599b13e39d 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts @@ -19,6 +19,7 @@ function lsFlags( recursive: opts.recursive ?? false, linked: true, local: opts.local ?? true, + projectRef: Option.none(), }; } @@ -220,6 +221,47 @@ describe("legacy storage ls", () => { }); }); + it.live("lists the project given via --project-ref, overriding LEGACY_VALID_REF", () => { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (LEGACY_VALID_REF) — the flag must win over it and drive the gateway host. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs({ + ...lsFlags({ local: false }), + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); + expect(requests.some((r) => r.url.includes(LEGACY_VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }); + + it.live("rejects --project-ref combined with --local", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageLs({ + ...lsFlags({ local: true }), + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + ); + // The guard fires before any network call or cache write. + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("emits a { paths } result in json mode", () => { const { layer, out } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md index 53c04d21df..d27a5dafc7 100644 --- a/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/mv/SIDE_EFFECTS.md @@ -34,6 +34,7 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME` — same roles as `storage ls`. +`SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command (Go `root.go:63`): `mv` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -45,6 +46,7 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | invalid/parse url, missing object path (both roots), cross-bucket move, object-not-found (recursive empty), API non-2xx, network, auth, config parse | +| `1` | `--project-ref` set with `--local` (see Notes) | ## Output @@ -75,6 +77,10 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre ## Notes +- **`--project-ref`** (TS-only, no Go equivalent) overrides ONLY the linked-ref + resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). + It never implies `--linked`: passing it with `--local` is a hard error + rather than a silently discarded flag. - Both `src` and `dst` must be `ss://` URLs (Go uses `ParseStorageURL`, not the lenient `url.Parse` that `cp` uses). - The cross-bucket and missing-path checks run before any network call. diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts index 25fefced7d..32b489c46b 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts @@ -10,6 +10,7 @@ import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, + LegacyStorageProjectRefFlagDef, legacyAssertStorageTargetsExclusive, } from "../storage.flags.ts"; import { legacyStorageMv } from "./mv.handler.ts"; @@ -23,6 +24,7 @@ const config = { ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, + projectRef: LegacyStorageProjectRefFlagDef, } as const; export type LegacyStorageMvFlags = CliCommand.Command.Config.Infer; @@ -47,8 +49,12 @@ export const legacyStorageMvCommand = Command.make("mv", config).pipe( recursive: flags.recursive, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }; return yield* legacyStorageMv(flags).pipe( + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. withLegacyCommandInstrumentation({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts b/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts index dfa6a64c07..d4f10f7845 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts @@ -17,6 +17,7 @@ import { } from "../storage.frame.ts"; import { LegacyStorageMissingPathError, + LegacyStorageMutuallyExclusiveFlagsError, LegacyStorageObjectNotFoundError, LegacyStorageUnsupportedMoveError, } from "../storage.errors.ts"; @@ -41,7 +42,19 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); + // `--project-ref` never implies `--linked` and must not be silently + // discarded on the local target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && flags.local) { + return yield* Effect.fail( + new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }), + ); + } + + const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; const loaded = yield* legacyLoadStorageConfig(cliConfig.workdir, projectRef); if (loaded.appliedRemote !== undefined) { diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts index d4963a6a3f..1da532be77 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Option } from "effect"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; import { @@ -24,6 +24,7 @@ function mvFlags(opts: { recursive: opts.recursive ?? false, linked: true, local: opts.local ?? true, + projectRef: Option.none(), }; } @@ -258,6 +259,49 @@ describe("legacy storage mv", () => { }); }); + it.live("moves within the project given via --project-ref, overriding LEGACY_VALID_REF", () => { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (LEGACY_VALID_REF) — the flag must win over it and drive the gateway host. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + local: false, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); + expect(requests.some((r) => r.url.includes(LEGACY_VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }); + + it.live("rejects --project-ref combined with --local", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + local: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + ); + // The guard fires before any network call or cache write. + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("propagates a 503 from the move endpoint even when recursive", () => { // Only a `not_found` body triggers the recursive fallback; a 503 must surface. const { layer } = setupLegacyStorage(tmp.current, { diff --git a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md index 5bf3726586..d39c5e8583 100644 --- a/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/storage/rm/SIDE_EFFECTS.md @@ -40,6 +40,7 @@ Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-pre `SUPABASE_PROJECT_ID`, `SUPABASE_SERVICES_HOSTNAME`, plus `SUPABASE_YES` (auto-confirm) — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files (shell wins; CLI-1878, matching Go's `loadNestedEnv` before `viper.GetBool("YES")`). +`SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command (Go `root.go:63`): `rm` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -51,6 +52,7 @@ read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (including a declined confirmation, and a tolerated `Bucket not found`) | | `1` | invalid/parse url, missing bucket (root path), missing `-r` flag (directory or no args), object-not-found (recursive empty prefix), API non-2xx, network, auth, config parse | +| `1` | `--project-ref` set with `--local` (see Notes) | ## Output @@ -82,6 +84,10 @@ read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` ## Notes +- **`--project-ref`** (TS-only, no Go equivalent) overrides ONLY the linked-ref + resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). + It never implies `--linked`: passing it with `--local` is a hard error + rather than a silently discarded flag. - Validation (missing bucket, missing `-r` for a directory) runs before any network call; the no-args missing-`-r` error runs after the client is built (matching Go). - A declined confirmation skips that bucket and is not an error. diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts index 9b29eb1afb..ebf625a252 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts @@ -10,6 +10,7 @@ import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, + LegacyStorageProjectRefFlagDef, legacyAssertStorageTargetsExclusive, } from "../storage.flags.ts"; import { legacyStorageRm } from "./rm.handler.ts"; @@ -25,6 +26,7 @@ const config = { ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, + projectRef: LegacyStorageProjectRefFlagDef, } as const; export const legacyStorageRmCommand = Command.make("rm", config).pipe( @@ -51,13 +53,20 @@ export const legacyStorageRmCommand = Command.make("rm", config).pipe( recursive: flags.recursive, linked: flags.linked, local: flags.local, + "project-ref": flags.projectRef, }; return yield* legacyStorageRm({ files: flags.files.map(String), recursive: flags.recursive, linked: flags.linked, local: flags.local, - }).pipe(withLegacyCommandInstrumentation({ flags: telemetryFlags })); + projectRef: flags.projectRef, + }).pipe( + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. + withLegacyCommandInstrumentation({ flags: telemetryFlags }), + ); }).pipe(withJsonErrorHandling), ), Command.provide(Layer.mergeAll(legacyStorageGatewayRuntimeLayer(["storage", "rm"]), stdinLayer)), diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts index 5b11ee22d2..3381b13646 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts @@ -23,6 +23,7 @@ import { import { LegacyStorageMissingBucketError, LegacyStorageMissingFlagError, + LegacyStorageMutuallyExclusiveFlagsError, LegacyStorageObjectNotFoundError, } from "../storage.errors.ts"; import { legacyListStoragePaths } from "../storage.iterate.ts"; @@ -34,6 +35,8 @@ export interface LegacyStorageRmFlags { // reads only `local` (Go `storage.go:21-32` reads `GetBool("local")`). readonly linked: boolean; readonly local: boolean; + // TS-only override of the linked project ref — see push.command.ts (db push). + readonly projectRef: Option.Option; } interface RmSummary { @@ -67,7 +70,19 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( // work). An unlinked workdir must fail fast with the not-linked guidance // before a malformed/unreadable `supabase/.env` gets a chance to mask it // with an env-parse error. - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(Option.none()); + // `--project-ref` never implies `--linked` and must not be silently + // discarded on the local target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && flags.local) { + return yield* Effect.fail( + new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }), + ); + } + + const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). Both the // `--local` and (default) `--linked` branches of `ParseDatabaseConfig` call diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index 858ea9953a..d4da8712d4 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Option } from "effect"; import { afterEach } from "vitest"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; @@ -48,6 +48,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const del = requests.find( @@ -70,6 +71,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Confirm deleting files in bucket"); @@ -93,6 +95,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("[y/N] y"); @@ -117,6 +120,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("[y/N] y"); @@ -142,6 +146,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: false, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("Cannot find project ref"); @@ -164,6 +169,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -186,6 +192,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(true); @@ -210,6 +217,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -229,6 +237,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -262,6 +271,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const deletes = requests.filter( @@ -284,6 +294,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("You must specify a bucket to delete."); @@ -302,6 +313,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("You must specify -r flag to delete directories."); @@ -320,6 +332,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("You must specify -r flag to delete directories."); @@ -344,6 +357,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Deleting bucket: b1"); @@ -378,6 +392,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Bucket not found: test"); @@ -435,6 +450,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect( @@ -468,6 +484,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Deleting bucket: test"); @@ -491,6 +508,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("Object not found: private/dir/"); @@ -511,6 +529,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const success = out.messages.find((m) => m.type === "success"); @@ -540,6 +559,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("Error status 500"); @@ -559,6 +579,7 @@ describe("legacy storage rm", () => { recursive: true, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("Error status 503"); @@ -579,6 +600,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: false, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect( @@ -590,6 +612,55 @@ describe("legacy storage rm", () => { }); }); + it.live("deletes from the project given via --project-ref, overriding LEGACY_VALID_REF", () => { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (LEGACY_VALID_REF) — the flag must win over it and drive the gateway host. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + yes: true, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: false, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); + expect(requests.some((r) => r.url.includes(LEGACY_VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }); + }); + + it.live("rejects --project-ref combined with --local", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: false, + local: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + ); + // The guard fires before any network call or cache write. + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); + }); + it.live("emits a { deleted, buckets_deleted } result in stream-json mode", () => { const { layer, out } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', @@ -604,6 +675,7 @@ describe("legacy storage rm", () => { recursive: false, linked: true, local: true, + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const success = out.messages.find((m) => m.type === "success"); diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index 708d254347..893961198d 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -1,5 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua } from "../../shared/legacy-colors.ts"; import { legacyGoQuote } from "../../shared/legacy-go-quote.ts"; @@ -22,6 +27,10 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag constructor() { super({ message: "URL must match pattern ss:///bucket/[prefix]" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -31,7 +40,11 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag */ export class LegacyStorageUrlParseError extends Data.TaggedError("LegacyStorageUrlParseError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `cp`'s local→local branch (`internal/storage/cp/cp.go:59-60`). Go sets @@ -50,6 +63,10 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( suggestion: `Run ${legacyAqua("cp -r ")} to copy between local directories.`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -82,6 +99,10 @@ export class LegacyStorageCopyBetweenBucketsError extends Data.TaggedError( constructor() { super({ message: "Copying between buckets is not supported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s cross-bucket branch (`internal/storage/mv/mv.go:19,38`). */ @@ -93,6 +114,10 @@ export class LegacyStorageUnsupportedMoveError extends Data.TaggedError( constructor() { super({ message: "Moving between buckets is unsupported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s both-root branch (`internal/storage/mv/mv.go:20,35`). */ @@ -104,6 +129,10 @@ export class LegacyStorageMissingPathError extends Data.TaggedError( constructor() { super({ message: "You must specify an object path" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s root-arg branch (`internal/storage/rm/rm.go:21,41`). */ @@ -115,6 +144,10 @@ export class LegacyStorageMissingBucketError extends Data.TaggedError( constructor() { super({ message: "You must specify a bucket to delete." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s directory-without-`-r` branch (`internal/storage/rm/rm.go:22,44,53`). */ @@ -126,6 +159,10 @@ export class LegacyStorageMissingFlagError extends Data.TaggedError( constructor() { super({ message: "You must specify -r flag to delete directories." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -141,12 +178,20 @@ export class LegacyStorageObjectNotFoundError extends Data.TaggedError( constructor(path: string) { super({ message: `Object not found: ${path}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** `failed to read file:` / `failed to create file:` (`pkg/storage/objects.go`). */ export class LegacyStorageFileError extends Data.TaggedError("LegacyStorageFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Both `--linked` and `--local` set, reproducing cobra's @@ -156,4 +201,8 @@ export class LegacyStorageMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyStorageMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts index f9112d4405..dc3e756c75 100644 --- a/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts @@ -10,12 +10,13 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, + mockTelemetryRuntime, mockTty, - processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; -import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; -import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { + legacyIsolatedHomeLayer, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyExperimentalRequiredError } from "../../shared/legacy-experimental-gate.ts"; import { legacyStorageCommand } from "./storage.command.ts"; import { LegacyStorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; @@ -28,6 +29,8 @@ import { LegacyStorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; // proves that ordering is wired into the actual `.command.ts` handler // pipeline for all four leaves, not just the shared helper in isolation. +const tempRoot = useLegacyTempWorkdir("supabase-storage-experimental-int-"); + const testRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyStorageCommand]), Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), @@ -42,30 +45,17 @@ function setup(args: ReadonlyArray) { Layer.succeed(CliArgs, { args }), // `legacyStorageGatewayRuntimeLayer`'s cliConfig/credentials layers read // real env/files when built. Neither check under test ever reaches that - // lazy factory, but isolate ambient env defensively anyway. - processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), - mockRuntimeInfo(), + // lazy factory, but isolate ambient env and homeDir defensively anyway — + // same rationale as the sibling experimental-gate tests (ssl-enforcement, + // postgres-config, network-bans). + legacyIsolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), mockProcessControl().layer, mockTty({ stdinIsTty: false, stdoutIsTty: false }), mockAnalytics().layer, - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: "/tmp/supabase-storage-experimental-gate-test/.supabase", - tracesDir: "/tmp/supabase-storage-experimental-gate-test/.supabase/traces", - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), + mockTelemetryRuntime({ + configDir: `${tempRoot.current}/.supabase`, + tracesDir: `${tempRoot.current}/.supabase/traces`, + }), ); return { layer }; } diff --git a/apps/cli/src/legacy/commands/storage/storage.flags.ts b/apps/cli/src/legacy/commands/storage/storage.flags.ts index 52645ea177..bbad725647 100644 --- a/apps/cli/src/legacy/commands/storage/storage.flags.ts +++ b/apps/cli/src/legacy/commands/storage/storage.flags.ts @@ -29,6 +29,15 @@ export const LegacyStorageLocalFlagDef = Flag.boolean("local").pipe( Flag.withDescription("Connects to Storage API of the local database."), ); +// TS-only override of the linked project ref — see push.command.ts (db push). +// No Go equivalent: `storage.go` never registers `--project-ref` on this +// command family. Declared once here (not per-leaf) since all four +// `storage ls/cp/mv/rm` leaves share the identical declaration. +export const LegacyStorageProjectRefFlagDef = Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, +); + /** Changed `--linked`/`--local` set (cobra `pflag.Changed`), for the exclusivity check. */ export function legacyStorageChangedTargetFlags( args: ReadonlyArray, diff --git a/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md index 84a65e67c3..832a83c794 100644 --- a/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md @@ -2,13 +2,14 @@ ## Files Read -| Path | Format | When | -| ------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/tests/**/*.{sql,pg}` | SQL | default test discovery when no `[path]` given | -| `` | SQL | when explicit test files/dirs are passed | -| `/supabase/config.toml` | TOML | always: `db.port`, `db.shadow_port`, `db.password`, `project_id`. Absent → defaults; **present but malformed → command fails** (Go's `config.Load` parity) | -| `/supabase/.temp/pooler-url` | text | `--linked` pooler fallback only — the connection-pooler URL written by `supabase link` (Go reads it here, not from config.toml) | -| `~/.supabase/access-token` | text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset | +| Path | Format | When | +| -------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/tests/**/*.{sql,pg}` | SQL | default test discovery when no `[path]` given | +| `` | SQL | when explicit test files/dirs are passed | +| `/supabase/config.toml` | TOML | always: `db.port`, `db.shadow_port`, `db.password`, `project_id`. Absent → defaults; **present but malformed → command fails** (Go's `config.Load` parity) | +| `/supabase/.temp/pooler-url` | text | `--linked` pooler fallback only — the connection-pooler URL written by `supabase link` (Go reads it here, not from config.toml) | +| `~/.supabase/access-token` | text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -62,6 +63,7 @@ One-shot `docker run --rm `, where the image is `supabase/pg_pro | `1` | `pg_prove` exits non-zero (test failures) — `error running container: exit N` | | `1` | `--db-url` / `--linked` / `--local` set together (mutually exclusive) | | `1` | database connection failure / pgTAP enable failure / docker failure / `--linked` auth or IPv6 errors | +| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | ## Telemetry Events Fired @@ -99,6 +101,13 @@ command (exit 1). ## Notes - Native TypeScript port (Phase 1+); no Go proxy. Hidden command (matches Go). +- **`--project-ref`** (TS-only, no Go equivalent on any user-facing command; + shared verbatim by `db test` via `legacyTestDbConfig`) overrides ONLY the + linked-ref resolution used for the connection (flag > `SUPABASE_PROJECT_ID` + > `.temp/project-ref`). It never implies `--linked`: passing it with a + > resolved `--local`/`--db-url` target is a hard error rather than a silently + > discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's + > equivalent env var simply leaves unused on a non-linked target). - Postgres TLS matches Go (`internal/utils/connect.go`): local connections disable TLS (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`); remote (`--db-url` / `--linked`) connections honor the URL's `sslmode` (`pgconn.ParseConfig` → `ConnectByUrl`) — diff --git a/apps/cli/src/legacy/commands/test/new/new.errors.ts b/apps/cli/src/legacy/commands/test/new/new.errors.ts index 8cfad19a4a..0edcc72f78 100644 --- a/apps/cli/src/legacy/commands/test/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/test/new/new.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The target test file already exists. Byte-matches Go's * `errors.New(path + " already exists.")` (`apps/cli-go/internal/test/new/new.go:26`). @@ -7,7 +13,11 @@ import { Data } from "effect"; export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNewFileExistsError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the test file failed (e.g. permission denied). Mirrors Go's @@ -16,4 +26,8 @@ export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNe export class LegacyTestNewWriteError extends Data.TaggedError("LegacyTestNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts index 8c1e9155f1..94e9e94279 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Reading `supabase/.temp/project-ref` failed for a reason other than the file * being absent (which maps to `LegacyProjectNotLinkedError`). Byte-matches Go's @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Removing the `supabase/.temp` directory failed. Byte-matches Go's @@ -15,4 +25,8 @@ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefR */ export class LegacyUnlinkTempRemovalError extends Data.TaggedError("LegacyUnlinkTempRemovalError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 1d68b40550..e1d952792f 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -80,12 +80,20 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapActivateError(cause)); if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, response: legacyGateResponse(cause), }); + return yield* Effect.fail( + new LegacyVanitySubdomainsActivateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index a1de5873f8..65d39f2acd 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -83,13 +83,21 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { // Go's check command calls SuggestUpgradeOnError without a following // TrackUpgradeSuggested, so suppress the analytics event for parity. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, response: legacyGateResponse(cause), trackAnalytics: false, }); + return yield* Effect.fail( + new LegacyVanitySubdomainsCheckUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts index 7a06a00976..145ff41002 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Raised by the `activate` and `check-availability` handlers when @@ -14,13 +20,24 @@ export class LegacyDesiredSubdomainRequiredError extends Data.TaggedError( "LegacyDesiredSubdomainRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsGetUnexpectedStatusError", @@ -28,13 +45,26 @@ export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedE readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Unlike check/activate, this gated wrapper does not yet retain the typed + // entitlement result. Keep 404 conservative rather than masking a plan gate. + return statusCodeActionability(this.status); + } +} export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsCheckNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsCheckUnexpectedStatusError", @@ -42,13 +72,28 @@ export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.Tagge readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: true, + }); + } +} export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsActivateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsActivateUnexpectedStatusError", @@ -56,13 +101,28 @@ export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.Ta readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: true, + }); + } +} export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteUnexpectedStatusError", @@ -70,4 +130,8 @@ export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index aedb80ebb5..8456ebb40f 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -37,7 +37,15 @@ function makeLayer(opts: { Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), Layer.provide(Layer.succeed(LegacyWorkdirFlag, workdirFlag)), Layer.provide(Layer.succeed(CliArgs, { args: opts.argv ?? [] })), - Layer.provide(mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", homeDir: opts.home })), + // The layer reads `/.supabase/profile` through the real BunServices + // filesystem, so homeDir must default to a per-test directory — a shared + // fixed path would leak stale profile files between runs and machines. + Layer.provide( + mockRuntimeInfo({ + cwd: opts.cwd ?? "/test/cwd", + homeDir: opts.home ?? join(tempRoot, "home"), + }), + ), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(opts.env ?? {})), ); diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index 680beb4213..fc1485e235 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -1,5 +1,10 @@ import { Data, Effect, FileSystem, Path } from "effect"; import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Helpers for the persisted profile-name file under the global Supabase home, @@ -29,7 +34,11 @@ export function legacySupabaseHome( * (`apps/cli-go/cmd/login.go:42-46`). */ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export function legacyProfileFilePath( path: Path.Path, diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts index a8dab2f7dc..81943235df 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -1,13 +1,27 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + export class LegacyProjectNotLinkedError extends Data.TaggedError("LegacyProjectNotLinkedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvalidProjectRefError")<{ readonly ref: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor @@ -19,4 +33,8 @@ export class LegacyProjectRefRequiredError extends Data.TaggedError( "LegacyProjectRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts index 87d34a913a..813630935e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts @@ -1,7 +1,8 @@ /** - * Port of Go's `AwaitStorageReady` (`apps/cli-go/internal/db/reset/reset.go:115-126`) — - * the storage-health gate local `db reset` runs before seeding buckets. Two things the - * seam this replaces got subtly wrong, corrected here: + * Port of the storage-health gate `Run` runs inline before seeding buckets — not a + * standalone Go function despite the name here (`apps/cli-go/internal/db/reset/ + * reset.go:66-71`). Two things the seam this replaces got subtly wrong, corrected + * here: * * 1. `resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); if err != nil { * return false, nil }` — ANY inspect error (not just "not found") maps to "absent" @@ -36,7 +37,7 @@ import { type Spawner = ChildProcessSpawner["Service"]; -/** Go's hardcoded `30*time.Second` (`reset.go:121`) — independent of `db.health_timeout`. */ +/** Go's hardcoded `30*time.Second` (`reset.go:68`) — independent of `db.health_timeout`. */ const LEGACY_AWAIT_STORAGE_READY_TIMEOUT_SECONDS = 30; /** diff --git a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts index 0df16a5968..c34ea6b783 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts @@ -25,6 +25,7 @@ import type { ProjectConfig } from "@supabase/config"; import { Effect, type FileSystem, type Path } from "effect"; import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; +import { legacyMakeRemoteWins } from "../legacy-db-config.toml-read.ts"; import { legacyResolveDbImage } from "../legacy-db-image.ts"; import { legacyResolveHealthTimeoutSeconds } from "../legacy-go-duration.ts"; import { @@ -33,6 +34,7 @@ import { legacyEnvOverrideMajorVersion, legacyEnvOverrideRealtimeIpVersion, legacyEnvOverrideRealtimeMaxHeaderLength, + LegacyInvalidRealtimeIpVersionEnvOverrideError, } from "../legacy-local-config-values.ts"; import { legacyReadServiceVersionOverrides } from "../legacy-service-version-overrides.ts"; import { ramInBytes } from "../legacy-size-units.ts"; @@ -42,6 +44,18 @@ export interface LegacyDbBootstrapConfigInput { readonly config: ProjectConfig; readonly projectEnvValues: Readonly> | undefined; readonly workdir: string; + /** + * Config keys a matched `[remotes.]` block contributed at viper's OVERRIDE tier + * (Go's `v.Set`, applied ABOVE `AutomaticEnv` — `apps/cli-go/pkg/config/config.go: + * 724`) — see `legacy-db-config.toml-read.ts`'s `LegacyRemoteOverride. + * remoteOverrideKeys` doc comment for the full precedence rationale. Every + * `legacyEnvOverride*` call below must NOT re-apply a `SUPABASE_*` value for a field + * the remote block already set. Defaults to empty: `db start`/`db reset` never resolve + * a remote block for this config read (see `legacyBuildLocalDbContainerInputs`'s own + * doc comment), so they're unaffected; `db diff --linked`/`db pull` (CLI-1956) pass + * the set their sibling `legacyReadDbToml` call already computed. + */ + readonly remoteOverrideKeys?: ReadonlySet; } export interface LegacyDbBootstrapConfig { @@ -107,41 +121,58 @@ export const legacyResolveDbBootstrapConfig = ( ): Effect.Effect => Effect.gen(function* () { const { config, projectEnvValues, workdir } = input; + const remoteOverrideKeys = input.remoteOverrideKeys ?? new Set(); + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); // Go's `Config.Load` folds `SUPABASE_DB_MAJOR_VERSION` into `c.Db.MajorVersion` before the // image-selection switch runs (`pkg/config/config.go:585-586,819-827`) — every later read of // `utils.Config.Db.MajorVersion` sees this same value. Not wrapped: `legacyCheckDbToml` // (called by both callers before this function) already validates this override. - const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // A matched remote block's `db.major_version` was installed at viper's OVERRIDE tier + // (above `AutomaticEnv`), so it must win over a conflicting `SUPABASE_DB_MAJOR_VERSION`. + const majorVersion = remoteWins("db.major_version") + ? config.db.major_version + : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); // `experimental.orioledb_version` -> `Config.Db.Image` rewrite (`pkg/config/config.go: // 1041-1046`), plus its four sibling S3 fields Go reads into the Postgres container's `S3_*` // env alongside it (`apps/cli-go/internal/db/start/start.go:70-77`). Both `legacyEnvOverride` // calls never throw (return the override or the configured value verbatim), so no wrap needed. - const orioledbVersion = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - projectEnvValues, - ); - const s3Host = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_HOST", - config.experimental.s3_host, - projectEnvValues, - ); - const s3Region = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_REGION", - config.experimental.s3_region, - projectEnvValues, - ); - const s3AccessKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - config.experimental.s3_access_key, - projectEnvValues, - ); - const s3SecretKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - config.experimental.s3_secret_key, - projectEnvValues, - ); + // Same remote-over-env precedence as `majorVersion` above applies to each of these. + const orioledbVersion = remoteWins("experimental.orioledb_version") + ? config.experimental.orioledb_version + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + config.experimental.orioledb_version, + projectEnvValues, + ); + const s3Host = remoteWins("experimental.s3_host") + ? config.experimental.s3_host + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_HOST", + config.experimental.s3_host, + projectEnvValues, + ); + const s3Region = remoteWins("experimental.s3_region") + ? config.experimental.s3_region + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_REGION", + config.experimental.s3_region, + projectEnvValues, + ); + const s3AccessKey = remoteWins("experimental.s3_access_key") + ? config.experimental.s3_access_key + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", + config.experimental.s3_access_key, + projectEnvValues, + ); + const s3SecretKey = remoteWins("experimental.s3_secret_key") + ? config.experimental.s3_secret_key + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", + config.experimental.s3_secret_key, + projectEnvValues, + ); // Go's one-shot fresh-DB setup jobs (`initSchema15`) read `utils.Config. // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden value — and run @@ -153,34 +184,40 @@ export const legacyResolveDbBootstrapConfig = ( const realtimeEnabledForSetup = yield* wrapConfigOverride( "realtime.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), + remoteWins("realtime.enabled") + ? config.realtime.enabled + : legacyEnvOverrideBool( + "SUPABASE_REALTIME_ENABLED", + config.realtime.enabled, + "realtime.enabled", + projectEnvValues, + ), mapConfigError, ); const storageEnabledForSetup = yield* wrapConfigOverride( "storage.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), + remoteWins("storage.enabled") + ? config.storage.enabled + : legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ), mapConfigError, ); const authEnabledForSetup = yield* wrapConfigOverride( "auth.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ), + remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ), mapConfigError, ); @@ -190,16 +227,35 @@ export const legacyResolveDbBootstrapConfig = ( // `internal/start/start.go:922,928`, `internal/db/start/start.go:283,290`). const realtimeIpVersion = yield* wrapConfigOverride( "realtime.ip_version", - () => legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), + () => { + // `legacyEnvOverrideRealtimeIpVersion` itself reads `process.env` unconditionally + // (`legacyEnvOverride`'s own fallback, regardless of `projectEnvValues`), so it can't + // simply be called with a neutered `projectEnvValues` here — that would still let a + // raw shell `SUPABASE_REALTIME_IP_VERSION` beat the remote block's viper OVERRIDE-tier + // value. Skip the override call entirely on this branch instead, re-validating into + // the same narrow type (the value is already guaranteed one of these two literals by + // `@supabase/config`'s own schema decode — `stringEnum(["IPv4","IPv6"])` — this only + // narrows the TS type to match {@link LegacyDbBootstrapConfig.realtimeIpVersion}). + if (remoteWins("realtime.ip_version")) { + const value = config.realtime.ip_version; + if (value !== "IPv4" && value !== "IPv6") { + throw new LegacyInvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); + } + return value; + } + return legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); + }, mapConfigError, ); const realtimeMaxHeaderLength = yield* wrapConfigOverride( "realtime.max_header_length", () => - legacyEnvOverrideRealtimeMaxHeaderLength( - config.realtime.max_header_length, - projectEnvValues, - ), + remoteWins("realtime.max_header_length") + ? config.realtime.max_header_length + : legacyEnvOverrideRealtimeMaxHeaderLength( + config.realtime.max_header_length, + projectEnvValues, + ), mapConfigError, ); @@ -212,12 +268,13 @@ export const legacyResolveDbBootstrapConfig = ( // `sizeInBytes.UnmarshalText`, `pkg/config/config.go:39-49`, decodes it unconditionally during // `Config.Load`, before either caller touches Docker) rather than left to surface only when a // container env builder happens to re-parse it. - const storageFileSizeLimit = - legacyEnvOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit; + const storageFileSizeLimit = remoteWins("storage.file_size_limit") + ? config.storage.file_size_limit + : (legacyEnvOverride( + "SUPABASE_STORAGE_FILE_SIZE_LIMIT", + config.storage.file_size_limit, + projectEnvValues, + ) ?? config.storage.file_size_limit); yield* wrapConfigOverride( "storage.file_size_limit", () => ramInBytes(storageFileSizeLimit), @@ -248,11 +305,9 @@ export const legacyResolveDbBootstrapConfig = ( // Overridden by SUPABASE_DB_HEALTH_TIMEOUT — Go's Config.Load binds this generically before // StartDatabase's health wait reads it (pkg/config/config.go:580-586, internal/db/start/ // start.go:180). - const dbHealthTimeout = legacyEnvOverride( - "SUPABASE_DB_HEALTH_TIMEOUT", - config.db.health_timeout, - projectEnvValues, - ); + const dbHealthTimeout = remoteWins("db.health_timeout") + ? config.db.health_timeout + : legacyEnvOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); const dbHealthTimeoutSeconds = yield* Effect.try({ try: () => legacyResolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), catch: (cause) => diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 2a9d8657b6..763bdf8e9a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -21,9 +21,15 @@ import { Data, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { - collectText, + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; +import { + legacyCollectText, + containerCliExitCode, legacyDescribeContainerCliFailure, - runContainerCliExpectSuccess, + legacyRunContainerCliExpectSuccess, spawnContainerCli, } from "../legacy-container-cli.ts"; import { @@ -31,7 +37,8 @@ import { legacyIsBindMountSource, } from "../legacy-docker-bind-classify.ts"; import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts"; -import { isUserDefinedDockerNetwork } from "../../../shared/functions/deploy.ts"; +import { legacyIsDockerDaemonUnreachable } from "../legacy-docker-suggest.ts"; +import { isUserDefinedDockerNetwork } from "../../../shared/functions/functions-docker.ts"; import { legacyBuildStartContainerCreateArgs, legacyApplyBitbucketStartContainerFilter, @@ -55,30 +62,72 @@ type Spawner = ChildProcessSpawner["Service"]; * otherwise silently stop recognizing the local stack's containers. * * A same-value private constant already exists at - * `shared/functions/deploy.ts` (`dockerComposeProjectLabel`, for the unrelated - * `functions deploy` Docker Desktop extension gateway) but is neither exported - * nor in the same Docker-usage domain as `start` — not hoisted from there. + * `shared/functions/functions-docker.ts` (`dockerComposeProjectLabel`, for the + * unrelated `functions deploy`/`functions serve` Docker Desktop extension + * gateway) but is neither exported nor in the same Docker-usage domain as + * `start` — not hoisted from there. */ export const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; +type LegacyContainerOperationReason = "runtime" | "configuration" | "filesystem" | "port_conflict"; + +function legacyContainerOperationActionability( + reason: LegacyContainerOperationReason | undefined, +): CliErrorActionabilityDeclaration { + switch (reason) { + case "runtime": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "filesystem": + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + case "port_conflict": + return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; + default: + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + } +} + +function legacyContainerCliReason(message: string): "runtime" | "configuration" { + return legacyIsDockerDaemonUnreachable(message) ? "runtime" : "configuration"; +} + /** `docker network create --label ...`/`docker volume create --label ...` failed. */ export class LegacyNetworkCreateError extends Data.TaggedError("LegacyNetworkCreateError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} export class LegacyVolumeCreateError extends Data.TaggedError("LegacyVolumeCreateError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} /** `docker create` failed. */ export class LegacyContainerCreateError extends Data.TaggedError("LegacyContainerCreateError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration" | "filesystem"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} /** `docker start` failed — see {@link legacyPortConflictSuggestion} for the port-already-allocated case. */ export class LegacyContainerStartError extends Data.TaggedError("LegacyContainerStartError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration" | "port_conflict"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} /** Every failure {@link legacyCreateContainer} itself can produce (network creation is separate, see {@link legacyEnsureNetwork}). */ export type LegacyContainerError = @@ -221,8 +270,9 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * created (`docker network create host` errors with "operation is not * permitted on predefined host network"), so this returns immediately without * spawning `docker network create` at all for those names, reusing the same - * `isUserDefinedDockerNetwork` check `shared/functions/deploy.ts` already - * applies for the unrelated `functions deploy` extension-gateway network. + * `isUserDefinedDockerNetwork` check `shared/functions/functions-docker.ts` + * already applies for the unrelated `functions deploy`/`functions serve` + * extension-gateway network. */ export function legacyEnsureNetwork( spawner: Spawner, @@ -234,6 +284,14 @@ export function legacyEnsureNetwork( } return Effect.scoped( Effect.gen(function* () { + const inspectExitCode = yield* containerCliExitCode( + spawner, + ["network", "inspect", networkId], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ).pipe(Effect.orElseSucceed(() => 1)); + if (inspectExitCode === 0) { + return; + } const args = [ "network", "create", @@ -249,15 +307,20 @@ export function legacyEnsureNetwork( (cause) => new LegacyNetworkCreateError({ message: `failed to create docker network: ${legacyDescribeContainerCliFailure(cause)}`, + reason: "runtime", }), ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyNetworkCreateError({ message: "failed to create docker network" }), + () => + new LegacyNetworkCreateError({ + message: "failed to create docker network", + reason: "runtime", + }), ), ); if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { @@ -268,6 +331,7 @@ export function legacyEnsureNetwork( message.length > 0 ? `failed to create docker network: ${message}` : "failed to create docker network", + reason: legacyContainerCliReason(message), }), ); } @@ -318,14 +382,21 @@ export function legacyEnsureVolume( (cause) => new LegacyVolumeCreateError({ message: `failed to create volume: ${legacyDescribeContainerCliFailure(cause)}`, + reason: "runtime", }), ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( - Effect.mapError(() => new LegacyVolumeCreateError({ message: "failed to create volume" })), + Effect.mapError( + () => + new LegacyVolumeCreateError({ + message: "failed to create volume", + reason: "runtime", + }), + ), ); if (exitCode !== 0 && !legacyIsVolumeAlreadyExistsError(stderr)) { const message = stderr.trim(); @@ -335,6 +406,7 @@ export function legacyEnsureVolume( message.length > 0 ? `failed to create volume: ${message}` : "failed to create volume", + reason: legacyContainerCliReason(message), }), ); } @@ -345,7 +417,11 @@ export function legacyEnsureVolume( /** `docker volume inspect` failed to spawn at all (no docker/podman binary). */ export class LegacyVolumeInspectError extends Data.TaggedError("LegacyVolumeInspectError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** Docker's/Podman's "no such volume" stderr shape for `volume inspect`. */ function isVolumeNotFoundMessage(message: string): boolean { @@ -396,7 +472,7 @@ export function legacyVolumeExists( ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( Effect.mapError( @@ -412,7 +488,12 @@ export function legacyVolumeExists( /** `docker container rm -f ` (or `docker rm -f`) failed. */ export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} /** * Port of Go's `db reset`-only `Docker.ContainerRemove(ctx, DbId, @@ -428,18 +509,24 @@ export function legacyRemoveContainer( spawner: Spawner, containerId: string, ): Effect.Effect { - return runContainerCliExpectSuccess( + return legacyRunContainerCliExpectSuccess( spawner, ["container", "rm", "-f", containerId], "remove container", - (message) => new LegacyContainerRemoveError({ message }), + (message) => + new LegacyContainerRemoveError({ message, reason: legacyContainerCliReason(message) }), ); } /** `docker volume rm -f ` failed. */ export class LegacyVolumeRemoveError extends Data.TaggedError("LegacyVolumeRemoveError")<{ readonly message: string; -}> {} + readonly reason: "runtime" | "configuration"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return legacyContainerOperationActionability(this.reason); + } +} /** * Port of Go's `db reset`-only `Docker.VolumeRemove(ctx, DbId, true)` @@ -453,11 +540,12 @@ export function legacyRemoveVolume( spawner: Spawner, volumeName: string, ): Effect.Effect { - return runContainerCliExpectSuccess( + return legacyRunContainerCliExpectSuccess( spawner, ["volume", "rm", "-f", volumeName], "remove volume", - (message) => new LegacyVolumeRemoveError({ message }), + (message) => + new LegacyVolumeRemoveError({ message, reason: legacyContainerCliReason(message) }), ); } @@ -496,19 +584,24 @@ function legacyDockerCreateContainer( (cause) => new LegacyContainerCreateError({ message: `failed to create docker container: ${legacyDescribeContainerCliFailure(cause)}`, + reason: "runtime", }), ), ); const [exitCode, stdout, stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), - collectText(child.stdout), - collectText(child.stderr), + legacyCollectText(child.stdout), + legacyCollectText(child.stderr), ], { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyContainerCreateError({ message: "failed to create docker container" }), + () => + new LegacyContainerCreateError({ + message: "failed to create docker container", + reason: "runtime", + }), ), ); if (exitCode !== 0) { @@ -519,6 +612,7 @@ function legacyDockerCreateContainer( message.length > 0 ? `failed to create docker container: ${message}` : "failed to create docker container", + reason: legacyContainerCliReason(message), }), ); } @@ -543,17 +637,19 @@ function legacyDockerStartContainer( (cause) => new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}": ${legacyDescribeContainerCliFailure(cause)}`, + reason: "runtime", }), ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( Effect.mapError( () => new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}"`, + reason: "runtime", }), ), ); @@ -564,12 +660,18 @@ function legacyDockerStartContainer( }`; const hostPort = legacyParsePortBindError(trimmed); if (hostPort === undefined) { - return yield* Effect.fail(new LegacyContainerStartError({ message: base })); + return yield* Effect.fail( + new LegacyContainerStartError({ + message: base, + reason: legacyContainerCliReason(trimmed), + }), + ); } const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; return yield* Effect.fail( new LegacyContainerStartError({ message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, + reason: "port_conflict", }), ); } @@ -604,11 +706,12 @@ function legacyDockerCopyIntoContainer( (cause) => new LegacyContainerCreateError({ message: `failed to create docker container: failed to copy secret file into container: ${legacyDescribeContainerCliFailure(cause)}`, + reason: "runtime", }), ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( Effect.mapError( @@ -616,6 +719,7 @@ function legacyDockerCopyIntoContainer( new LegacyContainerCreateError({ message: "failed to create docker container: failed to copy secret file into container", + reason: "runtime", }), ), ); @@ -627,6 +731,7 @@ function legacyDockerCopyIntoContainer( message.length > 0 ? `failed to create docker container: failed to copy secret file into container: ${message}` : "failed to create docker container: failed to copy secret file into container", + reason: legacyContainerCliReason(message), }), ); } @@ -674,6 +779,7 @@ function legacyCopyStartSecretFileIntoContainer( message: `failed to create docker container: failed to stage container secret file: ${ cause instanceof Error ? cause.message : String(cause) }`, + reason: "filesystem", }), }).pipe( Effect.flatMap((dir) => { @@ -690,6 +796,7 @@ function legacyCopyStartSecretFileIntoContainer( message: `failed to create docker container: failed to stage container secret file: ${ cause instanceof Error ? cause.message : String(cause) }`, + reason: "filesystem", }), }).pipe( Effect.flatMap(() => diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index 9a510dc374..39e224f6a8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -694,14 +694,19 @@ describe("legacyCreateContainer secretFiles", () => { }); describe("legacyEnsureNetwork", () => { - it.live("creates the network with labels", () => { - const mock = mockSpawner(() => ({ exitCode: 0 })); + it.live("creates the network with labels when it does not exist yet", () => { + const mock = mockSpawner((args) => + args[1] === "inspect" + ? { exitCode: 1, stderr: "Error: No such network: supabase_network_proj\n" } + : { exitCode: 0 }, + ); return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", { "com.supabase.cli.project": "proj", "com.docker.compose.project": "proj", }).pipe( Effect.map(() => { expect(mock.spawned).toEqual([ + ["network", "inspect", "supabase_network_proj"], [ "network", "create", @@ -716,6 +721,21 @@ describe("legacyEnsureNetwork", () => { ); }); + it.live("never spawns a create for an already-existing network", () => { + const mock = mockSpawner((args) => + args[1] === "inspect" + ? { exitCode: 0 } + : { exitCode: 1, stderr: "error during connect: write: broken pipe\n" }, + ); + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", { + "com.supabase.cli.project": "proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["network", "inspect", "supabase_network_proj"]]); + }), + ); + }); + it.live("treats an already-exists failure as success", () => { const mock = mockSpawner(() => ({ exitCode: 1, @@ -754,6 +774,22 @@ describe("legacyEnsureNetwork", () => { ); }, ); + + it.live("skips docker network create for a container: network mode", () => { + // Go's `container.NetworkMode.IsUserDefined()` + // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly + // excludes `IsContainer()` — `--network-id container:redis` attaches to + // another container's network stack, not a name `docker network create` + // could ever act on (review round on CLI-1963's `functions download` + // port, which surfaced the same gap in the shared + // `isUserDefinedDockerNetwork` predicate this helper reuses). + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "some failure" })); + return legacyEnsureNetwork(mock.spawner, "container:redis", {}).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + }), + ); + }); }); describe("legacyEnsureVolume", () => { @@ -947,3 +983,49 @@ describe("legacyRemoveVolume", () => { ); }); }); + +describe("legacyCreateContainer with an empty containerName (the shadow database)", () => { + it.live( + "omits --name from the create argv and still delivers secretFiles via `docker cp` against the container's own id, exactly like a named container", + () => { + let cpArgs: ReadonlyArray | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + expect(args).not.toContain("--name"); + return { exitCode: 0, stdout: "shadow-container-id\n" }; + } + if (args[0] === "cp") { + cpArgs = args; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + containerName: "", + binds: [], + networkAliases: undefined, + autoRemove: true, + secretFiles: [ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "root-key" }, + ], + }; + + return legacyCreateContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("shadow-container-id"); + // `docker cp` addresses the container by the id `docker create` returned, never by + // name — the unnamed shadow container is delivered its secret the same way a named + // one is. + expect(cpArgs?.[0]).toBe("cp"); + expect(cpArgs?.[2]).toBe("shadow-container-id:/etc/postgresql-custom/pgsodium_root.key"); + }), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-bootstrap-errors.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-bootstrap-errors.unit.test.ts new file mode 100644 index 0000000000..6d389f37ff --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-bootstrap-errors.unit.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { + LegacyContainerCreateError, + LegacyContainerStartError, + LegacyNetworkCreateError, +} from "./container-lifecycle.ts"; +import { LegacyDbSetupError } from "./db-setup.ts"; +import { LegacyImagePrepullError } from "./image-prepull.ts"; +import { LegacyLocalDbRunningError } from "./local-db-running.ts"; +import { LegacyResetReplicationSlotsError } from "./recreate-local-database.ts"; + +const classify = (error: unknown) => classifyCliErrorActionability(error); + +describe("db bootstrap error actionability discriminants", () => { + it("distinguishes container-runtime, configuration, filesystem, and port failures", () => { + expect( + classify(new LegacyNetworkCreateError({ message: "ignored", reason: "runtime" })), + ).toMatchObject({ error_category: "docker_not_running" }); + expect( + classify(new LegacyNetworkCreateError({ message: "ignored", reason: "configuration" })), + ).toMatchObject({ error_category: "invalid_config" }); + expect( + classify(new LegacyContainerCreateError({ message: "ignored", reason: "filesystem" })), + ).toMatchObject({ error_category: "permission" }); + expect( + classify(new LegacyContainerStartError({ message: "ignored", reason: "port_conflict" })), + ).toMatchObject({ + error_category: "invalid_config", + error_fingerprint: "tag:LegacyContainerStartError:port_conflict", + }); + }); + + it("keeps database setup causes in separate KPI families", () => { + expect( + classify(new LegacyDbSetupError({ message: "ignored", reason: "database" })), + ).toMatchObject({ error_category: "invalid_config" }); + expect( + classify(new LegacyDbSetupError({ message: "ignored", reason: "filesystem" })), + ).toMatchObject({ error_category: "permission" }); + expect( + classify(new LegacyDbSetupError({ message: "ignored", reason: "docker_daemon" })), + ).toMatchObject({ error_category: "docker_not_running" }); + expect( + classify(new LegacyDbSetupError({ message: "ignored", reason: "registry_pull" })), + ).toMatchObject({ error_kind: "external_service", error_category: "network" }); + expect( + classify(new LegacyDbSetupError({ message: "ignored", reason: "image_inspect" })), + ).toMatchObject({ error_category: "invalid_config" }); + }); + + it("distinguishes an active replication slot from a failed slot query", () => { + expect( + classify(new LegacyResetReplicationSlotsError({ message: "ignored", retryable: true })), + ).toMatchObject({ + error_category: "invalid_config", + error_fingerprint: "tag:LegacyResetReplicationSlotsError:replication_slots_active", + }); + expect( + classify(new LegacyResetReplicationSlotsError({ message: "ignored", retryable: false })), + ).toMatchObject({ + error_category: "db_connection", + error_fingerprint: "tag:LegacyResetReplicationSlotsError:replication_slots_query", + }); + }); + + it("distinguishes an unavailable daemon from another local DB inspect failure", () => { + expect( + classify(new LegacyLocalDbRunningError({ message: "ignored", daemonDown: true })), + ).toMatchObject({ error_category: "docker_not_running" }); + expect(classify(new LegacyLocalDbRunningError({ message: "ignored" }))).toMatchObject({ + error_category: "invalid_config", + suggested_command: "supabase start", + }); + }); + + it("distinguishes daemon, registry, and image-inspection prepull failures", () => { + expect( + classify(new LegacyImagePrepullError({ message: "ignored", reason: "docker_daemon" })), + ).toMatchObject({ error_category: "docker_not_running" }); + expect( + classify(new LegacyImagePrepullError({ message: "ignored", reason: "registry_pull" })), + ).toMatchObject({ error_kind: "external_service", error_category: "network" }); + expect( + classify(new LegacyImagePrepullError({ message: "ignored", reason: "image_inspect" })), + ).toMatchObject({ error_category: "invalid_config" }); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index 33d2ccd90a..dd09d0ab44 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -86,7 +86,10 @@ * Go env override is threaded explicitly via `projectEnvValues` — so this ONE * call is scoped with `legacyApplyProjectEnv` (the same opt-in helper `db * push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/ - * image work) for just its own duration, then reverted. + * image work) for just its own duration, then reverted. `legacySetupDatabase` + * (CLI-1956's extraction of steps 1-4 above, reused by shadow-database + * provisioning) never reaches this step at all — only this function's own + * trailing `MigrateAndSeed` + pgcache tail does. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -108,12 +111,17 @@ */ import type { ProjectConfig } from "@supabase/config"; -import { Clock, Data, Effect, type FileSystem, Option, type Path } from "effect"; +import { Data, Effect, type FileSystem, Option, type Path, Schedule } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; @@ -124,7 +132,7 @@ import { legacyResolveSeedSqlPath, } from "../legacy-db-config.toml-read.ts"; import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; -import { LEGACY_CLI_PROJECT_LABEL, legacyServiceContainerName } from "../legacy-docker-ids.ts"; +import { LEGACY_CLI_PROJECT_LABEL, localDbContainerId } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; @@ -134,7 +142,11 @@ import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; -import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.ts"; +import { + LegacyMigrationVaultError, + type LegacyVaultSecret, + legacyUpsertVaultSecrets, +} from "../legacy-vault.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; @@ -173,7 +185,40 @@ alter default privileges for role postgres in schema public */ export class LegacyDbSetupError extends Data.TaggedError("LegacyDbSetupError")<{ readonly message: string; -}> {} + readonly reason: + | "database" + | "filesystem" + | "invalid_config" + | "docker_daemon" + | "registry_pull" + | "image_inspect"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "database": + return { ...actionability.dbFinding, fingerprint_suffix: "database" }; + case "filesystem": + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + case "docker_daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "registry_pull": + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + case "image_inspect": + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + default: + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + } + } +} + +function legacyDbSetupDockerReason( + reason: "spawn" | "inspect" | "pull", + daemonDown: boolean, +): LegacyDbSetupError["reason"] { + if (reason === "spawn" || daemonDown) return "docker_daemon"; + if (reason === "pull") return "registry_pull"; + return "image_inspect"; +} /** Every failure {@link legacyStartSetupLocalDatabase} can produce. */ export type LegacyStartSetupLocalDatabaseError = @@ -185,7 +230,7 @@ export type LegacyStartSetupLocalDatabaseError = | LegacyImagePrepullError; /** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ -interface LegacyStartDbSetupImages { +export interface LegacyStartDbSetupImages { /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ readonly realtime: string; /** `utils.Config.Storage.Image`, ditto. */ @@ -196,16 +241,17 @@ interface LegacyStartDbSetupImages { /** * Computes the three PG15+ one-shot setup jobs' PINNED image names (`initSchema15`'s - * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyRunFreshDbSetup} — the - * ONE place both real Go callers (`db start`'s fresh-volume branch and `db reset`'s PG15 - * recreate) reach this from. Mirrors Go's `initSchema15`, which uses the SAME - * already-pin-rewritten `utils.Config.{Realtime,Storage,Auth}.Image` fields the - * long-running containers would use, regardless of `--exclude` — resolved via - * `legacyResolvePinnedImage`, not the raw Dockerfile default, so a linked project's - * version pins apply here too. Deliberately does NOT resolve these against the registry - * (`legacyEnsureImagesCached`) as a batch: Go resolves (and pulls) each one-shot job's - * own image individually, sequentially, right before THAT job runs (`DockerRunJob` -> - * `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, + * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyResolveDbSetupPrelude}, + * the ONE place every real caller (`db start`'s fresh-volume branch, `db reset`'s PG15 + * recreate, and the shadow-database variant's `legacySetupShadowDatabase`/ + * `legacyMigrateShadowDatabase`) reaches this resolution from — see that function's own doc + * comment. Mirrors Go's `initSchema15`, which uses the SAME already-pin-rewritten + * `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers would use, + * regardless of `--exclude` — resolved via `legacyResolvePinnedImage`, not the raw Dockerfile + * default, so a linked project's version pins apply here too. Deliberately does NOT resolve + * these against the registry (`legacyEnsureImagesCached`) as a batch: Go resolves (and pulls) + * each one-shot job's own image individually, sequentially, right before THAT job runs + * (`DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, * `docker.go:363-365`) — {@link legacyRunStartMigrateJob} does that lazily itself, right * before running each job (see its own doc comment): a batch resolve here would let one * unreachable image fail the WHOLE setup before an earlier job Go would already have run @@ -221,8 +267,73 @@ function legacyResolveDbSetupImages( }; } -/** Input to {@link legacyStartSetupLocalDatabase}. */ -export interface LegacyStartSetupLocalDatabaseInput { +/** + * Prints the banner + resolves JWKS (lazily, only when `majorVersion >= 15` AND + * `realtimeEnabledForSetup`) + the PG15+ one-shot job images' PINNED names (via + * {@link legacyResolveDbSetupImages}) — the exact prelude BOTH {@link legacyRunFreshDbSetup} + * (the real local `db` container) and `shadow-database.ts`'s + * `legacySetupShadowDatabase`/`legacyMigrateShadowDatabase` need before calling + * {@link legacySetupDatabase}. Hoisted here (CLI-1956 review follow-up) so the shadow path + * shares this exact resolution instead of keeping its own copy, which had silently drifted (a + * dead, never-forwarded `jwtExpiry` field on the shadow's own setup-input shape). Structurally + * typed against just the fields this needs (not the full {@link LegacyFreshDbSetupInput}) so + * both that type and `shadow-database.ts`'s `LegacyShadowDbSetupInput` — which is itself + * derived from it — satisfy this signature without an explicit cast. + * + * The banner print lives HERE, not in {@link legacySetupDatabase}'s own `initSchema` step, + * even though Go's `initSchema` (`start.go:243-254`) prints it immediately before branching on + * `MajorVersion` and, for PG15+, calling `initSchema15` -> `Config.Auth.ResolveJWKS` + * (`start.go:334-343`) — i.e. in Go, the print and the JWKS fetch are two steps of the SAME + * `initSchema` call, print first. This module's `jwks` field is a plain, already-resolved + * `string` on {@link LegacySetupDatabaseInput} (not a lazy effect `legacySetupDatabase` itself + * runs), so it MUST be resolved by the caller before `legacySetupDatabase` is ever invoked — + * printing the banner here, immediately before that resolution, is the only way to reproduce + * Go's exact observable order (banner, THEN a possible JWKS failure) without restructuring + * `legacySetupDatabase`'s input to carry a lazy JWKS effect instead. Previously the print lived + * solely in `legacyStartInitSchema` below, AFTER this whole prelude — so a JWKS discovery + * failure (realtime enabled, PG15+, third-party JWKS unreachable) meant `db diff --linked`/ + * `db pull`'s native shadow-provisioning path failed BEFORE ever printing "Initialising + * schema...", where Go always prints it first (review: PRRT_kwDOErm0O86W6R-O). + * + * The `majorVersion >= 15` gate matters, not just an optimization: Go's `initSchema` + * (`apps/cli-go/internal/db/start/start.go:243-253`) returns via `InitSchema14` for + * `MajorVersion <= 14` WITHOUT ever calling `initSchema15`, so `Config.Auth.ResolveJWKS` + * (`start.go:338`, only reached from `initSchema15`) never runs at all on PG13/14 — even + * with realtime enabled. `ResolveJWKS` can perform live discovery/JWKS HTTP requests for + * configured `auth.third_party` providers, so resolving it unconditionally on PG14 is not + * just wasted work: it can fail (or hang) when Go's own shadow/setup never would. + */ +export const legacyResolveDbSetupPrelude = (setup: { + readonly majorVersion: number; + readonly realtimeEnabledForSetup: boolean; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly jwks: Effect.Effect; +}): Effect.Effect< + { readonly jwks: string; readonly images: LegacyStartDbSetupImages }, + E, + Output +> => + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw("Initialising schema...\n", "stderr"); + const jwks = setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; + const images = legacyResolveDbSetupImages(setup.serviceVersionOverrides); + return { jwks, images }; + }); + +/** + * Input to {@link legacySetupDatabase} — Go's EXPORTED `SetupDatabase(ctx, conn, host, w, + * fsys)` (`start.go:383-399`): `initSchema -> ApplyApiPrivileges -> vault upsert -> + * SeedGlobals(roles.sql)`, deliberately WITHOUT `apply.MigrateAndSeed` (that extra step is + * what makes {@link LegacyStartSetupLocalDatabaseInput}/{@link legacyStartSetupLocalDatabase} + * bigger — see that interface's own doc comment). Extracted as its own exported shape + * (CLI-1956) so shadow-database provisioning (`shadow-database.ts`) can reach the exact same + * platform-baseline pipeline the real local `db` container's fresh-volume setup does, without + * also replaying migrations a second time or reaching `legacyMigrateAndSeed`'s + * declarative-schema-files branch, neither of which Go's own shadow provisioning + * (`setupShadowConn`) ever does either. + */ +export interface LegacySetupDatabaseInput { /** * An already-open session to the local Postgres database, dialed the same way * Go's `ConnectLocalPostgres(ctx, pgconn.Config{})` does (`internal/utils/ @@ -231,7 +342,7 @@ export interface LegacyStartSetupLocalDatabaseInput { * config.layer.ts`'s own `--local` branch already dials (`legacy-db-config. * layer.ts:518-529`). This is deliberately NOT the internal Docker-network `db` * container address the PG15+ one-shot jobs below connect through (see - * `networkId`/`projectId`) — the two addressing schemes are independent, exactly + * `networkId`/`dbHost`) — the two addressing schemes are independent, exactly * like Go's `conn` (host-facing) vs. `host` parameter (`utils.DbId`) in * `SetupDatabase(ctx, conn, utils.DbId, w, fsys)`. */ @@ -244,15 +355,30 @@ export interface LegacyStartSetupLocalDatabaseInput { readonly config: ProjectConfig; /** `db.major_version` (13-17) — Go's `utils.Config.Db.MajorVersion`, resolved by the caller once, ahead of the `db` container's own image tag selection. */ readonly majorVersion: number; - /** Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) — derives the `db` container's internal Docker name for the PG15+ one-shot jobs (`legacyServiceContainerName("db", projectId)`, Go's `utils.DbId`). */ - readonly projectId: string; /** - * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's - * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into - * {@link legacyMigrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ - * apply.go:19`); this module has no other use for it. + * The internal Docker-network address the PG15+ one-shot jobs connect through — Go's + * `host` parameter to `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383`). The real + * local `db` container's own caller (`legacyRunFreshDbSetup`) passes + * `legacyServiceContainerName("db", projectId)` (Go's `utils.DbId`, threaded straight + * through unchanged from before CLI-1956); the shadow-database variant + * (`shadow-database.ts`) passes the shadow container's own 12-char short id instead (Go's + * `container[:12]`, `apps/cli-go/internal/db/diff/diff.go:172` / `internal/migration/ + * squash/squash.go:96`) — empirically verified to resolve via Docker's embedded DNS even + * though the shadow container has no name/alias at all (see `shadow-database.ts`'s + * header). This field was hardcoded inside this module prior to CLI-1956; it is now the + * caller's responsibility, the one genuine parameterization this port needed for shadow + * provisioning to reuse `SetupDatabase` at all. */ - readonly experimental: boolean; + readonly dbHost: string; + /** + * Go's `Config.ProjectId` — labels the PG15+ one-shot job containers + * (`com.supabase.cli.project`/`com.docker.compose.project`, see {@link + * legacyRunStartMigrateJob}), matching Go's `DockerStart`, which sets both + * unconditionally for every container it starts (`docker.go:371-376`). Independent of + * {@link dbHost}: this labels the one-shot job containers THEMSELVES, not the (possibly + * different) container `dbHost` addresses. + */ + readonly projectId: string; /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ readonly networkId: string; /** `LegacyLocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `legacyStartInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ @@ -308,6 +434,25 @@ export interface LegacyStartSetupLocalDatabaseInput { * `utils.GetDebugLogger()` as the job's stderr writer (`start.go:349-353`). */ readonly debug: boolean; + /** `toml.baseline.apiAutoExposeNewTables` — Go's `api.auto_expose_new_tables` tri-state, threaded straight into {@link legacyApplyApiPrivileges}. */ + readonly apiAutoExposeNewTables: Option.Option; + /** `toml.vault` — Go's `utils.Config.Db.Vault`, threaded straight into {@link legacyUpsertVaultSecrets}. */ + readonly vault: ReadonlyArray; +} + +/** Input to {@link legacyStartSetupLocalDatabase}. */ +export interface LegacyStartSetupLocalDatabaseInput extends Omit< + LegacySetupDatabaseInput, + "apiAutoExposeNewTables" | "vault" +> { + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's + * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into + * {@link legacyMigrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ + * apply.go:19`); `legacySetupDatabase`/Go's own `SetupDatabase` have no use for it — + * only this function's own trailing `MigrateAndSeed` call does. + */ + readonly experimental: boolean; /** * The migration version to reapply (Go's `apply.MigrateAndSeed(ctx, version, ...)`). * `db start`'s own caller always passes `""` (Go's `SetupLocalDatabase(ctx, "", ...)`, @@ -376,6 +521,7 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( (error) => new LegacyDbSetupError({ message: `failed to write ${filename}: ${errMessage(error)}`, + reason: "filesystem", }), ), ); @@ -384,7 +530,7 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( fs, path, filePath, - (message) => new LegacyDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message, reason: "database" }), ); }); @@ -519,10 +665,21 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( // (review: Codex, PR #6022). const result = yield* docker .runStream(runOpts, { onStdout: () => Effect.void, teeStderr: opts.debug }) - .pipe(Effect.mapError((cause) => new LegacyDbSetupError({ message: cause.message }))); + .pipe( + Effect.mapError( + (cause) => + new LegacyDbSetupError({ + message: cause.message, + reason: legacyDbSetupDockerReason(cause.reason, cause.daemonDown), + }), + ), + ); if (result.exitCode !== 0) { return yield* Effect.fail( - new LegacyDbSetupError({ message: `error running container: exit ${result.exitCode}` }), + new LegacyDbSetupError({ + message: `error running container: exit ${result.exitCode}`, + reason: "database", + }), ); } }); @@ -596,9 +753,9 @@ function legacyStartAuthMigrateEnv(input: { */ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, ) { - const dbHost = legacyServiceContainerName("db", input.projectId); + const dbHost = input.dbHost; const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { @@ -651,6 +808,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( catch: (cause) => new LegacyDbSetupError({ message: `invalid config for storage: ${errMessage(cause)}`, + reason: "invalid_config", }), }); yield* legacyRunStartMigrateJob(spawner, { @@ -684,18 +842,19 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( }); /** - * Port of Go's `initSchema` (`start.go:243-254`): prints the banner line once, - * then branches on PG major version — unconditionally, for BOTH branches, exactly - * matching Go's `fmt.Fprintln(w, "Initialising schema...")` running before the - * `if utils.Config.Db.MajorVersion <= 14` check. + * Port of Go's `initSchema` (`start.go:243-254`) MINUS the banner print: branches on PG major + * version — unconditionally, for both branches. The banner itself + * (`fmt.Fprintln(w, "Initialising schema...")`, printed before the `if + * utils.Config.Db.MajorVersion <= 14` check) now prints from + * {@link legacyResolveDbSetupPrelude}, the caller-side step that runs immediately before this + * one — see that function's own doc comment for why the print had to move there instead of + * staying here. */ const legacyStartInitSchema = Effect.fnUntraced(function* ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, tmpDir: string, ) { - const output = yield* Output; - yield* output.raw("Initialising schema...\n", "stderr"); if (input.majorVersion <= 14) { yield* legacyStartInitSchemaPre15( input.session, @@ -762,6 +921,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( (error) => new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, + reason: "filesystem", }), ), ); @@ -771,6 +931,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( (error) => new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, + reason: "filesystem", }), ), ); @@ -785,55 +946,30 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( (error) => new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, + reason: "filesystem", }), ), ); }); /** - * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's - * header for the exact Go call chain and line-range citations. Call once, right - * after the `db` container's healthcheck passes on a fresh volume (Go's - * `NoBackupVolume` gate); the caller decides that gating, this function performs - * no health/readiness checks of its own. + * Runs Go's EXPORTED `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383-399`) — + * see {@link LegacySetupDatabaseInput}'s own doc comment for exactly what's in and out of + * scope. Extracted out of {@link legacyStartSetupLocalDatabase} (CLI-1956) so shadow-database + * provisioning can reuse this exact sequence without also reaching `apply.MigrateAndSeed`. */ -export const legacyStartSetupLocalDatabase = ( +export const legacySetupDatabase = ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, ): Effect.Effect< void, - LegacyStartSetupLocalDatabaseError, - | Output - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves - // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from - // the `fs`/`path` values this function already threads through as plain data — - // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be - // ambient here too; every real caller already gets them from `BunServices.layer` - // at the CLI root runtime, same as `db push`'s own composition. - | FileSystem.FileSystem - | Path.Path + LegacyDbSetupError | LegacyMigrationVaultError | LegacyImagePrepullError, + Output | LegacyDockerRun | RuntimeInfo > => Effect.gen(function* () { const { session, fs, path, workdir } = input; - // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ - // start.handler.ts` already ran an earlier, same-invocation `legacyCheckDbToml` - // purely for its Go-parity validation side effect (their own callers discard the - // result) before ever reaching this fresh-volume setup, so that earlier call - // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without - // this, this module's own accepted duplicate config-load pass (see this module's - // header) would print the SAME warning a second time — a real, observable stderr - // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless - // resolved-value duplication the header describes. - const toml = yield* legacyCheckDbToml(fs, path, workdir, undefined, { - warnOnUnresolvedEnv: false, - }); - - // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + // initSchema -> ApplyApiPrivileges (start.go:383-389). yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -843,22 +979,17 @@ export const legacyStartSetupLocalDatabase = ( (error) => new LegacyDbSetupError({ message: `failed to create temp directory: ${errMessage(error)}`, + reason: "filesystem", }), ), ); yield* legacyStartInitSchema(spawner, input, tmpDir); - yield* legacyApplyApiPrivileges( - session, - fs, - path, - tmpDir, - toml.baseline.apiAutoExposeNewTables, - ); + yield* legacyApplyApiPrivileges(session, fs, path, tmpDir, input.apiAutoExposeNewTables); }), ); // "Create vault secrets first so roles.sql can reference them" (start.go:390). - yield* legacyUpsertVaultSecrets(session, toml.vault); + yield* legacyUpsertVaultSecrets(session, input.vault); // Custom-roles seed (start.go:394-398, pkg/migration/seed.go:84-97): Go's // `SeedGlobals` prints "Seeding globals from roles.sql..." BEFORE attempting @@ -879,6 +1010,7 @@ export const legacyStartSetupLocalDatabase = ( (error) => new LegacyDbSetupError({ message: `failed to check roles.sql: ${errMessage(error)}`, + reason: "filesystem", }), ), ); @@ -888,9 +1020,63 @@ export const legacyStartSetupLocalDatabase = ( fs, path, customRolesPath, - (message) => new LegacyDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message, reason: "database" }), ); } + }); + +/** + * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's + * header for the exact Go call chain and line-range citations. Call once, right + * after the `db` container's healthcheck passes on a fresh volume (Go's + * `NoBackupVolume` gate); the caller decides that gating, this function performs + * no health/readiness checks of its own. + */ +export const legacyStartSetupLocalDatabase = ( + spawner: Spawner, + input: LegacyStartSetupLocalDatabaseInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError, + | Output + | LegacyDockerRun + | RuntimeInfo + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves + // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from + // the `fs`/`path` values this function already threads through as plain data — + // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be + // ambient here too; every real caller already gets them from `BunServices.layer` + // at the CLI root runtime, same as `db push`'s own composition. + | FileSystem.FileSystem + | Path.Path +> => + Effect.gen(function* () { + const { session, fs, path, workdir } = input; + + // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ + // start.handler.ts` already ran an earlier, same-invocation `legacyCheckDbToml` + // purely for its Go-parity validation side effect (their own callers discard the + // result) before ever reaching this fresh-volume setup, so that earlier call + // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without + // this, this module's own accepted duplicate config-load pass (see this module's + // header) would print the SAME warning a second time — a real, observable stderr + // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless + // resolved-value duplication the header describes. + const toml = yield* legacyCheckDbToml(fs, path, workdir, undefined, { + warnOnUnresolvedEnv: false, + }); + + // SetupDatabase: initSchema -> ApplyApiPrivileges -> vault secrets -> custom-roles seed + // (start.go:383-399) — extracted to {@link legacySetupDatabase} so shadow-database + // provisioning (CLI-1956) can reuse this exact sequence without also reaching + // `apply.MigrateAndSeed` below. + yield* legacySetupDatabase(spawner, { + ...input, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + }); // apply.MigrateAndSeed(ctx, version, conn, fsys) — `db start`'s own caller always // passes `version: ""` (every pending migration, matching `SetupLocalDatabase`'s @@ -913,6 +1099,8 @@ export const legacyStartSetupLocalDatabase = ( schemaPaths: toml.schemaPaths, }); + const output = yield* Output; + // pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{Host: Config.Hostname, // Port: Config.Db.Port, User: "postgres", Password: Config.Db.Password, Database: // "postgres"}, "local", version, fsys, ...) (start.go:371-379): best-effort, run @@ -938,6 +1126,7 @@ export const legacyStartSetupLocalDatabase = ( cwd: workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; const hostDbUrl = new URL(input.dbUrl); // Scope the `PGDELTA_NPM_REGISTRY`-from-project-`.env` apply to just this call: @@ -962,7 +1151,6 @@ export const legacyStartSetupLocalDatabase = ( }, isLocal: true, migrationsDir: path.join(workdir, "supabase", "migrations"), - nowMillis: yield* Clock.currentTimeMillis, }).pipe( // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, @@ -1001,7 +1189,7 @@ export interface LegacyFreshDbSetupInput { readonly experimental: boolean; readonly dbUrl: string; readonly jwtSecret: string; - /** Lazy — evaluated only when reached AND `realtimeEnabledForSetup`. See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ + /** Lazy — evaluated only when reached AND `majorVersion >= 15` AND `realtimeEnabledForSetup` (see {@link legacyResolveDbSetupPrelude}'s own doc comment for the Go citation). See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ readonly jwks: Effect.Effect; readonly apiUrl: string; readonly authExternalUrl: string | undefined; @@ -1027,11 +1215,9 @@ export interface LegacyFreshDbSetupInput { * healthcheck passes on a fresh database (`db start`'s fresh-volume branch and * `db reset`'s PG15 recreate, see {@link LegacyFreshDbSetupInput}'s own doc * comment): dial the host-facing session (Go's `ConnectLocalPostgres`), resolve - * JWKS lazily (only when `majorVersion >= 15` AND `realtimeEnabledForSetup` — Go's - * `initSchema`, `start.go:243-254`, only ever reaches `initSchema15`'s - * `ResolveJWKS` call on PG15+; the PG13/14 branch, `InitSchema14`, never touches - * JWKS at all), compute the three PG15+ one-shot job images' PINNED names via - * {@link legacyResolveDbSetupImages}, then run {@link legacyStartSetupLocalDatabase} + * JWKS + the three PG15+ one-shot job images' PINNED names via {@link + * legacyResolveDbSetupPrelude} (the same hoisted prelude the shadow-database variant + * uses), then run {@link legacyStartSetupLocalDatabase} * itself. `version`/`seedFlags` are the one genuine difference between the two * callers (`db start` always passes `""`/`{noSeed:false, sqlPaths:[]}`; `db * reset` passes its own resolved reset version/flags) — threaded straight @@ -1069,25 +1255,30 @@ export const legacyRunFreshDbSetup = ( const dbConnection = yield* LegacyDbConnection; const { setup } = input; const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); - const session = yield* dbConnection.connect( - { - host: input.hostname, - port: input.dbPort, - user: "postgres", - password: dbPassword, - database: "postgres", - }, - { isLocal: true, dnsResolver: "native" }, - ); - - // Go's `initSchema` (`start.go:243-254`) branches to `initSchema15` — the ONLY place - // `ResolveJWKS` is ever called — solely on `majorVersion >= 15`; the PG13/14 branch - // (`InitSchema14`) never touches JWKS, so a PG13/14 database with realtime enabled - // must not pay for (or fail on) an external JWKS fetch it will never use. - const jwks = - setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; + // Go's `SetupLocalDatabase` dials this first host-facing connect exactly + // once (`start.go:360-363`); we deliberately diverge and retry dial-level + // failures: the container's internal health check says nothing about the + // HOST side, where Docker Desktop (Windows/WSL2) can publish the port a + // few seconds late (#6136). + const session = yield* dbConnection + .connect( + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: dbPassword, + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, + ) + .pipe( + Effect.retry({ + schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), + while: (error) => error.retryable === true, + }), + ); - const dbSetupImages = legacyResolveDbSetupImages(setup.serviceVersionOverrides); + const { jwks, images: dbSetupImages } = yield* legacyResolveDbSetupPrelude(setup); yield* legacyStartSetupLocalDatabase(spawner, { session, @@ -1097,6 +1288,10 @@ export const legacyRunFreshDbSetup = ( config: setup.config, experimental: setup.experimental, majorVersion: setup.majorVersion, + // Go's `utils.DbId` — the internal Docker-network address the PG15+ one-shot + // jobs connect through. Unchanged from before CLI-1956, just now an explicit + // parameter on `LegacySetupDatabaseInput` instead of computed inside it. + dbHost: localDbContainerId(input.projectId), projectId: input.projectId, networkId: input.networkId, dbUrl: setup.dbUrl, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index f7d6fdc8c0..628bd0c8c9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -20,6 +20,7 @@ import { import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbSetupError, + legacyResolveDbSetupPrelude, legacyStartInitCurrentBranch, legacyStartSetupLocalDatabase, type LegacyStartSetupLocalDatabaseInput, @@ -124,9 +125,30 @@ function mockAlwaysCachedSpawner(): ChildProcessSpawner.ChildProcessSpawner["Ser function mockDockerRunFails() { const layer = Layer.succeed(LegacyDockerRun, { - run: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), - runCapture: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), - runStream: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), + run: () => + Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker", + reason: "spawn", + daemonDown: false, + }), + ), + runCapture: () => + Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker", + reason: "spawn", + daemonDown: false, + }), + ), + runStream: () => + Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker", + reason: "spawn", + daemonDown: false, + }), + ), }); return { layer }; } @@ -183,6 +205,7 @@ function baseInput( config: defaultConfig, experimental: false, majorVersion: 17, + dbHost: "supabase_db_proj", projectId: "proj", networkId: "supabase_network_proj", dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", @@ -286,21 +309,6 @@ describe("legacyStartSetupLocalDatabase", () => { }), ); }); - - it.effect('prints "Initialising schema..." once, for either branch', () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( - Effect.map(() => { - const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); - expect(banner.length).toBe(1); - expect(banner[0]?.stream).toBe("stderr"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); }); describe("PG15+ one-shot job gating", () => { @@ -383,7 +391,7 @@ describe("legacyStartSetupLocalDatabase", () => { baseInput(workdir, session, { majorVersion: 15, config, - projectId: "myproj", + dbHost: "supabase_db_myproj", jwks: '{"keys":["stub"]}', }), out, @@ -787,6 +795,75 @@ describe("legacyStartSetupLocalDatabase", () => { }); }); +describe("legacyResolveDbSetupPrelude", () => { + const run = ( + setup: { + readonly majorVersion: number; + readonly realtimeEnabledForSetup: boolean; + readonly jwks: Effect.Effect; + }, + out: ReturnType, + ) => + legacyResolveDbSetupPrelude({ ...setup, serviceVersionOverrides: {} }).pipe( + Effect.provide(out.layer), + ); + + it.effect('prints "Initialising schema..." to stderr exactly once, for either PG branch', () => { + const out = mockOutput(); + return run( + { majorVersion: 17, realtimeEnabledForSetup: false, jwks: Effect.succeed("") }, + out, + ).pipe( + Effect.map(() => { + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }); + + it.effect( + 'prints the banner BEFORE a JWKS resolution failure — matching Go\'s "initSchema" printing the banner before ever calling "initSchema15" -> "ResolveJWKS" (review: PRRT_kwDOErm0O86W6R-O)', + () => { + const out = mockOutput(); + return run( + { + majorVersion: 15, + realtimeEnabledForSetup: true, + jwks: Effect.fail(new Error("jwks discovery failed")), + }, + out, + ).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toBe("jwks discovery failed"); + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }, + ); + + it.effect( + "does not resolve JWKS on PG <= 14 even with realtime enabled — Go's initSchema never reaches initSchema15 there", + () => { + const out = mockOutput(); + let jwksCalled = false; + const jwks = Effect.sync(() => { + jwksCalled = true; + return "unused"; + }); + return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( + Effect.map((resolved) => { + expect(jwksCalled).toBe(false); + expect(resolved.jwks).toBe(""); + }), + ); + }, + ); +}); + describe("legacyStartInitCurrentBranch", () => { it.effect('writes supabase/.branches/_current_branch = "main" when absent', () => { const workdir = makeWorkdir(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index a0219f9995..4088d8a3d9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -124,7 +124,14 @@ interface LegacyStartSecretFileSpec { export interface LegacyStartContainerSpec { /** `container.Config.Image` (already resolved/pulled — resolution is out of scope here). */ readonly image: string; - /** The 4th `DockerStart` positional argument — `--name`. */ + /** + * The 4th `DockerStart` positional argument — `--name`. An empty string mirrors Go + * passing `""` (e.g. `CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:150`) + * and lets Docker auto-generate one — {@link legacyBuildStartContainerCreateArgs} omits + * `--name` entirely in that case (docker rejects an explicit empty `--name` value, unlike + * the Engine API's empty `containerName` positional, which it happily treats as "generate + * one"). Every real service container still passes a non-empty name, unchanged. + */ readonly containerName: string; /** * `container.Config.Hostname`. Only Logflare sets this (`start.go:353`, @@ -163,7 +170,10 @@ export interface LegacyStartContainerSpec { * container at `containerPath`, removing the temp file immediately * afterward — never a host bind mount. Generic by design — any future * service's spec can set this, not just the three call sites that need it - * today. + * today, and it makes no difference whether `containerName` is set: `docker + * cp` addresses the container by the id `docker create` returns, not by + * name, so the shadow database's own unnamed container (`db-bootstrap/ + * shadow-database.ts`) is delivered its pgsodium root key the exact same way. * * `docker cp` streams the file's content over the same Docker CLI/Engine * API connection as `docker create`/`docker start`, so — unlike the @@ -251,6 +261,15 @@ export interface LegacyStartContainerSpec { * supported for completeness/future callers, per the task brief. */ readonly restartPolicy?: "unless-stopped" | "no" | "always" | "on-failure"; + /** + * `container.HostConfig.AutoRemove` — only the shadow-database container sets this + * (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:144`), via `--rm`. + * `AutoRemove` only fires once the container's own main process exits on its own; it + * does NOT make an explicit remove redundant for a still-running container (verified + * empirically), so callers still remove the shadow explicitly once they are done with + * it — see `shadow-database.ts`'s `legacyRemoveShadowDatabase`. + */ + readonly autoRemove?: boolean; /** * `container.HostConfig.SecurityOpt`. Only Vector sets this * (`start.go:441`, `"label:disable"`, when mounting a non-root Docker @@ -429,8 +448,8 @@ export function legacyBuildStartContainerCreateArgs( ): ReadonlyArray { return [ "create", - "--name", - spec.containerName, + ...(spec.containerName.length === 0 ? [] : ["--name", spec.containerName]), + ...(spec.autoRemove === true ? ["--rm"] : []), ...(spec.hostname === undefined ? [] : ["--hostname", spec.hostname]), ...Object.entries(spec.env).flatMap(([key, value]) => legacyIsDockerClientEnvKey(key) ? ["-e", `${key}=${value}`] : ["-e", key], diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts index 2211a3059a..d1cbca69e2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts @@ -115,6 +115,36 @@ describe("legacyBuildStartContainerCreateArgs", () => { ]); }); + test("omits --name entirely when containerName is empty (Docker auto-generates one, e.g. the shadow database)", () => { + const spec: LegacyStartContainerSpec = { + image: "supabase/postgres:17.4.1.030", + containerName: "", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).not.toContain("--name"); + expect(args).toEqual(["create", "--network", "supabase_network_proj", spec.image]); + }); + + test("emits --rm when autoRemove is true, omits it otherwise", () => { + const base: LegacyStartContainerSpec = { + image: "supabase/postgres:17.4.1.030", + containerName: "", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + expect(legacyBuildStartContainerCreateArgs(base)).not.toContain("--rm"); + expect(legacyBuildStartContainerCreateArgs({ ...base, autoRemove: true })).toContain("--rm"); + expect(legacyBuildStartContainerCreateArgs({ ...base, autoRemove: false })).not.toContain( + "--rm", + ); + }); + test("never serializes env values into argv (CWE-214: secrets must not leak to ps)", () => { const args = legacyBuildStartContainerCreateArgs(full); expect(args).toContain("DB_PASSWORD"); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index ac4cc07bdf..c6487463d6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -15,6 +15,11 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacySpawnContainerCliWithRuntime, type LegacyContainerRuntime, @@ -62,10 +67,14 @@ export interface LegacyHealthCheckFailure { readonly reason: string; } -/** Internal-only: one probe round's failures, narrowing which containers are still watched next round. */ -class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ +/** Runtime-internal probe sentinel; exported only for the exhaustive actionability guard. */ +export class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ readonly failures: ReadonlyArray; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * The retry loop's final, and only surfaced, failure — mirrors Go returning @@ -83,7 +92,11 @@ export class LegacyHealthCheckTimeoutError extends Data.TaggedError( * pointing at an HTTP request logger is a non-sequitur (as in CLI-1973). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * PostgREST's local Kong gateway coordinates, mirroring Go's @@ -408,8 +421,12 @@ export function legacyWaitForHealthyServices( ); return yield* Effect.fail( new LegacyHealthCheckTimeoutError({ + // Go's `assertContainerHealthy` embeds the id INSIDE the message + // (`errors.Errorf("%s container is not running: %s", …)`, + // `status.go:150,154`) — a bare space, not an `: ` prefix, so the + // joined `errors.Join` text is ` container is not ready: `. message: probeError.failures - .map((failure) => `${failure.containerId}: ${failure.reason}`) + .map((failure) => `${failure.containerId} ${failure.reason}`) .join("\n"), unhealthy: probeError.failures, ...(suggestion === undefined ? {} : { suggestion }), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts index 8737d09001..ae48658fc2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts @@ -233,7 +233,7 @@ describe("legacyWaitForHealthyServices", () => { expect(error.unhealthy).toEqual([ { containerId: "supabase_rest_proj", reason: "container is not running: exited" }, ]); - expect(error.message).toBe("supabase_rest_proj: container is not running: exited"); + expect(error.message).toBe("supabase_rest_proj container is not running: exited"); }), ); @@ -316,7 +316,7 @@ describe("legacyWaitForHealthyServices", () => { // Reasons unchanged; the advice rides on `suggestion`, which is what // suppresses the unhelpful "--debug" hint downstream. - expect(error.message).toBe("supabase_inbucket_proj: container is not running: exited"); + expect(error.message).toBe("supabase_inbucket_proj container is not running: exited"); expect(error.unhealthy).toEqual([ { containerId: "supabase_inbucket_proj", reason: "container is not running: exited" }, ]); @@ -380,7 +380,7 @@ describe("legacyWaitForHealthyServices", () => { const error = yield* timeoutError(mock, ["supabase_storage_proj"]); - expect(error.message).toBe("supabase_storage_proj: container is not running: exited"); + expect(error.message).toBe("supabase_storage_proj container is not running: exited"); expect(error.suggestion).toBeUndefined(); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts index 99bd60544c..fa7d9abac1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts @@ -19,6 +19,11 @@ import { Data, Effect, Result } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyMakeDockerImageResolver } from "../legacy-docker-image-resolve.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL, @@ -35,7 +40,19 @@ type Spawner = ChildProcessSpawner["Service"]; */ export class LegacyImagePrepullError extends Data.TaggedError("LegacyImagePrepullError")<{ readonly message: string; -}> {} + readonly reason: "docker_daemon" | "registry_pull" | "image_inspect"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "docker_daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "registry_pull": + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + default: + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + } + } +} /** * Resolves every image in `images` concurrently (Go's `utils.WaitAll` — @@ -66,10 +83,19 @@ export function legacyEnsureImagesCached( const resolved = new Map(); const failures: Array = []; + let failureReason: LegacyImagePrepullError["reason"] = "image_inspect"; for (const [index, image] of uniqueImages.entries()) { const result = results[index]; if (result === undefined || Result.isFailure(result)) { failures.push(result === undefined ? `${image}: unknown error` : result.failure.message); + if (result !== undefined) { + const failure = result.failure; + if (failure.reason === "spawn" || failure.daemonDown) { + failureReason = "docker_daemon"; + } else if (failure.reason === "pull" && failureReason !== "docker_daemon") { + failureReason = "registry_pull"; + } + } continue; } resolved.set(image, result.success); @@ -86,7 +112,10 @@ export function legacyEnsureImagesCached( ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` : ""; return yield* Effect.fail( - new LegacyImagePrepullError({ message: `${failures.join("\n")}${hint}` }), + new LegacyImagePrepullError({ + message: `${failures.join("\n")}${hint}`, + reason: failureReason, + }), ); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts index bae3b3bb12..42780112b9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts @@ -39,9 +39,26 @@ export const LEGACY_START_INTERNAL_DB_NAME = "postgres"; * `legacyResolveLocalConfigValues`) always embeds the exact same password * value `dbConfig.Password` does, since both are sourced from the same * `config.Db.Password` field. + * + * Returns the RAW password, not the URI-encoded userinfo octets: `new URL(...) + * .password` preserves percent-encoding (`new URL("postgresql://u:p%40s@h:5/d") + * .password === "p%40s"`), but consumers split into plain-env uses that need the + * decoded value (Realtime's `DB_PASSWORD`, `realtime-env.ts`) and URI re-embedders + * that re-encode themselves ({@link legacyStartInternalDbUrl}). The shadow database + * path (`legacy-shadow-source.ts`) builds its `dbUrl` via `legacyToPostgresURL`, + * which `encodeURIComponent`s a config-derived password, so decoding here is what + * keeps a special-character password working against a container initialized with + * the raw value. For `start`'s constant `"postgres"` both forms are identical. The + * fallback covers a `dbUrl` whose userinfo was never percent-encoded (a raw `%` + * would throw `URIError`) — the undecoded octets are the best available value there. */ export function legacyStartInternalDbPassword(dbUrl: string): string { - return new URL(dbUrl).password; + const encoded = new URL(dbUrl).password; + try { + return decodeURIComponent(encoded); + } catch { + return encoded; + } } /** @@ -50,7 +67,14 @@ export function legacyStartInternalDbPassword(dbUrl: string): string { * (PostgREST's `PGRST_DB_URI` as `authenticator`, Storage's `DATABASE_URL` as * `supabase_storage_admin`, Storage's vector-bucket default `VECTOR_DATABASE_URL` * as `postgres` — see `appendStorageVectorEnv`, `start.go:1487-1501`). + * + * `dbPassword` is the RAW password ({@link legacyStartInternalDbPassword}); it is + * percent-encoded here so the consuming service's URI parser decodes it back to + * the same value. Go interpolates the raw string, but its only possible value is + * the reserved-character-free `"postgres"` literal, for which the two are + * byte-identical — encoding only diverges for the shadow path's config-derived + * password, where the raw form would produce an unparseable URI. */ export function legacyStartInternalDbUrl(role: string, dbHost: string, dbPassword: string): string { - return `postgresql://${role}:${dbPassword}@${dbHost}:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`; + return `postgresql://${role}:${encodeURIComponent(dbPassword)}@${dbHost}:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`; } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts index 25f27fdc1d..6bc9e0d6c1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts @@ -19,6 +19,21 @@ describe("legacyStartInternalDbPassword", () => { legacyStartInternalDbPassword("postgresql://postgres:super-secret@127.0.0.1:54322/postgres"), ).toBe("super-secret"); }); + + // The shadow path builds dbUrl via legacyToPostgresURL, which encodeURIComponent's a + // config-derived password; plain-env consumers (Realtime's DB_PASSWORD) need the raw + // value back or auth fails against a container initialized with the decoded form. + test("percent-decodes an encoded password back to its raw value", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:p%40ss%2Fw0rd@127.0.0.1:54322/postgres"), + ).toBe("p@ss/w0rd"); + }); + + test("falls back to the undecoded octets when userinfo was never percent-encoded", () => { + expect( + legacyStartInternalDbPassword("postgresql://postgres:100%pass@127.0.0.1:54322/postgres"), + ).toBe("100%pass"); + }); }); describe("legacyStartInternalDbUrl", () => { @@ -28,6 +43,12 @@ describe("legacyStartInternalDbUrl", () => { ); }); + test("percent-encodes a raw password so the consuming URI parser decodes it back", () => { + expect(legacyStartInternalDbUrl("authenticator", "supabase_db_proj", "p@ss/w0rd")).toBe( + `postgresql://authenticator:p%40ss%2Fw0rd@supabase_db_proj:${LEGACY_START_INTERNAL_DB_PORT}/${LEGACY_START_INTERNAL_DB_NAME}`, + ); + }); + test("port and database name are always the fixed internal values", () => { expect(LEGACY_START_INTERNAL_DB_PORT).toBe(5432); expect(LEGACY_START_INTERNAL_DB_NAME).toBe("postgres"); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts index 4195cd8774..03d332ed77 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -1,14 +1,17 @@ /** - * The local-container-bring-up prelude BOTH `db start` (`commands/db/start/start.handler.ts`) - * and `db reset` (`commands/db/reset/reset.handler.ts`) build before calling their own - * composition (`legacyStartDatabase`/`legacyRecreateLocalDatabase`): load the local project - * context, resolve config values + the `LegacyDbBootstrapConfig` derivation, the container's - * network id/opts/id, the Postgres container-spec fields common to both callers, the lazy - * image-resolve `Effect`, and the `LegacyFreshDbSetupInput` `setup` object `legacyRunFreshDbSetup` - * needs. Hoisted here (CLI-1955 review follow-up) — the two callers used to each run an - * independently-typed ~130-line copy of this exact sequence, with no test comparing them. + * The local-container-bring-up prelude shared by `db start` + * (`commands/db/start/start.handler.ts`), `db reset` + * (`commands/db/reset/reset.handler.ts`, via `reset-local-database.ts`), and + * `db diff`/`db pull`'s shadow-database provisioning (CLI-1956): load the local + * project context, resolve config values + the `LegacyDbBootstrapConfig` + * derivation, the container's network id/opts/id, the Postgres container-spec + * fields common to every caller, the lazy image-resolve `Effect`, and the + * `LegacyFreshDbSetupInput` `setup` object `legacyRunFreshDbSetup` needs. Hoisted + * here (CLI-1955 review follow-up) — `db start`/`db reset` used to each run an + * independently-typed ~130-line copy of this exact sequence, with no test + * comparing them. * - * Deliberately does NOT include the two callers' genuinely divergent parts, which stay at each + * Deliberately does NOT include the callers' genuinely divergent parts, which stay at each * call site instead of being forced into this shared shape: * - `db start`'s `fromBackup` (spliced into its OWN `postgresSpec` on top of * {@link LegacyLocalDbContainerInputs.postgresSpecBase}) and its `isFreshVolume`/`filterValue` @@ -33,7 +36,9 @@ import type { GlobalFlag } from "effect/unstable/cli"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { legacyResolveExperimentalWithProjectEnv } from "../../../shared/legacy/global-flags.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; -import { localDbContainerId, legacyResolveNetworkId } from "../legacy-docker-ids.ts"; +import { localDbContainerId } from "../legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyIsBitbucketPipeline } from "../legacy-bitbucket-pipeline.ts"; import { legacyResolveAuthExternalUrl, @@ -93,6 +98,14 @@ export interface LegacyLocalDbContainerInputs { /** * Builds {@link LegacyLocalDbContainerInputs} — see this module's header for the full call * order and for which parts are deliberately excluded (kept at each call site instead). + * + * Loads its own {@link LegacyLocalProjectContext} via {@link legacyLoadLocalProjectContext} + * UNLESS the caller passes {@link preloadedContext} — see that parameter's own doc comment for + * why `db start`'s handler must pass one (a double-print bug: `@supabase/config`'s + * `loadProjectConfig` unconditionally prints deprecated-config-section WARN lines to stderr, + * and `db start` already loads a context of its own, eagerly, ahead of this function, matching + * Go's single `flags.LoadConfig` call in `start.Run` (`apps/cli-go/internal/db/start/ + * start.go:45`)). */ export const legacyBuildLocalDbContainerInputs = ( spawner: Spawner, @@ -100,6 +113,47 @@ export const legacyBuildLocalDbContainerInputs = ( networkIdFlag: Option.Option, platform: string, debug: boolean, + // The resolved `--linked` ref, when the caller already has one (`db diff`/`db pull` — + // CLI-1956) — threaded straight through to `legacyLoadLocalProjectContext` so the shadow's + // OWN container-spec fields (image, `db.major_version`, JWT secret, root key, + // `db.settings`, service enabled-for-setup flags) reflect the matching `[remotes.]` + // override, the same way `legacyReadDbToml(..., ref)` already does for those commands' + // other config read. `db start`/`db reset` never pass this — see that function's own doc + // comment. + projectRef?: string, + // The `remoteOverrideKeys` the caller's OWN, separate `legacyReadDbToml(..., ref)` read + // already computed for the SAME matched `[remotes.]` block (`@supabase/config`'s + // `loadProjectConfig`, used by `legacyLoadLocalProjectContext` just above, merges the + // remote block's VALUES but tracks none of which keys it set) — threaded into + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` AND + // `legacyResolveLocalConfigValues` below so a remote-set field (e.g. `db.major_version`, + // `auth.jwt_secret`, `db.root_key`) isn't re-overridden by a conflicting `SUPABASE_*` env + // var (review: PRRT_kwDOErm0O86W2LL4, PRRT_kwDOErm0O86W2tRi). Go's `mergeRemoteConfig` + // installs remote leaves at viper's OVERRIDE tier, above `AutomaticEnv` + // (`apps/cli-go/pkg/config/config.go:718-730`). + // `db start`/`db reset` never pass a `projectRef` above, so they never need this either. + remoteOverrideKeys?: ReadonlySet, + // `db start`'s handler — the only real caller that already has a + // {@link LegacyLocalProjectContext} loaded in scope BEFORE calling this function, since it + // must eagerly load+validate config ahead of its own "is Postgres already running" + // short-circuit (matching Go's single `flags.LoadConfig` call, `start.go:45`, which also runs + // ahead of `AssertSupabaseDbIsRunning`, `start.go:45-47`). When provided, this function uses + // it AS-IS instead of calling `legacyLoadLocalProjectContext(workdir, mapError, projectRef)` + // again — the call is skipped entirely, not just its result discarded, because that reload is + // the one genuinely observable side effect this function would otherwise repeat: + // `@supabase/config`'s `loadProjectConfig` unconditionally prints deprecated-`[inbucket]`/ + // deprecated-`auth.external.{linkedin,slack}` WARN lines to stderr (`packages/config/src/ + // io.ts:705-710,792-797`), so reloading would print each warning TWICE for one `db start` + // invocation where Postgres isn't already running, instead of once like Go — whose entire + // config load is a single package-level-singleton pass, with no second `Config.Load` call + // anywhere in `db start`'s call graph to double the print. + // + // PRECONDITION (not enforced here — see this module's header for why `db start`/`db reset` + // never pass a `projectRef` above, so there is nothing to reconcile): the preloaded context + // must correspond to the SAME `workdir`/`projectRef` this call would otherwise have passed to + // `legacyLoadLocalProjectContext` itself. That's only ever true today for a caller — `db + // start` — that never passes `projectRef` at all. + preloadedContext?: LegacyLocalProjectContext, ): Effect.Effect< LegacyLocalDbContainerInputs, LegacyDbConfigLoadError, @@ -110,7 +164,8 @@ export const legacyBuildLocalDbContainerInputs = ( const path = yield* Path.Path; const mapError = (message: string) => new LegacyDbConfigLoadError({ message }); - const context = yield* legacyLoadLocalProjectContext(workdir, mapError); + const context = + preloadedContext ?? (yield* legacyLoadLocalProjectContext(workdir, mapError, projectRef)); const { config, projectEnvValues, loaded, hostname, projectId } = context; // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep // inside `legacyRunFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc @@ -125,6 +180,7 @@ export const legacyBuildLocalDbContainerInputs = ( workdir, projectEnvValues, loaded?.document, + remoteOverrideKeys, ), catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), }); @@ -132,22 +188,23 @@ export const legacyBuildLocalDbContainerInputs = ( const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( fs, path, - { config, projectEnvValues, workdir }, + { config, projectEnvValues, workdir, remoteOverrideKeys }, mapError, ); // Go's `DockerStart` forces every container's network mode (and the network it creates) to // `--network-id` when set, ahead of the generated `supabase_network_` fallback // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` - // shell/project-dotenv env var when the flag itself is omitted, via the same + // shell/project-dotenv env var ONLY when the flag was never passed, via the same // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: - // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT - // the same freeze-at-package-init shape as `utils.Config.Hostname`). - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), + // PRRT_kwDOErm0O86VlqIL; unlike `utils.Config.Hostname`, viper re-reads the dotenv-merged + // env fresh at `DockerStart`'s own call site, not at package init). See + // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env precedence. + const networkId = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(networkIdFlag), + envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, - projectEnvValues, - ); + }); // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` // extra host for every container it starts (`docker_linux.go`; empty on darwin/windows, where // Docker Desktop already resolves that hostname). @@ -165,7 +222,11 @@ export const legacyBuildLocalDbContainerInputs = ( ...config.db, port: values.dbPort, major_version: bootstrapConfig.majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + settings: legacyResolveDbSettingsEnvOverrides( + config.db.settings, + projectEnvValues, + remoteOverrideKeys, + ), }, experimental: { ...config.experimental, @@ -220,11 +281,22 @@ export const legacyBuildLocalDbContainerInputs = ( // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `legacyRunFreshDbSetup` only // evaluates this Effect when reached AND `realtimeEnabledForSetup`. jwks: Effect.tryPromise({ - try: () => legacyResolveLocalJwks(config, workdir, values.jwtSecret, projectEnvValues), + try: () => + legacyResolveLocalJwks( + config, + workdir, + values.jwtSecret, + projectEnvValues, + remoteOverrideKeys, + ), catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), }), apiUrl: values.apiUrl, - authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), + authExternalUrl: legacyResolveAuthExternalUrl( + loaded?.document, + projectEnvValues, + remoteOverrideKeys, + ), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts index 5ed7247888..16b59c6ad9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts @@ -1,6 +1,11 @@ import { Data, Effect, type FileSystem, Option, type Path, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyIsContainerNotFoundMessage, spawnContainerCli } from "../legacy-container-cli.ts"; import { legacyReadDbToml } from "../legacy-db-config.toml-read.ts"; import { legacyResolveLocalProjectId, localDbContainerId } from "../legacy-docker-ids.ts"; @@ -14,9 +19,18 @@ type Spawner = ChildProcessSpawner["Service"]; /** `docker container inspect` failed for a reason other than "the container doesn't exist". */ export class LegacyLocalDbRunningError extends Data.TaggedError("LegacyLocalDbRunningError")<{ readonly message: string; + /** Classified at the container-runtime boundary; never inferred from `message` by telemetry. */ + readonly daemonDown?: boolean; /** Set when the failure is a daemon-connection error, mirroring `utils.CmdSuggestion`. */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.daemonDown === true) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + return actionability.startStack; + } +} const decodeChunks = (chunks: ReadonlyArray): string => { const total = chunks.reduce((size, chunk) => size + chunk.length, 0); @@ -90,7 +104,11 @@ export function legacyIsLocalDbRunning( extendEnv: true, }).pipe( Effect.mapError( - () => new LegacyLocalDbRunningError({ message: "failed to inspect service" }), + () => + new LegacyLocalDbRunningError({ + message: "failed to inspect service", + daemonDown: true, + }), ), ); const stderrChunks: Array = []; @@ -122,15 +140,15 @@ export function legacyIsLocalDbRunning( // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` // on a daemon-connection failure (`misc.go:148-154`), so a down daemon // still surfaces the actionable Docker Desktop hint, not just raw stderr. + const daemonDown = legacyIsDockerDaemonUnreachable(stderr); return yield* Effect.fail( new LegacyLocalDbRunningError({ message: stderr.length > 0 ? `failed to inspect service: ${stderr}` : "failed to inspect service", - ...(legacyIsDockerDaemonUnreachable(stderr) - ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } - : {}), + daemonDown, + ...(daemonDown ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } : {}), }), ); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index d6ea252181..47f927084b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -32,9 +32,13 @@ import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; import { LEGACY_START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; -/** Go's `Db.Password` default (`pkg/config/config.go:459`). `db.password` has no - * config.toml field (`toml:"-"`, `pkg/config/db.go:88`), so this is the only value - * this port can ever observe — matches `DEFAULT_DB_PASSWORD` in +/** Go's `Db.Password` default (`pkg/config/config.go:459`). In Go this is the only + * value the field can ever hold on a db path: viper decodes with the `json` tag + * (`config.go:749-750`), and `json:"-"` (`pkg/config/db.go:88`) both blocks the + * `SUPABASE_DB_PASSWORD` env binding and makes a literal `[db] password` key a fatal + * `UnmarshalExact` error (`'db' has invalid keys: password`). The TS port honors the + * toml key as a deliberate extension — see `legacyBuildShadowPostgresContainerSpec`'s + * `password` field below. Matches `DEFAULT_DB_PASSWORD` in * `legacy-local-config-values.ts`, not imported from there since that constant * isn't exported and status/stop's resolver is otherwise unrelated to this module. */ const LEGACY_POSTGRES_PASSWORD = "postgres"; @@ -254,20 +258,25 @@ function legacyPostgresExtraEnv( * {@link legacyBuildPostgresStartContainerSpec}), so it never appears in this * process's own `docker create` argv (CWE-214/522). * - * Otherwise byte-for-byte derived from Go's raw-string concatenation - * (including the trailing space after `/etc/postgresql` — Go's - * `NewContainerConfig(args ...string)` joins its variadic `args` there, - * always empty for `supabase start`, so the space survives as-is); built via - * explicit `"...\n" +` concatenation rather than a multi-line template - * literal so that trailing space stays a visible, lint/format-proof string - * character instead of invisible end-of-line whitespace. + * Otherwise byte-for-byte derived from Go's raw-string concatenation — + * `NewContainerConfig(args ...string)` splices `strings.Join(args, " ")` + * straight after the literal trailing space following `/etc/postgresql` + * (`start.go:95`): `supabase start`'s own Postgres container always calls it + * with zero args (`args` here defaults to `""`, so the trailing space + * survives on its own, unchanged from before), while the shadow-database + * variant (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:140`) + * passes {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} — see + * {@link legacyBuildShadowPostgresContainerSpec}. Built via explicit + * `"...\n" +` concatenation rather than a multi-line template literal so that + * the trailing space (when `args` is empty) stays a visible, lint/format-proof + * string character instead of invisible end-of-line whitespace. */ -function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { +function legacyPostgresEntrypointScriptPg15(postgresConfig: string, args = ""): string { return ( "\n" + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + - "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SCHEMA_SQL}\n` + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + `${LEGACY_START_DB_SUPABASE_SQL}\n` + @@ -284,14 +293,15 @@ function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { * only), appends `postgresConfig` to `postgresql.conf`, then execs * `docker-entrypoint.sh`. See {@link legacyPostgresEntrypointScriptPg15}'s doc * comment for why this is explicit concatenation rather than a template - * literal. + * literal, and for the `args` parameter (same trailing-space splice, same + * default). */ -function legacyPostgresEntrypointScriptPg14(postgresConfig: string): string { +function legacyPostgresEntrypointScriptPg14(postgresConfig: string, args = ""): string { return ( "\n" + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + - "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SUPABASE_SQL}\n` + "EOF\n" + `${postgresConfig}\n` + @@ -346,6 +356,12 @@ export function legacyBuildPostgresStartContainerSpec( const isRestore = input.fromBackup !== undefined; const env: Record = { + // The constant `"postgres"` literal, matching Go, where `Db.Password` is + // `toml:"-"` (never decoded from config.toml) and only ever holds the default + // (`pkg/config/db.go:88`, `config.go:459`). The sibling shadow builder below + // (`legacyBuildShadowPostgresContainerSpec`) instead threads a config-derived + // `input.password` — a deliberate TS extension on the shadow path only; if this + // container ever honors `[db] password` too, both must change together. POSTGRES_PASSWORD: LEGACY_POSTGRES_PASSWORD, POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, @@ -401,3 +417,126 @@ export function legacyBuildPostgresStartContainerSpec( labels: {}, }; } + +/** + * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, + * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the + * shadow database. Not a docker flag: it is spliced into the entrypoint script's own + * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like every + * other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + */ +export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; + +/** + * Input to {@link legacyBuildShadowPostgresContainerSpec} — the subset of + * {@link LegacyPostgresStartServiceInput} the shadow variant actually needs (no + * `projectId`/`fromBackup`: the shadow container has no name and never restores from a + * backup) plus the shadow's own host port. + */ +export interface LegacyShadowPostgresContainerSpecInput { + readonly db: Pick; + readonly experimental: ProjectConfig["experimental"]; + readonly jwtSecret: string; + readonly jwtExpiry: number; + readonly networkId: string; + readonly image: string; + readonly configImage: string; + readonly rootKey?: string; + /** `utils.Config.Db.ShadowPort` — the shadow's own host port, published to `5432/tcp` in-container. */ + readonly shadowPort: number; + /** + * `[db] password` (already resolved from `config.toml`, `DEFAULT_DB_PASSWORD`/"postgres" when + * unset). Honoring the toml key is a deliberate TS extension, NOT Go parity: Go's + * `NewContainerConfig` does source `POSTGRES_PASSWORD` from `utils.Config.Db.Password` for both + * the real container and the shadow (`CreateShadowDatabase` reuses it verbatim, `diff.go:140`), + * but in Go that field is invariably the `"postgres"` default — `json:"-"` (`db.go:88`, the tag + * viper decodes with, `config.go:749-750`) makes a literal `[db] password` key a fatal + * `UnmarshalExact` config error, and blocks the env binding. The TS extension mirrors what + * `--local` connections already do on develop (`legacy-db-config.layer.ts`). Must be threaded + * through so the shadow's actual Postgres password matches what + * `legacyShadowRunInputFromLocalContainerInputs`'s caller connects with — otherwise a + * non-default `[db] password` authenticates against the wrong secret. + */ + readonly password: string; +} + +/** + * Builds the {@link LegacyStartContainerSpec} for the shadow database container. Port of + * Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`) — reuses + * the EXACT SAME `NewContainerConfig` (image/env/healthcheck/entrypoint-script shape) the + * real local `db` container uses, just with {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} spliced + * into the entrypoint and a materially different `container.HostConfig`/networking: + * + * - **Empty `containerName`** (Go passes `""` to `DockerStart`, letting Docker + * auto-generate one) — see {@link LegacyStartContainerSpec.containerName}'s own doc + * comment for how the arg-builder and secret-file staging handle this. + * - **`autoRemove: true`** — Go's `hostConfig.AutoRemove` (`--rm`). + * - **No volume bind** — the shadow is throwaway; Go's `hostConfig` sets no `Binds` at all. + * - **No `restartPolicy`** — Go's `hostConfig` sets no `RestartPolicy` either. + * - **No `networkAliases`** — Go's `networkingConfig` is a bare, empty + * `network.NetworkingConfig{}` (no `db`/`db.supabase.internal` aliases). The shadow + * still joins the network via `DockerStart`'s own default `NetworkMode` (confirmed + * empirically: Docker's embedded DNS resolves a container on a user-defined network by + * BOTH its auto-generated name and its 12-char short container id, with no alias + * needed — see `shadow-database.ts`'s header for why this matters). + * - **Tmpfs on PG <= 14 IS still applied** — same `isPg14OrEarlier` condition as the real + * `db` container. + * - **The pgsodium root key `secretFiles` entry is still applied on PG >= 15** — the + * shadow's entrypoint script is the SAME `legacyPostgresEntrypointScriptPg15`, which + * still heredocs it in Go (splice point unaffected by `args`), so this port still needs + * it delivered before `docker start` — via `docker cp` straight into the container + * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a + * host temp file. + * - **Labels ARE still applied** (merged in by `legacyCreateContainer`, same as every + * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow + * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, + * regardless of the `container.Config` literal passed in. The project label alone is + * enough for that sweep to recognize an orphaned shadow: it filters and removes by + * container id, so the shadow's lack of a stable name doesn't matter. + */ +export function legacyBuildShadowPostgresContainerSpec( + input: LegacyShadowPostgresContainerSpecInput, +): LegacyStartContainerSpec { + const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; + const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); + const isPg14OrEarlier = input.db.major_version <= 14; + + const env: Record = { + POSTGRES_PASSWORD: input.password, + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + ...legacyPostgresExtraEnv(input.experimental, input.configImage), + }; + + const script = isPg14OrEarlier + ? legacyPostgresEntrypointScriptPg14(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS) + : legacyPostgresEntrypointScriptPg15(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS); + + return { + image: input.image, + containerName: "", + env, + entrypoint: "sh", + cmd: ["-c", script], + binds: [], + autoRemove: true, + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], + healthcheck: { + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, + timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, + retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, + }, + networkId: input.networkId, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index 3b88393d98..122418f02f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -7,11 +7,14 @@ import { LEGACY_START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values.ts"; import { + LEGACY_SHADOW_ENTRYPOINT_ARGS, legacyBuildPostgresStartContainerSpec, + legacyBuildShadowPostgresContainerSpec, legacyPostgresImageVersionTag, legacyPostgresSettingsToPostgresConfig, legacyPostgresVersionCompare, type LegacyPostgresStartServiceInput, + type LegacyShadowPostgresContainerSpecInput, } from "./postgres.service.ts"; const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; @@ -377,3 +380,75 @@ describe("legacyPostgresImageVersionTag", () => { expect(legacyPostgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); }); }); + +function baseShadowInput( + overrides: Partial = {}, +): LegacyShadowPostgresContainerSpecInput { + return { + db: { major_version: 17, settings: {} }, + experimental: baseExperimental(), + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + networkId: "supabase_network_myproj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + shadowPort: 54320, + password: "postgres", + ...overrides, + }; +} + +describe("legacyBuildShadowPostgresContainerSpec", () => { + test("PG >= 15: splices the shadow entrypoint args into the SAME trailing-space join point the real db container uses, and still carries the pgsodium root key as a secretFile", () => { + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ db: { major_version: 17, settings: {} } }), + ); + const script = spec.cmd?.[1]; + expect(script).toContain( + `docker-entrypoint.sh postgres -D /etc/postgresql ${LEGACY_SHADOW_ENTRYPOINT_ARGS}\n`, + ); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + expect(spec.tmpfs).toBeUndefined(); + }); + + test("PG <= 14: splices the same args, no pgsodium secretFile, and sets the initdb tmpfs mount", () => { + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ db: { major_version: 14, settings: {} } }), + ); + const script = spec.cmd?.[1]; + expect(script).toContain( + `docker-entrypoint.sh postgres -D /etc/postgresql ${LEGACY_SHADOW_ENTRYPOINT_ARGS}\n`, + ); + expect(spec.secretFiles).toBeUndefined(); + expect(spec.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + }); + + test("has no name (Docker auto-generates one), no network aliases, no volume bind, and no restart policy — unlike the real db container", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput()); + expect(spec.containerName).toBe(""); + expect(spec.networkAliases).toBeUndefined(); + expect(spec.binds).toEqual([]); + expect(spec.restartPolicy).toBeUndefined(); + }); + + test("sets autoRemove and publishes the shadow port to 5432/tcp", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput({ shadowPort: 54399 })); + expect(spec.autoRemove).toBe(true); + expect(spec.ports).toEqual([{ hostPort: "54399", containerPort: "5432" }]); + }); + + test("labels are still applied (empty map here — the caller merges project/compose labels in, same as every other container)", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput()); + expect(spec.labels).toEqual({}); + }); + + test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal — the deliberate TS extension the input's own doc describes (Go rejects the toml key at config load and always uses 'postgres')", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput({ password: "hunter2" })); + expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts index 8db1504e85..f3fad9bafb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -1,7 +1,7 @@ /** * `db reset --local`'s container-recreate half — a strict 1:1 port of Go's * `resetDatabase`/`resetDatabase14`/`resetDatabase15` - * (`apps/cli-go/internal/db/reset/reset.go:81-208`). This is DELIBERATELY NOT a + * (`apps/cli-go/internal/db/reset/reset.go:81-176`). This is DELIBERATELY NOT a * thin wrapper over {@link legacyStartDatabase} (`./start-database.ts`, the * `StartDatabase`-equivalent `db start`/`supabase start` share) — Go's own * `resetDatabase15` never calls `StartDatabase` either. It is a distinctly @@ -10,7 +10,7 @@ * `legacyCreateContainer`, `legacyWaitForHealthyServices`, * `legacyStartSetupLocalDatabase`), matching Go's own structure: * - * **PG >= 15** (`resetDatabase15`, `reset.go:146-174`): + * **PG >= 15** (`resetDatabase15`, `reset.go:114-142`): * 1. `docker container rm -f ` — NOT tolerant of "not found" (a genuine * remove failure is a hard `failed to remove container`), unlike most other * container lookups in this codebase. @@ -35,8 +35,8 @@ * 8. `Restarting containers...\n` to stderr, then * {@link legacyRestartServicesAndReloadKong} (`./restart-services.ts`). * - * **PG <= 14** (`resetDatabase14`, `reset.go:128-144`): - * 1. `recreateDatabase` (`reset.go:188-208`) — connect as `supabase_admin` to + * **PG <= 14** (`resetDatabase14`, `reset.go:96-112`): + * 1. `recreateDatabase` (`reset.go:157-176`) — connect as `supabase_admin` to * `template1`, `DisconnectClients`, then four UNWRAPPED (no `BEGIN`/`COMMIT`) * statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE DATABASE * _supabase`. Go batches these via a pgconn protocol trick that has no TS @@ -47,13 +47,13 @@ * with the pinned pgconn/pgx versions — the batching trick is real, but its * OBSERVABLE effect is identical to sequential execution for this * particular statement set). - * 2. `initDatabase` (`reset.go:176-186`) — connect as `supabase_admin` to the + * 2. `initDatabase` (`reset.go:144-154`) — connect as `supabase_admin` to the * default `postgres` database, then Go's EXPORTED `InitSchema14` (schema SQL * ONLY, deliberately WITHOUT globals.sql — see `legacyInitSchema14`'s * own doc comment for why this is NOT the same as `db start`'s PG<=14 path) * + `ApplyApiPrivileges` (the exact same exported function `SetupDatabase` * also calls). - * 3. `RestartDatabase` (`reset.go:246-257`) — `Restarting containers...\n` + * 3. `RestartDatabase` (`reset.go:214-225`) — `Restarting containers...\n` * FIRST, then a REAL `docker restart` of the `db` container itself (NOT * tolerant of "not found" — pg_cron must restart after * `pg_terminate_backend`, Go's own comment), health wait (not swallowed), @@ -84,15 +84,24 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import { Output } from "../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyIsSqlState } from "../legacy-connect-errors.ts"; import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; -import type { LegacyDbConnectError, LegacyDbExecError } from "../legacy-db-connection.errors.ts"; +import { LegacyDbExecError, type LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; -import type { LegacyMigrationApplyError } from "../legacy-migration-apply.ts"; +import { + legacyFormatExecBatchError, + type LegacyMigrationApplyError, +} from "../legacy-migration-apply.ts"; +import { legacyErrorMessage } from "../legacy-error-message.ts"; import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; import { @@ -144,18 +153,24 @@ const errMessage = (e: unknown): string => /** * One or more replication slots are still active (retryable — the WAL sender * that owns the slot may still be tearing down), OR counting them failed - * outright (permanent — Go's `backoff.PermanentError`, `reset.go:236-238`: - * a query-execution failure is never retried, only "count > 0" is). Not - * exported outside this module — callers discriminate this via the - * {@link LegacyRecreateLocalDatabaseError} union's `_tag`, never by importing - * the class itself (same pattern as `legacy-docker-remove-all.ts`). + * outright (permanent — Go's `backoff.PermanentError`, `reset.go:204-206`: + * a query-execution failure is never retried, only "count > 0" is). Exported + * only so the exhaustive actionability guard can inspect its + * declaration; runtime callers discriminate it through the + * {@link LegacyRecreateLocalDatabaseError} union's `_tag`. */ -class LegacyResetReplicationSlotsError extends Data.TaggedError( +export class LegacyResetReplicationSlotsError extends Data.TaggedError( "LegacyResetReplicationSlotsError", )<{ readonly message: string; readonly retryable: boolean; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.retryable + ? { ...actionability.dbFinding, fingerprint_suffix: "replication_slots_active" } + : { ...actionability.dbConnection, fingerprint_suffix: "replication_slots_query" }; + } +} /** Every failure the PG14/PG15 `db reset` recreate composition can produce. */ export type LegacyRecreateLocalDatabaseError = @@ -207,7 +222,7 @@ export interface LegacyRecreateLocalDatabaseInput { const PG_INVALID_CATALOG_NAME = "3D000"; /** - * Port of Go's `DisconnectClients` (`reset.go:215-244`): disable new connections + * Port of Go's `DisconnectClients` (`reset.go:183-212`): disable new connections * to `postgres`/`_supabase`, terminate existing backends, then wait for WAL * senders to drop their replication slots (constant 1-second backoff, 10 * retries max — Go's `NewBackoffPolicy(ctx, 10*time.Second)`). @@ -225,7 +240,7 @@ const PG_INVALID_CATALOG_NAME = "3D000"; */ export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session: LegacyDbSession) { // Must be executed separately because looping in a transaction is unsupported - // (Go's own comment, `reset.go:216-217`) — sequential, unwrapped execs, relying on + // (Go's own comment, `reset.go:184-185`) — sequential, unwrapped execs, relying on // Effect's short-circuit-on-failure to stop at the first bad statement, exactly // like pgconn's own batch-pipeline semantics would. const disconnectResult = yield* Effect.forEach( @@ -256,6 +271,7 @@ export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session return yield* Effect.fail( new LegacyDbSetupError({ message: `failed to disconnect clients: ${failure.message}`, + reason: "database", }), ); } @@ -291,22 +307,48 @@ export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session ); }); +// Go builds these four as a single `migration.MigrationFile{Statements: [...]}` +// and calls `.ExecBatch` (`reset.go:165-173`), which formats a failed statement +// with the same rich error context (caret-marked position, `Detail` line, the +// SQLSTATE-42704 extension hint, `At statement: `) real migration files +// get — NOT a real SQL transaction: `DROP`/`CREATE DATABASE` cannot run inside a +// `BEGIN`/`COMMIT` block at all, so these stay bare, sequential, UNWRAPPED +// `session.exec` calls (the pgconn network-pipeline batching `ExecBatch` uses +// instead has no TS equivalent and no observable difference here). Each +// statement's index matches its position in this list, mirroring Go's +// `m.Statements` indexing. +const RESET_RECREATE_DATABASES_STATEMENTS = [ + "DROP DATABASE IF EXISTS postgres WITH (FORCE)", + "CREATE DATABASE postgres WITH OWNER postgres", + "DROP DATABASE IF EXISTS _supabase WITH (FORCE)", + "CREATE DATABASE _supabase WITH OWNER postgres", +] as const; + /** - * Port of Go's `recreateDatabase` (`reset.go:188-208`): connect as + * Port of Go's `recreateDatabase` (`reset.go:157-176`): connect as * `supabase_admin` to `template1`, disconnect clients, then four UNWRAPPED * statements. "We are not dropping roles here because they are cluster level * entities. Use stop && start instead." (Go's own comment.) */ const legacyResetRecreateDatabases = Effect.fnUntraced(function* (session: LegacyDbSession) { yield* legacyResetDisconnectClients(session); - yield* session.exec("DROP DATABASE IF EXISTS postgres WITH (FORCE)"); - yield* session.exec("CREATE DATABASE postgres WITH OWNER postgres"); - yield* session.exec("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"); - yield* session.exec("CREATE DATABASE _supabase WITH OWNER postgres"); + for (const [index, statement] of RESET_RECREATE_DATABASES_STATEMENTS.entries()) { + yield* session.exec(statement).pipe( + Effect.mapError( + (error) => + new LegacyDbExecError({ + message: legacyErrorMessage(legacyFormatExecBatchError(error, index, statement)), + code: error.code, + detail: error.detail, + position: error.position, + }), + ), + ); + } }); /** - * Port of Go's `resetDatabase15` (`reset.go:146-174`) — see this module's own + * Port of Go's `resetDatabase15` (`reset.go:114-142`) — see this module's own * header for the full sequence and citations. */ const legacyRecreateLocalDatabase15 = ( @@ -372,7 +414,7 @@ const legacyRecreateLocalDatabase15 = ( }); /** - * Port of Go's `resetDatabase14` (`reset.go:128-144`) — see this module's own + * Port of Go's `resetDatabase14` (`reset.go:96-112`) — see this module's own * header for the full sequence and citations. Loads `config.toml` once, ahead of * `initDatabase` (needs `api.auto_expose_new_tables`) and the final * `MigrateAndSeed` (needs `db.migrations.enabled`/`[db.seed]`/pg-delta gate) — @@ -427,6 +469,7 @@ const legacyRecreateLocalDatabase14 = ( (error) => new LegacyDbSetupError({ message: `failed to create temp directory: ${errMessage(error)}`, + reason: "filesystem", }), ), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts index 3bc87a9448..43ee93b2eb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -48,6 +48,11 @@ import { } from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua, legacyYellow } from "../legacy-colors.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { legacyCheckDbToml, legacyLoadProjectEnv } from "../legacy-db-config.toml-read.ts"; @@ -61,16 +66,19 @@ import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts"; * The local database container is not running. Byte-matches Go's * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local - * reset (`internal/db/reset/reset.go:57`). Not exported outside this module — - * callers discriminate this via the failure's own message, never by importing the - * class itself (same pattern as `recreate-local-database.ts`'s own - * `LegacyResetReplicationSlotsError`). + * reset (`internal/db/reset/reset.go:57`). Exported only so the exhaustive + * actionability guard can inspect its declaration; runtime callers consume the + * enclosing effect rather than importing this class. */ -class LegacyResetLocalDbNotRunningError extends Data.TaggedError( +export class LegacyResetLocalDbNotRunningError extends Data.TaggedError( "LegacyResetLocalDbNotRunningError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ const toLogMessage = (version: string): string => @@ -155,7 +163,7 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( debug, ); const { - context: { projectId, hostname }, + context: { projectId, hostname, config, loaded }, values, bootstrapConfig, networkId, @@ -202,22 +210,20 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune // confirmations take their defaults instead of blocking on input. // - // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, which - // mirrors Go's full nested-env walk (`.env..local`, `.env.local`, - // `.env.`, `.env`, across both `supabase/` and the project root — - // `pkg/config/config.go:1220-1257`). This reload instead goes through - // `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, which only - // ever reads `supabase/.env`/`.env.local` plus ambient env - // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, which - // only widens `env(VAR)` matching, not the file set consulted. So a config whose - // `env(VAR)` reference is backed by e.g. `supabase/.env.development` is genuinely + // `resolvedConfig` passes through the SAME config this function already resolved + // via `legacyBuildLocalDbContainerInputs`'s `context` (itself loaded through + // `legacyLoadLocalProjectContext`, which mirrors Go's full nested-env walk — + // `.env..local`, `.env.local`, `.env.`, `.env`, across + // both `supabase/` and the project root, `pkg/config/config.go:1220-1257`) — so + // `legacySeedBucketsRun` never independently reloads config.toml through + // `@supabase/config`'s narrower `loadProjectConfig` → `loadProjectEnvironment` + // (`supabase/.env`/`.env.local` plus ambient env only, + // `packages/config/src/project.ts:209-245`), which used to reject a config whose + // `env(VAR)` reference is backed by e.g. `supabase/.env.development` — genuinely // Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is real ambient env - // by the time Go resolves it — `config.go:1260-1261`) and already passed - // `legacyCheckDbToml` and the real recreate above, but this narrower reload can - // still reject it. A `LegacySeedConfigLoadError` here is that env-file-set gap, not - // a genuinely invalid config — and recreate already dropped/rebuilt the DB, so - // aborting now would leave the reset half-done; warn and skip buckets so the reset - // finishes like Go instead. + // by the time Go resolves it, `config.go:1260-1261`) and already accepted by + // `legacyCheckDbToml` and the real recreate above (review CLI-1958). Same pattern + // `start.handler.ts` already uses for its own `legacySeedBucketsRun` calls. yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false, @@ -225,7 +231,11 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` // auto-confirms bucket/vector/analytics prune prompts. yes, + resolvedConfig: { config, document: loaded?.document }, }).pipe( + // A genuinely invalid bucket entry (bad name, unparseable `file_size_limit`, …) — + // recreate already dropped/rebuilt the DB, so aborting now would leave the reset + // half-done; warn and skip buckets so the reset finishes like Go instead. Effect.catchTag("LegacySeedConfigLoadError", (error) => output.raw( `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts index fdc0df74b0..fd93b34fa8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts @@ -1,7 +1,7 @@ /** * Post-recreate satellite-container restart + Kong reload, shared by both PG14's * `RestartDatabase` and PG15's `resetDatabase15` (`apps/cli-go/internal/db/reset/ - * reset.go:246-317`) — the ONLY two Go call sites of `restartServices`. Neither `db + * reset.go:214-288`) — the ONLY two Go call sites of `restartServices`. Neither `db * start` nor `supabase start` calls any of this: it exists purely to bring the * satellite containers (storage/auth/realtime/pooler) back in sync with a `db` * container that was just recreated or force-restarted out from under them, and to @@ -12,12 +12,17 @@ import { Data, Effect, Option, Result } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua } from "../legacy-colors.ts"; import { - collectText, + legacyCollectText, legacyDescribeContainerCliFailure, legacyIsContainerNotFoundMessage, - runContainerCliExpectSuccess, + legacyRunContainerCliExpectSuccess, spawnContainerCli, } from "../legacy-container-cli.ts"; import { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; @@ -28,11 +33,15 @@ type Spawner = ChildProcessSpawner["Service"]; /** `docker restart ` (the db container itself) failed — used only by PG14's `RestartDatabase`. */ export class LegacyContainerRestartError extends Data.TaggedError("LegacyContainerRestartError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * Port of Go's `Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{})` - * (`apps/cli-go/internal/db/reset/reset.go:250-252`), used ONLY by PG14's + * (`apps/cli-go/internal/db/reset/reset.go:218-220`), used ONLY by PG14's * `RestartDatabase` to restart the `db` container itself after `pg_terminate_backend` * (pg_cron must restart, per Go's own comment). Unlike the satellite restarts below, * this one does NOT tolerate "not found" — Go's own `RestartDatabase` has no @@ -43,7 +52,7 @@ export function legacyRestartContainer( spawner: Spawner, containerId: string, ): Effect.Effect { - return runContainerCliExpectSuccess( + return legacyRunContainerCliExpectSuccess( spawner, ["restart", containerId], "restart container", @@ -53,7 +62,7 @@ export function legacyRestartContainer( /** * One satellite service's restart, tolerant of "not found" (Go's `!errdefs.IsNotFound(err)` - * guard, `reset.go:263`) — a service excluded from the stack (e.g. `[realtime] enabled = + * guard, `reset.go:231`) — a service excluded from the stack (e.g. `[realtime] enabled = * false`) has no container to restart, and that's not an error. Never fails the surrounding * `Effect.all` itself: resolves `Option.some(message)` on a genuine failure so the caller * can join every service's outcome the way Go's `errors.Join(result...)` does, and @@ -71,7 +80,7 @@ const legacyRestartSatelliteService = ( stderr: "pipe", }); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ); if (exitCode === 0) return Option.none(); @@ -94,10 +103,14 @@ const legacyRestartSatelliteService = ( /** One or more satellite-service restarts failed. Messages are newline-joined, matching Go's `errors.Join`. */ export class LegacyRestartServicesError extends Data.TaggedError("LegacyRestartServicesError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** - * Port of Go's `restartServices` restart half (`reset.go:259-271`): restarts + * Port of Go's `restartServices` restart half (`reset.go:227-239`): restarts * storage/auth/realtime/pooler CONCURRENTLY (Go's `utils.WaitAll`, a goroutine per * service) — NOT PostgREST, which "automatically reconnects and listens for schema * changes" (Go's own comment) — and does NOT wait for them to become healthy @@ -130,7 +143,7 @@ function legacyRestartSatelliteServices( /** * Gateway-recovery hint, byte-matching Go's `suggestKongRecovery` - * (`reset.go:307-317`): rendered as a `Suggestion:` line by `Output.fail`, mirroring + * (`reset.go:281-288`): rendered as a `Suggestion:` line by `Output.fail`, mirroring * `utils.CmdSuggestion`. */ function legacyKongRecoverySuggestion(kongId: string): string { @@ -146,7 +159,11 @@ function legacyKongRecoverySuggestion(kongId: string): string { export class LegacyKongReloadError extends Data.TaggedError("LegacyKongReloadError")<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** `docker exec `, combined stdout+stderr into one buffer — mirrors Go's shared `io.Writer` in `DockerExecOnceWithStream(ctx, KongId, "", nil, cmd, &out, &out)`. Never fails the Effect itself: a spawn failure (no docker/podman) folds into `exitCode: 1`. */ function legacyExecCaptureCombined( @@ -164,8 +181,8 @@ function legacyExecCaptureCombined( const [exitCode, stdout, stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), - collectText(child.stdout), - collectText(child.stderr), + legacyCollectText(child.stdout), + legacyCollectText(child.stderr), ], { concurrency: "unbounded" }, ); @@ -179,7 +196,7 @@ function legacyExecCaptureCombined( } /** - * Port of Go's `reloadKong` (`reset.go:285-305`): inspect Kong's container — not + * Port of Go's `reloadKong` (`reset.go:253-276`): inspect Kong's container — not * found means Kong is excluded from the stack (`return nil`, not an error); any OTHER * inspect failure is wrapped with the recovery suggestion; not running means there's * no stale cache to flush (`return nil`); otherwise `docker exec kong reload @@ -218,7 +235,7 @@ function legacyReloadKong( // Go's `DockerExecOnceWithStream` (`utils/docker.go:646-648`) sets a FIXED constant // error, `errors.New("error executing command")`, for `iresp.ExitCode > 0` — not the // exit code itself. `reloadKong` then wraps it as `failed to reload kong: %w[:\n%s]` - // (`reset.go:298-303`), so the `%w` slot is always this exact string, never `exit N`. + // (`reset.go:269-274`), so the `%w` slot is always this exact string, never `exit N`. return yield* Effect.fail( new LegacyKongReloadError({ message: @@ -233,7 +250,7 @@ function legacyReloadKong( } /** - * Port of Go's `restartServices` (`reset.go:259-273`): the satellite restarts above, + * Port of Go's `restartServices` (`reset.go:227-241`): the satellite restarts above, * then {@link legacyReloadKong} — ONLY when every restart succeeded (Go returns the * joined restart error immediately, without ever attempting the Kong reload). */ diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts new file mode 100644 index 0000000000..f4609a8226 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -0,0 +1,711 @@ +/** + * Native TypeScript port of Go's shadow-database provisioning primitives + * (`apps/cli-go/internal/db/diff/diff.go:138-209`) — CLI-1956. These are the low-level + * building blocks; `legacyPrepareRawShadow` below (create -> health-wait, no platform + * baseline) is one of the two composed shapes `db diff`/`db pull` actually call (Go's + * `PrepareRawShadow`, `apps/cli-go/internal/db/diff/shadow.go:93-116`) — it has zero + * pg-delta/declarative dependency, so it lives here rather than in + * `commands/db/shared/legacy-shadow-source.ts`, which owns the OTHER composed shape + * (`legacyPrepareShadowSource`, Go's `PrepareShadowSource`) precisely because that one also + * needs the `--target-local` declarative-schema branch and pg-delta, which this module — + * deliberately kept dependency-light, like every other `shared/db-bootstrap/` module — does + * not. + * + * Exposed separately (not fused into one monolithic function) because the composed shapes + * Go itself has are NOT all the same: `migration squash` (a future port, CLI-1969) only ever + * needs create -> health-wait -> connect -> `SetupDatabase` (no `CREATE_TEMPLATE`, no + * migrations at that point — `apps/cli-go/internal/migration/squash/squash.go:83-96`), while + * `db diff --use-pgadmin` (CLI-1968, realized: see `diff.handler.ts`'s pgadmin branch) needs + * create -> health-wait -> `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/ + * pgadmin.go:70-78`). Exposing every primitive individually lets each future caller compose + * exactly the subset it needs, matching Go's own module shape 1:1 rather than forcing every + * caller through one shape only `db diff`/`db pull` happen to need. + * + * A note on the shadow container's own addressing, since it's the one genuinely surprising + * empirical fact this whole module depends on: the shadow container is created with NO name + * (Docker auto-generates one) and NO network alias (`legacyBuildShadowPostgresContainerSpec`), + * unlike every other container this codebase creates. The PG15+ one-shot setup jobs + * (`legacySetupDatabase` -> `initSchema15`) still need SOME hostname to reach it over the + * shared Docker network, though — Go passes `container[:12]` (the container id's own 12-char + * short form) as that hostname (`diff.go:172`, `squash.go:96`). This was verified empirically + * against a real Docker daemon (matching Go's exact container-creation shape: no `--name`, no + * `--network-alias`, joined to a user-defined network via `NetworkMode` alone): `docker + * inspect`'s `NetworkSettings.Networks..DNSNames` lists BOTH the auto-generated name AND + * the 12-char short id, and a sibling container on the same network successfully resolved and + * authenticated against Postgres using ONLY the short id as hostname. So `dbHost: + * container.slice(0, 12)` below is not a guess — it is the exact mechanism Go itself relies on. + */ + +import { + Data, + Effect, + type Option, + Schedule, + type FileSystem, + type Path, + type Scope, +} from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { Output } from "../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; +import { + legacyCollectText, + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "../legacy-container-cli.ts"; +import type { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "../legacy-db-connection.service.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; +import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; +import { legacyApplyMigrations } from "../legacy-migration-apply.ts"; +import type { LegacyVaultSecret } from "../legacy-vault.ts"; +import { + legacyEnsureNetwork, + legacyCreateContainer, + LEGACY_COMPOSE_PROJECT_LABEL, + type LegacyContainerOpts, +} from "./container-lifecycle.ts"; +import type { LegacyImagePrepullError } from "./image-prepull.ts"; +import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { legacyWaitForHealthyServices } from "./health-check.ts"; +import type { LegacyLocalDbContainerInputs } from "./local-container-inputs.ts"; +import { legacyListLocalMigrationPaths } from "../legacy-migration-history.ts"; +import { legacyToPostgresURL } from "../legacy-postgres-url.ts"; +import { + type LegacyFreshDbSetupInput, + type LegacySetupDatabaseInput, + type LegacyStartDbSetupImages, + type LegacyStartSetupLocalDatabaseError, + legacyResolveDbSetupPrelude, + legacySetupDatabase, +} from "./db-setup.ts"; +import { + legacyBuildShadowPostgresContainerSpec, + type LegacyShadowPostgresContainerSpecInput, +} from "./postgres.service.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * Creating, connecting to, setting up, or migrating the shadow database failed. Kept in + * `shared/db-bootstrap/` (not `commands/db/shared/legacy-pgdelta.errors.ts`'s + * `LegacyDeclarativeShadowDbError`) so these primitives stay usable by future callers outside + * the `db diff`/`db pull` family (`migration squash`, `db diff --use-pgadmin`) without pulling + * in a pg-delta-family-specific error type — see this module's own header. + */ +export class LegacyShadowDbError extends Data.TaggedError("LegacyShadowDbError")<{ + readonly message: string; + readonly reason: + | "connect" + | "docker_daemon" + | "container_configuration" + | "port_conflict" + | "filesystem" + | "database"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "connect": + return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + case "docker_daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "port_conflict": + return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; + case "filesystem": + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + case "database": + return { ...actionability.dbFinding, fingerprint_suffix: "database" }; + default: + return { ...actionability.invalidConfig, fingerprint_suffix: "container_configuration" }; + } + } +} + +/** Carries `container-lifecycle.ts`'s own error classification through the shadow wrapper. */ +const legacyShadowContainerReason = ( + reason: "runtime" | "configuration" | "filesystem" | "port_conflict", +): LegacyShadowDbError["reason"] => { + switch (reason) { + case "runtime": + return "docker_daemon"; + case "filesystem": + return "filesystem"; + case "port_conflict": + return "port_conflict"; + default: + return "container_configuration"; + } +}; + +/** + * Required to bypass the pg_cron check + * (https://github.com/citusdata/pg_cron/blob/main/pg_cron.sql#L3). Go's `CREATE_TEMPLATE` + * (`apps/cli-go/internal/db/diff/diff.go:164`). + */ +export const LEGACY_SHADOW_CREATE_TEMPLATE_SQL = + "CREATE DATABASE contrib_regression TEMPLATE postgres"; + +/** + * Go's `ConnectShadowDatabase`'s fixed timeout — 10 seconds, EVERY real Go caller + * (`apps/cli-go/internal/db/diff/diff.go:187,200`, `internal/migration/squash/squash.go:91`) + * passes the same `10*time.Second` literal. + */ +export const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; + +/** + * Go's `NewBackoffPolicy(ctx, timeout)` (`apps/cli-go/internal/db/start/start.go:192-198`): a + * 1-second constant delay, capped at `timeout` (in whole seconds) retries after the initial + * attempt. + */ +const LEGACY_SHADOW_CONNECT_SCHEDULE = Schedule.max([ + Schedule.spaced("1 seconds"), + Schedule.recurs(LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS), +]); + +/** + * Port of Go's `ConnectShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:153-161`): a + * SECOND, independent connect-retry loop layered ON TOP OF the container health wait the + * caller already ran (`start.WaitForHealthyService`) — a healthy Postgres healthcheck doesn't + * guarantee the very next connection attempt succeeds instantly, so Go retries the connect + * itself too, constant 1s backoff, up to {@link LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS} retries. + * Scoped: the returned session's connection closes when the caller's scope closes, matching + * Go's `defer conn.Close(context.Background())` at each real call site. + */ +export const legacyConnectShadowDatabase = ( + cfg: LegacyPgConnInput, +): Effect.Effect => + Effect.gen(function* () { + const dbConnection = yield* LegacyDbConnection; + return yield* dbConnection.connect(cfg, { isLocal: true, dnsResolver: "native" }).pipe( + Effect.mapError( + (cause) => new LegacyShadowDbError({ message: cause.message, reason: "connect" }), + ), + Effect.retry({ schedule: LEGACY_SHADOW_CONNECT_SCHEDULE }), + ); + }); + +/** + * Input to {@link legacyCreateShadowDatabase} — the subset of the real `db` container's own + * bootstrap inputs the shadow variant needs, plus its own host port. See + * {@link LegacyShadowPostgresContainerSpecInput} (the container-spec shape this wraps) for + * the field-by-field Go citations. + */ +export interface LegacyCreateShadowDatabaseInput extends LegacyShadowPostgresContainerSpecInput { + /** Go's `Config.ProjectId` — merged onto the shadow's own labels (`DockerStart`'s unconditional label assignment) and the network-create call, matching every other container this codebase creates. */ + readonly projectId: string; + readonly isBitbucketPipeline: boolean; + readonly workdir: string; + readonly extraHosts: ReadonlyArray; +} + +/** Resolved by {@link legacyCreateShadowDatabase} — everything a caller needs to both use and later tear down the shadow. */ +export interface LegacyShadowDatabaseHandle { + /** Docker always returns the id from `docker create`, regardless of whether `--name` was passed. */ + readonly containerId: string; +} + +/** + * Port of Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`): + * ensures the local Docker network exists (Go's `DockerStart` calls + * `DockerNetworkCreateIfNotExists` on EVERY invocation, unlike the `start`/`reset` + * compositions, which hoist this to run once per orchestrated run — `db diff`/`db pull` have + * no such orchestrator, so this mirrors Go's own per-call behavior instead), then creates + + * starts the shadow container. + * + * Leak window (deliberate Go parity, not a bug — the canonical explanation every call site + * below cross-references): every real caller runs this whole function as the `acquire` of an + * `Effect.acquireUseRelease` whose `release` is {@link legacyRemoveShadowDatabase} (see + * `diff.handler.ts`/`pull.handler.ts`/`legacy-pgdelta.cache.ts`'s call sites). Effect only + * registers `release` once `acquire` itself resolves successfully; an `acquire` that fails + * partway through — `docker create` having already succeeded, but the LATER `docker + * cp`/`docker start` step inside {@link legacyCreateContainer} then failing + * (`container-lifecycle.ts`) — has, by definition, nothing for `release` to tear down, so the + * already-created container is never removed here. This matches Go exactly: `DockerStart` + * returns `(resp.ID, err)` from the SAME function that calls `ContainerCreate` then + * `ContainerStart` (`apps/cli-go/internal/utils/docker.go:420-436`), and + * `PrepareShadowSource`/`CreateShadowDatabase`'s own Go callers only register their `defer + * DockerRemove(shadow)` AFTER a successful return — on a `DockerStart` error, the returned id + * is discarded before that `defer` is ever reached (`internal/db/diff/shadow.go:38-41`), + * leaking the container identically. Not worth a bespoke "clean up whatever `docker create` + * already made" path just to be stricter than Go's own upstream behavior here. + */ +export const legacyCreateShadowDatabase = ( + spawner: Spawner, + input: LegacyCreateShadowDatabaseInput, +): Effect.Effect => + Effect.gen(function* () { + const labels = { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }; + yield* legacyEnsureNetwork(spawner, input.networkId, labels).pipe( + Effect.mapError( + (cause) => + new LegacyShadowDbError({ + message: cause.message, + reason: legacyShadowContainerReason(cause.reason), + }), + ), + ); + const spec = legacyBuildShadowPostgresContainerSpec(input); + // The shadow container has no name (Docker auto-generates one) and no network alias — + // see this module's own header for why that's still enough for the shadow's own one-shot + // setup jobs to reach it. The pgsodium root key itself (PG15+ only) never touches host + // disk at all — it's delivered straight into the container via `docker cp` + // ({@link LegacyStartContainerSpec.secretFiles}, `container-lifecycle.ts`), same as every + // other container's secrets. + const containerOpts: LegacyContainerOpts = { + projectId: input.projectId, + isBitbucketPipeline: input.isBitbucketPipeline, + workdir: input.workdir, + extraHosts: input.extraHosts, + }; + const containerId = yield* legacyCreateContainer(spawner, spec, containerOpts).pipe( + Effect.mapError( + (cause) => + new LegacyShadowDbError({ + message: cause.message, + reason: legacyShadowContainerReason(cause.reason), + }), + ), + ); + return { containerId }; + }); + +/** + * Port of Go's `utils.DockerRemove(shadow)` as called by every shadow caller + * (`apps/cli-go/internal/db/diff/diff.go:217`, `shadow.go:45,103`, + * `internal/migration/squash/squash.go:87`): `RemoveOptions{RemoveVolumes: true, Force: + * true}` via `docker rm -f -v `. Best-effort for the OVERALL operation — Go's own + * `DockerRemove` swallows the removal's ERROR RETURN (it has no return value at all), so a + * failure here must never mask whatever the caller was doing with the shadow — but it does + * NOT swallow the message: Go prints `"Failed to remove container:", containerId, err` to + * stderr on failure (`apps/cli-go/internal/utils/docker.go:442-449`), so this does the same + * before continuing. That includes a failure to even launch/collect the removal itself (the + * container CLI missing, a disconnected runtime, a stream-read error) — Go's single + * `Docker.ContainerRemove` SDK call folds every one of those causes into the same `err` it + * prints, so this catches {@link spawnContainerCli}/exit-code-collection failures the same way + * {@link legacyRestartSatelliteService} does (`restart-services.ts`), via + * {@link legacyDescribeContainerCliFailure}, rather than discarding them unreported. + */ +export const legacyRemoveShadowDatabase = ( + spawner: Spawner, + containerId: string, +): Effect.Effect => + Effect.gen(function* () { + if (containerId.length === 0) return; + const failureMessage = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["rm", "-f", "-v", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], + { concurrency: "unbounded" }, + ); + return exitCode === 0 ? undefined : stderr.trim(); + }), + ).pipe(Effect.catch((cause) => Effect.succeed(legacyDescribeContainerCliFailure(cause)))); + if (failureMessage !== undefined) { + const output = yield* Output; + yield* output.raw(`Failed to remove container: ${containerId} ${failureMessage}\n`, "stderr"); + } + }); + +/** A live shadow database left running for the caller to diff against and remove. Mirrors Go's `ShadowSource`. */ +export interface LegacyShadowSourceResult { + /** Container id; the caller MUST remove it (`legacyRemoveShadowDatabase`) when done. */ + readonly container: string; + /** The diff source Postgres URL (the provisioned shadow). */ + readonly sourceUrl: string; + /** + * When set, replaces the diff target with a second database on the SAME shadow container + * (`contrib_regression`, cloned from `postgres` by `CREATE_TEMPLATE` during shadow setup — + * see {@link legacySetupShadowConn}) with declarative schemas applied. Mirrors Go's + * local-target declarative branch, where the user's local DB is not diffed. Only ever set + * by `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` — {@link legacyPrepareRawShadow} + * below always leaves this `undefined`. + */ + readonly targetUrlOverride: string | undefined; +} + +/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput`/{@link LegacyPrepareRawShadowInput}. */ +export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly hostname: string; + /** `[db] password` (already resolved from `config.toml`) — the shadow's own connect password. */ + readonly password: string; + readonly healthTimeoutSeconds: number; +} + +export type LegacyPrepareRawShadowInput = LegacyShadowConnectionInput; + +/** + * {@link LegacyShadowConnectionInput} plus the platform-baseline setup fields + * {@link legacySetupDatabase}/`legacyMigrateShadowDatabase`/`legacySetupShadowDatabase` need — + * the full shape {@link legacyShadowRunInputFromLocalContainerInputs} returns. Named here + * (CLI-1969) rather than as an `Omit<...>` of a diff/pull-specific type, so `migration squash` + * — which has none of the diff/pull-specific fields (`targetLocal`/`usePgDelta`/`schemaPaths`/ + * `pgDelta`/`ctx`) — can consume the promoted function's return value directly, with no `as` + * cast. `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` extends this with + * those extra fields instead of duplicating the `setup` field itself. + */ +export interface LegacyShadowSetupInput extends LegacyShadowConnectionInput { + readonly setup: LegacyShadowDbSetupInput; +} + +/** + * Adapts {@link LegacyLocalDbContainerInputs} (`local-container-inputs.ts`, the SAME + * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own + * already-loaded `config.toml` slice into {@link LegacyShadowSetupInput} — every field + * `legacyPrepareShadowSource`/{@link legacyPrepareRawShadow} (`legacy-shadow-source.ts`/this + * module) or `migration squash`'s own shadow composition need EXCEPT the diff/pull-specific + * ones (`targetLocal`/`usePgDelta`/`schemaPaths`/`pgDelta`/`ctx`/`setup`, left to each call + * site — `legacy-shadow-source.ts` adds its own on top of this). Promoted here from + * `commands/db/shared/legacy-shadow-source.ts` (CLI-1969, hoist-before-duplicate): `migration + * squash` needs this same shadow run-input shape, but importing the `db`-family-scoped + * `legacy-shadow-source.ts` would drag its whole pg-delta/migra/declarative stack into a + * command that has no diff engine at all. `legacy-shadow-source.ts` re-exports this function + * unchanged so `db diff`/`db pull` keep compiling with a one-line import change. + * + * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight + * through to `legacyBuildLocalDbContainerInputs` (its own `projectRef` parameter — see + * that function's doc comment), which threads it into `legacyLoadLocalProjectContext` -> + * `loadProjectConfig({ projectRef })`. So the shadow's OWN container config (image, JWT + * secret, root key, `db.settings`, service enabled-for-setup flags, sourced from + * `localInputs.context.config`/`postgresSpecBase`) reflects the matching `[remotes.]` + * override, same as `toml` (the caller's own `legacyReadDbToml(..., linkedRef)` result, + * which feeds `pgDelta`/vault/`apiAutoExposeNewTables` below) — matching Go's own uniform + * remote-merge on the linked path (`LoadConfig` seeds `flags.ProjectRef` before every field + * read). The two config reads still go through independent remote-merge implementations + * (`@supabase/config`'s `applyRemoteOverride` for `localInputs.context.config`; + * `legacy-db-config.toml-read.ts`'s own TOML-based merge for `toml`) rather than a single + * shared decode — unifying those is a larger, out-of-scope refactor, not a per-command gap. + */ +export function legacyShadowRunInputFromLocalContainerInputs( + localInputs: LegacyLocalDbContainerInputs, + resolvedImage: string, + toml: { + readonly shadowPort: number; + readonly password: string; + readonly baseline: { readonly apiAutoExposeNewTables: Option.Option }; + readonly vault: ReadonlyArray; + }, + fs: FileSystem.FileSystem, + path: Path.Path, +): LegacyShadowSetupInput { + const { postgresSpecBase } = localInputs; + return { + db: { + major_version: postgresSpecBase.db.major_version, + settings: postgresSpecBase.db.settings, + }, + experimental: postgresSpecBase.experimental, + jwtSecret: postgresSpecBase.jwtSecret, + jwtExpiry: postgresSpecBase.jwtExpiry, + networkId: localInputs.networkId, + image: resolvedImage, + configImage: postgresSpecBase.configImage, + rootKey: postgresSpecBase.rootKey, + shadowPort: toml.shadowPort, + projectId: localInputs.context.projectId, + isBitbucketPipeline: localInputs.containerOpts.isBitbucketPipeline, + workdir: localInputs.containerOpts.workdir, + extraHosts: localInputs.containerOpts.extraHosts, + fs, + path, + hostname: localInputs.context.hostname, + password: toml.password, + healthTimeoutSeconds: localInputs.dbHealthTimeoutSeconds, + setup: { + majorVersion: localInputs.setup.majorVersion, + config: localInputs.setup.config, + // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own + // hardcoded-"postgres" password (`legacy-local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), + // for a DIFFERENT container. The shadow's own one-shot setup jobs + // (`legacyBuildShadowSetupDatabaseInput`) only ever consume this `dbUrl` to extract a + // password (`legacyStartInternalDbPassword`) for the SHADOW they actually run against, so + // it must carry the SAME resolved `toml.password` the shadow container itself is + // initialized with (see `legacyBuildShadowPostgresContainerSpec`) — otherwise a non-default + // `[db] password` authenticates against the wrong secret and every setup job fails. + dbUrl: legacyToPostgresURL({ + host: localInputs.context.hostname, + port: toml.shadowPort, + user: "postgres", + password: toml.password, + database: "postgres", + }), + jwtSecret: localInputs.setup.jwtSecret, + jwks: localInputs.setup.jwks, + apiUrl: localInputs.setup.apiUrl, + authExternalUrl: localInputs.setup.authExternalUrl, + siteUrl: localInputs.setup.siteUrl, + anonKey: localInputs.setup.anonKey, + serviceRoleKey: localInputs.setup.serviceRoleKey, + storageTargetMigration: localInputs.setup.storageTargetMigration, + realtimeEnabledForSetup: localInputs.setup.realtimeEnabledForSetup, + storageEnabledForSetup: localInputs.setup.storageEnabledForSetup, + authEnabledForSetup: localInputs.setup.authEnabledForSetup, + serviceVersionOverrides: localInputs.setup.serviceVersionOverrides, + projectEnvValues: localInputs.setup.projectEnvValues, + debug: localInputs.setup.debug, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + }, + }; +} + +/** + * Port of Go's `PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go:93-116`): health-wait + * against an already-{@link legacyCreateShadowDatabase}-created shadow (created + healthy, no + * platform baseline or migrations applied) — used inline (`db pull --declarative`'s empty + * declarative-export source), not the `ok`-sentinel error-path pattern + * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` uses, since there is only ONE step + * here that can fail (the health wait) rather than several. Lives here (not + * `legacy-shadow-source.ts`) because it has zero pg-delta/declarative dependency — see this + * module's own header. + * + * Deliberately does NOT call {@link legacyCreateShadowDatabase} itself — the caller does, as the + * `acquire` of an `Effect.acquireUseRelease` whose `use` phase is this function (see + * `diff.handler.ts`/`pull.handler.ts`'s call sites). Go's `PrepareRawShadow` threads a single + * cancellable `ctx` through both creation and the health wait, so a SIGINT can interrupt either; + * an earlier shape here instead passed the WHOLE create-then-health-wait effect as `acquire`, + * which Effect's `uninterruptibleMask` (`acquireUseRelease(acquire, use, release) => + * uninterruptibleMask(restore => flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`) + * makes entirely uninterruptible — a SIGINT during the health wait (which can run for up to + * `healthTimeoutSeconds`) was silently swallowed until the wait finished or timed out on its + * own, unlike Go. Splitting `legacyCreateShadowDatabase` out as the (brief, Docker-API-bound) + * `acquire` and keeping this health-wait as part of the interruptible `use` restores that parity + * — a SIGINT here now lands immediately, same as Go's ctx cancellation, while + * `legacyRemoveShadowDatabase` still runs as the `release` finalizer regardless of how `use` + * exits (review: PRRT_kwDOErm0O86XMrID). + */ +export const legacyPrepareRawShadow = ( + spawner: Spawner, + handle: LegacyShadowDatabaseHandle, + input: LegacyPrepareRawShadowInput, +): Effect.Effect< + LegacyShadowSourceResult, + LegacyHealthCheckTimeoutError, + Output | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + Effect.gen(function* () { + const { containerId } = handle; + yield* legacyWaitForHealthyServices(spawner, [containerId], { + timeoutSeconds: input.healthTimeoutSeconds, + }); + const connConfig: LegacyPgConnInput = { + host: input.hostname, + port: input.shadowPort, + user: "postgres", + password: input.password, + database: "postgres", + }; + return { + container: containerId, + sourceUrl: legacyToPostgresURL(connConfig), + targetUrlOverride: undefined, + }; + }); + +/** + * Port of Go's `setupShadowConn` (`apps/cli-go/internal/db/diff/diff.go:171-179`): + * {@link legacySetupDatabase} (Go's `SetupDatabase`) against an already-connected shadow, + * dialed at `input.dbHost` = `container.slice(0, 12)` (see this module's own header), then + * unconditionally {@link LEGACY_SHADOW_CREATE_TEMPLATE_SQL} — every real Go caller of + * `setupShadowConn` itself (`SetupShadowDatabase`/`MigrateShadowDatabase` below) always + * creates the template database; a future caller that only needs the bare `SetupDatabase` + * step (`migration squash`, which calls `start.SetupDatabase` DIRECTLY, bypassing + * `setupShadowConn` entirely — `squash.go:96`) calls {@link legacySetupDatabase} on its own + * instead, so this function stays the exact `setupShadowConn` shape without a parameter for + * a branch no real caller of THIS function takes. + */ +export const legacySetupShadowConn = ( + spawner: Spawner, + input: LegacySetupDatabaseInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError, + Output | LegacyDockerRun | RuntimeInfo +> => + Effect.gen(function* () { + yield* legacySetupDatabase(spawner, input); + yield* input.session.exec(LEGACY_SHADOW_CREATE_TEMPLATE_SQL).pipe( + Effect.mapError( + (cause) => + new LegacyShadowDbError({ + message: `failed to create template database: ${errMessage(cause)}`, + reason: "database", + }), + ), + ); + }); + +/** + * Shared fields both {@link legacySetupShadowDatabase} and {@link legacyMigrateShadowDatabase} + * need to resolve JWKS/images and run {@link legacySetupDatabase} — derived from `db-setup.ts`'s + * `LegacyFreshDbSetupInput` (the exact same shape `legacyRunFreshDbSetup` resolves for the real + * local `db` container) rather than hand-copied, so the two never silently drift: swap + * `experimental` (which only `legacyStartSetupLocalDatabase`'s trailing `MigrateAndSeed` call + * needs — irrelevant to the shadow's `SetupDatabase`-only pipeline, see {@link + * LegacySetupDatabaseInput}'s own doc comment) for the two fields the shadow's own caller + * (`legacy-shadow-source.ts`) resolves from an already-loaded `config.toml` instead + * (`apiAutoExposeNewTables`/`vault`), threaded straight through here rather than re-read. + */ +export type LegacyShadowDbSetupInput = Omit, "experimental"> & { + readonly apiAutoExposeNewTables: LegacySetupDatabaseInput["apiAutoExposeNewTables"]; + readonly vault: LegacySetupDatabaseInput["vault"]; +}; + +/** Common caller-supplied plumbing for {@link legacySetupShadowDatabase}/{@link legacyMigrateShadowDatabase}. */ +interface LegacyShadowSetupRunInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + /** Go's `Config.ProjectId` — labels the shadow's own PG15+ one-shot migrate job containers, same as the real local `db` container's — see {@link LegacySetupDatabaseInput.projectId}'s own doc comment. */ + readonly projectId: string; + readonly container: string; + readonly networkId: string; + /** The shadow's own connect target — host/port/user/password/database (`postgres`/`postgres`). */ + readonly connConfig: LegacyPgConnInput; + readonly setup: LegacyShadowDbSetupInput; +} + +/** + * Builds a {@link LegacySetupDatabaseInput} for {@link legacySetupDatabase} out of an + * already-connected shadow session plus the resolved images/JWKS prelude — exported so a + * future caller that only needs `SetupDatabase` directly (`migration squash`, which calls + * Go's `start.SetupDatabase` without going through `setupShadowConn` at all — see {@link + * legacySetupShadowConn}'s own doc comment) can build this same shape without duplicating the + * `container[:12]` dbHost derivation. + */ +export const legacyBuildShadowSetupDatabaseInput = ( + input: LegacyShadowSetupRunInput, + session: LegacyDbSession, + resolved: { readonly jwks: string; readonly images: LegacyStartDbSetupImages }, +): LegacySetupDatabaseInput => ({ + session, + fs: input.fs, + path: input.path, + workdir: input.workdir, + config: input.setup.config, + majorVersion: input.setup.majorVersion, + // Go's `container[:12]` — see this module's own header for why this resolves as a + // hostname at all despite the shadow container having no name/alias. + dbHost: input.container.slice(0, 12), + projectId: input.projectId, + networkId: input.networkId, + dbUrl: input.setup.dbUrl, + jwtSecret: input.setup.jwtSecret, + jwks: resolved.jwks, + apiUrl: input.setup.apiUrl, + authExternalUrl: input.setup.authExternalUrl, + siteUrl: input.setup.siteUrl, + anonKey: input.setup.anonKey, + serviceRoleKey: input.setup.serviceRoleKey, + storageTargetMigration: input.setup.storageTargetMigration, + images: resolved.images, + projectEnvValues: input.setup.projectEnvValues, + debug: input.setup.debug, + apiAutoExposeNewTables: input.setup.apiAutoExposeNewTables, + vault: input.setup.vault, +}); + +/** + * Port of Go's `SetupShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:181-193`): + * connects to the shadow (Go's `ConnectShadowDatabase`, {@link legacyConnectShadowDatabase}) + * FIRST, THEN resolves the setup prelude (JWKS/pinned image names, {@link + * legacyResolveDbSetupPrelude}) and runs {@link legacySetupShadowConn} — the platform + * baseline plus the template database, no user migrations. Connect-then-setup, matching Go's + * own `SetupShadowDatabase` (which dials `ConnectShadowDatabase` before ever calling + * `start.SetupDatabase`, `diff.go:186-192`) and this same module's `legacyRunFreshDbSetup` + * (`db-setup.ts`) for the real local `db` container: an unconnectable shadow must surface a + * connect error immediately, not pay for JWKS work first. The connection is closed once this + * resolves (Go's `defer conn.Close(...)`), matching `Effect.scoped`'s finalizer running at the + * end of this function rather than leaking a `Scope.Scope` requirement to the caller. + */ +export const legacySetupShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => + Effect.scoped( + Effect.gen(function* () { + const session = yield* legacyConnectShadowDatabase(input.connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupShadowConn( + spawner, + legacyBuildShadowSetupDatabaseInput(input, session, resolved), + ); + }), + ); + +/** + * Port of Go's `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:195-209`): + * lists local migrations FIRST (Go's `migration.ListLocalMigrations`, fails fast on a bad + * migrations directory before any DB connection is even attempted), THEN connects (Go's + * `ConnectShadowDatabase`), THEN resolves the setup prelude (JWKS/pinned image names, {@link + * legacyResolveDbSetupPrelude}) and sets up the platform baseline + template database ({@link + * legacySetupShadowConn}), then applies every listed migration (Go's + * `migration.ApplyMigrations`). Connect-then-setup (not the reverse) matches Go's own + * `MigrateShadowDatabase` (`diff.go:195-209`) and this same module's `legacyRunFreshDbSetup` + * (`db-setup.ts`) for the real local `db` container — see {@link legacySetupShadowDatabase}'s + * own doc comment for why the ordering matters. Connection closed once this resolves, matching + * Go's `defer conn.Close(...)`. + */ +export const legacyMigrateShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => + Effect.scoped( + Effect.gen(function* () { + const migrationsDir = input.path.join(input.workdir, "supabase", "migrations"); + const pending = yield* legacyListLocalMigrationPaths( + input.fs, + input.path, + migrationsDir, + ).pipe( + Effect.mapError( + (cause) => new LegacyShadowDbError({ message: cause.message, reason: "filesystem" }), + ), + ); + + const session = yield* legacyConnectShadowDatabase(input.connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupShadowConn( + spawner, + legacyBuildShadowSetupDatabaseInput(input, session, resolved), + ); + yield* legacyApplyMigrations( + session, + input.fs, + input.path, + pending, + (message) => new LegacyShadowDbError({ message, reason: "database" }), + ); + }), + ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts new file mode 100644 index 0000000000..b303ae756a --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts @@ -0,0 +1,727 @@ +import type { ProjectConfig } from "@supabase/config"; +import { ProjectConfigSchema } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Fiber, Layer, Option, Path, Schema, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; +import { legacyContainerRuntimeNotFoundMessage } from "../legacy-container-cli.ts"; +import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbConnection } from "../legacy-db-connection.service.ts"; +import { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; +import type { LegacySetupDatabaseInput } from "./db-setup.ts"; +import { LEGACY_SHADOW_ENTRYPOINT_ARGS } from "./postgres.service.ts"; +import { + LEGACY_SHADOW_CREATE_TEMPLATE_SQL, + LegacyShadowDbError, + legacyBuildShadowSetupDatabaseInput, + legacyConnectShadowDatabase, + legacyCreateShadowDatabase, + legacyMigrateShadowDatabase, + legacyRemoveShadowDatabase, + legacySetupShadowConn, + legacySetupShadowDatabase, + type LegacyCreateShadowDatabaseInput, + type LegacyShadowDbSetupInput, +} from "./shadow-database.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const defaultConfig: ProjectConfig = decodeConfig({}); + +const tempRoot = useLegacyTempWorkdir("legacy-shadow-database-"); + +function fakeSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + calls.push({ kind: "exec", sql }); + }), + query: (sql) => + Effect.sync(() => { + calls.push({ kind: "query", sql }); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +function mockDbConnection(session: LegacyDbSession) { + return Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed(session) }); +} + +/** + * A `LegacyDbConnection` whose `connect` fails with `LegacyDbConnectError` on + * the first `failTimes` calls, then succeeds with `session` on every call + * after that (`failTimes: Number.POSITIVE_INFINITY` never succeeds at all) — + * for pinning {@link legacyConnectShadowDatabase}'s retry-schedule ATTEMPT + * COUNT precisely, not merely "it eventually succeeds"/"it eventually fails". + */ +function mockFlakyDbConnection(session: LegacyDbSession, failTimes: number) { + let attempts = 0; + return { + layer: Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.suspend(() => { + attempts++; + return attempts <= failTimes + ? Effect.fail(new LegacyDbConnectError({ message: "connection refused" })) + : Effect.succeed(session); + }), + }), + get attempts() { + return attempts; + }, + }; +} + +function mockDockerRun() { + const runs: Array = []; + return Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(0), + runCapture: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ exitCode: 0, stdout: new Uint8Array(), stderr: "" }); + }, + // The shadow's own PG15+ one-shot platform-baseline jobs (`legacyRunStartMigrateJob`) + // go through `runStream`, not `runCapture` — see `db-setup.ts`'s own doc comment. + runStream: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ exitCode: 0, stderr: "" }); + }, + }); +} + +/** Fakes `docker image inspect` (always cached), `network inspect`/`create`, `create` (returns a fixed id), `start`, and `rm`. */ +function mockSpawner() { + const spawned: Array> = []; + const encoder = new TextEncoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + // `legacyEnsureNetwork` probes with `network inspect` before ever creating one — report + // it as missing so a `legacyCreateShadowDatabase` call actually reaches `network create`, + // rather than short-circuiting on the pre-check the way an always-exit-0 mock would (the + // ONLY caller of this mock that ever spawns `network`/`create` args at all). + const exitCode = args[0] === "network" && args[1] === "inspect" ? 1 : 0; + const stdout = args[0] === "create" ? "shadow-container-id-0123456789abcdef" : ""; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(stdout.length > 0 ? [encoder.encode(`${stdout}\n`)] : []), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, spawned }; +} + +/** + * A spawner that fails to even launch a process — for both `docker` and + * `podman` — mirroring the "daemon not on PATH" scenario `health-check.unit.test.ts` + * scripts for `legacyWaitForHealthyServices`. Every `spawner.spawn` call fails + * before ever returning a handle, so `legacySpawnContainerCliWithRuntime` + * exhausts both runtimes and surfaces `LegacyContainerRuntimeNotFoundError`. + */ +function mockUnspawnableSpawner() { + return ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker: command not found", + }), + ), + ); +} + +function baseCreateInput( + overrides: Partial = {}, +): LegacyCreateShadowDatabaseInput { + return { + db: { major_version: 17, settings: {} }, + experimental: defaultConfig.experimental, + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + networkId: "supabase_network_proj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + shadowPort: 54320, + password: "postgres", + projectId: "proj", + isBitbucketPipeline: false, + workdir: tempRoot.current, + extraHosts: [], + ...overrides, + }; +} + +describe("legacyCreateShadowDatabase / legacyRemoveShadowDatabase", () => { + it.effect( + "creates the network then the container with no --name, and returns the created id", + () => { + const mock = mockSpawner(); + return legacyCreateShadowDatabase(mock.spawner, baseCreateInput()).pipe( + Effect.map(({ containerId }) => { + expect(containerId).toBe("shadow-container-id-0123456789abcdef"); + const networkCreateIdx = mock.spawned.findIndex( + (a) => a[0] === "network" && a[1] === "create", + ); + const createIdx = mock.spawned.findIndex((a) => a[0] === "create"); + expect(mock.spawned[networkCreateIdx]).toEqual([ + "network", + "create", + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + "supabase_network_proj", + ]); + expect(networkCreateIdx).toBeGreaterThanOrEqual(0); + expect(networkCreateIdx).toBeLessThan(createIdx); + expect(mock.spawned[createIdx]).not.toContain("--name"); + expect(mock.spawned[createIdx]).toContain("--rm"); + // Go's `NewContainerConfig("-c", "max_worker_processes=0")` splice + // (`CreateShadowDatabase`, `diff.go:140`) is not a bare docker flag — it's rendered + // into the entrypoint script's own `docker-entrypoint.sh postgres -D /etc/postgresql + // ` line (the script is the LAST `docker create` argv element, `cmd`'s second + // entry). Assert it lands there, not merely that the literal string appears somewhere + // in argv. + const script = mock.spawned[createIdx]?.at(-1) ?? ""; + expect(script).toContain( + `docker-entrypoint.sh postgres -D /etc/postgresql ${LEGACY_SHADOW_ENTRYPOINT_ARGS}`, + ); + }), + ); + }, + ); + + it.effect("legacyRemoveShadowDatabase issues docker rm -f -v against the given id", () => { + const mock = mockSpawner(); + return legacyRemoveShadowDatabase(mock.spawner, "shadow-container-id-0123456789abcdef").pipe( + Effect.map(() => { + expect(mock.spawned).toContainEqual([ + "rm", + "-f", + "-v", + "shadow-container-id-0123456789abcdef", + ]); + }), + Effect.provide(mockOutput().layer), + ); + }); + + it.effect( + "legacyRemoveShadowDatabase is a pure no-op (no spawn at all) for an empty container id", + () => { + const mock = mockSpawner(); + const out = mockOutput(); + return legacyRemoveShadowDatabase(mock.spawner, "").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + expect(out.stderrText).toBe(""); + }), + Effect.provide(out.layer), + ); + }, + ); + + it.effect( + "reports (but never fails the caller for) a failure to even spawn the removal itself", + () => { + const out = mockOutput(); + return legacyRemoveShadowDatabase( + mockUnspawnableSpawner(), + "shadow-container-id-0123456789abcdef", + ).pipe( + Effect.exit, + Effect.map((exit) => { + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toBe( + `Failed to remove container: shadow-container-id-0123456789abcdef ${legacyContainerRuntimeNotFoundMessage}\n`, + ); + }), + Effect.provide(out.layer), + ); + }, + ); +}); + +const shadowConnConfig = { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +describe("legacyConnectShadowDatabase", () => { + it.effect( + "dials the shadow's own connect config and returns the session on the first successful attempt", + () => { + const { session } = fakeSession(); + return legacyConnectShadowDatabase(shadowConnConfig).pipe( + Effect.scoped, + Effect.map((resolvedSession) => { + expect(resolvedSession).toBe(session); + }), + Effect.provide(mockDbConnection(session)), + ); + }, + ); + + it.effect( + "retries a failing connect on a 1-second backoff and returns the session once it stops failing", + () => { + const { session } = fakeSession(); + const mock = mockFlakyDbConnection(session, 3); + return Effect.gen(function* () { + const fiber = yield* legacyConnectShadowDatabase(shadowConnConfig).pipe( + Effect.scoped, + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + + const resolvedSession = yield* Fiber.join(fiber); + expect(resolvedSession).toBe(session); + // 3 failed attempts, then the 4th that finally succeeds — pins the EXACT + // attempt count (a `Schedule.min` regression would also "eventually + // succeed" here, since 3 retries is well under either combinator's + // ceiling — see the always-failing case below for the test that + // actually distinguishes `min` from `max`). + expect(mock.attempts).toBe(4); + }).pipe(Effect.provide(mock.layer)); + }, + ); + + it.effect( + "gives up after exactly 11 attempts (1 initial + 10 retries) instead of retrying forever — pins Schedule.max over Schedule.min", + () => { + const { session } = fakeSession(); + // Never succeeds — pegs the retry schedule to its hard 10-retry ceiling. + // `Schedule.max` (the correct combinator: recur while BOTH inputs can + // still recur) stops here because `Schedule.recurs(10)` is exhausted. A + // regression to `Schedule.min` (recur while EITHER input can still + // recur) would keep recurring forever on `Schedule.spaced`'s unbounded + // side, so this fiber would never complete — `Fiber.join` below would + // hang/time out rather than resolve, catching exactly that swap. + const mock = mockFlakyDbConnection(session, Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + const fiber = yield* legacyConnectShadowDatabase(shadowConnConfig).pipe( + Effect.scoped, + Effect.forkChild({ startImmediately: true }), + ); + + for (let i = 0; i < 9; i++) { + yield* TestClock.adjust("1 seconds"); + } + // Not yet exhausted — 9 retries is one short of the 10-retry cap. + expect(fiber.pollUnsafe()).toBeUndefined(); + + // The 10th one-second backoff crosses the boundary. + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyShadowDbError); + expect(mock.attempts).toBe(11); + }).pipe(Effect.provide(mock.layer)); + }, + ); +}); + +function baseSetupDatabaseInput( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): LegacySetupDatabaseInput { + return { + session, + fs, + path, + workdir, + config: defaultConfig, + majorVersion: 17, + dbHost: "abcdef012345", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + apiUrl: "http://127.0.0.1:54321", + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + projectEnvValues: undefined, + debug: false, + apiAutoExposeNewTables: Option.some(true), + vault: [], + }; +} + +describe("legacySetupShadowConn", () => { + it.effect("runs SetupDatabase, then unconditionally execs CREATE_TEMPLATE", () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowConn( + mock.spawner, + baseSetupDatabaseInput(session, fs, path, workdir), + ); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll(BunServices.layer, mockOutput().layer, mockDockerRun(), mockRuntimeInfo()), + ), + ); + }); +}); + +function baseShadowSetup( + overrides: Partial> = {}, +): LegacyShadowDbSetupInput { + return { + majorVersion: 17, + config: defaultConfig, + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: Effect.succeed('{"keys":[]}') as Effect.Effect, + apiUrl: "http://127.0.0.1:54321", + authExternalUrl: undefined, + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + realtimeEnabledForSetup: false, + storageEnabledForSetup: false, + authEnabledForSetup: false, + serviceVersionOverrides: {}, + projectEnvValues: undefined, + debug: false, + apiAutoExposeNewTables: Option.some(true), + vault: [], + ...overrides, + }; +} + +describe("legacyBuildShadowSetupDatabaseInput", () => { + it.effect( + "derives dbHost from the container's own 12-char short id and threads every field through", + () => { + const { session } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const built = legacyBuildShadowSetupDatabaseInput( + { + fs, + path, + workdir: "/proj", + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }, + session, + { + jwks: '{"keys":[]}', + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + }, + ); + // Go's `container[:12]` — the future callers this was exported for (`migration + // squash`) need this exact same derivation, not a re-implementation. + expect(built.dbHost).toBe("shadow-conta"); + expect(built.session).toBe(session); + expect(built.workdir).toBe("/proj"); + expect(built.networkId).toBe("supabase_network_proj"); + expect(built.majorVersion).toBe(17); + expect(built.jwks).toBe('{"keys":[]}'); + expect(built.images.realtime).toBe("public.ecr.aws/supabase/realtime:v2.34.7"); + expect(built.apiAutoExposeNewTables).toEqual(Option.some(true)); + expect(built.vault).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); + +describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { + it.effect( + "legacySetupShadowDatabase connects, sets up the platform baseline, and creates the template database", + () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect( + "legacyMigrateShadowDatabase applies pending local migrations after the platform baseline", + () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "migrations", "20240101000000_init.sql"), + "create table t ();", + ); + yield* legacyMigrateShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + expect(calls.some((c) => c.sql.includes("create table t ()"))).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect( + "does not resolve JWKS on PG14 even when realtime is enabled (Go's initSchema never reaches ResolveJWKS for MajorVersion <= 14)", + () => { + const { session } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup({ + majorVersion: 14, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }); + expect(jwksEvaluated).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect("resolves JWKS on PG15+ when realtime is enabled", () => { + const { session } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup({ + majorVersion: 17, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }); + expect(jwksEvaluated).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }); + + it.effect( + "legacyMigrateShadowDatabase lists local migrations BEFORE connecting, tolerating a missing migrations directory as an empty list rather than a failure", + () => { + const workdir = tempRoot.current; + const mock = mockSpawner(); + // One shared, ordered log — recording both events into separate booleans (the prior + // version of this test) would still pass if the two steps were swapped, since both + // would still end up `true`; only an ordered log actually proves the sequence. + const events: Array = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.sync(() => { + events.push("connect"); + return fakeSession().session; + }), + }); + return Effect.gen(function* () { + const realFs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const fs = FileSystem.FileSystem.of({ + ...realFs, + readDirectory: (dir, opts) => { + if (dir === migrationsDir) events.push("list"); + return realFs.readDirectory(dir, opts); + }, + }); + // No `supabase/migrations` directory exists — Go's `ListLocalMigrations` on a + // missing dir resolves to an empty list (not an error), so this exercises the + // ordering guarantee (list BEFORE connect) rather than a failure path. + yield* legacyMigrateShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(events).toEqual(["list", "connect"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + dbConnection, + ), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index ea89e4175b..28be581aaf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -53,6 +53,11 @@ import type * as HttpClient from "effect/unstable/http/HttpClient"; import { Output } from "../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua } from "../legacy-colors.ts"; import { LegacyDbConnection } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; @@ -101,15 +106,19 @@ type Spawner = ChildProcessSpawner["Service"]; * has) — Go refuses outright rather than guessing which the caller wants. Raised BEFORE any * container is created (no `docker create`/`docker start` happens on this path). Only ever * reachable via `db start` (the sole caller that ever sets `postgresSpec.fromBackup`). - * Not exported outside this module — callers only ever observe it through the - * {@link LegacyStartDatabaseError} union and its `_tag`, never by importing the class. + * Exported only so the exhaustive actionability guard can inspect its declaration; + * runtime callers observe it through {@link LegacyStartDatabaseError}. */ -class LegacyStartBackupVolumeExistsError extends Data.TaggedError( +export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( "LegacyStartBackupVolumeExistsError", )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.stopStack; + } +} /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index 4fbc5f5be1..b34a8b3cba 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -1,5 +1,11 @@ import { isAbsolute, join } from "node:path"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** @@ -161,7 +167,12 @@ export function legacyParseGoBool(value: string): boolean | undefined { * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class * is a byte-identical, purely internal refactor. */ -export class LegacyConfigValidateError extends Error {} +export class LegacyConfigValidateError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyConfigValidateError"; + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ export interface LegacyApiInput { diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index d888f8993c..14c8eeb788 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -175,6 +175,29 @@ const LEGACY_DIAL_ERROR_CODES = new Set([ "EHOSTUNREACH", "EADDRNOTAVAIL", ]); +// Connect-timeout failures that carry no errno `code`, matched by their exact +// driver text: node-postgres' client connect timeout (`pg/lib/client.js`), its +// pool acquire timeout (`pg/lib/pool.js`), and this layer's own probe timeout +// (`legacyAcquireProbedPool`). All three are the port of Go's `connect_timeout` +// firing — a `context.DeadlineExceeded`, which satisfies `net.Error`. +const LEGACY_CONNECT_TIMEOUT_MESSAGES = new Set([ + "Connection timed out", + "timeout expired", + "timeout exceeded when trying to connect", +]); + +/** + * Whether a connect failure is a dial-level error — refused, timed out, or + * unreachable — rather than a server, auth, TLS, or config error. Sets + * `LegacyDbConnectError.retryable`, which the fresh-db bootstrap's connect + * retry keys off (`db-setup.ts`, #6136). + */ +export function legacyIsDialFailure(error: unknown): boolean { + const cause = legacyDeepestConnectCause(error); + if (hasStringCode(cause) && LEGACY_DIAL_ERROR_CODES.has(cause.code)) return true; + const message = typeof cause === "object" && cause !== null ? Reflect.get(cause, "message") : ""; + return typeof message === "string" && LEGACY_CONNECT_TIMEOUT_MESSAGES.has(message); +} // The complete documented Node/OpenSSL X509 certificate-verification code // family (Node tls docs "X509 certificate error codes", OpenSSL's // `X509_verify_cert_error` set), complemented by node's ERR_TLS_*/ERR_SSL_* diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 627c52575c..f9f52da639 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -8,6 +8,7 @@ import { legacyConnectFailureMessage, legacyConnectSuggestion, legacyIpv6Suggestion, + legacyIsDialFailure, legacyIsIPv6ConnectivityError, legacyIsIPv6ConnectivityErrorCause, } from "./legacy-connect-errors.ts"; @@ -482,6 +483,48 @@ describe("legacyConnectSuggestion", () => { }); }); +describe("legacyIsDialFailure", () => { + it("classifies dial errno failures", () => { + expect( + legacyIsDialFailure(realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 54322))), + ).toBe(true); + expect( + legacyIsDialFailure(realSqlConnectError(dialError("ETIMEDOUT", "127.0.0.1", 54322))), + ).toBe(true); + }); + + it("classifies the last attempt of a multi-address dial", () => { + expect( + legacyIsDialFailure( + realSqlConnectError( + new AggregateError([ + dialError("ENETUNREACH", "::1", 54322), + dialError("ECONNREFUSED", "127.0.0.1", 54322), + ]), + ), + ), + ).toBe(true); + }); + + it("classifies the code-less connect timeouts", () => { + expect(legacyIsDialFailure(realSqlConnectError(new Error("Connection timed out")))).toBe(true); + expect(legacyIsDialFailure(realSqlConnectError(new Error("timeout expired")))).toBe(true); + expect( + legacyIsDialFailure( + realSqlConnectError(new Error("timeout exceeded when trying to connect")), + ), + ).toBe(true); + }); + + it("does not classify server, auth, or unknown errors", () => { + expect(legacyIsDialFailure(realSqlConnectError(authFailedError()))).toBe(false); + expect( + legacyIsDialFailure(realSqlConnectError(new Error("Connection terminated unexpectedly"))), + ).toBe(false); + expect(legacyIsDialFailure(undefined)).toBe(false); + }); +}); + describe("legacyIsIPv6ConnectivityErrorCause", () => { it("classifies Node getaddrinfo and network-unreachable errors", () => { expect( diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 8327137bfd..c1e1f559d9 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -2,6 +2,12 @@ import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Container CLIs tried in order: Docker is preferred, Podman is the fallback * for Docker-less hosts (e.g. Podman-only Linux setups). @@ -17,16 +23,21 @@ type Spawner = ChildProcessSpawner["Service"]; /** * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. - * Not exported: callers never need to match on this type directly, they fold it - * into their own tagged error via {@link legacyDescribeContainerCliFailure} so - * the "no runtime found" root cause survives instead of collapsing into a - * generic "failed to ..." message. + * Callers never need to match on this type directly, they fold it into their + * own tagged error via {@link legacyDescribeContainerCliFailure} so the "no + * runtime found" root cause survives instead of collapsing into a generic + * "failed to ..." message. Exported only so the coverage test can verify its + * own actionability declaration. */ -class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( +export class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( "LegacyContainerRuntimeNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** * Exported so `legacy-docker-suggest.ts`'s daemon-unreachable matcher can test @@ -128,7 +139,7 @@ export const containerCliExitCode = ( * `legacy-docker-lifecycle.ts` — every module that spawns `docker`/`podman` and * needs its stdout/stderr as text — stop each defining their own copy. */ -export function collectText(stream: Stream.Stream) { +export function legacyCollectText(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -167,11 +178,11 @@ export function legacyIsContainerNotFoundMessage(message: string): boolean { * failure mode (spawn failure, non-zero exit) — the shared shape behind every * "docker verb target" primitive that fails hard on any problem * (`legacyRemoveContainer`/`legacyRemoveVolume`/`legacyRestartContainer`; see - * `containers/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). + * `db-bootstrap/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). * `verb` is the human-readable action embedded in the error message (e.g. * `"remove container"` → `"failed to remove container: "`). */ -export function runContainerCliExpectSuccess( +export function legacyRunContainerCliExpectSuccess( spawner: Spawner, args: ReadonlyArray, verb: string, @@ -189,7 +200,7 @@ export function runContainerCliExpectSuccess( ), ); const [exitCode, stderr] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stderr)], { concurrency: "unbounded" }, ).pipe(Effect.mapError(() => makeError(`failed to ${verb}`))); if (exitCode !== 0) { @@ -241,7 +252,7 @@ export const legacyContainerCliExitCodeAndStdout = ( // so a late subscriber would see an already-ended, empty stream (same // pattern as `legacy-docker-lifecycle.ts`'s `spawnDockerPsLines`). const [exitCode, stdout] = yield* Effect.all( - [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stdout)], + [handle.exitCode.pipe(Effect.map(Number)), legacyCollectText(handle.stdout)], { concurrency: "unbounded" }, ); return { exitCode, stdout }; @@ -297,7 +308,7 @@ export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => }), ); const [exitCode, stdout] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], + [child.exitCode.pipe(Effect.map(Number)), legacyCollectText(child.stdout)], { concurrency: "unbounded" }, ); if (exitCode !== 0) return false; diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index d725cbc4c2..4f8906a5ec 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -1,4 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + CliSuggestionType, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * `--db-url` could not be parsed as a Postgres connection string. Mirrors Go's @@ -7,7 +14,11 @@ import { Data } from "effect"; */ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfigParseUrlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `supabase/config.toml` exists but could not be read or parsed. Mirrors Go's @@ -19,12 +30,22 @@ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfi */ export class LegacyDbConfigLoadError extends Data.TaggedError("LegacyDbConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleNetworkError extends Data.TaggedError( "LegacyDbConfigLoginRoleNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-201 status creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( @@ -33,12 +54,22 @@ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** Transport failure listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansNetworkError extends Data.TaggedError( "LegacyDbConfigListBansNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-2xx status listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( @@ -47,12 +78,22 @@ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** Transport failure removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanNetworkError extends Data.TaggedError( "LegacyDbConfigUnbanNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-2xx status removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( @@ -61,7 +102,11 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} /** * The linked project's direct database host is unreachable (IPv6-only) and no @@ -72,7 +117,18 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( export class LegacyDbConfigIpv6Error extends Data.TaggedError("LegacyDbConfigIpv6Error")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The rendered remediation is "Run supabase link --project-ref to + // setup IPv4 connection", so the suggestion is link-shaped even though the + // category stays db_connection. + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }; + } +} /** * Failed to connect to the linked project as the temporary login role after the @@ -84,7 +140,11 @@ export class LegacyDbConfigConnectTempRoleError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * The configured pooler connection string does not match the linked project ref @@ -97,4 +157,8 @@ export class LegacyDbConfigPoolerLoginError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index 83c414b3b6..58c6c1b56f 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -831,3 +831,256 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { ); }); }); + +// CLI P1 fix (codex review, legacy-db-config.types.ts:81): an explicit +// `--project-ref`/`linkedProjectRef` on a NON-ad-hoc `db` command must +// independently unlock the Management API pooler fetch on an IPv4-only +// network — it must not stay confined to the workdir's saved +// `.temp/pooler-url` the way the plain `--linked` default path is. +describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHocProjectRef)", () => { + it.effect( + "an unlinked workdir + explicit --project-ref resolves via the API pooler config, honoring the ambient password with no login-role mint", + () => { + const ref = "targetprojectrefabcd"; + // Fully unlinked: `withWorkdir()` creates no `supabase/` directory at all, + // so there is no `.temp/project-ref` and no `.temp/pooler-url` to reuse. + const dir = withWorkdir(); + + const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.die("unexpected connect() — the ambient password path never verify-connects"), + }); + const fetchMock = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { + return new Response( + JSON.stringify([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(JSON.stringify({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: previousFetch.preconnect }, + ); + + process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; + process.env["SUPABASE_DB_PASSWORD"] = "ambient-password"; + globalThis.fetch = fetchMock; + + return resolve( + dir, + { + ...linkedFlags, + linkedProjectRef: Option.some(ref), + }, + { projectHost: "invalid", dbConnection }, + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toEqual({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${ref}`, + password: "ambient-password", + database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + }, + }); + expect(r.ref).toEqual(Option.some(ref)); + // Only the pooler-config GET fires — no `cli/login-role` POST, since + // the ambient password takes precedence once the pooler is resolved. + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; + if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; + else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "a saved pooler URL for a DIFFERENT project than --project-ref is rejected and the target ref's pooler is fetched", + () => { + const linkedRef = "workdirlinkedrefabcd"; + const targetRef = "targetprojectrefabcd"; + const dir = withWorkdir( + [`project_id = "${linkedRef}"`, "[db]", "major_version = 15", ""].join("\n"), + ); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "project-ref"), linkedRef); + writeFileSync( + join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, + ); + + const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + const previousFetch = globalThis.fetch; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.die("unexpected connect() — the ambient password path never verify-connects"), + }); + const fetchMock = Object.assign( + async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, + ); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); + + if ( + method === "GET" && + url.pathname === `/v1/projects/${targetRef}/config/database/pooler` + ) { + return new Response( + JSON.stringify([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(JSON.stringify({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + { preconnect: previousFetch.preconnect }, + ); + + process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; + process.env["SUPABASE_DB_PASSWORD"] = "ambient-password"; + globalThis.fetch = fetchMock; + + return resolve( + dir, + { + ...linkedFlags, + linkedProjectRef: Option.some(targetRef), + }, + { projectHost: "invalid", dbConnection }, + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toEqual({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${targetRef}`, + password: "ambient-password", + database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + }, + }); + expect(r.ref).toEqual(Option.some(targetRef)); + // The saved URL is read (not skipped — `ignoreSavedUrl` stays tied to + // `adHocProjectRef` only) but rejected by the tenant-ref check, so a + // second-chance API fetch for `targetRef` follows. + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${targetRef}/config/database/pooler` }, + ]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; + else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; + if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; + else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", + () => { + // Pins the untouched default path: `linkedProjectRef` is absent, so + // `fetchPoolerFromApi` stays false and an unreachable direct host with no + // saved `.temp/pooler-url` still fails with Go's IPv6 suggestion — the fix + // only widens the explicit `--project-ref` path, not this default one. + const ref = "plainlinkedrefabcdef"; + const dir = withWorkdir( + [`project_id = "${ref}"`, "[db]", "major_version = 15", ""].join("\n"), + ); + mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(dir, "supabase", ".temp", "project-ref"), ref); + + return resolve(dir, linkedFlags, { projectHost: "invalid" }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyDbConfigIpv6Error"); + expect(json).toContain( + `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index d73753d8b6..11f459d613 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -244,6 +244,7 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( // (possibly different) linked workdir, so ignore it and resolve the pooler for // `ref` from the Management API instead. ignoreSavedUrl = false, + resolveVaultSecrets = true, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -253,7 +254,9 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( // ref-aware read on the main linked branch rather than validating base config. // For an ad-hoc `--project-id` ref, skip the saved workdir pooler URL because // it belongs to the linked project, not necessarily the explicit ref. - const tomlValues = yield* legacyReadDbToml(fs, path, workdir, ref); + const tomlValues = yield* legacyReadDbToml(fs, path, workdir, ref, { + resolveVaultSecrets, + }); let connectionString = ignoreSavedUrl ? undefined : Option.getOrUndefined(tomlValues.poolerConnectionString); @@ -321,8 +324,26 @@ export const legacyResolveLinkedConn = Effect.fnUntraced(function* ( poolerHost: string, dnsResolver: "native" | "https", passwordFlag: Option.Option, - adHocProjectRef = false, + options: { + readonly adHocProjectRef?: boolean; + readonly resolveVaultSecrets?: boolean; + /** + * Requests the Management API pooler-config fetch on an IPv4-only network + * independent of `adHocProjectRef`'s credential/saved-URL semantics — see + * `LegacyDbConfigFlags.linkedProjectRef`'s doc comment. Set when the caller + * supplied an explicit ref (`--project-ref`/`--project-id`) rather than + * falling back to `.temp/project-ref`, so an unlinked or mismatched-tenant + * workdir still reaches the primary pooler config instead of dead-ending in + * the "run supabase link" IPv6 error. + */ + readonly fetchPoolerFromApi?: boolean; + } = {}, ) { + const { + adHocProjectRef = false, + resolveVaultSecrets = true, + fetchPoolerFromApi = false, + } = options; const debug = yield* LegacyDebugLogger; // Read lazily (per invocation) rather than at layer build, so tests and // env-substitution see the current value. For an ad-hoc `--project-id` ref, @@ -353,15 +374,21 @@ export const legacyResolveLinkedConn = Effect.fnUntraced(function* ( // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc // `--project-id` ref the command already holds a Management API token, so fall // back to the API pooler config (and ignore the workdir's saved pooler URL) - // rather than failing with the IPv6 "run supabase link" suggestion. + // rather than failing with the IPv6 "run supabase link" suggestion. An explicit + // `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the + // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still + // rejects a stale saved URL for a DIFFERENT ref), but independently requests the + // same API fetch via `fetchPoolerFromApi` so an unlinked or mismatched-tenant + // workdir doesn't dead-end in the IPv6 "run supabase link" error. const poolerConn = yield* resolvePoolerConn( ref, workdir, poolerHost, dnsResolver, base.password, + adHocProjectRef || fetchPoolerFromApi, adHocProjectRef, - adHocProjectRef, + resolveVaultSecrets, ); if (Option.isNone(poolerConn)) { return yield* Effect.fail( @@ -457,6 +484,7 @@ export const legacyDbConfigLayer = Layer.effect( const resolve = (flags: LegacyDbConfigFlags) => Effect.gen(function* () { + const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; // Config is read per branch, NOT unconditionally up front: the linked branch // resolves the ref first and reads the `[remotes.]`-merged config (below). // A base read here would validate base config (db.major_version, deno_version, @@ -470,7 +498,9 @@ export const legacyDbConfigLayer = Layer.effect( // --db-url (direct) takes precedence. if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { - const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { + resolveVaultSecrets, + }); // Go's direct path runs `LoadConfig` before `pgconn.ParseConfig` // (`internal/utils/flags/db_url.go:59-68`), so the project `.env*` files // populate the environment that the libpq `PG*` fallbacks read. Layer the @@ -535,7 +565,9 @@ export const legacyDbConfigLayer = Layer.effect( // validate the merged config here, before `resolveLinked`'s TCP probe / // pooler / temp-role Management API calls, rather than letting those mask // (or run side effects ahead of) the real config error. - yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref); + yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref, { + resolveVaultSecrets, + }); const resolved = yield* legacyResolveLinkedConn( ref, cliConfig.workdir, @@ -543,7 +575,15 @@ export const legacyDbConfigLayer = Layer.effect( cliConfig.poolerHost, flags.dnsResolver, flags.password ?? Option.none(), - flags.adHocProjectRef ?? false, + { + adHocProjectRef: flags.adHocProjectRef ?? false, + resolveVaultSecrets, + // An explicit ref (the eight `db` commands' `--project-ref`, or + // `gen types --project-id`) independently unlocks the Management API + // pooler fetch, regardless of `adHocProjectRef`'s credential semantics + // — see `LegacyDbConfigFlags.linkedProjectRef`'s doc comment. + fetchPoolerFromApi: Option.isSome(flags.linkedProjectRef ?? Option.none()), + }, ); // NB: the linked-project telemetry cache (GET /v1/projects/{ref}) is NOT // issued here. Go caches it in `PersistentPostRun` @@ -568,7 +608,9 @@ export const legacyDbConfigLayer = Layer.effect( } // --local (default). - const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { + resolveVaultSecrets, + }); return { conn: { host: localHost, @@ -612,6 +654,7 @@ export const legacyDbConfigLayer = Layer.effect( password, true, adHocProjectRef, + flags.resolveVaultSecrets ?? true, ); }).pipe( Effect.provide( diff --git a/apps/cli/src/legacy/shared/legacy-db-config.service.ts b/apps/cli/src/legacy/shared/legacy-db-config.service.ts index c21f8c42db..685edc1889 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.service.ts @@ -1,4 +1,6 @@ import { Context, type Effect, type Option } from "effect"; +import type { SupabaseApiInputError } from "@supabase/api/effect"; +import type * as HttpBody from "effect/unstable/http/HttpBody"; import type { LegacyPlatformApiFactoryError } from "../auth/legacy-platform-api-factory.service.ts"; import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import type { @@ -38,6 +40,8 @@ export type LegacyDbConfigError = | LegacyDbConfigListBansStatusError | LegacyDbConfigUnbanNetworkError | LegacyDbConfigUnbanStatusError + | SupabaseApiInputError + | HttpBody.HttpBodyError | LegacyDbConfigIpv6Error | LegacyDbConfigConnectTempRoleError | LegacyDbConfigPoolerLoginError diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index dc2ac7b869..bd6e2f9372 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -101,10 +101,27 @@ export interface LegacyDbTomlValues { /** * `[db.migrations] schema_paths`, default `[]` — resolved (supabase-prefixed when * relative, Go's `path.Join`/`path.Clean`) and `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` - * env-overridable exactly like `seed.sqlPaths` below. Only consumed by the - * `--experimental` declarative-schema-files branch of `legacyMigrateAndSeed`. + * env-overridable exactly like `seed.sqlPaths` below, resolved unconditionally (not + * gated on `db.migrations.enabled`). Feeds `apply.MigrateAndSeed`'s EXPERIMENTAL + * declarative branch (`legacyApplySchemaFiles`) — consumed by `legacyMigrateAndSeed` + * (`start`'s fresh-volume setup, `migration down`) and by `db reset`'s own + * `--experimental` remote path. */ readonly schemaPaths: ReadonlyArray; + /** + * `[db.migrations] schema_paths`, RAW patterns — the SAME env/remote-override + * resolution as {@link schemaPaths} above (`SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`, + * remote-override tiering), but WITHOUT the `supabase/`-prefix + `path.Join`/`path.Clean` + * step (`config.go:976-979`) — Go's `utils.Config.Db.Migrations.SchemaPaths` pre-that- + * resolution form. `legacyPrepareShadowSource`'s `schemaPaths` input (`db diff`/`db pull`'s + * shadow-provisioning prelude) does that join itself (`legacyResolveSeedSqlPath`), so it + * needs THIS raw form — passing {@link schemaPaths} there would double-join a relative + * pattern (`supabase/supabase/...`). The `@supabase/config`-backed + * `context.config.db.migrations.schema_paths` these two callers used before is a DIFFERENT + * raw form: correct patterns, but never `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`-overridden + * (`@supabase/config` has no viper-`AutomaticEnv` equivalent) — review: PRRT_kwDOErm0O86XDr4S. + */ + readonly schemaPathPatterns: ReadonlyArray; /** `[db.seed]` enabled + supabase-prefixed `sql_paths` globs — used by `down`. */ readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ @@ -114,6 +131,17 @@ export interface LegacyDbTomlValues { * (Go's `Loading config override: [remotes.]` line), else `undefined`. */ readonly appliedRemote: string | undefined; + /** + * The config keys the matched remote block contributed at viper's OVERRIDE tier — see + * {@link LegacyRemoteOverride.remoteOverrideKeys}'s own doc comment for the full + * precedence rationale. Exposed here (in addition to being used internally, above) so a + * caller resolving a SEPARATE config read for the same linked ref — `legacyBuildLocalDbContainerInputs`, + * whose `@supabase/config`-backed loader merges the same remote block's VALUES but + * tracks none of which keys it set — can preserve the identical remote-over-env + * precedence for the shadow's own bootstrap fields (`db diff --linked`/`db pull`, + * CLI-1956), without re-deriving this set a third time. Empty when no remote matched. + */ + readonly remoteOverrideKeys: ReadonlySet; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -187,8 +215,6 @@ const DEFAULT_SHADOW_PORT = 54320; const DEFAULT_MAJOR_VERSION = 17; const DEFAULT_PASSWORD = "postgres"; const DEFAULT_API_SCHEMAS = ["public", "graphql_public"] as const; -/** `[db.migrations] schema_paths` default — Go's `Glob` zero value (`pkg/config/db.go:101`). */ -const DEFAULT_SCHEMA_PATHS: ReadonlyArray = []; /** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ const DEFAULT_DENO_VERSION = 2; @@ -235,7 +261,7 @@ interface LegacyRemoteOverride { /** * The config keys the matched remote block contributed at viper's OVERRIDE tier. Go's * `mergeRemoteConfig` applies every block key via `v.Set(...)` after `AutomaticEnv` - * (`config.go:635-640`), and `v.Set` sits ABOVE `AutomaticEnv` (`viper.go:1167-1174` vs + * (`config.go:718-730`), and `v.Set` sits ABOVE `AutomaticEnv` (`viper.go:1167-1174` vs * `:1226-1237`), so each explicitly-set remote key — plus the forced `db.seed.enabled` * default Go injects when the block omits it — must outrank the matching `SUPABASE_*` * env override (a plain TOML value elsewhere is still env-overridable). Holds every key in @@ -290,9 +316,18 @@ function legacyResolveValidatedRemoteProjectId( * Every dotted config key this reader resolves with a `SUPABASE_*` AutomaticEnv override. * When a matched `[remotes.*]` block supplies any of these, Go's `mergeRemoteConfig` flattens * the whole block via `u.AllKeys()` and applies each leaf with `v.Set` (override tier, above - * `AutomaticEnv` — `config.go:635-637`), so the block value must beat the env override. + * `AutomaticEnv` — `config.go:718-730`), so the block value must beat the env override. */ -const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ +export const LEGACY_ENV_OVERRIDABLE_KEYS = [ + // The matched `[remotes.]` block's own `project_id` field is what selected it in the + // first place (`applyRemoteOverride` above matches on exactly this key) — same override-tier + // reasoning as every other key in this array. NOT guaranteed present, though: a block can also + // match purely via its `SUPABASE_REMOTES__PROJECT_ID` env override with no literal + // `project_id` line in the block's own TOML table, in which case Go's `u.AllKeys()` (and this + // reader's own `legacyBlockProvidesKey` check below) correctly finds the key absent from the + // block, so `remoteOverrideKeys` omits it and the env override still applies for that + // (nonexistent) literal key — matching Go exactly (review: PRRT_kwDOErm0O86XHGDL). + "project_id", "api.schemas", "db.port", "db.shadow_port", @@ -302,6 +337,14 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.seed.enabled", "db.seed.sql_paths", "auth.enabled", + // Not read by THIS reader's own resolved fields (nor by `apiUrl`'s own `api.port`/ + // `api.tls.enabled`/`api.external_url` inputs, unlike those three) — tracked purely because + // `legacyResolveLocalConfigValues`'s `legacyEnvOverrideBool("SUPABASE_API_ENABLED", ...)` + // call THROWS on a malformed override, which would abort resolution of the caller-needed + // fields it computes afterward (`apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc.) — same + // "throws before a value the caller needs is resolved" rationale as `auth.enabled` above and + // `analytics.enabled`/`edge_runtime.deno_version` below (review: PRRT_kwDOErm0O86W5UlV). + "api.enabled", "edge_runtime.deno_version", "experimental.webhooks.enabled", "experimental.pgdelta.enabled", @@ -313,7 +356,340 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "analytics.gcp_project_id", "analytics.gcp_project_number", "analytics.gcp_jwt_path", -]; + // Not read by THIS reader's own resolved fields — tracked so `remoteOverrideKeys` (exposed + // on this module's return value, see its own doc comment) also covers every field + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` + // (`legacy/shared/db-bootstrap/`) resolve for the shadow's own container spec on the + // `db diff --linked`/`db pull` native-provisioning path (CLI-1956). + "experimental.orioledb_version", + "experimental.s3_host", + "experimental.s3_region", + "experimental.s3_access_key", + "experimental.s3_secret_key", + "realtime.enabled", + "realtime.ip_version", + "realtime.max_header_length", + "storage.enabled", + "storage.file_size_limit", + "db.health_timeout", + "db.settings.effective_cache_size", + "db.settings.logical_decoding_work_mem", + "db.settings.maintenance_work_mem", + "db.settings.max_connections", + "db.settings.max_locks_per_transaction", + "db.settings.max_parallel_maintenance_workers", + "db.settings.max_parallel_workers", + "db.settings.max_parallel_workers_per_gather", + "db.settings.max_replication_slots", + "db.settings.max_slot_wal_keep_size", + "db.settings.max_standby_archive_delay", + "db.settings.max_standby_streaming_delay", + "db.settings.max_wal_size", + "db.settings.max_wal_senders", + "db.settings.max_worker_processes", + "db.settings.session_replication_role", + "db.settings.shared_buffers", + "db.settings.statement_timeout", + "db.settings.track_activity_query_size", + "db.settings.track_commit_timestamp", + "db.settings.wal_keep_size", + "db.settings.wal_sender_timeout", + "db.settings.work_mem", + "db.network_restrictions.enabled", + // Not read by `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` above — + // these feed `legacyResolveLocalConfigValues`'s OWN fields instead (`apiUrl`/`dbUrl`/ + // `dbPort`/`rootKey`/`jwtSecret`/`authSiteUrl`/`authJwtExpiry`/`anonKey`/`serviceRoleKey`), + // which the shadow's container spec/fresh-DB setup input also consume on the same + // `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W2tRi) — same override-tier + // gap as the block above, just for that resolver's reachable subset instead of this one's. + "db.root_key", + "api.port", + "api.tls.enabled", + // Not read by `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` above, + // same as `api.tls.enabled`/`api.port` — these feed `legacyResolveLocalConfigValues`'s own + // `readApiTlsFiles` gate (`apiEnabled && apiTlsEnabled`), which the shadow's own + // `db diff --linked`/`db pull` setup input also consumes on the same path. Without this, + // a matched remote's override-tier `api.tls.cert_path`/`key_path` could still lose to a + // stale/missing ambient `SUPABASE_API_TLS_CERT_PATH`/`SUPABASE_API_TLS_KEY_PATH` (review: + // PRRT_kwDOErm0O86W8ZYk). + "api.tls.cert_path", + "api.tls.key_path", + "api.external_url", + "auth.jwt_secret", + "auth.jwt_expiry", + "auth.site_url", + "auth.anon_key", + "auth.service_role_key", + // Not read by ANY of the resolvers above — these feed `legacyResolveLocalJwks`'s/ + // `legacyResolveAuthExternalUrl`'s/`legacyResolveConfiguredSigningKeys`'s own fields + // instead, which the shadow's PG15+ one-shot auth-migration job also consumes on the + // same `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W3Ox_) — same + // override-tier gap as the two blocks above, just for THOSE resolvers' reachable subset. + "auth.signing_keys_path", + "auth.external_url", + "auth.third_party.firebase.enabled", + "auth.third_party.firebase.project_id", + "auth.third_party.auth0.enabled", + "auth.third_party.auth0.tenant", + "auth.third_party.auth0.tenant_region", + "auth.third_party.aws_cognito.enabled", + "auth.third_party.aws_cognito.user_pool_id", + "auth.third_party.aws_cognito.user_pool_region", + "auth.third_party.clerk.enabled", + "auth.third_party.clerk.domain", + "auth.third_party.workos.enabled", + "auth.third_party.workos.issuer_url", + // `auth.jwt_issuer`/`auth.additional_redirect_urls` are plain, non-throwing + // `legacyEnvOverride`/comma-split string reads in `legacyResolveLocalConfigValues` — same + // "non-throwing read is still a precedence bug" reasoning as `auth.external.*`'s + // `client_id`/`url`/`redirect_uri` above: a matched remote's own value must beat a stale + // ambient `SUPABASE_AUTH_JWT_ISSUER`/`SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS`. + "auth.jwt_issuer", + "auth.additional_redirect_urls", + // Same "throws before a value the caller needs is resolved" bug class as `api.enabled`/ + // `auth.enabled`/`analytics.*`/`edge_runtime.deno_version` above, just for a much larger set of + // fields the doc comment on `legacyResolveLocalConfigValues`'s `remoteOverrideKeys` parameter + // used to claim were safe to leave ungated. That claim rested on "their own `legacyEnvOverride*` + // calls cannot throw before a value the caller needs has already been resolved" — which doesn't + // actually hold: `legacyResolveLocalConfigValues` is a single synchronous function that either + // returns its whole object or throws, so ANY unconditional throw anywhere in its body (not just + // ones textually positioned before a caller-needed field) aborts the entire call and denies the + // shadow every field, including the ones already computed as local variables earlier in the + // function. Every dotted key below resolves through `legacyEnvOverrideBool`/`legacyEnvOverrideUint`/ + // `legacyEnvOverrideAuthPasswordRequirements`, all of which throw on a malformed override — same + // as `api.enabled`'s own reasoning, just generalized (review: PRRT_kwDOErm0O86W6R-G). + "studio.enabled", + "studio.port", + "local_smtp.enabled", + "local_smtp.port", + "auth.enable_signup", + "auth.enable_anonymous_sign_ins", + "auth.enable_refresh_token_rotation", + "auth.refresh_token_reuse_interval", + "auth.enable_manual_linking", + "auth.minimum_password_length", + "auth.password_requirements", + "auth.passkey.enabled", + // `auth.webauthn.rp_id`/`.rp_origins` are the same shape of plain, non-throwing string/slice + // reads `legacyResolveLocalConfigValues` resolves for its `passkey` validation input — same + // "non-throwing read is still a precedence bug" reasoning as `auth.jwt_issuer` above. + "auth.webauthn.rp_id", + "auth.webauthn.rp_origins", + "auth.hook.mfa_verification_attempt.enabled", + "auth.hook.mfa_verification_attempt.uri", + "auth.hook.mfa_verification_attempt.secrets", + "auth.hook.password_verification_attempt.enabled", + "auth.hook.password_verification_attempt.uri", + "auth.hook.password_verification_attempt.secrets", + "auth.hook.custom_access_token.enabled", + "auth.hook.custom_access_token.uri", + "auth.hook.custom_access_token.secrets", + "auth.hook.send_sms.enabled", + "auth.hook.send_sms.uri", + "auth.hook.send_sms.secrets", + "auth.hook.send_email.enabled", + "auth.hook.send_email.uri", + "auth.hook.send_email.secrets", + "auth.hook.before_user_created.enabled", + "auth.hook.before_user_created.uri", + "auth.hook.before_user_created.secrets", + "auth.mfa.totp.enroll_enabled", + "auth.mfa.totp.verify_enabled", + "auth.mfa.phone.enroll_enabled", + "auth.mfa.phone.verify_enabled", + "auth.mfa.phone.otp_length", + "auth.mfa.web_authn.enroll_enabled", + "auth.mfa.web_authn.verify_enabled", + "auth.mfa.max_enrolled_factors", + // `auth.mfa.phone.template`/`.max_frequency` are plain, non-throwing `legacyEnvOverride` string + // reads in `legacyResolveAuthMfa` — same "non-throwing read is still a precedence bug" + // reasoning as `auth.jwt_issuer`/`auth.webauthn.rp_id` above. + "auth.mfa.phone.template", + "auth.mfa.phone.max_frequency", + "auth.captcha.enabled", + // `auth.captcha.provider` can't throw on its own (`legacyEnvOverride` is a plain string read), + // but `legacyValidateResolvedConfig`'s enum check (`legacy-config-validate.ts`, ported from + // `config.go:1099-1109`) rejects any value other than `hcaptcha`/`turnstile` — same + // "non-throwing read, throwing downstream consumer" class as `studio.api_url` below. A matched + // remote's own valid `provider` must beat a stale/unsupported ambient + // `SUPABASE_AUTH_CAPTCHA_PROVIDER`, or `legacyValidateResolvedConfig` aborts the whole + // synchronous `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's + // `v.Set` (override tier, above `AutomaticEnv`) never lets win. + "auth.captcha.provider", + // `auth.captcha.secret` is a `config.Secret` (`pkg/config/auth.go:292`), decrypted the same + // way `auth.email.smtp.pass` below is — `legacyResolveAuthCaptcha`'s ungated `legacyEnvOverride` + // call let a malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own + // valid `secret` and throw during decryption, aborting the whole synchronous + // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's `v.Set` + // (override tier, above `AutomaticEnv`) silently ignores — same bug class as `.pass` below + // (review: PRRT_kwDOErm0O86XJ4HR). + "auth.captcha.secret", + "auth.email.smtp.enabled", + "auth.email.smtp.port", + // `auth.email.smtp.pass` is a `config.Secret` (`pkg/config/auth.go:260`), decrypted uniformly + // by Go's `DecryptSecretHookFunc` decode hook regardless of which viper tier supplied the + // raw value — so when a matched remote block sets it, Go's `v.Set` (override tier) wins over + // `AutomaticEnv` and the decode hook decrypts the REMOTE's value; an ambient malformed + // `SUPABASE_AUTH_EMAIL_SMTP_PASS` never reaches decryption at all. `legacyResolveAuthEmailSmtp` + // previously ran `legacyEnvOverride` unconditionally before decrypting, so that same malformed + // env value could win over a matched remote's valid `pass` and throw, aborting the whole + // synchronous `legacyResolveLocalConfigValues` call — same bug class as `.enabled`/`.port` + // above, just for this Secret-typed leaf (review: PRRT_kwDOErm0O86XJYol). + "auth.email.smtp.pass", + // `auth.email.smtp.host`/`.user`/`.admin_email`/`.sender_name` are plain, non-throwing + // `legacyEnvOverride` string reads in `legacyResolveAuthEmailSmtp` — unlike `.enabled`/`.port`/ + // `.pass` above, none of these can throw, but leaving them ungated is still a precedence bug, + // same reasoning as `auth.email.template.*`'s `subject`/`content` below. + "auth.email.smtp.host", + "auth.email.smtp.user", + "auth.email.smtp.admin_email", + "auth.email.smtp.sender_name", + // Not read by THIS reader's own resolved fields — tracked so `legacyResolveAuthEmail` + // (`legacy-local-config-values.ts`) also gates its own throw-capable + // `legacyEnvOverrideBool`/`legacyEnvOverrideUint` calls for these `auth.email.*` scalars, + // same "throws before a value the caller needs is resolved" bug class as + // `auth.email.smtp.enabled`/`.port` above (review: PRRT_kwDOErm0O86XHvYh). + "auth.email.enable_signup", + "auth.email.double_confirm_changes", + "auth.email.enable_confirmations", + "auth.email.secure_password_change", + "auth.email.otp_length", + "auth.email.otp_expiry", + // `auth.email.max_frequency` is a plain, non-throwing `legacyEnvOverride` string read in + // `legacyResolveAuthEmail` — same "non-throwing read is still a precedence bug" reasoning as + // `auth.email.smtp.host` above. + "auth.email.max_frequency", + // `auth.sms.*` (`legacyResolveAuthSms`) has the identical "throws before a value the caller + // needs is resolved" bug class as every other group above: `enable_signup`/`enable_confirmations` + // and each provider's `enabled` run an UNGATED `legacyEnvOverrideBool`, and each provider's + // Secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`, `pkg/config/auth.go: + // 339,345,351,358`) runs an UNGATED `legacyDecryptAuthSecret` — either can throw on a malformed + // ambient `SUPABASE_AUTH_SMS_*` override even when a matched remote block already set that field + // at viper's OVERRIDE tier, aborting the whole `legacyResolveLocalConfigValues` call (and the + // shadow it feeds via `legacyBuildLocalDbContainerInputs`) — reachable via `validateAuthSmsProviders`, + // called unconditionally whenever `authEnabled` (review: PRRT_kwDOErm0O86XFmjZ — the prior + // "unreachable from the shadow path" rejection missed this call site). + "auth.sms.enable_signup", + "auth.sms.enable_confirmations", + "auth.sms.twilio.enabled", + "auth.sms.twilio.auth_token", + "auth.sms.twilio_verify.enabled", + "auth.sms.twilio_verify.auth_token", + "auth.sms.messagebird.enabled", + "auth.sms.messagebird.access_key", + "auth.sms.textlocal.enabled", + "auth.sms.textlocal.api_key", + "auth.sms.vonage.enabled", + "auth.sms.vonage.api_secret", + // The remaining `auth.sms..*` fields (`resolveField` in `legacyResolveAuthSms`) are + // plain, non-throwing `legacyEnvOverride` string reads — `vonage.api_key` sitting right next to + // the already-gated `vonage.api_secret` was the clearest tell that these were missed. Same + // "non-throwing read is still a precedence bug" reasoning as `auth.email.smtp.host` above. + "auth.sms.twilio.account_sid", + "auth.sms.twilio.message_service_sid", + "auth.sms.twilio_verify.account_sid", + "auth.sms.twilio_verify.message_service_sid", + "auth.sms.messagebird.originator", + "auth.sms.textlocal.sender", + "auth.sms.vonage.from", + "auth.sms.vonage.api_key", + // `auth.sms.template`/`.max_frequency` are the same shape, sibling to `auth.email.max_frequency` + // above. + "auth.sms.template", + "auth.sms.max_frequency", + // `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) and + // `studio.openai_api_key` (`pkg/config/config.go:264`) are `config.Secret`-typed exactly like + // `auth.email.smtp.pass`/`auth.captcha.secret` above, decrypted via the same throw-capable + // `legacyDecryptAuthSecret` — but were never added to this allowlist when `anon_key`/ + // `service_role_key` (their sibling API-key pair, right next to them in + // `legacyResolveLocalConfigValues`'s return block) were gated. Same bug class: an ungated + // malformed ambient override can throw during decryption even when a matched remote block + // already set the field, aborting the whole call. + "auth.publishable_key", + "auth.secret_key", + "studio.openai_api_key", + // `studio.api_url` is validated with `legacyGoUrlParse` inside `legacyValidateResolvedConfig` + // (gated on `studio.enabled`, matching `studio.port` above) — a plain, non-throwing + // `legacyEnvOverride` read here can still flip that downstream validate() outcome, same + // "non-throwing read, throwing downstream consumer" class as the third_party required fields + // above. + "studio.api_url", +] as const; + +/** + * `auth.external.` is a genuine map keyed by arbitrary provider name — not just the ~19 + * known ids `@supabase/config`'s schema recognizes, but any custom/unmodeled name a user's + * `[auth.external.]` table declares (`legacyResolveAuthExternalProviders`'s own doc + * comment). A fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry per provider can't cover every possible + * name a `[remotes.]` block might set, so these per-provider leaves are tracked dynamically + * in {@link applyRemoteOverride} instead (flattening whichever provider names the matched block + * actually supplies) rather than enumerated here. + */ +const LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS = [ + "enabled", + "client_id", + "secret", + "url", + "redirect_uri", + "skip_nonce_check", + "email_optional", +] as const; + +/** + * `auth.email.template.`/`auth.email.notification.` are the same shape of genuine, + * arbitrarily-keyed map as `auth.external.` above — a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` + * entry per template/notification name can't cover every name a `[remotes.]` block might + * set, so these are also tracked dynamically in {@link applyRemoteOverride}. `content_path` is + * the field that can actually abort resolution (a matched remote's own valid path losing to a + * stale/missing ambient `_CONTENT_PATH` env var makes {@link legacyResolveAuthEmail}'s caller-side + * file read throw — same "non-throwing read, throwing downstream consumer" class as + * `auth.captcha.provider` above); `subject`/`content` can't throw the same way, but leaving them + * ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + * `redirect_uri` above (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + */ +const LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as const; + +/** {@link LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS}'s notification-section sibling — same fields, plus `enabled`. */ +const LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS = [ + "enabled", + "subject", + "content_path", + "content", +] as const; + +/** + * Every literal member of {@link LEGACY_ENV_OVERRIDABLE_KEYS}, PLUS the dotted-key patterns for + * the three genuinely dynamically-keyed families {@link applyRemoteOverride} tracks separately — + * arbitrary user-declared names (provider ids, email template/notification names), not a fixed + * list, so they can't be enumerated as literal members (see + * {@link LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS}/{@link LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS}/ + * {@link LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS}'s own doc comments). Every + * `remoteWins(...)`/`remoteOverrideKeys.has(...)` call site across this module, + * `legacy-local-config-values.ts`, and `db-bootstrap/bootstrap-config.ts` is typed against this + * union (via {@link legacyMakeRemoteWins}), so a typo'd dotted key is a compile error instead of a + * silently-always-false gate. + */ +export type LegacyRemoteOverridableKey = + | (typeof LEGACY_ENV_OVERRIDABLE_KEYS)[number] + | `auth.external.${string}.${(typeof LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS)[number]}` + | `auth.email.template.${string}.${(typeof LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS)[number]}` + | `auth.email.notification.${string}.${(typeof LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS)[number]}`; + +/** + * Hoists the `const remoteWins = (p: string): boolean => remoteOverrideKeys.has(p)` closure that + * used to be copy-pasted once per remote-gated resolver (five times in + * `legacy-local-config-values.ts`, once in `db-bootstrap/bootstrap-config.ts`) into a single + * helper. The returned function's parameter is typed as {@link LegacyRemoteOverridableKey} — + * narrower than `keys` itself, which stays the loosely-typed `ReadonlySet` every resolver + * already threads a `remoteOverrideKeys` parameter as — so every call site is checked against the + * allowlist without having to also re-type every `remoteOverrideKeys` parameter/field across the + * db-bootstrap/shadow-provisioning call graph (CLI-1956). + */ +export function legacyMakeRemoteWins( + keys: ReadonlySet, +): (key: LegacyRemoteOverridableKey) => boolean { + return (key) => keys.has(key); +} /** Whether `block` provides a value at the dotted `key` path (scalar, array, or sub-table). */ function legacyBlockProvidesKey(block: RawDoc, key: string): boolean { @@ -344,15 +720,51 @@ function applyRemoteOverride( const merged = deepMergeDoc(doc, block); const blockSeed = asRecord(asRecord(block["db"])?.["seed"]); // Go's `mergeRemoteConfig` flattens the WHOLE matched block via `u.AllKeys()` and applies - // every leaf with `v.Set` (override tier, above `AutomaticEnv` — `config.go:635-637`). + // every leaf with `v.Set` (override tier, above `AutomaticEnv` — `config.go:718-730`). // Record every env-overridable key the block supplies — not just migrations/seed — so the // resolution below suppresses their `SUPABASE_*` value. const remoteOverrideKeys = new Set(); for (const key of LEGACY_ENV_OVERRIDABLE_KEYS) { if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); } + // `auth.external.` is a genuine map (arbitrary/custom provider names — see + // `LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS`'s own doc comment), so flatten whichever provider + // names/fields THIS matched block actually supplies instead of relying on a fixed list — + // same per-leaf override-tier semantics as `LEGACY_ENV_OVERRIDABLE_KEYS` above, just + // computed dynamically for this one dynamically-keyed section. + const externalBlock = asRecord(asRecord(block["auth"])?.["external"]); + if (externalBlock !== undefined) { + for (const providerName of Object.keys(externalBlock)) { + for (const field of LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS) { + const key = `auth.external.${providerName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } + // `auth.email.template.`/`auth.email.notification.` are the same + // arbitrarily-keyed shape as `auth.external.` above — see + // `LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS`'s own doc comment. + const emailBlock = asRecord(block["auth"])?.["email"]; + const emailTemplateBlock = asRecord(asRecord(emailBlock)?.["template"]); + if (emailTemplateBlock !== undefined) { + for (const templateName of Object.keys(emailTemplateBlock)) { + for (const field of LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS) { + const key = `auth.email.template.${templateName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } + const emailNotificationBlock = asRecord(asRecord(emailBlock)?.["notification"]); + if (emailNotificationBlock !== undefined) { + for (const notificationName of Object.keys(emailNotificationBlock)) { + for (const field of LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS) { + const key = `auth.email.notification.${notificationName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } // `db.seed.enabled` is ALWAYS override-tier for a matched block: either the block set - // it, or Go's `mergeRemoteConfig` forces it `false` when omitted (`config.go:638-640`) + // it, or Go's `mergeRemoteConfig` forces it `false` when omitted (`config.go:726-728`) // — so env never overrides it on a matched-remote linked run. remoteOverrideKeys.add("db.seed.enabled"); if (blockSeed?.["enabled"] === undefined) { @@ -532,6 +944,38 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { return out.length === 0 ? "." : out.join("/"); } +/** + * Go's `filepath.IsAbs` on Windows (`internal/filepathlite/path_windows.go`'s + * `IsAbs`/`volumeNameLen`) requires a volume name — a drive letter (`C:\`) or a UNC + * prefix (`\\server\share`) — before a path counts as absolute; a bare leading + * separator (`/schemas`, `\schemas`) has no volume name, so Go treats it as RELATIVE + * and joins it under `supabase/`. `pathSvc.isAbsolute` is backed by `node:path`, which + * selects `path.win32` on an actual Windows host, and Node's win32 `isAbsolute` treats + * a bare leading separator as rooted at the *current drive* — i.e. absolute — so it + * disagrees with Go on exactly this shape. Verified empirically: Node's + * `path.win32.isAbsolute("/schemas/*.sql")` is `true`, while Go's `filepath.IsAbs` on + * the same input is `false` (`volumeNameLen` returns `0` — none of its drive-letter, + * UNC, or device-path cases match a path with no volume component). Only the resolve + * step below (`config.go:970-980`'s literal `!filepath.IsAbs(pattern)` gate for + * `[db.seed].sql_paths`/`[db.migrations].schema_paths`) needs this Go-exact rule — + * real filesystem calls elsewhere in this shell still need the platform's own + * `isAbsolute` to resolve an actual path on disk. + */ +const legacyGoIsAbs = (pathSvc: Path.Path, pattern: string): boolean => { + if (process.platform !== "win32") { + return pathSvc.isAbsolute(pattern); + } + const isSeparator = (c: string | undefined): boolean => c === "/" || c === "\\"; + // Drive-letter volume (`C:\`, `c:/`): Go's `volumeNameLen` accepts any byte before + // `:` (case 2, `path[1] === ':'`), then `IsAbs` requires a separator right after. + if (pattern.length >= 3 && pattern[1] === ":" && isSeparator(pattern[2])) { + return true; + } + // UNC volume (`\\server\share`, `//server/share`): Go's `IsAbs` treats a + // double-separator-prefixed volume as absolute unconditionally. + return pattern.length >= 2 && isSeparator(pattern[0]) && isSeparator(pattern[1]); +}; + /** * Resolves a single seed/schema-paths entry to Go's config-load form: a relative * pattern is joined under `supabase/` (Go's `path.Join`, `config.go:918-921` for @@ -543,7 +987,7 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { * the glob the same resolved paths. */ export const legacyResolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => - pattern.length === 0 || pathSvc.isAbsolute(pattern) + pattern.length === 0 || legacyGoIsAbs(pathSvc, pattern) ? pattern : legacyJoinSupabaseSeedPath(pattern); @@ -788,6 +1232,8 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( ); }); +const LEGACY_VAULT_SECRET_PATH = ["db", "vault", "*"] as const; + /** * Dotted paths of every `config.Secret`-typed field Go decrypts via its global * `DecryptSecretHookFunc` (`pkg/config/secret.go`, `config.go:730`) — the hook only runs @@ -805,7 +1251,7 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( */ const LEGACY_SECRET_PATHS: ReadonlyArray> = [ ["db", "root_key"], - ["db", "vault", "*"], + LEGACY_VAULT_SECRET_PATH, ["auth", "publishable_key"], ["auth", "secret_key"], ["auth", "jwt_secret"], @@ -888,9 +1334,11 @@ export const legacyAssertDecryptableSecrets = ( doc: unknown, lookup: EnvLookup, dotenvPrivateKeys: ReadonlyArray, + opts?: { readonly includeVault?: boolean }, ): string | undefined => { const scan = (node: unknown): string | undefined => { for (const segs of LEGACY_SECRET_PATHS) { + if (opts?.includeVault === false && segs === LEGACY_VAULT_SECRET_PATH) continue; const values: Array = []; legacyCollectSecretStrings(node, segs, 0, values); for (const value of values) { @@ -953,6 +1401,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `false` so the warning still fires exactly once per invocation, matching // Go, instead of two or three times. warnOnUnresolvedEnv = true, + resolveVaultSecrets = true, ) { const supabaseDir = path.join(workdir, "supabase"); const configPath = path.join(supabaseDir, "config.toml"); @@ -1076,14 +1525,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // EVERY `config.Secret` field during `UnmarshalExact`, so an `encrypted:` secret anywhere // in the merged config that cannot be decrypted (e.g. no DOTENV_PRIVATE_KEY) aborts the // load with `failed to parse config: ` (secret.go:34,103; config.go:704) — before - // Validate and before connecting. This also covers `[db.vault]` (see - // `LEGACY_SECRET_PATHS`), so the vault loop below never actually reaches an - // undecryptable value — it just decrypts-and-populates the already-asserted-valid ones. - const secretError = legacyAssertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys); + // Validate and before connecting. This covers `[db.vault]` unless the caller is + // explicitly skipping Vault sync, so the vault loop below only materializes values + // that this assertion has already proved decryptable. + const secretError = legacyAssertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys, { + includeVault: resolveVaultSecrets, + }); if (secretError !== undefined) { return yield* Effect.fail(new LegacyDbConfigLoadError({ message: secretError })); } } + // `remoteOverrideKeys` has its final value from here on — see `legacyMakeRemoteWins`'s own doc + // comment for why this is typed narrower than the `ReadonlySet` it wraps. + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); // Go: `config.go:626` — read the linked pooler URL from `.temp/pooler-url` and // treat it as configured only when the file exists and is non-empty. @@ -1123,7 +1577,16 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `test db --local` joins `supabase_network_` while Go honors the // env id. This is independent of the linked-ref resolver, which reads the env var on // its own chain; the env value is bound regardless of whether a config file exists. - const projectIdEnv = envOverride("SUPABASE_PROJECT_ID"); + // UNLESS a matched `[remotes.]` block already set `project_id` at viper's override tier + // (`remoteWins("project_id")` — NOT guaranteed whenever `appliedRemote` is set: a + // block can also match purely via its own `SUPABASE_REMOTES__PROJECT_ID` env override + // with no literal `project_id` key, in which case this stays `false` — see + // `LEGACY_ENV_OVERRIDABLE_KEYS`'s own doc comment on that key): that Set-tier value, when + // present, outranks `AutomaticEnv`, so a stale/differently-scoped `SUPABASE_PROJECT_ID` must + // not clobber it — otherwise a linked `db diff`/`db pull` mounts the wrong + // `supabase_edge_runtime_` Deno-cache volume for the matched remote (review: + // PRRT_kwDOErm0O86XHGDL). + const projectIdEnv = remoteWins("project_id") ? undefined : envOverride("SUPABASE_PROJECT_ID"); if (projectIdEnv !== undefined) { projectId = nonEmptyString(legacyExpandEnv(projectIdEnv, lookup)); } @@ -1142,15 +1605,13 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // that so `test db --local` never silently targets the default local database // while hiding a broken `[db]` config. const port = resolvePort( - (remoteOverrideKeys.has("db.port") ? undefined : envOverride("SUPABASE_DB_PORT")) ?? - db?.["port"], + (remoteWins("db.port") ? undefined : envOverride("SUPABASE_DB_PORT")) ?? db?.["port"], DEFAULT_PORT, lookup, ); const shadowPort = resolvePort( - (remoteOverrideKeys.has("db.shadow_port") - ? undefined - : envOverride("SUPABASE_DB_SHADOW_PORT")) ?? db?.["shadow_port"], + (remoteWins("db.shadow_port") ? undefined : envOverride("SUPABASE_DB_SHADOW_PORT")) ?? + db?.["shadow_port"], DEFAULT_SHADOW_PORT, lookup, ); @@ -1171,11 +1632,15 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } - // Go's `db.Password` is tagged `json:"-"` (`apps/cli-go/pkg/config/db.go:88`), so - // it is NOT bound from `SUPABASE_DB_PASSWORD` — the local password is the fixed - // config value/`"postgres"` default. `DB_PASSWORD` is read only by linked password - // resolution (`legacy-db-config.layer.ts`), so the local password must not source - // it or `db query --local` etc. would authenticate with a remote secret. + // Go's `db.Password` is tagged `json:"-"` (`apps/cli-go/pkg/config/db.go:88`, the + // tag viper decodes with) — that blocks the `SUPABASE_DB_PASSWORD` env binding AND + // makes a literal `[db] password` toml key a fatal `UnmarshalExact` config error in + // Go (`'db' has invalid keys: password`), so Go's local password is invariably the + // `"postgres"` default. Honoring the toml key here is a deliberate TS extension + // (established for `--local` connections, `legacy-db-config.layer.ts`). `DB_PASSWORD` + // is read only by linked password resolution (`legacy-db-config.layer.ts`), so the + // local password must not source it or `db query --local` etc. would authenticate + // with a remote secret. const passwordRaw = typeof db?.["password"] === "string" ? db["password"] : undefined; // Go expands a quoted `env(VAR)` reference for `major_version` and then decodes @@ -1184,9 +1649,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // (`apps/cli-go/pkg/config/config.go` viper + mapstructure). `resolveConfigInt` // mirrors that; `SUPABASE_DB_MAJOR_VERSION` overrides the TOML via AutomaticEnv. const majorVersionRaw = - (remoteOverrideKeys.has("db.major_version") - ? undefined - : envOverride("SUPABASE_DB_MAJOR_VERSION")) ?? db?.["major_version"]; + (remoteWins("db.major_version") ? undefined : envOverride("SUPABASE_DB_MAJOR_VERSION")) ?? + db?.["major_version"]; const majorVersionResolved = resolveConfigInt(majorVersionRaw, lookup); if (majorVersionResolved === "invalid") { // Present but not a whole integer (`17foo`, or an `env(VAR)` that does not @@ -1238,7 +1702,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // validation (same generic prefix+replacer binding as the pg-delta env vars below), // so a CI env override decides which edge-runtime image pg-delta runs under. const denoVersionRaw = - (remoteOverrideKeys.has("edge_runtime.deno_version") + (remoteWins("edge_runtime.deno_version") ? undefined : envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION")) ?? edgeRuntimeRaw?.["deno_version"]; // Go decodes `deno_version` into a `uint` before validation, so a present non-integer @@ -1295,7 +1759,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const webhooksPresent = webhooksRaw !== undefined; const webhooksEnabledRaw = webhooksRaw?.["enabled"]; const webhooksEnabledEnv = webhooksPresent - ? remoteOverrideKeys.has("experimental.webhooks.enabled") + ? remoteWins("experimental.webhooks.enabled") ? undefined : envOverride("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED") : undefined; @@ -1339,7 +1803,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // CI env override decides the gate / paths. `envOverride` is the shell→project-.env // lookup that ignores empty values, matching viper. const enabledRaw = pgDeltaRaw?.["enabled"]; - const enabledEnv = remoteOverrideKeys.has("experimental.pgdelta.enabled") + const enabledEnv = remoteWins("experimental.pgdelta.enabled") ? undefined : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"); // Go decodes this bool via `strconv.ParseBool` (mapstructure weakly typed), so `"1"` @@ -1386,7 +1850,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // before the path is used — whichever source wins. Expand once over the resolved value // (`legacyExpandEnv` is a no-op on a non-`env()` string). const declarativeSchemaPathValue = legacyExpandEnv( - (remoteOverrideKeys.has("experimental.pgdelta.declarative_schema_path") + (remoteWins("experimental.pgdelta.declarative_schema_path") ? undefined : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH")) ?? (typeof declarativeSchemaPathRaw === "string" ? declarativeSchemaPathRaw : ""), @@ -1405,7 +1869,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // Same `LoadEnvHook` path: expand the resolved value (env override or TOML literal) before // the JSON validation below runs. const formatOptionsExpanded = legacyExpandEnv( - (remoteOverrideKeys.has("experimental.pgdelta.format_options") + (remoteWins("experimental.pgdelta.format_options") ? undefined : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS")) ?? (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), @@ -1458,7 +1922,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( authRaw?.["enabled"], true, lookup, - remoteOverrideKeys.has("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), + remoteWins("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), ); // Local helpers mirroring the deleted `legacyValidateAuthConfig`'s closures — its Go-parity @@ -1757,8 +2221,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a // matched remote block makes those keys env-immune, same as every other // `LEGACY_ENV_OVERRIDABLE_KEYS` field above. - const analyticsString = (key: string, envName: string): string => { - const fromEnv = remoteOverrideKeys.has(`analytics.${key}`) ? undefined : envOverride(envName); + const analyticsString = ( + key: "backend" | "gcp_project_id" | "gcp_project_number" | "gcp_jwt_path", + envName: string, + ): string => { + const fromEnv = remoteWins(`analytics.${key}`) ? undefined : envOverride(envName); const raw = fromEnv ?? analyticsRaw?.[key]; return typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; }; @@ -1768,9 +2235,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( analyticsRaw?.["enabled"], true, lookup, - remoteOverrideKeys.has("analytics.enabled") - ? undefined - : envOverride("SUPABASE_ANALYTICS_ENABLED"), + remoteWins("analytics.enabled") ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays // non-empty and passes the shared validator's `length === 0` check, exactly like Go. @@ -1906,42 +2371,15 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `[db.migrations] enabled` — Go default true (`config.go:384`); overridable by // `SUPABASE_DB_MIGRATIONS_ENABLED` via viper AutomaticEnv (`config.go:494-498`) — EXCEPT // when the matched remote block explicitly set it (then the remote override-tier value - // wins, `config.go:635-637`). + // wins, `config.go:724`). const migrationsRaw = asRecord(db?.["migrations"]); const migrationsEnabled = yield* resolveBoolOrFail( "db.migrations.enabled", migrationsRaw?.["enabled"], true, lookup, - remoteOverrideKeys.has("db.migrations.enabled") - ? undefined - : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), + remoteWins("db.migrations.enabled") ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), ); - // `[db.migrations] schema_paths` — Go default `[]`; overridable by - // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` via viper AutomaticEnv (`config.go:494-498`) — EXCEPT - // when the matched remote block explicitly set it, same tiering as every other field in - // `LEGACY_ENV_OVERRIDABLE_KEYS`. A STRING value (the env override, or a TOML string) is - // env-expanded then comma-split; a TOML ARRAY is expanded element-by-element with no - // re-split (`resolveStringSlice`, shared with `api.schemas`). Each resulting pattern is then - // resolved to Go's config-load form (`path.Join(builder.SupabaseDirPath, pattern)`, - // `config.go:976-978`) via the same `legacyResolveSeedSqlPath` helper `db.seed.sql_paths` uses - // below — this is the only current TS reader of this field that needs real, Go-path-cleaned - // filesystem paths, so resolution happens here rather than in the declarative-schema-files - // consumer (`legacy-migrate-and-seed.ts`), matching where `seedSqlPaths` is resolved. - const rawSchemaPaths = - (remoteOverrideKeys.has("db.migrations.schema_paths") - ? undefined - : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS")) ?? migrationsRaw?.["schema_paths"]; - const schemaPathPatterns = resolveStringSlice(rawSchemaPaths, DEFAULT_SCHEMA_PATHS, lookup); - if (schemaPathPatterns === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: "failed to parse config: invalid db.migrations.schema_paths.", - }), - ); - } - const schemaPaths = schemaPathPatterns.map((pattern) => legacyResolveSeedSqlPath(path, pattern)); - // `[db.seed]` — Go defaults enabled true, sql_paths ["seed.sql"]; relative // patterns are supabase-prefixed (`config.go:801-806`). `db.seed.enabled` is // overridable by `SUPABASE_DB_SEED_ENABLED` via viper AutomaticEnv — EXCEPT when a @@ -1952,7 +2390,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( seedRaw?.["enabled"], true, lookup, - remoteOverrideKeys.has("db.seed.enabled") ? undefined : envOverride("SUPABASE_DB_SEED_ENABLED"), + remoteWins("db.seed.enabled") ? undefined : envOverride("SUPABASE_DB_SEED_ENABLED"), ); // Go decodes `db.seed.sql_paths` through the mapstructure hook chain in order: // `LoadEnvHook` (expands `env(VAR)`) runs BEFORE `StringToSliceHookFunc(",")` @@ -1968,23 +2406,271 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const expanded = legacyExpandEnv(value, lookup); return expanded.length === 0 ? [] : expanded.split(","); }; + // Go's `decodeString` renders a weakly-converted float via `strconv.FormatFloat(v, + // 'f', -1, 64)` (`mapstructure.go:747-748`, format `'f'`) — ALWAYS fixed decimal + // notation, never scientific, regardless of magnitude. JS's `String(value)` agrees + // for ordinary magnitudes (both use the same shortest-round-trip digit sequence — + // `String()` just chooses "e" notation once `|value| >= 1e21` or `< 1e-6`, which + // `FormatFloat('f', …)` never does). Verified empirically: `strconv.FormatFloat(1e21, + // 'f', -1, 64)` returns `"1000000000000000000000"`, not `"1e+21"`. Expand JS's own + // exponential notation back into fixed notation instead of re-deriving the digits, + // since `Number.prototype.toString()`/`toExponential()` already computed the same + // shortest round-tripping digit sequence Go's algorithm would — only the notation + // differs. + // + // `strconv.FormatFloat` special-cases the three non-finite values BEFORE it ever + // looks at the format verb, so `'f'` never applies to them: verified empirically + // (`apps/cli-go` probe against a real `schema_paths = [inf, -inf, nan]` config load) — + // `+Inf`/`-Inf`/`NaN` (note the "+Inf" sign Go always prints, and the short "Inf"/"NaN" + // spelling) — never JS's own `Infinity`/`-Infinity`/`NaN` (which happens to already + // match the "NaN" case, but not the two `Infinity` ones). TOML v1.0's bare `inf`/ + // `+inf`/`-inf`/`nan` float literals (smol-toml) parse to exactly these JS values, so + // a `schema_paths`/`sql_paths` array entry can realistically hit this branch. + // + // `strconv.FormatFloat` also preserves the IEEE754 sign bit on zero: a genuine + // negative-zero float64 formats as `"-0"`, never `"0"`. Verified empirically — + // `strconv.FormatFloat(math.Copysign(0, -1), 'f', -1, 64)` returns `"-0"` — and + // end-to-end through the real decode pipeline this weak-decode mirrors + // (`BurntSushi/toml` + `go-viper/mapstructure`'s `WeaklyTypedInput`): a + // `schema_paths = [-0.0]` config decodes its glob entry to the literal string + // `"-0"`. JS's own `(-0).toString()` is `"0"` (the sign is dropped), by spec — + // `Object.is(value, -0)` is JS's only way to detect it, since `-0 === 0`. + const legacyFormatGoWeakFloat = (value: number): string => { + if (Number.isNaN(value)) return "NaN"; + if (value === Number.POSITIVE_INFINITY) return "+Inf"; + if (value === Number.NEGATIVE_INFINITY) return "-Inf"; + if (Object.is(value, -0)) return "-0"; + const str = value.toString(); + const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(str); + if (match === null) return str; + const [, sign = "", intPart = "", fracPart = "", expStr = "0"] = match; + const digits = intPart + fracPart; + const pointPos = intPart.length + Number(expStr); + if (pointPos <= 0) return `${sign}0.${"0".repeat(-pointPos)}${digits}`; + if (pointPos >= digits.length) return `${sign}${digits}${"0".repeat(pointPos - digits.length)}`; + return `${sign}${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`; + }; + // Go decodes both `[db.seed].sql_paths` and `[db.migrations].schema_paths` as + // `config.Glob` (`[]string`) through the SAME mapstructure `UnmarshalExact` call + // (`config.go:749-756`), whose decoder config never sets `WeaklyTypedInput: false` + // — viper's `defaultDecoderConfig` defaults it to `true` and nothing here overrides + // it. So a non-string array element isn't dropped: `decodeString` + // (`github.com/go-viper/mapstructure/v2@v2.5.0/mapstructure.go:729-780`) weakly + // converts a bool to `"1"`/`"0"` and a number to its decimal string, THEN the + // result flows through the same env-expand/resolve pipeline as a real string + // entry. Verified empirically against `apps/cli-go` (`schema_paths = [42]` resolves + // to `supabase/42`, `schema_paths = [true]` to `supabase/1`). + const legacyWeakCoerceGlobEntry = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "1" : "0"; + if (typeof value === "number") return legacyFormatGoWeakFloat(value); + return undefined; + }; + // A non-scalar element (nested array/table, e.g. `schema_paths = [[]]` or + // `[{path = "x.sql"}]`) is mapstructure's `UnconvertibleTypeError` instead of a weak + // conversion, and mapstructure reports every offending index from the SAME + // `UnmarshalExact` call together, joined by its own `Error.Error()` — which is what + // aborts the entire config load, not just that one array. Verified empirically + // against `apps/cli-go`: `schema_paths = [[]]` / `[{path = "x.sql"}]` both fail with + // `failed to parse config: decoding failed due to the following error(s):\n\n'db. + // migrations.schema_paths[0]' expected type 'string', got unconvertible type + // '[]interface {}'` / `'map[string]interface {}'` respectively — never silently + // dropping the element and continuing with an empty/partial glob list. + // + // A bare TOML datetime (e.g. `schema_paths = 1979-05-27T07:32:00Z`) hits this same + // `UnconvertibleTypeError` path in Go, but as a DIFFERENT Go type per TOML datetime + // variant — `BurntSushi/toml` decodes an offset date-time to stdlib `time.Time` and + // each of the 3 zone-less "local" variants to its own `toml.Local*` wrapper type. + // Verified empirically against the real `apps/cli-go` `config.Load` (review + // CLI-1958): `schema_paths = 1979-05-27T07:32:00Z` → `unconvertible type + // 'time.Time'`; `= 1979-05-27T07:32:00` (no zone) → `'toml.LocalDateTime'`; + // `= 1979-05-27` → `'toml.LocalDate'`; `= 07:32:00` → `'toml.LocalTime'` — same four + // messages whether the datetime is this top-level scalar or an array element. + // `smol-toml` parses every TOML datetime to a `TomlDate` (a `Date` subclass, so + // `typeof`/`Array.isArray` alone can't tell it apart from an inline table) exposing + // exactly the `isDate`/`isTime`/`isDateTime`/`isLocal` discriminators needed to + // reproduce Go's per-variant type name. + const legacyGoTomlDateType = (value: SmolToml.TomlDate): string => { + if (value.isDate()) return "toml.LocalDate"; + if (value.isTime()) return "toml.LocalTime"; + return value.isLocal() ? "toml.LocalDateTime" : "time.Time"; + }; + const legacyGoUnconvertibleType = (value: unknown): string | undefined => + value instanceof SmolToml.TomlDate + ? legacyGoTomlDateType(value) + : Array.isArray(value) + ? "[]interface {}" + : typeof value === "object" && value !== null + ? "map[string]interface {}" + : undefined; + // Pure — returns the mapstructure-style issue strings for a real `Glob` array's + // unconvertible elements WITHOUT failing. Go's `UnmarshalExact` decodes the WHOLE + // config in a SINGLE mapstructure pass: `decodeStructFromMap`'s per-field loop + // (`mapstructure.go:1657-1724`) appends each field's decode error to a shared `errs` + // slice and keeps going — it never stops at the first field's error — then + // `errors.Join(errs...)`-s everything together at the very end + // (`mapstructure.go:1777`). So an invalid `db.migrations.schema_paths` does NOT + // prevent `db.seed.sql_paths` from ALSO being decoded (and erroring) in the same + // pass; both surface together in ONE combined error. Verified empirically against + // `apps/cli-go` (`config.Load` with both fields containing an unconvertible entry, + // e.g. `sql_paths = [[]]` + `schema_paths = [[]]`): the single returned error + // contains BOTH lines, `db.migrations.schema_paths[0]` BEFORE `db.seed.sql_paths[0]` + // — Go's `db` struct declares `Migrations` before `Seed` (`pkg/config/db.go:90-91`), + // and mapstructure iterates struct fields in declaration order, not alphabetically, + // so callers below must combine in that same order before failing once (see + // `legacyFailOnGlobIssues`). + const legacyGlobArrayIssues = ( + keyPath: string, + values: ReadonlyArray, + ): ReadonlyArray => + values.flatMap((value, index) => { + const goType = legacyGoUnconvertibleType(value); + return goType === undefined + ? [] + : [`'${keyPath}[${index}]' expected type 'string', got unconvertible type '${goType}'`]; + }); + // Fails ONCE with every issue collected across BOTH `Glob` fields (see + // `legacyGlobArrayIssues`'s doc comment) — never called per-field, so a config + // invalid in both `db.seed.sql_paths` and `db.migrations.schema_paths` reports both, + // matching Go's single combined `UnmarshalExact` error instead of only the first + // field checked. + const legacyFailOnGlobIssues = ( + issues: ReadonlyArray, + ): Effect.Effect => + issues.length === 0 + ? Effect.void + : fail( + `failed to parse config: decoding failed due to the following error(s):\n\n${issues.join("\n")}`, + ); + // A TOP-LEVEL raw value that is neither an array nor a string (e.g. + // `schema_paths = 42`/`true`, or a stray inline table) still reaches + // mapstructure's `decodeSlice`, which is weakly typed the same way an array + // ELEMENT is (see `legacyWeakCoerceGlobEntry` above): a zero-length map + // decodes straight to an empty slice; anything else is wrapped into a + // synthetic single-element `[]any{value}` and decoded through the exact + // same per-element rules as a real array entry — a scalar weakly coerces, + // an unconvertible value (map/array) fails with `'[0]' expected + // type 'string', got unconvertible type '...'` (mapstructure always + // reports the synthetic wrapped index, which is `0`). Verified empirically + // against `apps/cli-go`: `schema_paths = 42` → `["42"]`, `= true` → + // `["1"]`, `= {}` → `[]`, `[db.migrations.schema_paths]\nfoo = "bar"` → + // `failed to parse config: … 'db.migrations.schema_paths[0]' expected type + // 'string', got unconvertible type 'map[string]interface {}'`. Never + // called with `undefined` — an absent key has its own Go-matching default + // per caller below, so callers guard that case before reaching here. + // + // The zero-length-map special case must NOT match a `TomlDate` (e.g. `schema_paths = + // 1979-05-27T07:32:00Z`): a `TomlDate` stores its value internally, not as an + // enumerable own property, so `Object.keys(tomlDate).length === 0` is ALSO true for + // it — but Go does not treat a bare datetime as an empty map; mapstructure reports it + // unconvertible and aborts the whole load (see `legacyGoUnconvertibleType` above). + // Without this exclusion, a `TomlDate` would silently resolve to `[]` here instead of + // falling through to the unconvertible-type issue below, turning Go's hard config-load + // failure into a silently-empty schema/seed path list (review CLI-1958). + // Pure — the TOP-LEVEL scalar fallback (see doc comment above), returning either the + // one resolved pattern or the one issue it would raise, WITHOUT failing (same reason + // as `legacyGlobArrayIssues`: the caller combines issues across both `Glob` fields + // before deciding whether to fail). + const legacyResolveScalarGlobFallback = ( + keyPath: string, + value: unknown, + ): { readonly resolved: ReadonlyArray; readonly issues: ReadonlyArray } => { + if ( + typeof value === "object" && + value !== null && + !(value instanceof SmolToml.TomlDate) && + Object.keys(value).length === 0 + ) { + return { resolved: [], issues: [] }; + } + const coerced = legacyWeakCoerceGlobEntry(value); + if (coerced !== undefined) { + return { resolved: [coerced], issues: [] }; + } + return { resolved: [], issues: legacyGlobArrayIssues(keyPath, [value]) }; + }; + /** + * Resolves ONE `Glob`-typed field (`[db.seed].sql_paths` / `[db.migrations]. + * schema_paths`) into its pre-supabase-join patterns, covering every decode branch + * in one place: override env var, real array, bare string, absent key (caller's own + * Go-matching default), or the top-level scalar fallback. Returns any issues + * alongside the best-effort patterns rather than failing here — see + * `legacyFailOnGlobIssues`'s doc comment for why the two `Glob` fields must combine + * their issues into ONE error before failing, matching Go's single `UnmarshalExact` + * pass, instead of each field failing independently on its own first bad entry. + */ + const legacyResolveGlobField = ( + keyPath: string, + raw: unknown, + override: string | undefined, + absentDefault: ReadonlyArray, + ): { readonly patterns: ReadonlyArray; readonly issues: ReadonlyArray } => { + if (override !== undefined) { + return { patterns: splitGoSeedPaths(override), issues: [] }; + } + if (Array.isArray(raw)) { + return { + patterns: raw + .map((pattern) => legacyWeakCoerceGlobEntry(pattern)) + .filter((pattern): pattern is string => pattern !== undefined) + .map((pattern) => legacyExpandEnv(pattern, lookup)), + issues: legacyGlobArrayIssues(keyPath, raw), + }; + } + if (typeof raw === "string") { + return { patterns: splitGoSeedPaths(raw), issues: [] }; + } + if (raw === undefined) { + return { patterns: absentDefault, issues: [] }; + } + const fallback = legacyResolveScalarGlobFallback(keyPath, raw); + return { + patterns: fallback.resolved.map((pattern) => legacyExpandEnv(pattern, lookup)), + issues: fallback.issues, + }; + }; const rawSqlPaths = seedRaw?.["sql_paths"]; - const sqlPathsOverride = remoteOverrideKeys.has("db.seed.sql_paths") + const sqlPathsOverride = remoteWins("db.seed.sql_paths") ? undefined : envOverride("SUPABASE_DB_SEED_SQL_PATHS"); - const sqlPathPatterns = - sqlPathsOverride !== undefined - ? splitGoSeedPaths(sqlPathsOverride) - : Array.isArray(rawSqlPaths) - ? rawSqlPaths - .filter((pattern): pattern is string => typeof pattern === "string") - .map((pattern) => legacyExpandEnv(pattern, lookup)) - : typeof rawSqlPaths === "string" - ? splitGoSeedPaths(rawSqlPaths) - : ["seed.sql"]; + const sqlPathsResolved = legacyResolveGlobField( + "db.seed.sql_paths", + rawSqlPaths, + sqlPathsOverride, + ["seed.sql"], + ); // Patterns are already env-expanded above (Go's LoadEnvHook runs before the split); // resolve each to Go's config-load form (absolute verbatim, relative supabase-joined). - const seedSqlPaths = sqlPathPatterns.map((pattern) => legacyResolveSeedSqlPath(path, pattern)); + const seedSqlPaths = sqlPathsResolved.patterns.map((pattern) => + legacyResolveSeedSqlPath(path, pattern), + ); + + // `[db.migrations] schema_paths` — Go default `[]` (`pkg/config/templates/config.toml:64`), + // resolved through the exact same decode + env-expand + supabase-join pipeline as + // `[db.seed].sql_paths` above, but UNCONDITIONALLY (Go's resolve loop for schema_paths, + // `config.go:976-980`, is not gated on `db.migrations.enabled` the way the seed loop is + // gated on `db.seed.enabled`, `config.go:968-975`). + const rawSchemaPaths = migrationsRaw?.["schema_paths"]; + const schemaPathsOverride = remoteOverrideKeys.has("db.migrations.schema_paths") + ? undefined + : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"); + const schemaPathsResolved = legacyResolveGlobField( + "db.migrations.schema_paths", + rawSchemaPaths, + schemaPathsOverride, + [], + ); + + // Go's `UnmarshalExact` decodes the whole config in ONE mapstructure pass (see + // `legacyGlobArrayIssues`'s doc comment) — combine BOTH `Glob` fields' issues, in + // Go's struct-declaration order (`Migrations` before `Seed`), before failing once, + // so a config invalid in both surfaces both, matching Go's single combined error + // instead of only the first field checked. + yield* legacyFailOnGlobIssues([...schemaPathsResolved.issues, ...sqlPathsResolved.issues]); + + const schemaPaths = schemaPathsResolved.patterns.map((pattern) => + legacyResolveSeedSqlPath(path, pattern), + ); // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` // ciphertext. `resolved` mirrors Go's `len(SHA256) > 0` gate (Go sets SHA256 only @@ -1994,7 +2680,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `failed to parse config: ` (`secret.go:30-73`, `config.go:661-667`) — it // is never silently skipped, which an earlier port did and which diverged from Go. const vault: Array = []; - if (vaultRaw !== undefined) { + if (resolveVaultSecrets && vaultRaw !== undefined) { for (const name of Object.keys(vaultRaw).sort()) { const raw = vaultRaw[name]; const value = typeof raw === "string" ? legacyExpandEnv(raw, lookup) : ""; @@ -2025,14 +2711,14 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `env(...)` parse correctly and `maybe` aborts rather than silently coercing to false. const apiAutoExposeNewTables = yield* resolveOptionalBoolOrFail( "api.auto_expose_new_tables", - remoteOverrideKeys.has("api.auto_expose_new_tables") + remoteWins("api.auto_expose_new_tables") ? undefined : envOverride("SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"), apiRaw?.["auto_expose_new_tables"], lookup, ); const apiSchemas = resolveStringSlice( - (remoteOverrideKeys.has("api.schemas") ? undefined : envOverride("SUPABASE_API_SCHEMAS")) ?? + (remoteWins("api.schemas") ? undefined : envOverride("SUPABASE_API_SCHEMAS")) ?? apiRaw?.["schemas"], DEFAULT_API_SCHEMAS, lookup, @@ -2080,9 +2766,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }, migrationsEnabled, schemaPaths, + schemaPathPatterns: schemaPathsResolved.patterns, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, appliedRemote, + remoteOverrideKeys, }; return values; }); @@ -2106,8 +2794,21 @@ export const legacyCheckDbToml = ( // caller known to run AFTER an earlier, same-invocation `legacyCheckDbToml`/ // `legacyReadDbToml` call already printed the OrioleDB S3 `assertEnvLoaded` WARN // once. Omit (default `true`) for every standalone command entry point. - opts?: { readonly warnOnUnresolvedEnv?: boolean }, -) => readDbTomlCore(fs, path, workdir, ref, false, opts?.warnOnUnresolvedEnv ?? true); + opts?: { + readonly warnOnUnresolvedEnv?: boolean; + /** Skip resolving `[db.vault]` values while validating the rest of the config. */ + readonly resolveVaultSecrets?: boolean; + }, +) => + readDbTomlCore( + fs, + path, + workdir, + ref, + false, + opts?.warnOnUnresolvedEnv ?? true, + opts?.resolveVaultSecrets ?? true, + ); /** * Read `config.toml`. Defaults to Go's validating behavior (identical to @@ -2122,18 +2823,23 @@ export const legacyReadDbToml = ( path: Path.Path, workdir: string, ref?: string, - opts?: { readonly validate?: boolean; readonly warnOnUnresolvedEnv?: boolean }, + opts?: { + readonly validate?: boolean; + readonly warnOnUnresolvedEnv?: boolean; + readonly resolveVaultSecrets?: boolean; + }, ) => { const warnOnUnresolvedEnv = opts?.warnOnUnresolvedEnv ?? true; + const resolveVaultSecrets = opts?.resolveVaultSecrets ?? true; return opts?.validate === false - ? readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv).pipe( + ? readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets).pipe( // Fall back to the ignore-file defaults path (never re-reads the broken config) // so a best-effort caller gets a well-formed defaults result instead of a throw. Effect.catchTag("LegacyDbConfigLoadError", () => - readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv), + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv, resolveVaultSecrets), ), ) - : readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv); + : readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv, resolveVaultSecrets); }; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index a35e7048de..0813b9e416 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Option, Path } from "effect"; @@ -11,6 +11,7 @@ import { legacyLoadProjectEnv, legacyReadDbToml, legacyResolveDeclarativeDir, + legacyResolveSeedSqlPath, } from "./legacy-db-config.toml-read.ts"; function withConfig(content: string | undefined, poolerUrl?: string) { @@ -76,6 +77,31 @@ describe("read (lenient) vs check (throws) split", () => { ); }); + it.effect("can skip vault resolution without skipping the rest of config validation", () => { + const dir = withConfig( + [ + "[db.vault]", + 'local_secret = "encrypted:not-valid"', + "[remotes.preview]", + 'project_id = "abcdefghijklmnopqrst"', + "[remotes.preview.db.vault]", + 'remote_secret = "encrypted:not-valid"', + "", + ].join("\n"), + ); + return withServices(dir, (fs, path) => + legacyCheckDbToml(fs, path, dir, undefined, { resolveVaultSecrets: false }), + ).pipe( + Effect.tap((values) => + Effect.sync(() => { + expect(values.vault).toEqual([]); + expect(values.baseline.vaultNames).toEqual(["local_secret"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect( "legacyReadDbToml({ validate: false }) tolerates the same secret, returning defaults", () => { @@ -293,6 +319,486 @@ describe("legacyReadDbToml", () => { ); }); + it.effect( + "weakly coerces non-string db.seed.sql_paths array elements (Go mapstructure parity)", + () => { + // Same `config.Glob` decode path as schema_paths below — a bool/number element + // is coerced to its Go string form ("1"/"0" for bool, decimal for a number), + // not dropped. + const dir = withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.seed.sqlPaths).toEqual(["supabase/42", "supabase/1", "supabase/seed.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", + () => { + const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "a.sql, b.sql"; + const dir = withConfig(["[db.migrations]", 'schema_paths = ["ignored.sql"]', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/a.sql", "supabase/ b.sql"]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "decodes a STRING db.migrations.schema_paths via StringToSliceHookFunc (comma, no trim)", + () => { + const dir = withConfig(["[db.migrations]", 'schema_paths = "a.sql,b.sql"', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/a.sql", "supabase/b.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "on Windows, resolves a leading-slash schema/seed path pattern under supabase/ instead of treating it as absolute (Go filepath.IsAbs parity)", + () => { + // Go's `resolve()` gates the `supabase/`-join on `!filepath.IsAbs(pattern)` + // (`config.go:976-980`), and `filepath.IsAbs` on Windows requires a volume name — + // a drive letter (`C:\`) or UNC prefix (`\\server\share`) — before a path counts as + // absolute (`internal/filepathlite/path_windows.go`'s `IsAbs`/`volumeNameLen`). A + // bare leading `/` has no volume name, so Go treats `/schemas/*.sql` as RELATIVE and + // joins it to `supabase/schemas/*.sql`. Node's `path.win32.isAbsolute`, backing the + // injected `Path.Path` service on an actual Windows host, instead treats a leading + // separator as rooted at the current drive — i.e. absolute — which would otherwise + // skip Go's `supabase/`-join entirely. Exercises `legacyResolveSeedSqlPath` (the + // single function `[db.migrations].schema_paths` and `[db.seed].sql_paths` both + // resolve through) directly with `BunPath.layerWin32`, rather than through the full + // `legacyReadDbToml` pipeline: that pipeline's OWN config-file lookup also runs + // through the same injected `Path.Path` service to open the real (POSIX-pathed, + // since this test host isn't Windows) temp config file on disk, so forcing win32 + // path semantics there breaks the read itself rather than exercising the fix. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + return Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = legacyResolveSeedSqlPath(path, "/schemas/*.sql"); + expect(resolved).toBe("supabase/schemas/*.sql"); + }).pipe( + Effect.provide(BunPath.layerWin32), + Effect.ensuring( + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + }), + ), + ); + }, + ); + + it.effect( + "weakly coerces non-string db.migrations.schema_paths array elements (Go mapstructure parity)", + () => { + // Go's `v.UnmarshalExact` never sets `WeaklyTypedInput: false`, so viper's + // `defaultDecoderConfig` default of `true` stands — mapstructure's `decodeString` + // coerces a bool to "1"/"0" and a number to its decimal string rather than + // erroring or dropping the element. Verified empirically against `apps/cli-go`: + // `schema_paths = [42, true, "schemas/*.sql"]` resolves to + // `supabase/{42,1,schemas/*.sql}`, not a filtered two-element list. + const dir = withConfig( + ["[db.migrations]", 'schema_paths = [42, true, "schemas/*.sql"]', ""].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/42", "supabase/1", "supabase/schemas/*.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation (Go strconv.FormatFloat parity)", + () => { + // Go's `decodeString` renders a weakly-converted float via + // `strconv.FormatFloat(v, 'f', -1, 64)` — format `'f'` is ALWAYS fixed decimal, + // never scientific, regardless of magnitude. JS's bare `String(1e21)` switches to + // exponential notation ("1e+21") once the magnitude crosses 1e21, which would + // record (and later search for) the wrong file path. Verified empirically against + // Go's stdlib: `strconv.FormatFloat(1e21, 'f', -1, 64)` returns + // `"1000000000000000000000"`, not `"1e+21"`. + const dir = withConfig(["[db.migrations]", "schema_paths = [1e21]", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/1000000000000000000000"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "formats TOML special-float db.migrations.schema_paths entries like Go's strconv.FormatFloat, not JS's toString (Go parity)", + () => { + // `strconv.FormatFloat` special-cases the three non-finite values BEFORE the + // format verb is even consulted, so `'f'` never applies to them — it renders + // `+Inf` / `-Inf` / `NaN` (verified empirically against `apps/cli-go`: a real + // `schema_paths = [inf, -inf, nan]` config load resolves to exactly + // `supabase/{+Inf,-Inf,NaN}`). JS's own `Number.prototype.toString()` renders + // the two infinities as `"Infinity"`/`"-Infinity"` instead — a naive port would + // record (and later glob) the wrong path. TOML v1.0's bare `inf`/`-inf`/`nan` + // float literals parse to exactly these JS values (smol-toml). + const dir = withConfig(["[db.migrations]", "schema_paths = [inf, -inf, nan]", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/+Inf", "supabase/-Inf", "supabase/NaN"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths (Go mapstructure weak-decode of a []string field)", + () => { + // Go's `decodeSlice` wraps a non-array/non-string value into a synthetic + // single-element `[]any{value}` and decodes it through the same per-element + // rules as a real array entry — it does NOT fall back to the `[]` default the + // way an absent key does. Verified empirically against `apps/cli-go`: + // `schema_paths = 42` → `["42"]` (resolves to `supabase/42`), `= true` → `["1"]`. + const dirNumber = withConfig(["[db.migrations]", "schema_paths = 42", ""].join("\n")); + const dirBool = withConfig(["[db.migrations]", "schema_paths = true", ""].join("\n")); + return Effect.all([read(dirNumber), read(dirBool)]).pipe( + Effect.tap(([numberResult, boolResult]) => + Effect.sync(() => { + expect(numberResult.schemaPaths).toEqual(["supabase/42"]); + expect(boolResult.schemaPaths).toEqual(["supabase/1"]); + rmSync(dirNumber, { recursive: true, force: true }); + rmSync(dirBool, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns (Go mapstructure zero-length-map special case)", + () => { + // Go's `decodeSlice` special-cases a zero-length map BEFORE the generic weak-typing + // wrap above: it decodes straight to an empty slice. Verified empirically against + // `apps/cli-go`: `schema_paths = {}` → `[]`. + const dir = withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect.each([ + { name: "offset date-time", literal: "1979-05-27T07:32:00Z", goType: "time.Time" }, + { name: "local date-time", literal: "1979-05-27T07:32:00", goType: "toml.LocalDateTime" }, + { name: "local date", literal: "1979-05-27", goType: "toml.LocalDate" }, + { name: "local time", literal: "07:32:00", goType: "toml.LocalTime" }, + ])( + "aborts the whole config load on a TOP-LEVEL bare $name db.migrations.schema_paths instead of silently treating it as empty (Go mapstructure UnconvertibleTypeError, review CLI-1958)", + ({ literal, goType }) => { + // `smol-toml` parses every TOML datetime variant to a `TomlDate` (a `Date` + // subclass) that stores its value internally, not as an enumerable own + // property — so `Object.keys(tomlDate).length === 0`, same as a genuine empty + // inline table (`schema_paths = {}`, tested above). Without excluding `TomlDate` + // from that zero-length-map special case, this would silently resolve to `[]` + // instead of aborting. Verified empirically against the real `apps/cli-go` + // `config.Load`: a bare datetime literal here fails with exactly this message, + // never resolving to an empty/partial glob list — one distinct Go type per TOML + // datetime variant (`time.Time` for the offset form, `toml.Local*` wrappers for + // the 3 zone-less "local" forms). + const dir = withConfig(["[db.migrations]", `schema_paths = ${literal}`, ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + `'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '${goType}'`, + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a bare datetime db.migrations.schema_paths ARRAY element (Go mapstructure UnconvertibleTypeError, review CLI-1958)", + () => { + // Same `TomlDate`-vs-generic-object collision as the top-level scalar case above, + // but reached through the real-array branch (`legacyGoUnconvertibleType`) instead + // of the scalar fallback. Verified empirically against `apps/cli-go`: + // `schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]` fails config load with + // exactly this message — the valid glob entry never masks the datetime's failure. + const dir = withConfig( + ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""].join( + "\n", + ), + ); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'time.Time'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a TOP-LEVEL bare datetime db.seed.sql_paths (same UnmarshalExact call as schema_paths, review CLI-1958)", + () => { + const dir = withConfig(["[db.seed]", "sql_paths = 1979-05-27T07:32:00Z", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type 'time.Time'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (Go mapstructure UnconvertibleTypeError, synthetic index 0)", + () => { + // A non-empty map isn't weakly coercible, so Go's `decodeSlice` wraps it into + // `[]any{value}` and fails decoding element 0 the same way a nested-array/table + // ARRAY element does. Verified empirically against `apps/cli-go`: + // `[db.migrations.schema_paths]\nfoo = "bar"` fails with this exact message. + const dir = withConfig(["[db.migrations.schema_paths]", 'foo = "bar"', ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type 'map[string]interface {}'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "weakly coerces a TOP-LEVEL scalar db.seed.sql_paths instead of falling back to the ['seed.sql'] default", + () => { + // The absent-key default (`["seed.sql"]`) only applies when the key is missing + // entirely — a PRESENT scalar still goes through Go's weak-decode wrap, same as + // schema_paths above. Verified empirically against `apps/cli-go`: + // `[db.seed]\nenabled = true\nsql_paths = 42` → `["42"]`, not `["seed.sql"]`. + const dir = withConfig(["[db.seed]", "enabled = true", "sql_paths = 42", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.seed.sqlPaths).toEqual(["supabase/42"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a non-scalar db.migrations.schema_paths element (Go mapstructure UnconvertibleTypeError)", + () => { + // Unlike a bool/number (weakly coerced above), a nested array/table is + // mapstructure's `UnconvertibleTypeError`, which fails `UnmarshalExact` entirely + // rather than dropping just that element. Verified empirically against + // `apps/cli-go`: `schema_paths = [[]]` fails config load with exactly this + // message, never resolving to an empty/partial glob list. + const dir = withConfig(["[db.migrations]", "schema_paths = [[]]", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse config: decoding failed due to the following error(s):\\n\\n'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index (Go mapstructure parity)", + () => { + // Verified empirically against `apps/cli-go`: a second bad entry is reported + // alongside the first (mapstructure aggregates every `UnmarshalExact` error from + // the same decode call), and an inline table decodes as `map[string]interface {}`. + const dir = withConfig( + ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""].join("\n"), + ); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'map[string]interface {}'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aborts the whole config load on a non-scalar db.seed.sql_paths element (same UnmarshalExact call as schema_paths)", + () => { + const dir = withConfig(["[db.seed]", "sql_paths = [[]]", ""].join("\n")); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", + ); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "aggregates unconvertible-entry issues from BOTH db.seed.sql_paths and db.migrations.schema_paths in one error (Go UnmarshalExact single-pass parity, review CLI-1958)", + () => { + // Go's `UnmarshalExact` decodes the WHOLE config in a SINGLE mapstructure pass: + // `decodeStructFromMap`'s per-field loop never stops at the first field's error + // — it visits every field, collects every error, then joins them all together + // at the end. So a config invalid in BOTH `Glob` fields reports BOTH, not just + // whichever field is checked first. Verified empirically against `apps/cli-go` + // (`config.Load` with `sql_paths = [[]]` + `schema_paths = [[]]`): the single + // returned error contains both lines, `db.migrations.schema_paths[0]` BEFORE + // `db.seed.sql_paths[0]` — Go's `db` struct declares `Migrations` before `Seed` + // (`pkg/config/db.go:90-91`), so mapstructure visits (and therefore reports) + // `schema_paths` first regardless of which field this reader happens to resolve + // first internally. + const dir = withConfig( + ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( + "\n", + ), + ); + return read(dir).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + const schemaIssue = + "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; + const seedIssue = + "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; + expect(message).toContain(schemaIssue); + expect(message).toContain(seedIssue); + // Both issues in ONE combined error, schema_paths first (Go's struct + // field declaration order), not two separate failures. + expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", + () => { + // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv + // (config.go:635-637), so an explicit remote value wins over the env var. + const ref = "schmschmschmschmschm"; + const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-only.sql"; + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + 'db.migrations.schema_paths = ["remote-only.sql"]', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/remote-only.sql"]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("decodes a numeric db.seed.enabled = 0 as false (Go weak-bool decode)", () => { const dir = withConfig(["[db.seed]", "enabled = 0", ""].join("\n")); return read(dir).pipe( @@ -352,7 +858,7 @@ describe("legacyReadDbToml", () => { it.effect("an explicit remote db.migrations.enabled beats SUPABASE_DB_MIGRATIONS_ENABLED", () => { // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv - // (config.go:635-637), so an explicit remote value wins over the env var. + // (config.go:724), so an explicit remote value wins over the env var. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; @@ -462,7 +968,7 @@ describe("legacyReadDbToml", () => { it.effect( "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", () => { - // Same override-tier precedence as db.migrations.enabled above (config.go:635-637). + // Same override-tier precedence as db.migrations.enabled above (config.go:724). const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-wins.sql"; @@ -494,7 +1000,7 @@ describe("legacyReadDbToml", () => { it.effect("an explicit remote experimental.pgdelta.enabled beats its SUPABASE_* env var", () => { // Go's mergeRemoteConfig applies EVERY matched-block key via v.Set (above AutomaticEnv, - // config.go:635-637), not just db/seed — so a remote experimental.pgdelta.enabled wins + // config.go:718-730), not just db/seed — so a remote experimental.pgdelta.enabled wins // over SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; @@ -549,7 +1055,7 @@ describe("legacyReadDbToml", () => { it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { // Same v.Set-above-AutomaticEnv precedence as db.migrations.enabled / pgdelta.enabled - // (config.go:635-637), but for auth.enabled specifically (CLI-1878): a matched remote + // (config.go:724), but for auth.enabled specifically (CLI-1878): a matched remote // block's auth.enabled must win over SUPABASE_AUTH_ENABLED. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_AUTH_ENABLED"]; @@ -606,7 +1112,7 @@ describe("legacyReadDbToml", () => { "an explicit remote experimental.webhooks.enabled beats its SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED env var", () => { // Same v.Set-above-AutomaticEnv precedence as auth.enabled/pgdelta.enabled - // (config.go:635-637): a matched remote block's experimental.webhooks.enabled must win + // (config.go:724): a matched remote block's experimental.webhooks.enabled must win // over SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED. Without the fix, the suppressed env value // (false) would win instead, and the merged [experimental.webhooks] section (present via // the remote block) would then fail validation ("Webhooks cannot be deactivated"). @@ -888,7 +1394,7 @@ describe("legacyReadDbToml", () => { }); it.effect("forces db.seed.enabled false when the matched remote block omits it", () => { - // Go's mergeRemoteConfig (config.go:638-640) forces db.seed.enabled=false when the + // Go's mergeRemoteConfig (config.go:726-728) forces db.seed.enabled=false when the // matched remote block itself doesn't set it — even if the base config enables it. const dir = withConfig( [ @@ -2871,4 +3377,163 @@ describe("legacyReadDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity) Effect.ensuring(restore(previous)), ); }); + + it.effect( + "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", + () => { + // Regression (review: PRRT_kwDOErm0O86XHGDL) — Go's `mergeRemoteConfig` installs the + // matched block's OWN `project_id` at viper's override tier, above `AutomaticEnv` + // (`apps/cli-go/pkg/config/config.go:718-724`); that block is selected BECAUSE its + // `project_id` equals the resolved ref, so it must win even when an unrelated + // `SUPABASE_PROJECT_ID` is set to something else entirely. + const previous = process.env["SUPABASE_PROJECT_ID"]; + process.env["SUPABASE_PROJECT_ID"] = "local"; + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig( + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("project_id")).toBe(true); + expect(Option.getOrNull(v.projectId)).toBe(ref); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.ensuring(restore(previous)), + ); + }, + ); + + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + const previous = process.env["SUPABASE_PROJECT_ID"]; + process.env["SUPABASE_PROJECT_ID"] = "env-project"; + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBeUndefined(); + expect(Option.getOrNull(v.projectId)).toBe("env-project"); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.ensuring(restore(previous)), + ); + }); +}); + +describe("legacyReadDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.template/notification", () => { + const ref = "abcdefghijklmnopqrst"; + + it.effect("tracks auth.captcha.provider when a matched remote block supplies it", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn) — `provider` is a plain string leaf, not one of + // `applyRemoteOverride`'s dynamically-keyed sections, so it must be tracked via + // `LEGACY_ENV_OVERRIDABLE_KEYS` like any other fixed-name field. + const dir = withConfig( + [ + "[auth.captcha]", + 'provider = "hcaptcha"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.captcha]", + 'provider = "turnstile"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("auth.captcha.provider")).toBe(true); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("tracks a matched remote block's auth.email.template. leaves dynamically", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn) — `auth.email.template..*` is a + // genuinely arbitrarily-keyed map, same shape as `auth.external..*`, so it must be + // flattened dynamically instead of relying on a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry. + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.email.template.invite]", + 'content_path = "remote-invite.html"', + "", + ].join("\n"), + ); + // Template `content_path` resolves relative to the project root (`workdir`, i.e. `dir`). + writeFileSync(join(dir, "remote-invite.html"), ""); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("auth.email.template.invite.content_path")).toBe(true); + expect(v.remoteOverrideKeys.has("auth.email.template.invite.subject")).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "tracks a matched remote block's auth.email.notification. leaves dynamically", + () => { + // Regression (review: PRRT_kwDOErm0O86XLAYo) — `auth.email.notification..*`'s + // sibling case, including `enabled` (a direct `legacyEnvOverrideBool` throw site). + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.email.notification.password_changed]", + "enabled = true", + 'content_path = "remote-pw-changed.html"', + "", + ].join("\n"), + ); + // Notification `content_path` resolves relative to the `supabase/` dir. + writeFileSync(join(dir, "supabase", "remote-pw-changed.html"), ""); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.enabled"), + ).toBe(true); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.content_path"), + ).toBe(true); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.subject"), + ).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.types.ts b/apps/cli/src/legacy/shared/legacy-db-config.types.ts index 11af4fc5d2..fe1dd5ca47 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.types.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.types.ts @@ -24,6 +24,11 @@ export interface LegacyDbConfigFlags { readonly dbUrl: Option.Option; readonly connType: LegacyDbConnType | undefined; readonly dnsResolver: "native" | "https"; + /** + * Whether config resolution should decrypt and materialize `[db.vault]` values. + * Defaults to true; `db push --skip-vault` is the only caller that disables it. + */ + readonly resolveVaultSecrets?: boolean; /** * The `--password` / `-p` flag value (Go's `viper.GetString("DB_PASSWORD")`, * bound via `viper.BindPFlag` in `apps/cli-go/cmd/db.go`). When `Some`, it @@ -36,7 +41,22 @@ export interface LegacyDbConfigFlags { * Optional explicit linked project ref override. Commands such as * `gen types --project-id ` need the linked DB resolver's temp-role and * pooler fallback behavior without requiring the current workdir to be linked. - * Absent for the normal `--linked` path, which still reads `.temp/project-ref`. + * The eight `db` commands that resolve a project ref (`push`, `pull`, `diff`, + * `dump`, `reset`, `lint`, `advisors`, `query`) also thread their own + * `--project-ref` flag value through here, taking effect only on the linked + * path (it does NOT imply `--linked` — those handlers reject the flag + * outright on a non-linked target instead of silently discarding it, unlike + * the env var below). `None` when the flag is unset, which preserves the + * normal `--linked` path's fallback to `.temp/project-ref`. + * + * This shares ONLY the `SUPABASE_PROJECT_ID` env var's linked-ref-resolution + * precedence (flag > env > `.temp/project-ref` file, via + * `LegacyProjectRefResolver.loadProjectRef`) — it is NOT a full substitute for + * that env var. `SUPABASE_PROJECT_ID` also drives the LOCAL container id and + * the pg-delta project id (`legacyResolvePgDeltaProjectId`, read from + * `cliConfig.projectId` in `db diff`/`db pull`/`db reset`), which this flag + * deliberately does NOT touch — the `db` commands' `--project-ref` only ever + * feeds the resolver above, never the local-side id derivation. */ readonly linkedProjectRef?: Option.Option; /** @@ -53,6 +73,32 @@ export interface LegacyDbConfigFlags { * "run supabase link" suggestion. * Absent / false for the normal `--linked` path, which is the workdir's own * project and may legitimately reuse those env vars and saved files. + * + * The eight `db` commands' `--project-ref` deliberately leave this unset: + * unlike `gen types --project-id`'s genuinely ad-hoc target, `db`'s + * `--project-ref` is meant to have identical workdir credential semantics to + * `SUPABASE_PROJECT_ID` — it may still reuse the ambient `SUPABASE_DB_PASSWORD` + * / `--password`, since forcing ad-hoc would silently break existing + * password-based workflows that already set `--project-ref` expecting + * `SUPABASE_PROJECT_ID`-equivalent behavior. The mismatched-pooler-url risk + * `adHocProjectRef` guards against for a genuinely different project is + * already rejected independently: `legacyPoolerConfigFromConnectionString` + * (`legacy-db-config.parse.ts`) verifies the saved `.temp/pooler-url`'s + * tenant ref matches the resolved ref before reusing it, so a stale pooler + * URL for a DIFFERENT project than the one `--project-ref` now selects fails + * loudly instead of silently connecting to the wrong project. + * + * Leaving this unset does NOT, however, confine the eight `db` commands to + * the workdir's saved `.temp/pooler-url` on an IPv4-only network: any + * explicit `linkedProjectRef` (this flag's own presence, independent of + * `adHocProjectRef`) additionally unlocks the Management API pooler-config + * fetch (`resolvePoolerConn`'s `fetchFromApi`) whenever that saved URL is + * absent or fails the tenant-ref check above — so `--project-ref` against an + * unlinked workdir (no saved pooler URL at all) still resolves an IPv4 + * pooler connection instead of dead-ending in the "run supabase link" IPv6 + * error. `ignoreSavedUrl` (skip a matching saved URL outright) stays keyed to + * `adHocProjectRef` alone, so a `db` command's own linked workdir's saved URL + * for the SAME ref is still reused with no API call. */ readonly adHocProjectRef?: boolean; } diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 90d9fdbf71..7194acdbf5 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Opening a Postgres connection failed. Mirrors Go's `pgx`/`pgconn` connect @@ -9,7 +14,17 @@ import { Data } from "effect"; export class LegacyDbConnectError extends Data.TaggedError("LegacyDbConnectError")<{ readonly message: string; readonly suggestion?: string; -}> {} + /** + * True when the failure was dial-level (`legacyIsDialFailure`) rather than + * a server, auth, or config error — the fresh-db bootstrap's connect retry + * keys off this field (`db-setup.ts`, #6136). + */ + readonly retryable?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * Executing a SQL statement against an open connection failed. Mirrors the Go @@ -38,7 +53,11 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ * caret under the error position (`pkg/migration/file.go:98`, `markError`). */ readonly position?: number; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A server-side `COPY (...) TO STDOUT` stream failed. Mirrors Go's @@ -57,4 +76,8 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ */ export class LegacyDbCopyError extends Data.TaggedError("LegacyDbCopyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 0af60b46c9..c2ca32c7c7 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -203,6 +203,7 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { const port = yield* Effect.promise(acquireClosedPort); const error = yield* connectFailure({ port }); expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + expect(error.retryable).toBe(true); }), ); @@ -235,6 +236,7 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { ); expect(error.suggestion).toBe(LEGACY_SUGGEST_ENV_VAR); expect(error.message).not.toContain(SENTINEL_PASSWORD); + expect(error.retryable).toBeUndefined(); }), ); @@ -259,6 +261,8 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { "Connection terminated unexpectedly", ); expect(error.suggestion).toBeUndefined(); + // An unexpected EOF is not a dial-level failure — never marked retryable. + expect(error.retryable).toBeUndefined(); }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index a05da9fd73..50e51a2048 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -14,6 +14,7 @@ import { to as pgCopyTo } from "pg-copy-streams"; import { legacyConnectFailureMessage, legacyConnectSuggestion, + legacyIsDialFailure, legacyIsSqlState, } from "./legacy-connect-errors.ts"; import { @@ -665,6 +666,7 @@ const connect = ( return new LegacyDbConnectError({ message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), + ...(legacyIsDialFailure(error) ? { retryable: true } : {}), }); }; diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index be8759fda7..808203c0c3 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; @@ -105,6 +105,7 @@ export interface LegacyDbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; + readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** * `LegacyCliConfig.projectId` (`SUPABASE_PROJECT_ID` env override only) — the @@ -161,6 +162,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush includeAll, includeRoles, includeSeed, + includeVault, dnsResolver, projectId, toml, @@ -313,7 +315,9 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } - yield* legacyUpsertVaultSecrets(session, vaultSecrets); + if (includeVault) { + yield* legacyUpsertVaultSecrets(session, vaultSecrets); + } yield* legacyApplyMigrations(session, fs, path, pending, applyError); const cacheEnabled = toml.pgDelta.enabled || @@ -339,6 +343,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush cwd: workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { enabled: cacheEnabled, @@ -346,7 +351,6 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush conn, isLocal, migrationsDir: path.join(workdir, "supabase", "migrations"), - nowMillis: yield* Clock.currentTimeMillis, }).pipe( Effect.catch((error) => output.raw( diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 1a145feceb..4ac08af9cd 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -8,22 +8,27 @@ import { basename } from "node:path"; -import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; - /** * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it * with `SUPABASE_PROJECT_ID`; when both are absent Go falls back to the working - * directory basename (`utils.Config.ProjectId` default). So the precedence is - * `SUPABASE_PROJECT_ID` → config.toml `project_id` → workdir basename. + * directory basename (`utils.Config.ProjectId` default) — UNLESS a `--project-ref` + * was resolved for this invocation, in which case `flags.LoadConfig` pre-sets + * `Config.ProjectId = ProjectRef` before ever merging the file + * (`pkg/config/config.go:561-570`), so `Eject` only reaches the basename fallback + * when that default is itself empty. `projectRefDefault` is `undefined` for + * `start`/`stop`/`status`, which have no such flag. So the full precedence is + * `SUPABASE_PROJECT_ID` → config.toml `project_id` → `--project-ref` → workdir basename. */ export function legacyResolveLocalProjectId( envProjectId: string | undefined, tomlProjectId: string | undefined, workdir: string, + projectRefDefault?: string, ): string { if (envProjectId !== undefined && envProjectId.length > 0) return envProjectId; if (tomlProjectId !== undefined && tomlProjectId.length > 0) return tomlProjectId; + if (projectRefDefault !== undefined && projectRefDefault.length > 0) return projectRefDefault; return basename(workdir); } @@ -77,35 +82,13 @@ export function localNetworkId(projectId: string) { return legacyServiceContainerName("network", projectId); } -/** - * `utils.NetId`/`DockerStart`'s network-mode resolution (`apps/cli-go/internal/utils/docker.go: - * 379-383`, `internal/utils/config.go:62`): an explicit `--network-id` flag wins, then - * `SUPABASE_NETWORK_ID` — `network-id` is one of the persistent flags Go binds to viper under - * `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` (`cmd/root.go:318-334`, same mechanism as - * `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL`), and `viper.GetString("network-id")` reads the - * (dotenv-merged) process env fresh at `DockerStart`'s own call site — deep inside container - * bring-up, well after `Config.Load`'s dotenv pass already ran — unlike `utils.Config.Hostname`, - * which is fixed once via `GetHostname()` at the `utils` package's `var` init, before `main()` - * ever runs a command's `Config.Load` (see {@link legacyGetHostname}'s own doc comment for why a - * project-dotenv-only override does NOT reach that field). Only when both the flag and the env - * are absent does Go fall back to the generated `supabase_network_` name. - * - * `db start` and `start` both compute this identically — hoisted here (rather than duplicated in - * each handler) per the "hoist before you duplicate" rule (`apps/cli/CLAUDE.md`). - */ -export function legacyResolveNetworkId( - flagValue: string | undefined, - projectId: string, - projectEnvValues: Readonly>, -): string { - if (flagValue !== undefined && flagValue.length > 0) return flagValue; - const envNetworkId = legacyViperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - projectEnvValues, - ); - if (envNetworkId.length > 0) return envNetworkId; - return localNetworkId(projectId); -} +// `utils.NetId`/`DockerStart`'s network-mode resolution has ONE home: +// `resolveDockerNetworkMode` (`shared/functions/functions-docker.ts`). An +// earlier `legacyResolveNetworkId` here fell through to `SUPABASE_NETWORK_ID` +// on an explicit-but-empty `--network-id=`, which viper's `find()` never does +// (a `Changed` pflag resolves BEFORE the env branch — see the shared helper's +// doc comment); `start`/`db start` now call the shared helper directly +// (review round on CLI-1963). /** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the * Docker label every container/volume/network created by `supabase start` carries. */ diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index adc825453f..9019cd3aa9 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -4,12 +4,13 @@ import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, legacyResolveLocalProjectId, - legacyResolveNetworkId, legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, localNetworkId, } from "./legacy-docker-ids.ts"; +import { resolveDockerNetworkMode } from "../../shared/functions/functions-docker.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -83,50 +84,59 @@ describe("legacyCliProjectFilterValue", () => { }); }); -describe("legacyResolveNetworkId", () => { +describe("resolveDockerNetworkMode composed with legacyViperEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; afterEach(() => { delete process.env[KEY]; }); + // `start`/`db start` resolve the network exactly like the `functions` + // Docker paths: the shared 3-way resolver fed by the viper-shaped + // shell/project-dotenv env read — one home, per the review round on + // CLI-1963 that deleted `legacyResolveNetworkId`'s divergent copy. + function resolve(flagValue: string | undefined, projectEnv: Record) { + return resolveDockerNetworkMode({ + explicit: flagValue, + envOverride: legacyViperEnvStringWithProjectFallback(KEY, projectEnv), + projectId: "my-app", + }); + } + it("prefers an explicit --network-id flag over everything else", () => { process.env[KEY] = "env-network"; - expect(legacyResolveNetworkId("flag-network", "my-app", { [KEY]: "toml-network" })).toBe( - "flag-network", - ); + expect(resolve("flag-network", { [KEY]: "toml-network" })).toBe("flag-network"); }); it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { process.env[KEY] = "shell-network"; - expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe("shell-network"); + expect(resolve(undefined, {})).toBe("shell-network"); }); it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { delete process.env[KEY]; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - "project-network", - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); }); it("prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", () => { process.env[KEY] = "shell-network"; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - "shell-network", - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe("shell-network"); }); it("falls back to the generated network name when the flag and env are all absent/empty", () => { delete process.env[KEY]; - expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe(localNetworkId("my-app")); - expect(legacyResolveNetworkId("", "my-app", {})).toBe(localNetworkId("my-app")); + expect(resolve(undefined, {})).toBe(localNetworkId("my-app")); + expect(resolve("", {})).toBe(localNetworkId("my-app")); + }); + + it("an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", () => { + process.env[KEY] = "env-network"; + expect(resolve("", { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); }); it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { process.env[KEY] = ""; - expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( - localNetworkId("my-app"), - ); + expect(resolve(undefined, { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index 9049accc8e..9351040caa 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -26,6 +26,8 @@ const spawnError = () => // credential-free message that still points at the likely cause. new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }); /** @@ -76,10 +78,12 @@ const concat = (chunks: ReadonlyArray): Uint8Array => { * the caller's process lifecycle differs. * * `projectEnvValues` is optional (see `legacy-docker-registry.ts`'s doc - * comment) — only `start` currently threads it through, since its caller - * already has the project's dotenv-merged values in scope; `legacy-docker-run.layer.ts` - * is a statically-composed `Layer` built before any `projectEnvValues` is - * known, so its own callers stay ambient-only for now. + * comment) — `start` and the `functions` Docker paths (`deploy`, `download`, + * `serve`, via `resolveFunctionsDockerImage`) all thread it through, since + * each already has the project's dotenv-merged values in scope by the time + * it resolves an image; `legacy-docker-run.layer.ts` is a statically-composed + * `Layer` built before any `projectEnvValues` is known, so its own callers + * stay ambient-only for now. */ export function legacyMakeDockerImageResolver( spawner: Spawner, @@ -121,12 +125,13 @@ export function legacyMakeDockerImageResolver( // aggregate. So this must default to failing, and only treat a confirmed not-found as a // cache miss — the inverse of the daemon-unreachable-only gate this replaced. if (isImageNotFoundMessage(stderr)) return false; - const hint = legacyIsDockerDaemonUnreachable(stderr) - ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` - : ""; + const daemonDown = legacyIsDockerDaemonUnreachable(stderr); + const hint = daemonDown ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` : ""; return yield* Effect.fail( new LegacyDockerRunError({ message: `failed to inspect docker image: ${stderr}${hint}`, + reason: "inspect", + daemonDown, }), ); }).pipe(Effect.scoped); @@ -303,6 +308,8 @@ export function legacyMakeDockerImageResolver( return yield* Effect.fail( new LegacyDockerRunError({ message: `failed to pull docker image from all registries: ${failures.join("; ")}`, + reason: "pull", + daemonDown: failures.some(legacyIsDockerDaemonUnreachable), }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index d76b01a168..402f8e6c1a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -1,7 +1,17 @@ +import { isDockerDaemonDownMessage } from "@supabase/stack/effect"; import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; +import { + LegacyContainerRuntimeNotFoundError, + legacyDescribeContainerCliFailure, + spawnContainerCli, +} from "./legacy-container-cli.ts"; import { LEGACY_CLI_WORKDIR_LABEL } from "./legacy-docker-ids.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -16,14 +26,43 @@ export class LegacyDockerLifecycleListError extends Data.TaggedError( "LegacyDockerLifecycleListError", )<{ readonly message: string; -}> {} +}> { + // `docker ps`/`docker volume ls` never fail because nothing matches the + // label filter — an empty match is a successful, empty result. Every real + // failure here is therefore a container-runtime problem: neither + // docker/podman could be spawned, or the daemon itself rejected the call. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -/** Inspecting a single container's state failed for a reason other than "not found". */ +/** + * Inspecting a single container's state failed for a reason other than "not + * found" — except Go's `assertContainerHealthy` (and this port, matching it, + * see `status.handler.ts`) never special-cases a missing container either: an + * absent container is just another non-zero `docker container inspect` exit, + * which is by far the dominant real trigger of this error (the user hasn't + * run `supabase start` yet) — same fix as the other "stack isn't running" + * errors elsewhere in this codebase. + */ export class LegacyDockerLifecycleInspectError extends Data.TaggedError( "LegacyDockerLifecycleInspectError", )<{ readonly message: string; -}> {} + /** + * Set at the container boundary when neither runtime can be spawned or the + * daemon is unreachable. Every other inspect failure (the dominant "stack + * isn't running yet" case) keeps the `startStack` classification. + */ + readonly daemonDown?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.daemonDown === true) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + return actionability.startStack; + } +} function collectByteStream(stream: Stream.Stream) { const decoder = new TextDecoder(); @@ -201,12 +240,15 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin stderr: "pipe", }, ).pipe( - Effect.mapError( - (cause) => - new LegacyDockerLifecycleInspectError({ - message: `failed to inspect container health: ${legacyDescribeContainerCliFailure(cause)}`, - }), - ), + Effect.mapError((cause) => { + const description = legacyDescribeContainerCliFailure(cause); + return new LegacyDockerLifecycleInspectError({ + message: `failed to inspect container health: ${description}`, + daemonDown: + cause instanceof LegacyContainerRuntimeNotFoundError || + isDockerDaemonDownMessage(description), + }); + }), ); // Concurrency is required, not cosmetic — see the matching comment in // `legacyListContainersByLabel` above. @@ -233,6 +275,7 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin message.length > 0 ? `failed to inspect container health: ${message}` : "failed to inspect container health", + daemonDown: isDockerDaemonDownMessage(message), }), ); } diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 03a802f197..628c530dad 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { LegacyDockerLifecycleInspectError, LegacyDockerLifecycleListError, @@ -290,6 +291,10 @@ describe("legacyInspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", ); + // The dominant "stack isn't running yet" case: not daemon-down, so it + // keeps the start-stack classification. + expect(error.daemonDown).toBeFalsy(); + expect(classifyCliErrorActionability(error).error_category).toBe("invalid_config"); }), ); }, @@ -304,6 +309,14 @@ describe("legacyInspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Cannot connect to the Docker daemon", ); + // A daemon-down stderr flips the discriminant so the failure classifies + // as docker-not-running instead of a broken running stack. + expect(error.daemonDown).toBe(true); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe("docker_not_running"); + expect(result.error_fingerprint).toBe( + "tag:LegacyDockerLifecycleInspectError:docker_not_running", + ); }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts index ffba39e75a..61bbc28f25 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts @@ -1,6 +1,11 @@ import { Data, Effect, Result } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { containerCliExitCode, legacyContainerCliExitCodeAndStdout, @@ -19,34 +24,58 @@ type Spawner = ChildProcessSpawner["Service"]; * cause carrying only a `.message` — same generalization pattern as `legacy-docker-lifecycle.ts`'s * `LegacyDockerLifecycleListError`/`LegacyDockerLifecycleInspectError`. Callers (`stop.handler.ts` * via `Effect.catchTags`; `legacy/shared/db-bootstrap/rollback.ts` via a blanket swallow) discriminate/consume these by - * their string `_tag`, never by importing the classes themselves, so only the union below is - * exported — matching every constructor's actual usage (confirmed via `knip`). + * their string `_tag`, never by importing the classes themselves. The classes + * are exported so the exhaustive telemetry guard can verify their declarations. */ -class LegacyDockerRemoveAllListError extends Data.TaggedError("LegacyDockerRemoveAllListError")<{ +export class LegacyDockerRemoveAllListError extends Data.TaggedError( + "LegacyDockerRemoveAllListError", +)<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -class LegacyDockerRemoveAllStopError extends Data.TaggedError("LegacyDockerRemoveAllStopError")<{ +export class LegacyDockerRemoveAllStopError extends Data.TaggedError( + "LegacyDockerRemoveAllStopError", +)<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( +export class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( "LegacyDockerRemoveAllContainerPruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( +export class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( "LegacyDockerRemoveAllVolumePruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( +export class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( "LegacyDockerRemoveAllNetworkPruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** * Extracts the deleted-object IDs/names from `docker`/`podman` `… prune` diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts index a2e6ce2a71..f4e36a889f 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts @@ -1,6 +1,32 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** Spawning or running the `docker` CLI failed (binary missing, daemon down, non-spawn failure). */ export class LegacyDockerRunError extends Data.TaggedError("LegacyDockerRunError")<{ readonly message: string; -}> {} + /** + * Structured discriminant set at the docker boundary: `spawn` when the + * container runtime could not be executed, `inspect` when image inspection + * failed, and `pull` when every registry candidate failed. + */ + readonly reason: "spawn" | "inspect" | "pull"; + /** + * Whether runtime output indicates the daemon itself is unreachable, + * detected where docker's output is produced so consumers never inspect + * `message` text. + */ + readonly daemonDown: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "spawn" || this.daemonDown) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + return this.reason === "pull" + ? { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" } + : { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index 654038bb16..59ffb7f809 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -28,6 +28,8 @@ export const legacyDockerRunLayer: Layer.Layer< // credential-free message that still points at the likely cause. new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }); const concat = (chunks: ReadonlyArray): Uint8Array => { @@ -181,6 +183,8 @@ export const legacyDockerRunLayer: Layer.Layer< () => new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-drop-objects.ts b/apps/cli/src/legacy/shared/legacy-drop-objects.ts index 523c193a12..7d7de97a93 100644 --- a/apps/cli/src/legacy/shared/legacy-drop-objects.ts +++ b/apps/cli/src/legacy/shared/legacy-drop-objects.ts @@ -1,11 +1,20 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Dropping the user schemas failed (Go's `DropUserSchemas` error). */ export class LegacyMigrationDropError extends Data.TaggedError("LegacyMigrationDropError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * The embedded `DO $$ ... $$` block from Go's `pkg/migration/queries/drop.sql`, diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts index 009d602462..2e5c457871 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Running a TypeScript program inside the edge-runtime container failed (non-zero @@ -10,4 +15,25 @@ import { Data } from "effect"; */ export class LegacyEdgeRuntimeScriptError extends Data.TaggedError("LegacyEdgeRuntimeScriptError")<{ readonly message: string; -}> {} + /** + * Threaded from a wrapped `LegacyDockerRunError` so a docker-boundary failure + * (docker daemon down or registry pull) does not misclassify as a user-SQL + * (`dbFinding`) failure. `daemon` maps to docker-not-running, `pull` to an + * external network problem. `undefined` for genuine script/config failures, + * which keep the user-SQL classification. + */ + readonly docker?: "daemon" | "inspect" | "pull"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.docker === "daemon") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (this.docker === "pull") { + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } + if (this.docker === "inspect") { + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + } + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts new file mode 100644 index 0000000000..85fe494413 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; + +describe("LegacyEdgeRuntimeScriptError actionability", () => { + it("classifies a docker-daemon failure as docker-not-running", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: ...", docker: "daemon" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("docker_not_running"); + expect(result.suggestion_type).toBe("start_docker"); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError:docker_not_running"); + }); + + it("classifies a registry-pull failure as an external network problem", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: ...", docker: "pull" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError:registry_pull"); + }); + + it("classifies an image-inspect failure as invalid config", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: ...", docker: "inspect" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError:image_inspect"); + }); + + it("classifies a non-docker script failure as a user db finding", () => { + const result = classifyCliErrorActionability( + new LegacyEdgeRuntimeScriptError({ message: "error diffing schema: exit 1:\n..." }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:LegacyEdgeRuntimeScriptError"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 8237a55331..5bf87861de 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -80,9 +80,11 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // config read happens here, not at layer acquisition, so merely composing // the db diff/pull runtime never validates the base config before the // linked ref is known (Go validates the `[remotes.]`-merged config, - // and even `db diff --use-pgadmin --linked` must not fail at layer build). - // Every pg-delta/migra caller passes `opts.denoVersion`, so the base read - // is a defensive fallback that does not run for them. + // and even `db diff --use-pgadmin --linked` — a native path since CLI-1968, + // reading config directly rather than exec'ing a Go child, and never calling + // this layer's `run` at all — must not fail at layer build). Every pg-delta/ + // migra caller passes `opts.denoVersion`, so the base read is a defensive + // fallback that does not run for them. // // Same per-run override for `workdir`: `cliConfig.workdir` is fixed at // layer-build time, before a command's own `process.chdir` (bootstrap's @@ -129,11 +131,19 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( }) // A spawn failure (e.g. Docker not installed) carries no container // stderr; wrap it with the caller's prefix like Go's `%s: %w`. + // Thread the docker discriminant so a daemon-down / registry-pull + // failure at the docker boundary is not misclassified as user SQL. .pipe( Effect.mapError( (cause) => new LegacyEdgeRuntimeScriptError({ message: `${opts.errPrefix}: ${cause.message}`, + docker: + cause.reason === "spawn" || cause.daemonDown + ? "daemon" + : cause.reason === "pull" + ? "pull" + : "inspect", }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-ensure-login.ts b/apps/cli/src/legacy/shared/legacy-ensure-login.ts index e94aa87a20..508d41c12c 100644 --- a/apps/cli/src/legacy/shared/legacy-ensure-login.ts +++ b/apps/cli/src/legacy/shared/legacy-ensure-login.ts @@ -137,7 +137,14 @@ export const legacyBrowserLogin = Effect.fnUntraced(function* (opts: LegacyBrows Effect.gen(function* () { const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { - return yield* Effect.fail(new LegacyLoginFailedError({ message: err.message })); + return yield* Effect.fail( + new LegacyLoginFailedError({ + message: err.message, + statusCode: err.statusCode, + network: err.network, + decode: err.decode, + }), + ); } yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr"); return yield* verifyWithRetries(failures); diff --git a/apps/cli/src/legacy/shared/legacy-error-message.ts b/apps/cli/src/legacy/shared/legacy-error-message.ts new file mode 100644 index 0000000000..f13d8c64fe --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-error-message.ts @@ -0,0 +1,31 @@ +/** + * Best-effort extraction of a human-readable message from an unknown thrown/failed + * value — an Effect `PlatformError`, a driver error, a plain `Error`, or anything else. + * Shared by every legacy module that wraps a raw Effect/driver failure into Go-style + * error text (Go's own `err.Error()` equivalent), so wording stays consistent across + * call sites instead of each one re-deriving its own fallback. + */ +export const legacyErrorMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * Substitutes an absolute path a real syscall needed back to its Go-equivalent + * display path inside an already-rendered error message. Go's own `fsys` is always a + * real `afero.OsFs` with the process cwd already `chdir`'ed into the workdir + * (`ChangeWorkDir`, `cmd/root.go`), so every Go error message embeds the + * workdir-relative (or verbatim-absolute) path it was actually called with. This shell + * deliberately never `process.chdir`s, so its own syscalls need a real absolute path to + * work — but the wrapped message must still report the Go-equivalent path, not the + * local temp/workdir absolute path the syscall needed, or it leaks a path Go would + * never show. Shared by every legacy module that wraps a raw filesystem failure this + * way (`legacy-sql-files-glob.ts`'s matched-file/matched-directory warnings, + * `legacy-migration-apply.ts`'s migration-file read errors). + */ +export const legacyRelativizeErrorMessage = ( + rawMessage: string, + absolutePath: string, + displayPath: string, +): string => + absolutePath === displayPath ? rawMessage : rawMessage.split(absolutePath).join(displayPath); diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts index 325f2e96bb..27ea08fe5c 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts @@ -1,5 +1,10 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveExperimental } from "../../shared/legacy/global-flags.ts"; /** @@ -35,6 +40,10 @@ export class LegacyExperimentalRequiredError extends Data.TaggedError( constructor() { super({ message: "must set the --experimental flag to run this command" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** Fails with {@link LegacyExperimentalRequiredError} unless experimental is enabled. */ diff --git a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts new file mode 100644 index 0000000000..14c52a698b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts @@ -0,0 +1,68 @@ +import { Effect } from "effect"; +import type { FunctionsGoConfigCompat } from "../../shared/functions/functions-config.ts"; +import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; + +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +/** + * Go-parity config resolution for the native `functions` Docker paths + * (`deploy`/`download`/`serve`), injected into `functions-config.ts`'s + * `loadFunctionsProjectConfig` so `shared/functions/` never imports + * `legacy/`-specific validation directly (same isolation rationale as + * `styleEmphasis`/`styleAqua`). + * + * Delegates entirely to the SAME two functions `start`/`stop`/`status` + * already share — `legacyLoadLocalProjectContext` (dotenv + config load) and + * `legacyResolveLocalConfigValues` (`Config.Validate`, one home per + * `apps/cli/CLAUDE.md`) — rather than re-implementing either. Their + * derived local-dev values (JWTs, URLs) are discarded here; only + * `projectId`/`edgeRuntimeDenoVersion` and the validation side effect + * (throws on the first Go-parity failure) matter to these three commands. + */ +export const legacyFunctionsGoConfigCompat: FunctionsGoConfigCompat = { + load: ({ projectRoot, projectRef }) => + Effect.gen(function* () { + const context = yield* legacyLoadLocalProjectContext(projectRoot, toError, projectRef); + const validated = yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + context.config, + context.hostname, + projectRoot, + context.projectEnvValues, + context.loaded?.document, + // No `[remotes.]` override-tier gating (empty set, the + // parameter default): the remote block itself already merged over + // the base config at file level via `legacyLoadLocalProjectContext`'s + // `projectRef` threading above. Known narrow divergence: without + // the key set, an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` + // still beats a matched remote block's own `deno_version`, where + // Go's OVERRIDE-tier `v.Set` would win — computing the keys here + // needs `legacy-db-config.toml-read.ts`'s remote-resolution + // pipeline, which this `loadProjectConfig`-based path doesn't run + // (review round on CLI-1963). + undefined, + projectRef, + ), + catch: toError, + }); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + // `context.projectId`, NOT `validated.projectId`: the context's id is + // the one built for Docker naming/labels — sanitized, `--project-ref` + // defaulted, and `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` + // block matched (Go installs the remote's own `project_id` at viper's + // OVERRIDE tier, above `AutomaticEnv` — `pkg/config/config.go:718-724`; + // see `legacy-local-project-context.ts`'s gate, review + // PRRT_kwDOErm0O86XHGDL). `validated.projectId` exists only to feed + // `legacyValidateResolvedConfig`'s emptiness check and deliberately + // skips that gate — see its own doc comment. + projectId: context.projectId, + denoVersion: validated.edgeRuntimeDenoVersion, + }; + }), +}; diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts index b734d16c75..cdd758940d 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -105,26 +105,63 @@ export const legacyGlobPattern = ( return result; }); +/** + * Go's `sort.Strings` compares byte-wise over each string's UTF-8 encoding; JS's default + * `Array.prototype.sort()` instead compares UTF-16 CODE UNITS, which diverges from byte/codepoint + * order for a supplementary-plane character (encoded as a surrogate pair, code units + * `0xD800`-`0xDBFF` + `0xDC00`-`0xDFFF`) alongside a BMP private-use character (`0xE000`- + * `0xFFFF`): JS ranks the surrogate pair BEFORE the private-use character (`0xD800 < 0xE000`), + * while Go's UTF-8 byte order — which preserves Unicode codepoint order — ranks the + * supplementary-plane codepoint (`>= U+10000 > U+FFFF`) AFTER it. Verified empirically: + * `["a\u{1F600}.sql","a.sql"].sort()` (default) disagrees with `Buffer.compare` on the + * same two strings' UTF-8 bytes. Used for every `sort.Strings` this module (and its callers + * across `legacy-shadow-source.ts`/`legacy-pgdelta.cache.ts`) ports, so a directory with such + * filenames applies/lists in the same order Go would. + */ +export function legacyCompareUtf8Bytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + /** * Port of Go's `walkMatchedDir` (`pkg/config/config.go:194-207`, called by `Glob.SQLFiles` on - * every directory match): a manual, non-recursing-through-`{recursive: true}` walk, because - * Go's `fs.WalkDir` never follows a symlinked `DirEntry` — its `IsDir()` is false for a - * symlink regardless of target, so `WalkDir` neither descends into a symlinked subdirectory - * nor lets `entry.Type().IsRegular()` (the `.sql`-file inclusion check) pass a symlinked file. - * The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` succeeding on a - * path IS Effect's only non-following "is this a symlink" primitive, so it stands in for that - * check at each level, both for recursion (a symlinked directory is skipped, not walked) and - * for file inclusion (a symlinked `.sql` file is skipped, not applied) — using `fs.stat` - * (which follows) here instead would silently include a symlink's target, unlike Go. Returns - * paths relative to `dir`; the caller does the single final sort over the whole aggregate, - * matching Go's one `sort.Strings(files)` after the complete walk rather than per-directory. + * every directory match) AND `afero.Walk` (`commands/db/shared/legacy-shadow-source.ts`'s + * declared-schema walkers' own Go counterpart, `apps/cli-go/internal/db/diff/diff.go:65-76, + * 86-96`) — both are `Lstat`-based and therefore never descend into a symlinked directory: + * `io/fs.WalkDir`'s doc comment: "WalkDir does not follow symbolic links found in directories, + * but if root itself is a symbolic link, its target will be walked"; `afero.walk` confirms the + * same via its own `lstatIfPossible` call (`github.com/spf13/afero/path.go`), which reports a + * symlinked subdirectory's `IsDir()` as false so the recursive `walk` call returns without + * descending. The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` + * succeeding on a path IS Effect's only non-following "is this a symlink" primitive, so it + * stands in for that check at each level, both for recursion (a symlinked directory is skipped, + * not walked) and for file inclusion (a symlinked `.sql` file is skipped, not applied) — using + * `fs.stat` (which follows) here instead would silently include a symlink's target, unlike Go. + * Deliberately never checks its OWN root (`dir`) for being a symlink — only entries it reads + * FROM that root — so each caller layers on whichever of Go's two root-symlink behaviors its + * own Go counterpart needs (`fs.WalkDir` follows a symlinked root; `afero.Walk` does not) on + * top of this shared walk. + * + * Both Go walkers byte-sort every directory level (`os.ReadDir`/`io/fs.ReadDir`'s + * `bytealg.CompareString`/`sort.Strings`, not just the final flattened result — see + * `afero/path.go`'s `readDirNames`), so the traversal order itself (which determines which + * entry's read/stat error surfaces first when a walk aborts early) uses + * {@link legacyCompareUtf8Bytes} too, not JS's default UTF-16-code-unit comparator (review: + * PRRT_kwDOErm0O86XAlIo). They also both finish with a plain `sort.Strings` over the complete + * set of collected paths (`config.go:186`/`walkMatchedDir:207`, `diff.go:75,95`) — a full + * lexicographic sort over full relative paths, NOT merely a per-directory-level sort (a + * directory's own files can byte-sort AFTER a sibling's full path even though the directory + * NAME sorts first, e.g. `"foo.sql" < "foo/bar.sql"` since `.` (`0x2E`) sorts before `/` + * (`0x2F`)) — so the final sort below is required even though entries are already read in + * sorted order at each level. Returns paths relative to `dir`. * * Hoisted here (from `legacy-migrate-and-seed.ts`, the first caller, for `db.migrations. * schema_paths`) once `legacy-seed.ts`'s `db.seed.sql_paths` resolution became a second - * caller — Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128`) is the SAME method both - * config fields resolve through (`GetPendingSeeds` calls `locals.SQLFiles(fsys)` exactly like - * `applySchemaFiles`'s `SchemaPaths.SQLFiles(fsys)`), so a matched seed directory must expand - * to its sorted regular `.sql` files exactly like a matched schema-path directory does. + * caller, and `legacy-shadow-source.ts`'s declared-schema walkers (a matched `schema_paths` + * directory, or the `supabase/schemas`/pg-delta-declarative-dir fallbacks) became a third — + * Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128`) and `afero.Walk` are the SAME shape + * (byte-sorted, no-follow, regular-`.sql`-file-filtered) every one of these config fields + * resolves through, so a matched directory must expand to its sorted regular `.sql` files + * identically regardless of which config field it came from. */ export const legacyWalkSqlFiles = ( fs: FileSystem.FileSystem, @@ -132,7 +169,7 @@ export const legacyWalkSqlFiles = ( relativePrefix: string, ): Effect.Effect, PlatformError> => Effect.gen(function* () { - const names = yield* fs.readDirectory(dir); + const names = [...(yield* fs.readDirectory(dir))].sort(legacyCompareUtf8Bytes); const files: Array = []; for (const name of names) { const absChild = `${dir}/${name}`; @@ -157,5 +194,5 @@ export const legacyWalkSqlFiles = ( files.push(relChild); } } - return files; + return files.sort(legacyCompareUtf8Bytes); }); diff --git a/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts b/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts index d4d33628e6..c24c6105d9 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts @@ -2,7 +2,12 @@ import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; -import { legacyGlobPattern, legacyResolveUnderWorkdir, legacyWalkSqlFiles } from "./legacy-glob.ts"; +import { + legacyCompareUtf8Bytes, + legacyGlobPattern, + legacyResolveUnderWorkdir, + legacyWalkSqlFiles, +} from "./legacy-glob.ts"; /** * A `FileSystem.FileSystem` that answers `readDirectory` from a fixed map (keyed by the exact @@ -129,23 +134,25 @@ const statFailure = (path: string) => /** * A `FileSystem.FileSystem` entirely backed by fixed maps: `readDirectory` answers from - * `entries`, `readLink` always fails (every entry looks like "not a symlink" to - * `legacyWalkSqlFiles`'s probe), and `stat` answers from `statTypes` — except for - * `statFailsFor`, which fails, simulating a permission/I/O error reading an entry - * `readDirectory` just listed (distinct from a benign not-a-symlink `readLink` failure). Every - * other method is `legacyWalkSqlFiles`-unreachable noise, so it's left as `FileSystem.makeNoop`'s - * default `NotFound` failure. + * `entries`, `readLink` succeeds (with a dummy target) only for paths listed in `symlinks` — + * every other entry looks like "not a symlink" to `legacyWalkSqlFiles`'s probe — and `stat` + * answers from `statTypes`, except for `statFailsFor`, which fails, simulating a + * permission/I/O error reading an entry `readDirectory` just listed (distinct from a benign + * not-a-symlink `readLink` failure). Every other method is `legacyWalkSqlFiles`-unreachable + * noise, so it's left as `FileSystem.makeNoop`'s default `NotFound` failure. */ function fakeWalkFs( entries: Record>, statTypes: Record, statFailsFor?: string, + symlinks: ReadonlySet = new Set(), ) { return Layer.succeed( FileSystem.FileSystem, FileSystem.makeNoop({ readDirectory: (dir) => Effect.succeed([...(entries[dir] ?? [])]), - readLink: (path) => Effect.fail(notASymlink(path)), + readLink: (path) => + symlinks.has(path) ? Effect.succeed("/somewhere/else") : Effect.fail(notASymlink(path)), stat: (path) => path === statFailsFor ? Effect.fail(statFailure(path)) @@ -181,7 +188,7 @@ describe("legacyWalkSqlFiles", () => { }).pipe(Effect.provide(layer)); }); - it.effect("recurses into subdirectories and sorts is left to the caller", () => { + it.effect("recurses into subdirectories, already sorted in Go's byte order", () => { const layer = fakeWalkFs( { "/schemas": ["nested", "top.sql"], "/schemas/nested": ["inner.sql"] }, { @@ -193,7 +200,65 @@ describe("legacyWalkSqlFiles", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const files = yield* legacyWalkSqlFiles(fs, "/schemas", ""); - expect([...files].sort()).toEqual(["nested/inner.sql", "top.sql"]); + // No caller-side `.sort()` — `legacyWalkSqlFiles` now returns the fully-sorted result + // itself, matching both Go walkers' own trailing `sort.Strings(files)`. + expect([...files]).toEqual(["nested/inner.sql", "top.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order (review: PRRT_kwDOErm0O86XAlIo)", + () => { + // A supplementary-plane character (U+1F600, encoded as a UTF-16 surrogate pair + // 0xD800-0xDFFF) alongside a BMP private-use character (U+E000): JS's default + // `Array.prototype.sort()` ranks the surrogate pair FIRST (0xD800 < 0xE000), while Go's + // `sort.Strings` (byte-wise over UTF-8, which preserves Unicode codepoint order) ranks + // the supplementary-plane codepoint (0x1F600 > 0xE000) AFTER it — see + // `legacyCompareUtf8Bytes`'s own doc comment. + const surrogatePair = "a\u{1f600}.sql"; + const privateUse = "a\u{e000}.sql"; + const layer = fakeWalkFs( + { "/schemas": [surrogatePair, privateUse] }, + { [`/schemas/${surrogatePair}`]: "File", [`/schemas/${privateUse}`]: "File" }, + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* legacyWalkSqlFiles(fs, "/schemas", ""); + expect([...files]).toEqual([privateUse, surrogatePair]); + expect([...files]).not.toEqual([...files].sort()); + expect([...files]).toEqual([...files].sort(legacyCompareUtf8Bytes)); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "does not descend into a symlinked subdirectory (Go's fs.WalkDir/afero.Walk no-follow)", + () => { + const layer = fakeWalkFs( + { "/schemas": ["linked", "top.sql"], "/schemas/linked": ["secret.sql"] }, + { "/schemas/linked": "Directory", "/schemas/top.sql": "File" }, + undefined, + new Set(["/schemas/linked"]), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* legacyWalkSqlFiles(fs, "/schemas", ""); + expect([...files]).toEqual(["top.sql"]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("excludes a symlinked .sql file instead of applying its target", () => { + const layer = fakeWalkFs( + { "/schemas": ["linked.sql", "top.sql"] }, + { "/schemas/linked.sql": "File", "/schemas/top.sql": "File" }, + undefined, + new Set(["/schemas/linked.sql"]), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* legacyWalkSqlFiles(fs, "/schemas", ""); + expect([...files]).toEqual(["top.sql"]); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts index fc943b7884..48cea50187 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Per-command `--output`/`-o` enums, mirroring Go. Go registers `--output` per @@ -25,7 +30,11 @@ export const LEGACY_QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; */ export class LegacyInvalidOutputFormatError extends Data.TaggedError( "LegacyInvalidOutputFormatError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** Go's `must be one of [ a | b | c ]` (`enum.go:23`, joined with `" | "`). */ export function legacyOutputFormatEnumMessage(allowed: ReadonlyArray): string { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 95402530bf..2c69427fe1 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -1,3 +1,10 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Byte-faithful reproductions of the Go CLI's `-o yaml` / `-o toml` output for * **struct** payloads (CLI-1975). @@ -1062,10 +1069,15 @@ function yamlBlockLiteral(s: string, indent: number): string { * on `snippets list -o toml`) or a `nil` element inside an inline array. */ export class LegacyGoTomlEncodeError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyGoTomlEncodeError"; constructor(message = "toml: cannot encode a map with non-string key type") { super(message); this.name = "LegacyGoTomlEncodeError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } } /** diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index 7639e83f99..08042e78bf 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -1,5 +1,6 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; +import { SupabaseApiInputError, type SupabaseApiError } from "@supabase/api/effect"; import { Effect } from "effect"; +import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; // HttpClientError reasons that indicate the server returned an actual response (vs a transport @@ -47,7 +48,10 @@ function sanitizeErrorBody(input: string): string { return out; } -export type NetworkErrorFactory = new (args: { readonly message: string }) => E; +export type NetworkErrorFactory = new (args: { + readonly message: string; + readonly decode?: boolean; +}) => E; export type StatusErrorFactory = new (args: { readonly status: number; @@ -69,9 +73,17 @@ export function mapLegacyHttpError(opts: { readonly statusError: StatusErrorFactory; readonly networkMessage: (cause: string) => string; readonly statusMessage: (status: number, body: string) => string; -}): (cause: SupabaseApiError) => Effect.Effect { +}): ( + cause: SupabaseApiError, +) => Effect.Effect { return (cause) => Effect.gen(function* () { + if (cause instanceof SupabaseApiInputError || cause instanceof HttpBody.HttpBodyError) { + // These failures occur while the generated client validates or builds + // the request. Keep their identity because this generic mapper cannot + // safely infer user provenance or reclassify them as response errors. + return yield* Effect.fail(cause); + } if (HttpClientError.isHttpClientError(cause)) { if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { const status = cause.response.status; @@ -92,9 +104,12 @@ export function mapLegacyHttpError(opts: { new opts.networkError({ message: opts.networkMessage(description) }), ); } - // SchemaError or HttpBodyError — treat as transport-level network error. + // SchemaError — the server returned a response whose body failed schema + // decoding (a 200 the generated client could not parse). This is not a + // transport failure, so flag `decode` to classify it as an API-response + // problem rather than a network problem. return yield* Effect.fail( - new opts.networkError({ message: opts.networkMessage(String(cause)) }), + new opts.networkError({ message: opts.networkMessage(String(cause)), decode: true }), ); }); } diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts new file mode 100644 index 0000000000..fa92d83d8b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-http-errors.unit.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; +import { SupabaseApiInputError } from "@supabase/api/effect"; +import { Data, Effect } from "effect"; +import * as HttpBody from "effect/unstable/http/HttpBody"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; + +import { mapLegacyHttpError } from "./legacy-http-errors.ts"; + +class TestNetworkError extends Data.TaggedError("TestNetworkError")<{ + readonly message: string; + readonly decode?: boolean; +}> {} + +class TestStatusError extends Data.TaggedError("TestStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +const mapError = mapLegacyHttpError({ + networkError: TestNetworkError, + statusError: TestStatusError, + networkMessage: (cause) => cause, + statusMessage: (status, body) => `${status}: ${body}`, +}); + +describe("mapLegacyHttpError", () => { + it.effect("preserves generated API input errors", () => + Effect.gen(function* () { + const inputError = new SupabaseApiInputError("invalid request input"); + + const error = yield* mapError(inputError).pipe(Effect.flip); + + expect(error).toBe(inputError); + expect(inputError.source).toBe("generated_client"); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "internal_bug", + error_category: "impossible_state", + error_fingerprint: "tag:SupabaseApiInputError:request_encoding", + }); + }), + ); + + it.effect("preserves request-body construction errors", () => + Effect.gen(function* () { + const bodyError = new HttpBody.HttpBodyError({ + reason: { _tag: "JsonError" }, + cause: new Error("body read failed"), + }); + + const error = yield* mapError(bodyError).pipe(Effect.flip); + + expect(error).toBe(bodyError); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "internal_bug", + error_category: "impossible_state", + error_fingerprint: "tag:HttpBodyError:request_encoding", + }); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index d62df19e18..9bcce7c616 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -12,7 +12,17 @@ import { toPublicJwk, type ThirdPartyProvidersLike, } from "../../shared/auth/jwks.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { + legacyMakeRemoteWins, + type LegacyRemoteOverridableKey, +} from "./legacy-db-config.toml-read.ts"; import { legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyApiTlsCertReadErrorMessage, @@ -144,6 +154,17 @@ export interface LegacyLocalConfigValues { readonly gcpProjectNumber: string; /** Already env-overridden `analytics.gcp_jwt_path` (`SUPABASE_ANALYTICS_GCP_JWT_PATH`). */ readonly gcpJwtPath: string; + /** + * Go's `Config.ProjectId`, sanitized (`config.go:938-944`) — the SAME + * env-overridden, `projectIdFallback`-aware value this function already + * validates internally (see `resolvedProjectId` above), just also + * returned so callers that need it for Docker resource naming (`functions` + * deploy`/`download`/`serve`) don't re-derive it with a second + * implementation that could drift from this one. + */ + readonly projectId: string; + /** Already env-overridden `edge_runtime.deno_version` (`SUPABASE_EDGE_RUNTIME_DENO_VERSION`). */ + readonly edgeRuntimeDenoVersion: number; } /** @@ -166,10 +187,14 @@ function apiUrlWithPath(apiExternalUrl: string, path: string): string { * short secret. */ export class LegacyInvalidJwtSecretError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidJwtSecretError"; constructor() { super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); this.name = "LegacyInvalidJwtSecretError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ @@ -188,10 +213,14 @@ const MIN_JWT_SECRET_LENGTH = 16; * string), but the parity-relevant part — hard-fail, same field name — is. */ export class LegacyInvalidPortEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidPortEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); this.name = "LegacyInvalidPortEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ @@ -285,10 +314,14 @@ export function legacyEnvOverride( * `stop` with a malformed bool override. */ export class LegacyInvalidBoolEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidBoolEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); this.name = "LegacyInvalidBoolEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -338,12 +371,17 @@ export function legacyEnvOverrideBool( * {@link LegacyInvalidBoolEnvOverrideError}. */ export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = + "LegacyInvalidAnalyticsBackendEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, ); this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -360,13 +398,25 @@ export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { * analytics.ts:31-39`) already guards the `config.toml`-sourced value at * decode time, so this is belt-and-suspenders for that source and the sole * guard for the env-override one, which bypasses that schema entirely. + * + * `skipEnvOverride` (default `false`) is `legacyResolveLocalConfigValues`'s `remoteWins + * ("analytics.backend")` — `analytics.backend` is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`), so a matched remote block's value must win over a + * conflicting `SUPABASE_ANALYTICS_BACKEND` the same way every other gated field in that function + * does (review: PRRT_kwDOErm0O86W30n6). Threaded as a parameter (rather than gating at the call + * site with a bare ternary) so the single validation check below still narrows `configured` + * itself to the return type on the remote-wins path — `ProjectConfig["analytics"]["backend"]`'s + * declared type is a plain `string`, not the literal union, so a call-site ternary would + * re-widen the result. */ function envOverrideAnalyticsBackend( configured: string, projectEnvValues: Readonly> | undefined, + skipEnvOverride = false, ): "postgres" | "bigquery" { - const value = - legacyEnvOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + const value = skipEnvOverride + ? configured + : (legacyEnvOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured); if (value !== "postgres" && value !== "bigquery") { throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); } @@ -381,12 +431,18 @@ function envOverrideAnalyticsBackend( * {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. */ export class LegacyInvalidRealtimeIpVersionEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = + "LegacyInvalidRealtimeIpVersionEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, ); this.name = "LegacyInvalidRealtimeIpVersionEnvOverrideError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -440,12 +496,17 @@ export function legacyEnvOverrideApiMaxRows( * {@link LegacyInvalidRealtimeIpVersionEnvOverrideError}. */ export class LegacyInvalidPoolModeEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidPoolModeEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, ); this.name = "LegacyInvalidPoolModeEnvOverrideError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -474,12 +535,18 @@ export function legacyEnvOverridePoolMode( * {@link LegacyInvalidPoolModeEnvOverrideError}. */ export class LegacyInvalidEdgeRuntimePolicyEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = + "LegacyInvalidEdgeRuntimePolicyEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, ); this.name = "LegacyInvalidEdgeRuntimePolicyEnvOverrideError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -585,58 +652,102 @@ function legacyDecryptAuthSecret( export function legacyResolveAuthEmailSmtp( authDocument: Readonly> | undefined, projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — `auth.email.smtp.enabled`/`.port`/`.pass` are in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because their ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverridePort`/`legacyEnvOverride` calls below THROW (directly, or via + * `legacyDecryptAuthSecret` for `.pass`) on a malformed override even when a matched remote + * block already set them, which would abort the whole caller (`legacyResolveLocalConfigValues`, + * and the shadow it feeds) on an env value Go silently ignores. `host`/`user`/`admin_email`/ + * `sender_name` are also in the allowlist: their `legacyEnvOverride` reads can't throw, but + * leaving them ungated is still a precedence bug, same reasoning as `auth.external.*`'s + * `client_id`/`url`/`redirect_uri`. Defaults to empty for `start.handler.ts`'s callers, which + * never resolve a `[remotes.]` block for this read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): (LegacySmtpInput & { readonly senderName: string | undefined }) | undefined { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); if (smtpDoc === undefined) return undefined; return { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", - smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, - "auth.email.smtp.enabled", - projectEnvValues, - ), - host: - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_HOST", - typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", - projectEnvValues, - ) ?? "", - port: legacyEnvOverridePort( - "SUPABASE_AUTH_EMAIL_SMTP_PORT", - typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, - "auth.email.smtp.port", - projectEnvValues, - ), - user: - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_USER", - typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", - projectEnvValues, - ) ?? "", + enabled: remoteWins("auth.email.smtp.enabled") + ? smtpDoc["enabled"] === undefined + ? true + : smtpDoc["enabled"] === true + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", + smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + "auth.email.smtp.enabled", + projectEnvValues, + ), + host: remoteWins("auth.email.smtp.host") + ? typeof smtpDoc["host"] === "string" + ? smtpDoc["host"] + : "" + : (legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_HOST", + typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", + projectEnvValues, + ) ?? ""), + port: remoteWins("auth.email.smtp.port") + ? typeof smtpDoc["port"] === "number" + ? smtpDoc["port"] + : 0 + : legacyEnvOverridePort( + "SUPABASE_AUTH_EMAIL_SMTP_PORT", + typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + "auth.email.smtp.port", + projectEnvValues, + ), + user: remoteWins("auth.email.smtp.user") + ? typeof smtpDoc["user"] === "string" + ? smtpDoc["user"] + : "" + : (legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_USER", + typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", + projectEnvValues, + ) ?? ""), // Go's `Auth.Email.Smtp.Pass` is a `config.Secret` (`pkg/config/auth.go:260`), // decrypted by `DecryptSecretHookFunc` at decode time for both the TOML // value and any env override — same treatment as `jwt_secret`/the API - // keys below, via the same `legacyDecryptAuthSecret` helper. - pass: - legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_PASS", + // keys below, via the same `legacyDecryptAuthSecret` helper. Same remote-over-env + // precedence as `.enabled`/`.port` above — `auth.email.smtp.pass` is now in + // `LEGACY_ENV_OVERRIDABLE_KEYS` because an ungated `legacyEnvOverride` call here let a + // malformed ambient `SUPABASE_AUTH_EMAIL_SMTP_PASS` outrank a matched remote's own valid + // `pass` and throw during decryption, aborting the whole caller (review: PRRT_kwDOErm0O86XJYol). + pass: remoteWins("auth.email.smtp.pass") + ? (legacyDecryptAuthSecret( typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", projectEnvValues, - ) ?? "", - projectEnvValues, - ) ?? "", - adminEmail: - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", - typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", - projectEnvValues, - ) ?? "", - senderName: legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", - typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined, - projectEnvValues, - ), + ) ?? "") + : (legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_PASS", + typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + projectEnvValues, + ) ?? "", + projectEnvValues, + ) ?? ""), + adminEmail: remoteWins("auth.email.smtp.admin_email") + ? typeof smtpDoc["admin_email"] === "string" + ? smtpDoc["admin_email"] + : "" + : (legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", + typeof smtpDoc["admin_email"] === "string" ? smtpDoc["admin_email"] : "", + projectEnvValues, + ) ?? ""), + senderName: remoteWins("auth.email.smtp.sender_name") + ? typeof smtpDoc["sender_name"] === "string" + ? smtpDoc["sender_name"] + : undefined + : legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", + typeof smtpDoc["sender_name"] === "string" ? smtpDoc["sender_name"] : undefined, + projectEnvValues, + ), }; } @@ -667,12 +778,29 @@ export function legacyResolveAuthCaptcha( authDocument: Readonly> | undefined, captcha: ProjectConfig["auth"]["captcha"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — `auth.captcha.enabled`/`.secret` are in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because their ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverride` calls below THROW (directly, or via `legacyDecryptAuthSecret` for + * `.secret`) on a malformed override even when a matched remote block already set them, which + * would abort the whole caller (`legacyResolveLocalConfigValues`, and the shadow it feeds) on + * an env value Go silently ignores. `auth.captcha.provider` can't throw the same way + * (`legacyEnvOverride` is a plain string read), but `legacyValidateResolvedConfig`'s enum check + * downstream rejects anything other than `hcaptcha`/`turnstile` — same "non-throwing read, + * throwing downstream consumer" class as `studio.api_url` (review: PRRT_kwDOErm0O86XLAYn). + * Defaults to empty for `start.handler.ts`'s callers, which never resolve a `[remotes.]` + * block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyCaptchaInput | undefined { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const captchaDoc = asRecord(authDocument?.["captcha"]); return captcha ? { - enabled: - captchaDoc !== undefined + enabled: remoteWins("auth.captcha.enabled") + ? (captcha.enabled ?? false) + : captchaDoc !== undefined ? legacyEnvOverrideBool( "SUPABASE_AUTH_CAPTCHA_ENABLED", captcha.enabled ?? false, @@ -680,18 +808,28 @@ export function legacyResolveAuthCaptcha( projectEnvValues, ) : (captcha.enabled ?? false), - provider: - captchaDoc !== undefined + provider: remoteWins("auth.captcha.provider") + ? captcha.provider + : captchaDoc !== undefined ? legacyEnvOverride( "SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues, ) : captcha.provider, + // Go's `Auth.Captcha.Secret` is a `config.Secret` (`pkg/config/auth.go:292`), decrypted + // by `DecryptSecretHookFunc` at decode time — same treatment as `auth.email.smtp.pass` + // above. Same remote-over-env precedence as `.enabled` above — `auth.captcha.secret` is + // in `LEGACY_ENV_OVERRIDABLE_KEYS` because an ungated `legacyEnvOverride` call here let a + // malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own valid + // `secret` and throw during decryption, aborting the whole caller + // (review: PRRT_kwDOErm0O86XJ4HR). secret: legacyDecryptAuthSecret( - captchaDoc !== undefined - ? legacyEnvOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) - : captcha.secret, + remoteWins("auth.captcha.secret") + ? captcha.secret + : captchaDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) + : captcha.secret, projectEnvValues, ), } @@ -817,23 +955,43 @@ function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArra * utils.Config.Auth.SigningKeys`) so the two resolvers can never disagree on * which signing key(s) apply — a prerequisite for GoTrue-issued tokens to * verify against the published JWKS at all. + * + * `remoteOverrideKeys` (default empty, so `supabase start`/`legacyResolveLocalConfigValues`'s + * OTHER callers see exactly the same behavior as before): `auth.signing_keys_path` set at + * viper's OVERRIDE tier by a matched remote block must win over a conflicting + * `SUPABASE_AUTH_SIGNING_KEYS_PATH` — this resolver's caller `legacyResolveLocalJwks` feeds the + * shadow's PG15+ one-shot auth-migration job on the `db diff --linked`/`db pull` path (CLI-1956, + * review: PRRT_kwDOErm0O86W3Ox_), and `legacyResolveLocalConfigValues`'s own `signingKey` (used + * to sign `anonKey`/`serviceRoleKey`, already remote-gated fields) reaches the same shadow. + * `auth.enabled` itself needs the identical gate: it's in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`), and an ungated `legacyEnvOverrideBool` call THROWS on a + * malformed `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` + * at viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the env var for in + * that case — which would otherwise abort this whole resolver (and the shadow it feeds) on an + * env value Go silently ignores (review: PRRT_kwDOErm0O86W30n6). */ export function legacyResolveConfiguredSigningKeys( config: ProjectConfig, workdir: string, projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray | undefined { - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadSigningKeys(workdir, signingKeysPath) : undefined; @@ -948,7 +1106,25 @@ export function legacyResolveAuthEmail( email: ProjectConfig["auth"]["email"], authDocument: Record | undefined, projectEnvValues: Readonly> | undefined, + // `remoteOverrideKeys` (default empty, so `start.handler.ts`/`db/start/start.handler.ts` — + // which never resolve a matched `[remotes.*]` block — see identical behavior to before this + // parameter existed): a matched remote's override-tier `auth.email.*` leaf must win over a + // conflicting `SUPABASE_AUTH_EMAIL_*` env var the same way every other gated field in this + // file does, and — same "throws before a value the caller needs is resolved" bug class as + // `auth.enabled`/`api.enabled` — an ungated malformed override here aborts the WHOLE + // `legacyResolveLocalConfigValues` call for the `db diff --linked`/`db pull` shadow-provisioning + // path (CLI-1956), denying the shadow every field, not just this one (review: PRRT_kwDOErm0O86XHvYh). + // Per-entry `template..*`/`notification..*` leaves (dynamically keyed, tracked via + // `LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS`/`LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS`, same shape as + // `auth.external..*`) need the identical gating: `content_path` is the field that can + // actually abort resolution (a stale/missing ambient `_CONTENT_PATH` env var wins over a + // matched remote's own valid path and makes the caller-side file read below throw); + // `subject`/`content`/notification's `enabled` can't throw the same way, but leaving them + // ungated is still a precedence bug, same reasoning as `auth.external.*`'s non-throwing fields + // (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyResolvedAuthEmail { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const emailDoc = asRecord(authDocument?.["email"]); const templateDoc = asRecord(emailDoc?.["template"]); const notificationDoc = asRecord(emailDoc?.["notification"]); @@ -956,13 +1132,16 @@ export function legacyResolveAuthEmail( const template: Record = {}; for (const [name, tmpl] of Object.entries(email.template)) { const envPrefix = `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}`; - const envSubject = legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); const rawSubjectPresent = asRecord(templateDoc?.[name])?.["subject"] !== undefined; + const envSubject = remoteWins(`auth.email.template.${name}.subject`) + ? undefined + : legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); template[name] = { subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: - legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path, + content_path: remoteWins(`auth.email.template.${name}.content_path`) + ? tmpl.content_path + : (legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path), // Go's `Content *string` is folded from `${envPrefix}_CONTENT` by the same generic // Viper/`AutomaticEnv` bind as every other field (`config.go:749`, before `Config.Validate` // at `config.go:882`) — so an env override makes `content` "present" here exactly like a raw @@ -970,77 +1149,99 @@ export function legacyResolveAuthEmail( // unless `content_path` is also set. content_present: asRecord(templateDoc?.[name])?.["content"] !== undefined || - legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined, + (remoteWins(`auth.email.template.${name}.content`) + ? false + : legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), }; } const notification: Record = {}; for (const [name, tmpl] of Object.entries(email.notification)) { const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; - const envSubject = legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); const rawSubjectPresent = asRecord(notificationDoc?.[name])?.["subject"] !== undefined; + const envSubject = remoteWins(`auth.email.notification.${name}.subject`) + ? undefined + : legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); notification[name] = { - enabled: legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - tmpl.enabled, - `auth.email.notification.${name}.enabled`, - projectEnvValues, - ), + enabled: remoteWins(`auth.email.notification.${name}.enabled`) + ? tmpl.enabled + : legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + tmpl.enabled, + `auth.email.notification.${name}.enabled`, + projectEnvValues, + ), subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: - legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path, + content_path: remoteWins(`auth.email.notification.${name}.content_path`) + ? tmpl.content_path + : (legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path), // Same `_CONTENT` env-presence fold as the template loop above. content_present: asRecord(notificationDoc?.[name])?.["content"] !== undefined || - legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined, + (remoteWins(`auth.email.notification.${name}.content`) + ? false + : legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), }; } return { ...email, - enable_signup: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", - email.enable_signup, - "auth.email.enable_signup", - projectEnvValues, - ), - double_confirm_changes: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", - email.double_confirm_changes, - "auth.email.double_confirm_changes", - projectEnvValues, - ), - enable_confirmations: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", - email.enable_confirmations, - "auth.email.enable_confirmations", - projectEnvValues, - ), - secure_password_change: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", - email.secure_password_change, - "auth.email.secure_password_change", - projectEnvValues, - ), - max_frequency: - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", - email.max_frequency, - projectEnvValues, - ) ?? email.max_frequency, - otp_length: legacyEnvOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_LENGTH", - "auth.email.otp_length", - email.otp_length, - projectEnvValues, - ), - otp_expiry: legacyEnvOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", - "auth.email.otp_expiry", - email.otp_expiry, - projectEnvValues, - ), + enable_signup: remoteWins("auth.email.enable_signup") + ? email.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", + email.enable_signup, + "auth.email.enable_signup", + projectEnvValues, + ), + double_confirm_changes: remoteWins("auth.email.double_confirm_changes") + ? email.double_confirm_changes + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", + email.double_confirm_changes, + "auth.email.double_confirm_changes", + projectEnvValues, + ), + enable_confirmations: remoteWins("auth.email.enable_confirmations") + ? email.enable_confirmations + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", + email.enable_confirmations, + "auth.email.enable_confirmations", + projectEnvValues, + ), + secure_password_change: remoteWins("auth.email.secure_password_change") + ? email.secure_password_change + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", + email.secure_password_change, + "auth.email.secure_password_change", + projectEnvValues, + ), + max_frequency: remoteWins("auth.email.max_frequency") + ? email.max_frequency + : (legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", + email.max_frequency, + projectEnvValues, + ) ?? email.max_frequency), + otp_length: remoteWins("auth.email.otp_length") + ? email.otp_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_EMAIL_OTP_LENGTH", + "auth.email.otp_length", + email.otp_length, + projectEnvValues, + ), + otp_expiry: remoteWins("auth.email.otp_expiry") + ? email.otp_expiry + : legacyEnvOverrideUint( + "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", + "auth.email.otp_expiry", + email.otp_expiry, + projectEnvValues, + ), template, notification, }; @@ -1271,12 +1472,18 @@ function legacyEnvOverrideOptionalBool( * {@link LegacyInvalidRealtimeIpVersionEnvOverrideError}. */ export class LegacyInvalidSessionReplicationRoleEnvOverrideError extends Error { + static readonly [ErrorActionabilityFingerprintId] = + "LegacyInvalidSessionReplicationRoleEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, ); this.name = "LegacyInvalidSessionReplicationRoleEnvOverrideError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -1318,135 +1525,188 @@ function legacyEnvOverrideSessionReplicationRole( * serialize — mirroring the `db.port`/`db.major_version`-style fix already * applied at this same `start` call site, just fanned out across every * `[db.settings]` field instead of one. + * + * `remoteOverrideKeys` (default empty, so `db start`/`db reset` — which never resolve a + * `[remotes.]` block for this config read — see exactly the same behavior as + * before): the `db.settings.*` keys a matched remote block set at viper's OVERRIDE tier + * (`v.Set`, above `AutomaticEnv`, `apps/cli-go/pkg/config/config.go:724`) — a remote + * value for, say, `max_connections` must beat a conflicting `SUPABASE_DB_SETTINGS_MAX_ + * CONNECTIONS`, exactly like `legacy-db-config.toml-read.ts`'s own `db.major_version` + * gate. `db diff --linked`/`db pull` (CLI-1956) pass the set their sibling `legacyReadDbToml` + * call already computed, via `legacyBuildLocalDbContainerInputs`. */ export function legacyResolveDbSettingsEnvOverrides( settings: ProjectConfig["db"]["settings"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): NonNullable { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); return { - effective_cache_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - settings?.effective_cache_size, - projectEnvValues, - ), - logical_decoding_work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - settings?.logical_decoding_work_mem, - projectEnvValues, - ), - maintenance_work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - settings?.maintenance_work_mem, - projectEnvValues, - ), - max_connections: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "db.settings.max_connections", - settings?.max_connections, - projectEnvValues, - ), - max_locks_per_transaction: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "db.settings.max_locks_per_transaction", - settings?.max_locks_per_transaction, - projectEnvValues, - ), - max_parallel_maintenance_workers: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "db.settings.max_parallel_maintenance_workers", - settings?.max_parallel_maintenance_workers, - projectEnvValues, - ), - max_parallel_workers: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "db.settings.max_parallel_workers", - settings?.max_parallel_workers, - projectEnvValues, - ), - max_parallel_workers_per_gather: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "db.settings.max_parallel_workers_per_gather", - settings?.max_parallel_workers_per_gather, - projectEnvValues, - ), - max_replication_slots: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "db.settings.max_replication_slots", - settings?.max_replication_slots, - projectEnvValues, - ), - max_slot_wal_keep_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - settings?.max_slot_wal_keep_size, - projectEnvValues, - ), - max_standby_archive_delay: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - settings?.max_standby_archive_delay, - projectEnvValues, - ), - max_standby_streaming_delay: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - settings?.max_standby_streaming_delay, - projectEnvValues, - ), - max_wal_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", - settings?.max_wal_size, - projectEnvValues, - ), - max_wal_senders: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "db.settings.max_wal_senders", - settings?.max_wal_senders, - projectEnvValues, - ), - max_worker_processes: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "db.settings.max_worker_processes", - settings?.max_worker_processes, - projectEnvValues, - ), - session_replication_role: legacyEnvOverrideSessionReplicationRole( - settings?.session_replication_role, - projectEnvValues, - ), - shared_buffers: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - settings?.shared_buffers, - projectEnvValues, - ), - statement_timeout: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - settings?.statement_timeout, - projectEnvValues, - ), - track_activity_query_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - settings?.track_activity_query_size, - projectEnvValues, - ), - track_commit_timestamp: legacyEnvOverrideOptionalBool( - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - settings?.track_commit_timestamp, - "db.settings.track_commit_timestamp", - projectEnvValues, - ), - wal_keep_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - settings?.wal_keep_size, - projectEnvValues, - ), - wal_sender_timeout: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - settings?.wal_sender_timeout, - projectEnvValues, - ), - work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WORK_MEM", - settings?.work_mem, - projectEnvValues, - ), + effective_cache_size: remoteWins("db.settings.effective_cache_size") + ? settings?.effective_cache_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", + settings?.effective_cache_size, + projectEnvValues, + ), + logical_decoding_work_mem: remoteWins("db.settings.logical_decoding_work_mem") + ? settings?.logical_decoding_work_mem + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", + settings?.logical_decoding_work_mem, + projectEnvValues, + ), + maintenance_work_mem: remoteWins("db.settings.maintenance_work_mem") + ? settings?.maintenance_work_mem + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", + settings?.maintenance_work_mem, + projectEnvValues, + ), + max_connections: remoteWins("db.settings.max_connections") + ? settings?.max_connections + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", + "db.settings.max_connections", + settings?.max_connections, + projectEnvValues, + ), + max_locks_per_transaction: remoteWins("db.settings.max_locks_per_transaction") + ? settings?.max_locks_per_transaction + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", + "db.settings.max_locks_per_transaction", + settings?.max_locks_per_transaction, + projectEnvValues, + ), + max_parallel_maintenance_workers: remoteWins("db.settings.max_parallel_maintenance_workers") + ? settings?.max_parallel_maintenance_workers + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", + "db.settings.max_parallel_maintenance_workers", + settings?.max_parallel_maintenance_workers, + projectEnvValues, + ), + max_parallel_workers: remoteWins("db.settings.max_parallel_workers") + ? settings?.max_parallel_workers + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", + "db.settings.max_parallel_workers", + settings?.max_parallel_workers, + projectEnvValues, + ), + max_parallel_workers_per_gather: remoteWins("db.settings.max_parallel_workers_per_gather") + ? settings?.max_parallel_workers_per_gather + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", + "db.settings.max_parallel_workers_per_gather", + settings?.max_parallel_workers_per_gather, + projectEnvValues, + ), + max_replication_slots: remoteWins("db.settings.max_replication_slots") + ? settings?.max_replication_slots + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", + "db.settings.max_replication_slots", + settings?.max_replication_slots, + projectEnvValues, + ), + max_slot_wal_keep_size: remoteWins("db.settings.max_slot_wal_keep_size") + ? settings?.max_slot_wal_keep_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", + settings?.max_slot_wal_keep_size, + projectEnvValues, + ), + max_standby_archive_delay: remoteWins("db.settings.max_standby_archive_delay") + ? settings?.max_standby_archive_delay + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", + settings?.max_standby_archive_delay, + projectEnvValues, + ), + max_standby_streaming_delay: remoteWins("db.settings.max_standby_streaming_delay") + ? settings?.max_standby_streaming_delay + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", + settings?.max_standby_streaming_delay, + projectEnvValues, + ), + max_wal_size: remoteWins("db.settings.max_wal_size") + ? settings?.max_wal_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", + settings?.max_wal_size, + projectEnvValues, + ), + max_wal_senders: remoteWins("db.settings.max_wal_senders") + ? settings?.max_wal_senders + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", + "db.settings.max_wal_senders", + settings?.max_wal_senders, + projectEnvValues, + ), + max_worker_processes: remoteWins("db.settings.max_worker_processes") + ? settings?.max_worker_processes + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", + "db.settings.max_worker_processes", + settings?.max_worker_processes, + projectEnvValues, + ), + session_replication_role: remoteWins("db.settings.session_replication_role") + ? settings?.session_replication_role + : legacyEnvOverrideSessionReplicationRole( + settings?.session_replication_role, + projectEnvValues, + ), + shared_buffers: remoteWins("db.settings.shared_buffers") + ? settings?.shared_buffers + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", + settings?.shared_buffers, + projectEnvValues, + ), + statement_timeout: remoteWins("db.settings.statement_timeout") + ? settings?.statement_timeout + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", + settings?.statement_timeout, + projectEnvValues, + ), + track_activity_query_size: remoteWins("db.settings.track_activity_query_size") + ? settings?.track_activity_query_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", + settings?.track_activity_query_size, + projectEnvValues, + ), + track_commit_timestamp: remoteWins("db.settings.track_commit_timestamp") + ? settings?.track_commit_timestamp + : legacyEnvOverrideOptionalBool( + "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", + settings?.track_commit_timestamp, + "db.settings.track_commit_timestamp", + projectEnvValues, + ), + wal_keep_size: remoteWins("db.settings.wal_keep_size") + ? settings?.wal_keep_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", + settings?.wal_keep_size, + projectEnvValues, + ), + wal_sender_timeout: remoteWins("db.settings.wal_sender_timeout") + ? settings?.wal_sender_timeout + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", + settings?.wal_sender_timeout, + projectEnvValues, + ), + work_mem: remoteWins("db.settings.work_mem") + ? settings?.work_mem + : legacyEnvOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), }; } @@ -1501,15 +1761,29 @@ function asRecord(value: unknown): Record | undefined { * this single standalone helper instead of independent per-caller derivations. Hoisted here (was * private to `start/start.handler.ts`) once `db/start/start.handler.ts`'s own native container * bootstrap became a third caller — see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * + * `remoteOverrideKeys` (default empty, so `db start`/`supabase start` — which never resolve a + * `[remotes.]` block for this config read — see exactly the same behavior as before): + * `auth.external_url` set at viper's OVERRIDE tier by a matched remote block + * (`apps/cli-go/pkg/config/config.go:718-730`) must win over a conflicting + * `SUPABASE_AUTH_EXTERNAL_URL`, matching the `db.root_key`/`auth.jwt_secret`-style gates already + * applied elsewhere in this file — `db diff --linked`/`db pull` (CLI-1956) pass the set their + * sibling `legacyReadDbToml` call already computed, via `legacyBuildLocalDbContainerInputs` + * (review: PRRT_kwDOErm0O86W3Ox_). */ export function legacyResolveAuthExternalUrl( document: Readonly> | undefined, projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): string | undefined { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; + const configuredAuthExternalUrl = + typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined; + if (remoteWins("auth.external_url")) return configuredAuthExternalUrl; return legacyEnvOverride( "SUPABASE_AUTH_EXTERNAL_URL", - typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined, + configuredAuthExternalUrl, projectEnvValues, ); } @@ -1573,29 +1847,53 @@ export function legacyResolveAuthHooks( authDocument: Readonly> | undefined, hook: ProjectConfig["auth"]["hook"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — every `auth.hook..{enabled,uri,secrets}` leaf is in + * `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) because Go's + * `mergeRemoteConfig` flattens the WHOLE matched block via `u.AllKeys()` and applies every + * leaf — not just `enabled` — with `v.Set` (override tier, above `AutomaticEnv`, + * `apps/cli-go/pkg/config/config.go:718-724`). `enabled`'s ungated `legacyEnvOverrideBool` + * call additionally THROWS on a malformed override even when a matched remote block already + * set it, which would abort the whole caller (`legacyResolveLocalConfigValues`, and the shadow + * it feeds) on an env value Go silently ignores. `uri`/`secrets` can't throw the same way + * (plain `legacyEnvOverride`), but leaving them ungated is still a precedence bug: a remote's + * valid `uri` must beat a stale/malformed `SUPABASE_AUTH_HOOK__URI`, otherwise + * `legacyValidateResolvedConfig`'s scheme check can reject a linked diff/pull that Go would + * accept (review: PRRT_kwDOErm0O86XGTq5). Defaults to empty for `start.handler.ts`'s callers, + * which never resolve a `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyResolvedAuthHooks { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const hookDocument = asRecord(authDocument?.["hook"]); const result = {} as Record; for (const hookType of LEGACY_HOOK_TYPE_ORDER) { const h = hook[hookType]; const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; - const enabled = hookSectionPresent - ? legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - h.enabled, - `auth.hook.${hookType}.enabled`, - projectEnvValues, - ) - : h.enabled; + const enabled = remoteWins(`auth.hook.${hookType}.enabled`) + ? h.enabled + : hookSectionPresent + ? legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + h.enabled, + `auth.hook.${hookType}.enabled`, + projectEnvValues, + ) + : h.enabled; const uri = - (hookSectionPresent - ? legacyEnvOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) - : h.uri) ?? ""; + (remoteWins(`auth.hook.${hookType}.uri`) + ? h.uri + : hookSectionPresent + ? legacyEnvOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) + : h.uri) ?? ""; const secrets = - (hookSectionPresent - ? legacyEnvOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) - : h.secrets) ?? ""; + (remoteWins(`auth.hook.${hookType}.secrets`) + ? h.secrets + : hookSectionPresent + ? legacyEnvOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) + : h.secrets) ?? ""; result[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]] = { enabled, uri, secrets }; } return result as LegacyResolvedAuthHooks; @@ -1619,74 +1917,107 @@ export function legacyResolveAuthHooks( export function legacyResolveAuthMfa( mfa: ProjectConfig["auth"]["mfa"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — every throw-capable `auth.mfa.*` leaf below (`enroll_enabled`/`verify_enabled` + * per factor, `phone.otp_length`, `max_enrolled_factors`) is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because its ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverrideUint` call THROWS on a malformed override even when a matched remote block + * already set it, which would abort the whole caller (`legacyResolveLocalConfigValues`, and the + * shadow it feeds) on an env value Go silently ignores. `phone.template`/`.max_frequency` are + * also in the allowlist: their `legacyEnvOverride` reads can't throw, but leaving them ungated + * is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + * `redirect_uri`. Defaults to empty for `start.handler.ts`'s callers, which never resolve a + * `[remotes.]` block for this read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): ProjectConfig["auth"]["mfa"] { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); return { totp: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", - mfa.totp.enroll_enabled, - "auth.mfa.totp.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", - mfa.totp.verify_enabled, - "auth.mfa.totp.verify_enabled", - projectEnvValues, - ), + enroll_enabled: remoteWins("auth.mfa.totp.enroll_enabled") + ? mfa.totp.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", + mfa.totp.enroll_enabled, + "auth.mfa.totp.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteWins("auth.mfa.totp.verify_enabled") + ? mfa.totp.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", + mfa.totp.verify_enabled, + "auth.mfa.totp.verify_enabled", + projectEnvValues, + ), }, phone: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", - mfa.phone.enroll_enabled, - "auth.mfa.phone.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", - mfa.phone.verify_enabled, - "auth.mfa.phone.verify_enabled", - projectEnvValues, - ), - otp_length: legacyEnvOverrideUint( - "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", - "auth.mfa.phone.otp_length", - mfa.phone.otp_length, - projectEnvValues, - ), - template: - legacyEnvOverride( - "SUPABASE_AUTH_MFA_PHONE_TEMPLATE", - mfa.phone.template, - projectEnvValues, - ) ?? mfa.phone.template, - max_frequency: - legacyEnvOverride( - "SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", - mfa.phone.max_frequency, - projectEnvValues, - ) ?? mfa.phone.max_frequency, + enroll_enabled: remoteWins("auth.mfa.phone.enroll_enabled") + ? mfa.phone.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", + mfa.phone.enroll_enabled, + "auth.mfa.phone.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteWins("auth.mfa.phone.verify_enabled") + ? mfa.phone.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", + mfa.phone.verify_enabled, + "auth.mfa.phone.verify_enabled", + projectEnvValues, + ), + otp_length: remoteWins("auth.mfa.phone.otp_length") + ? mfa.phone.otp_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", + "auth.mfa.phone.otp_length", + mfa.phone.otp_length, + projectEnvValues, + ), + template: remoteWins("auth.mfa.phone.template") + ? mfa.phone.template + : (legacyEnvOverride( + "SUPABASE_AUTH_MFA_PHONE_TEMPLATE", + mfa.phone.template, + projectEnvValues, + ) ?? mfa.phone.template), + max_frequency: remoteWins("auth.mfa.phone.max_frequency") + ? mfa.phone.max_frequency + : (legacyEnvOverride( + "SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", + mfa.phone.max_frequency, + projectEnvValues, + ) ?? mfa.phone.max_frequency), }, web_authn: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", - mfa.web_authn.enroll_enabled, - "auth.mfa.web_authn.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", - mfa.web_authn.verify_enabled, - "auth.mfa.web_authn.verify_enabled", - projectEnvValues, - ), + enroll_enabled: remoteWins("auth.mfa.web_authn.enroll_enabled") + ? mfa.web_authn.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", + mfa.web_authn.enroll_enabled, + "auth.mfa.web_authn.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteWins("auth.mfa.web_authn.verify_enabled") + ? mfa.web_authn.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", + mfa.web_authn.verify_enabled, + "auth.mfa.web_authn.verify_enabled", + projectEnvValues, + ), }, - max_enrolled_factors: legacyEnvOverrideUint( - "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", - "auth.mfa.max_enrolled_factors", - mfa.max_enrolled_factors, - projectEnvValues, - ), + max_enrolled_factors: remoteWins("auth.mfa.max_enrolled_factors") + ? mfa.max_enrolled_factors + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", + "auth.mfa.max_enrolled_factors", + mfa.max_enrolled_factors, + projectEnvValues, + ), }; } @@ -1967,105 +2298,137 @@ export function legacyResolveGotrueOAuthServer( * need the same eager `auth.third_party.*` resolution to reproduce Go's unconditional * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" (review: * PRRT_kwDOErm0O86WXFqj). + * + * `remoteOverrideKeys` (default empty, so neither existing caller's behavior changes): each + * `auth.third_party..*` field is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) and `legacyEnvOverrideBool` THROWS on a malformed override, + * so an ungated call here would abort this whole function (and the shadow it feeds via + * `legacyBuildLocalDbContainerInputs`) on a malformed `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED` even + * when a matched remote block already set that provider's field at viper's OVERRIDE tier — same + * `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6). */ export function legacyResolveThirdPartyProviders( thirdParty: ProjectConfig["auth"]["third_party"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const resolved: Array = []; if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - thirdParty.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.firebase.enabled") + ? thirdParty.firebase.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + thirdParty.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "firebase", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - thirdParty.firebase.project_id, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.firebase.project_id") + ? thirdParty.firebase.project_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + thirdParty.firebase.project_id, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - thirdParty.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.auth0.enabled") + ? thirdParty.auth0.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + thirdParty.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "auth0", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - thirdParty.auth0.tenant, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.auth0.tenant") + ? thirdParty.auth0.tenant + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + thirdParty.auth0.tenant, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - thirdParty.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.aws_cognito.enabled") + ? thirdParty.aws_cognito.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + thirdParty.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "cognito", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - thirdParty.aws_cognito.user_pool_id, - projectEnvValues, - ) ?? "", - cognitoUserPoolRegion: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - thirdParty.aws_cognito.user_pool_region, - projectEnvValues, - ), + (remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? thirdParty.aws_cognito.user_pool_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + thirdParty.aws_cognito.user_pool_id, + projectEnvValues, + )) ?? "", + cognitoUserPoolRegion: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? thirdParty.aws_cognito.user_pool_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + thirdParty.aws_cognito.user_pool_region, + projectEnvValues, + ), }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - thirdParty.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.clerk.enabled") + ? thirdParty.clerk.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + thirdParty.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "clerk", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - thirdParty.clerk.domain, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.clerk.domain") + ? thirdParty.clerk.domain + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + thirdParty.clerk.domain, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - thirdParty.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.workos.enabled") + ? thirdParty.workos.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + thirdParty.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "workos", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - thirdParty.workos.issuer_url, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.workos.issuer_url") + ? thirdParty.workos.issuer_url + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + thirdParty.workos.issuer_url, + projectEnvValues, + )) ?? "", }); } return resolved; @@ -2122,8 +2485,25 @@ export function legacyResolveAuthSms( authDocument: Readonly> | undefined, sms: ProjectConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as every other gated resolver in this file. Reachable from + * the `db diff --linked`/`db pull` shadow path via `validateAuthSmsProviders`, called + * unconditionally from `legacyResolveLocalConfigValues` whenever `authEnabled` — a prior review + * (PRRT_kwDOErm0O86XFmjZ) rejected this gap as "unreachable," having only grepped direct + * `legacyResolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and + * missed this file's own `validateAuthSmsProviders` wrapper. `enable_signup`/ + * `enable_confirmations`/each provider's `enabled` THROW via `legacyEnvOverrideBool`, and each + * provider's Secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`, + * `pkg/config/auth.go:339,345,351,358`) THROWS via `legacyDecryptAuthSecret` — either can abort + * this whole call (and the shadow it feeds) on a malformed ambient `SUPABASE_AUTH_SMS_*` + * override even when a matched remote block already set that field at viper's OVERRIDE tier. + * Defaults to empty for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never + * resolve a `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): ProjectConfig["auth"]["sms"] { const smsDoc = asRecord(authDocument?.["sms"]); + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); function providerPresent(providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number]): boolean { // `twilio` is always considered present — see this function's doc comment. @@ -2135,6 +2515,7 @@ export function legacyResolveAuthSms( providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number], configured: boolean, ): boolean { + if (remoteWins(`auth.sms.${providerName}.enabled`)) return configured; if (!providerPresent(providerName)) return configured; return legacyEnvOverrideBool( `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_ENABLED`, @@ -2144,11 +2525,19 @@ export function legacyResolveAuthSms( ); } + // `remoteOverrideKey` is passed in explicitly (rather than reconstructed from + // `providerName`/`field` internally, the way `resolveEnabled` does for the fixed `.enabled` + // suffix) because `field` here ranges over a different, non-uniform set per provider — a + // reconstructed template type would have to admit every provider × field combination, most of + // which aren't real config keys, defeating the point of typing this against + // `LegacyRemoteOverridableKey` at all. function resolveField( providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number], field: string, + remoteOverrideKey: LegacyRemoteOverridableKey, configured: string | undefined, ): string | undefined { + if (remoteWins(remoteOverrideKey)) return configured; if (!providerPresent(providerName)) return configured; return legacyEnvOverride( `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_${field.toUpperCase()}`, @@ -2157,6 +2546,21 @@ export function legacyResolveAuthSms( ); } + /** Resolves a provider's Secret-typed field, gated the same way `auth.email.smtp.pass` is. */ + function resolveSecretField( + providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number], + field: string, + remoteOverrideKey: LegacyRemoteOverridableKey, + configured: string | undefined, + ): string | undefined { + return remoteWins(remoteOverrideKey) + ? legacyDecryptAuthSecret(configured, projectEnvValues) + : legacyDecryptAuthSecret( + resolveField(providerName, field, remoteOverrideKey, configured), + projectEnvValues, + ); + } + const twilioEnabled = resolveEnabled("twilio", sms.twilio.enabled); const twilioVerifyEnabled = resolveEnabled("twilio_verify", sms.twilio_verify.enabled); const messagebirdEnabled = resolveEnabled("messagebird", sms.messagebird.enabled); @@ -2164,12 +2568,14 @@ export function legacyResolveAuthSms( const vonageEnabled = resolveEnabled("vonage", sms.vonage.enabled); const anyProviderEnabled = twilioEnabled || twilioVerifyEnabled || messagebirdEnabled || textlocalEnabled || vonageEnabled; - const enableSignupConfigured = legacyEnvOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ); + const enableSignupConfigured = remoteWins("auth.sms.enable_signup") + ? sms.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", + sms.enable_signup, + "auth.sms.enable_signup", + projectEnvValues, + ); return { ...sms, @@ -2178,64 +2584,108 @@ export function legacyResolveAuthSms( // `EnableSignup = false` before `buildGotrueEnv` ever reads it, so phone signup is never // enabled with no provider configured to actually deliver an OTP. enable_signup: anyProviderEnabled ? enableSignupConfigured : false, - enable_confirmations: legacyEnvOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", - sms.enable_confirmations, - "auth.sms.enable_confirmations", - projectEnvValues, - ), - template: - legacyEnvOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? - sms.template, - max_frequency: - legacyEnvOverride("SUPABASE_AUTH_SMS_MAX_FREQUENCY", sms.max_frequency, projectEnvValues) ?? - sms.max_frequency, + enable_confirmations: remoteWins("auth.sms.enable_confirmations") + ? sms.enable_confirmations + : legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", + sms.enable_confirmations, + "auth.sms.enable_confirmations", + projectEnvValues, + ), + template: remoteWins("auth.sms.template") + ? sms.template + : (legacyEnvOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? + sms.template), + max_frequency: remoteWins("auth.sms.max_frequency") + ? sms.max_frequency + : (legacyEnvOverride( + "SUPABASE_AUTH_SMS_MAX_FREQUENCY", + sms.max_frequency, + projectEnvValues, + ) ?? sms.max_frequency), twilio: { enabled: twilioEnabled, - account_sid: resolveField("twilio", "account_sid", sms.twilio.account_sid) ?? "", + account_sid: + resolveField( + "twilio", + "account_sid", + "auth.sms.twilio.account_sid", + sms.twilio.account_sid, + ) ?? "", message_service_sid: - resolveField("twilio", "message_service_sid", sms.twilio.message_service_sid) ?? "", - auth_token: legacyDecryptAuthSecret( - resolveField("twilio", "auth_token", sms.twilio.auth_token), - projectEnvValues, + resolveField( + "twilio", + "message_service_sid", + "auth.sms.twilio.message_service_sid", + sms.twilio.message_service_sid, + ) ?? "", + auth_token: resolveSecretField( + "twilio", + "auth_token", + "auth.sms.twilio.auth_token", + sms.twilio.auth_token, ), }, twilio_verify: { enabled: twilioVerifyEnabled, - account_sid: resolveField("twilio_verify", "account_sid", sms.twilio_verify.account_sid), + account_sid: resolveField( + "twilio_verify", + "account_sid", + "auth.sms.twilio_verify.account_sid", + sms.twilio_verify.account_sid, + ), message_service_sid: resolveField( "twilio_verify", "message_service_sid", + "auth.sms.twilio_verify.message_service_sid", sms.twilio_verify.message_service_sid, ), - auth_token: legacyDecryptAuthSecret( - resolveField("twilio_verify", "auth_token", sms.twilio_verify.auth_token), - projectEnvValues, + auth_token: resolveSecretField( + "twilio_verify", + "auth_token", + "auth.sms.twilio_verify.auth_token", + sms.twilio_verify.auth_token, ), }, messagebird: { enabled: messagebirdEnabled, - originator: resolveField("messagebird", "originator", sms.messagebird.originator), - access_key: legacyDecryptAuthSecret( - resolveField("messagebird", "access_key", sms.messagebird.access_key), - projectEnvValues, + originator: resolveField( + "messagebird", + "originator", + "auth.sms.messagebird.originator", + sms.messagebird.originator, + ), + access_key: resolveSecretField( + "messagebird", + "access_key", + "auth.sms.messagebird.access_key", + sms.messagebird.access_key, ), }, textlocal: { enabled: textlocalEnabled, - sender: resolveField("textlocal", "sender", sms.textlocal.sender), - api_key: legacyDecryptAuthSecret( - resolveField("textlocal", "api_key", sms.textlocal.api_key), - projectEnvValues, + sender: resolveField( + "textlocal", + "sender", + "auth.sms.textlocal.sender", + sms.textlocal.sender, + ), + api_key: resolveSecretField( + "textlocal", + "api_key", + "auth.sms.textlocal.api_key", + sms.textlocal.api_key, ), }, vonage: { enabled: vonageEnabled, - from: resolveField("vonage", "from", sms.vonage.from), - api_key: resolveField("vonage", "api_key", sms.vonage.api_key), - api_secret: legacyDecryptAuthSecret( - resolveField("vonage", "api_secret", sms.vonage.api_secret), - projectEnvValues, + from: resolveField("vonage", "from", "auth.sms.vonage.from", sms.vonage.from), + api_key: resolveField("vonage", "api_key", "auth.sms.vonage.api_key", sms.vonage.api_key), + api_secret: resolveSecretField( + "vonage", + "api_secret", + "auth.sms.vonage.api_secret", + sms.vonage.api_secret, ), }, }; @@ -2252,8 +2702,9 @@ function validateAuthSmsProviders( authDocument: Record | undefined, sms: ProjectConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): void { - const resolved = legacyResolveAuthSms(authDocument, sms, projectEnvValues); + const resolved = legacyResolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); function requireField(provider: string, field: string, value: string | undefined): void { if (value === undefined || value.length === 0) { @@ -2407,7 +2858,25 @@ export function legacyResolveAuthExternalProviders( authDocument: Readonly> | undefined, external: ProjectConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as every other gated resolver in this file — + * `auth.external..*` leaves are tracked dynamically in `applyRemoteOverride` + * (`legacy-db-config.toml-read.ts`), not via a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry, + * since provider names are an arbitrary/custom-keyed map (see this function's own doc comment + * above). `enabled`/`skip_nonce_check`/`email_optional` THROW via `legacyEnvOverrideBool` and + * `secret` THROWS via `legacyDecryptAuthSecret` (`Secret`-typed, `pkg/config/auth.go:364`) on a + * malformed override even when a matched remote block already set that field, which would abort + * the whole caller (`legacyResolveLocalConfigValues`, and the shadow it feeds) on a value Go's + * `v.Set` (override tier) silently ignores; `client_id`/`url`/`redirect_uri` can't throw the + * same way, but leaving them ungated is still a precedence bug — a remote's valid value must + * beat a stale `SUPABASE_AUTH_EXTERNAL__*` env var, same reasoning as + * `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XKYiF). Defaults to empty + * for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never resolve a + * `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): Record { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); const externalDoc = asRecord(authDocument?.["external"]); const result: Record = {}; @@ -2458,36 +2927,48 @@ export function legacyResolveAuthExternalProviders( ); result[name] = { - enabled: legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - configuredEnabled, - `auth.external.${name}.enabled`, - projectEnvValues, - ), + enabled: remoteWins(`auth.external.${name}.enabled`) + ? configuredEnabled + : legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + configuredEnabled, + `auth.external.${name}.enabled`, + projectEnvValues, + ), clientId: - legacyEnvOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues) ?? "", - secret: legacyDecryptAuthSecret( - legacyEnvOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), - projectEnvValues, - ), - url: legacyEnvOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues) ?? "", - redirectUri: legacyEnvOverride( - `${envPrefix}_REDIRECT_URI`, - configuredRedirectUri, - projectEnvValues, - ), - skipNonceCheck: legacyEnvOverrideBool( - `${envPrefix}_SKIP_NONCE_CHECK`, - configuredSkipNonceCheck, - `auth.external.${name}.skip_nonce_check`, - projectEnvValues, - ), - emailOptional: legacyEnvOverrideBool( - `${envPrefix}_EMAIL_OPTIONAL`, - configuredEmailOptional, - `auth.external.${name}.email_optional`, - projectEnvValues, - ), + (remoteWins(`auth.external.${name}.client_id`) + ? configuredClientId + : legacyEnvOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? + "", + secret: remoteWins(`auth.external.${name}.secret`) + ? legacyDecryptAuthSecret(configuredSecret, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), + projectEnvValues, + ), + url: + (remoteWins(`auth.external.${name}.url`) + ? configuredUrl + : legacyEnvOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", + redirectUri: remoteWins(`auth.external.${name}.redirect_uri`) + ? configuredRedirectUri + : legacyEnvOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), + skipNonceCheck: remoteWins(`auth.external.${name}.skip_nonce_check`) + ? configuredSkipNonceCheck + : legacyEnvOverrideBool( + `${envPrefix}_SKIP_NONCE_CHECK`, + configuredSkipNonceCheck, + `auth.external.${name}.skip_nonce_check`, + projectEnvValues, + ), + emailOptional: remoteWins(`auth.external.${name}.email_optional`) + ? configuredEmailOptional + : legacyEnvOverrideBool( + `${envPrefix}_EMAIL_OPTIONAL`, + configuredEmailOptional, + `auth.external.${name}.email_optional`, + projectEnvValues, + ), }; } return result; @@ -2524,11 +3005,17 @@ function validateAuthExternalProviders( authDocument: Record | undefined, external: ProjectConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): void { // Derived from `legacyResolveAuthExternalProviders`'s unfiltered result so this validation // path and `start.handler.ts`'s GoTrue env builder can't drift — same precedent as // `legacyResolveAuthHooks`'s validation caller above. - const resolved = legacyResolveAuthExternalProviders(authDocument, external, projectEnvValues); + const resolved = legacyResolveAuthExternalProviders( + authDocument, + external, + projectEnvValues, + remoteOverrideKeys, + ); for (const [name, provider] of Object.entries(resolved)) { if (!provider.enabled) continue; if (provider.clientId.length === 0) { @@ -2610,22 +3097,115 @@ export function legacyResolveLocalConfigValues( * guessed at. */ document: Readonly> | undefined = undefined, + /** + * Config keys a matched `[remotes.]` block contributed at viper's OVERRIDE tier (Go's + * `v.Set`, applied ABOVE `AutomaticEnv` — `apps/cli-go/pkg/config/config.go:724`) — see + * `legacy-db-config.toml-read.ts`'s `LegacyRemoteOverride.remoteOverrideKeys` doc comment for + * the full precedence rationale. Every `legacyEnvOverride*` call below that resolves a field + * this function's shadow-consuming caller (`legacyBuildLocalDbContainerInputs`) actually + * threads onward (`dbPort`/`rootKey`/`jwtSecret`/`authJwtExpiry`/`authSiteUrl`/`anonKey`/ + * `serviceRoleKey`, plus `apiUrl`'s own `api.port`/`api.tls.enabled`/`api.external_url` + * inputs, plus `signingKeysPath`'s `auth.signing_keys_path` gate feeding the `signingKey` that + * signs `anonKey`/`serviceRoleKey` — review: PRRT_kwDOErm0O86W3Ox_) must NOT re-apply a + * `SUPABASE_*` value for a field the remote block already set — same gate + * `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` already apply for the + * OTHER shadow-bootstrap fields (review: PRRT_kwDOErm0O86W2tRi, following on from + * PRRT_kwDOErm0O86W2LL4's fix to those two). Defaults to empty: `db start`/`db reset`/ + * `status`/`stop` never resolve a remote block for this config read (they never pass a + * `projectRef`), so they are unaffected. `api.enabled`/`auth.enabled`/ + * `edge_runtime.deno_version`/`analytics.enabled`/`analytics.backend`/every + * `auth.third_party.*.enabled` are ALSO gated below even though their resolved values are + * never part of the returned `LegacyLocalConfigValues` — each one's `legacyEnvOverride*` call + * THROWS on a malformed override + * (`legacyEnvOverrideBool`/`legacyEnvOverrideDenoVersion`/`envOverrideAnalyticsBackend`) + * even when the remote block already set that field, which would abort this entire function + * (and every field it DOES return) on an env value Go silently ignores (review: + * PRRT_kwDOErm0O86W30n6 for `auth.enabled`/`analytics.*`, PRRT_kwDOErm0O86W4gCk for + * `edge_runtime.deno_version`, PRRT_kwDOErm0O86W5UlV for `api.enabled`) — "not read by the + * caller" is not the same as "cannot abort the caller." An earlier version of this comment + * claimed the remaining `studio`/`local_smtp`/`passkey`/`mfa`/hooks/`captcha`/`auth.email.smtp`/ + * `experimental.webhooks`/the auth `enable_signup`/`enable_anonymous_sign_ins`/refresh-token/ + * manual-linking/password-length/-requirements group could stay ungated because their own + * `legacyEnvOverride*` calls "cannot throw before a value the caller needs has already been + * resolved" — that reasoning doesn't hold: this function is a single synchronous call that + * either returns its whole object or throws, so ANY unconditional throw anywhere in its body + * aborts the entire call and denies the shadow every field, including ones already computed as + * local variables earlier in the function — textual position relative to a caller-needed field + * is irrelevant. All of those fields are now gated the same way as `api.enabled` above and + * tracked in `LEGACY_ENV_OVERRIDABLE_KEYS` (review: PRRT_kwDOErm0O86W6R-G). An earlier version + * of this comment also claimed `jwtIssuer`/`additionalRedirectUrls`/the mfa phone factor's + * `template`/`max_frequency`/the webauthn `rp_id`/`rp_origins`/the sms `template`/`max_frequency`/ + * the GCP analytics fields could stay ungated because their own reads genuinely cannot throw — + * that reasoning doesn't hold either: a non-throwing read is still a precedence bug when a + * matched remote's own value loses to a stale/differently-scoped ambient env var, same "cannot + * throw" vs. "no Go-observable consequence" distinction already drawn for `auth.external.*`'s + * `client_id`/`url`/`redirect_uri` above. All of those are now gated too and tracked in + * `LEGACY_ENV_OVERRIDABLE_KEYS`. `studioApiUrl` is gated for a related but distinct reason + * (below) — a "non-throwing read, throwing downstream + * consumer" case like the third_party required fields just below: `legacyGoUrlParse` inside + * `legacyValidateResolvedConfig` throws on a malformed URL even though `legacyEnvOverride` + * itself never does (review: PRRT_kwDOErm0O86XKYiF's sibling gap). `studio.openai_api_key`/ + * `auth.publishable_key`/`auth.secret_key` are `config.Secret`-typed exactly like `anon_key`/ + * `service_role_key` below and are now gated the same way, having been missed when that pair + * was fixed. `auth.sms.*` (`legacyResolveAuthSms`, reached via `validateAuthSmsProviders` + * below) and `auth.external.*` (`legacyResolveAuthExternalProviders`, reached via + * `validateAuthExternalProviders` below) are threaded through and gated in their own resolvers + * now too — see those functions' own doc comments (review: PRRT_kwDOErm0O86XFmjZ, + * PRRT_kwDOErm0O86XKYiF). This function's OWN validation-only `thirdParty` + * block's non-`enabled` leaves (`requiredField`/`cognitoUserPoolRegion`) are gated too, despite + * `legacyEnvOverride` itself never throwing: each provider's per-field `validate()` + * (`config.go:1560-1629` — domain/tenant/user_pool_id/issuer_url emptiness, plus Clerk's domain + * regex) runs inside the single {@link legacyValidateResolvedConfig} call below, so an + * ungated read that picks up a stale/differently-invalid env override over a remote's own + * valid value can flip that provider's validation verdict — accepting a config Go would + * reject, or (as the reported case) rejecting one Go would accept — even though nothing + * actually throws during resolution itself (review: PRRT_kwDOErm0O86W93Ex). "Cannot throw" and + * "has no Go-observable failure mode" are different properties; this block has the former but + * not the latter. This is NOT the same `third_party` as {@link legacyResolveLocalJwks}'s/ + * {@link legacyResolveConfiguredSigningKeys}'s own, SEPARATE third-party/signing-keys + * resolution, which DOES feed the shadow's JWKS document and IS gated (see those functions' + * own doc comments). + */ + remoteOverrideKeys: ReadonlySet = new Set(), + /** + * Go's `Eject` default (`pkg/config/config.go:561-570`): `flags.LoadConfig` + * pre-sets `Config.ProjectId` to the resolved `--project-ref`/linked project + * ref BEFORE merging the file, so `Eject`'s own basename fallback only + * triggers when that default is itself empty. `undefined` for `status`/ + * `stop`, which have no such flag and fall straight to the basename, same + * as before this parameter existed. + */ + projectIdFallback?: string, ): LegacyLocalConfigValues { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above // for why a workdir basename that sanitizes to `""` fails here even when // `project_id` is absent from the file entirely. `config.project_id` is // `undefined` only when the key is genuinely absent (`optionalKey`, see // `packages/config/src/base.ts`) — that's the ONE case where Go's own - // sanitized-basename viper default shows through instead of a file value, - // so the fallback belongs here, not as a third branch after `legacyEnvOverride`. + // sanitized-basename-or-`projectIdFallback` viper default shows through + // instead of a file value, so the fallback belongs here, not as a third + // branch after `legacyEnvOverride`. // `SUPABASE_PROJECT_ID` is checked via the same `legacyEnvOverride` precedence // every other field here uses, since Viper's `AutomaticEnv` binds it too // (`config.go:529-535`) and it can turn an explicit-empty file value (or an - // unsanitizable basename fallback) back into a valid override. + // unsanitizable basename fallback) back into a valid override. Deliberately NOT + // gated by `remoteWins("project_id")` (unlike the fields below): the ONLY consumer + // of this value is `legacyValidateResolvedConfig`'s emptiness check + // (`legacy-config-validate.ts:336`), and `legacyEnvOverride` (a plain, non-throwing + // string read) can never turn an already non-empty remote-merged `project_id` into + // an empty one, nor vice versa — so gating here would change no observable + // accept/reject outcome. The real "shadow's network id/labels resolve the wrong + // project id" bug this pattern otherwise guards against lives in + // `legacy-local-project-context.ts`'s OWN, separately-consumed project id (see its + // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. const resolvedProjectId = legacyEnvOverride( "SUPABASE_PROJECT_ID", - config.project_id ?? legacySanitizeProjectId(basename(workdir)), + config.project_id ?? + (projectIdFallback !== undefined && projectIdFallback.length > 0 + ? projectIdFallback + : legacySanitizeProjectId(basename(workdir))), projectEnvValues, ); @@ -2636,31 +3216,53 @@ export function legacyResolveLocalConfigValues( // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- // `scheme://host:port` derivation (which picks `https` vs `http` from // `tls.enabled`) must be the overridden ones too. - const apiTlsEnabled = legacyEnvOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ); + // A matched remote block's `api.tls.enabled` was installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so it must win over a conflicting `SUPABASE_API_TLS_ENABLED` — this field + // reaches `apiUrl`/`restUrl`/etc, which the shadow's own `db diff --linked`/`db pull` setup + // input consumes (`legacyBuildLocalDbContainerInputs`). + const apiTlsEnabled = remoteWins("api.tls.enabled") + ? config.api.tls.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. - const apiEnabled = legacyEnvOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const apiTlsCertPath = legacyEnvOverride( - "SUPABASE_API_TLS_CERT_PATH", - config.api.tls.cert_path, - projectEnvValues, - ); - const apiTlsKeyPath = legacyEnvOverride( - "SUPABASE_API_TLS_KEY_PATH", - config.api.tls.key_path, - projectEnvValues, - ); + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above and `authEnabled` below + // — `api.enabled` is now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) + // and that reader's own resolver already gates it (`legacyBlockProvidesKey(block, + // "api.enabled")`); this resolver must match, since an ungated `legacyEnvOverrideBool` call + // THROWS on a malformed `SUPABASE_API_ENABLED` even when a matched remote block already set + // `api.enabled` at viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the + // env var for in that case — which would otherwise abort this whole function (and the shadow + // it feeds via `legacyBuildLocalDbContainerInputs`, denying it `apiPort`/`apiUrl`/`dbPort`/ + // `rootKey`/etc.) on an env value Go silently ignores. `apiEnabled`'s own resolved value is + // never part of the returned `LegacyLocalConfigValues` — same "throws before caller-needed + // fields are resolved" rationale as `authEnabled`/`analytics.*`/`edge_runtime.deno_version` + // below, not the "value is consumed downstream" rationale `apiTlsEnabled`/`apiPort` above have. + const apiEnabled = remoteWins("api.enabled") + ? config.api.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above: a matched remote + // block's `api.tls.cert_path`/`key_path` were installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so they must win over a conflicting `SUPABASE_API_TLS_CERT_PATH`/ + // `SUPABASE_API_TLS_KEY_PATH` — otherwise a stale/missing ambient env path can fail + // `readApiTlsFiles` below even though the remote block already supplied a valid path + // Go would actually use (review: PRRT_kwDOErm0O86W8ZYk). + const apiTlsCertPath = remoteWins("api.tls.cert_path") + ? config.api.tls.cert_path + : legacyEnvOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); + const apiTlsKeyPath = remoteWins("api.tls.key_path") + ? config.api.tls.key_path + : legacyEnvOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); if (apiEnabled && apiTlsEnabled) { readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } @@ -2669,19 +3271,16 @@ export function legacyResolveLocalConfigValues( // below, which has no `enabled` gate. Resolved once into a named const so the // check and the URL derivation below share the same overridden value instead // of calling `legacyEnvOverridePort` twice. - const apiPort = legacyEnvOverridePort( - "SUPABASE_API_PORT", - config.api.port, - "api.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiTlsEnabled` above. + const apiPort = remoteWins("api.port") + ? config.api.port + : legacyEnvOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); const apiExternalUrl = legacyResolveApiExternalUrl( { - external_url: legacyEnvOverride( - "SUPABASE_API_EXTERNAL_URL", - config.api.external_url, - projectEnvValues, - ), + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above. + external_url: remoteWins("api.external_url") + ? config.api.external_url + : legacyEnvOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), port: apiPort, tls: { enabled: apiTlsEnabled }, }, @@ -2693,15 +3292,22 @@ export function legacyResolveLocalConfigValues( // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` // render anything, same wording already used for the `db query`/`test db` // path (`legacy-db-config.toml-read.ts:1380`). - const dbPort = legacyEnvOverridePort( - "SUPABASE_DB_PORT", - config.db.port, - "db.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiPort`/`apiTlsEnabled` above — `dbPort` also reaches + // `dbUrl`, consumed by the shadow's own `db diff --linked`/`db pull` setup input. + const dbPort = remoteWins("db.port") + ? config.db.port + : legacyEnvOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); // Go's `Config.Validate` checks `db.major_version` right after `db.port` - // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). - const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). Validate-only here + // (this function's return type has no `majorVersion` field — the shadow's own resolved value + // comes from `legacyResolveDbBootstrapConfig`, which already gates it) — but a matched + // remote's `db.major_version` must still suppress a conflicting `SUPABASE_DB_MAJOR_VERSION` + // here too, otherwise a malformed env value the remote block should have made irrelevant + // fails this validate-only read outright before the (correctly gated) real value is ever + // reached (review: PRRT_kwDOErm0O86W2tRi). + const majorVersion = remoteWins("db.major_version") + ? config.db.major_version + : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); // Go's `flags.LoadConfig` applies every `SUPABASE_DB_SETTINGS_*` override unconditionally // during `Config.Load` (`config.go:576-586`), BEFORE `start`/`status`/`stop` do anything else // (formerly `internal/start/start.go:51`, ran before `AssertSupabaseDbIsRunning` at line 54; @@ -2711,20 +3317,25 @@ export function legacyResolveLocalConfigValues( // `start.handler.ts`'s `bringUp` after Postgres may already be created. Validate-only: the // actual resolved settings `start` needs are recomputed at their own call site (same // "validate early, recompute at point of use" split already used for those three fields). - legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues); + // `remoteOverrideKeys` threaded through so a matched remote's `db.settings.*` value doesn't + // fail this validate-only read the same way `majorVersion` above doesn't. + legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); // Same gap for `db.network_restrictions.enabled` — `[db.network_restrictions]` ships // uncommented in Go's default template (unlike the commented-out `[db.ssl_enforcement]`) and // `NetworkRestrictions` is a plain, non-pointer `db` struct field, so Viper always registers a // default and decodes a malformed `SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED` override // unconditionally during `Config.Load` — same bucket as `db.port`/`db.major_version` above, not // the presence-gated `db.ssl_enforcement`/`auth.sms.twilio`/`auth.external.apple` cases. - // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). - legacyEnvOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ); + // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). Same + // remote-over-env precedence as `majorVersion` above. + if (!remoteWins("db.network_restrictions.enabled")) { + legacyEnvOverrideBool( + "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", + config.db.network_restrictions.enabled, + "db.network_restrictions.enabled", + projectEnvValues, + ); + } // `db.root_key` isn't modeled in `@supabase/config`'s schema (every other // `db.*` field is), so it's read off the raw pre-schema document — same // presence-based pattern as `authDocument` below. Go writes the @@ -2746,7 +3357,14 @@ export function legacyResolveLocalConfigValues( "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); } - const rawRootKey = legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); + // Same remote-over-env precedence as `apiPort`/`dbPort` above — `rootKey` reaches the + // shadow's own Postgres container spec (`legacyBuildLocalDbContainerInputs`). `rawRootKeyValue` + // already reflects a matched remote's `db.root_key` (`document` is the remote-merged raw doc — + // see `LoadedProjectConfig.document`'s own doc comment), so `remoteWins` here just means + // "don't let a conflicting `SUPABASE_DB_ROOT_KEY` clobber that already-merged value." + const rawRootKey = remoteWins("db.root_key") + ? rawRootKeyValue + : legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); const rootKey = rawRootKey === undefined || rawRootKey.length === 0 ? LEGACY_POSTGRES_DEFAULT_ROOT_KEY @@ -2758,56 +3376,88 @@ export function legacyResolveLocalConfigValues( // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same // enabled-gated pattern as `api.port` above. - const studioEnabled = legacyEnvOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const studioPort = legacyEnvOverridePort( - "SUPABASE_STUDIO_PORT", - config.studio.port, - "studio.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiEnabled`/`apiPort` above — `studio.enabled`/ + // `studio.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`): + // an ungated `legacyEnvOverrideBool`/`legacyEnvOverridePort` call here THROWS on a malformed + // `SUPABASE_STUDIO_ENABLED`/`SUPABASE_STUDIO_PORT` even when a matched remote block already + // set that field at viper's OVERRIDE tier, which would abort this whole function — and the + // shadow it feeds — on an env value Go silently ignores (review: PRRT_kwDOErm0O86W6R-G). + const studioEnabled = remoteWins("studio.enabled") + ? config.studio.enabled + : legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = remoteWins("studio.port") + ? config.studio.port + : legacyEnvOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right // after the port check, still inside `if c.Studio.Enabled` // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required // (defaulted) field, so `legacyEnvOverride` can only return `undefined` here if // that default itself were somehow undefined — the `??` fallback just // satisfies that generic signature. - const studioApiUrl = - legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url; + // `legacyEnvOverride` itself never throws, but `studio.api_url` feeds + // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check below, which DOES throw on a + // malformed URL — same "non-throwing read, throwing downstream consumer" bug class already + // fixed for `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XGTq5). An + // ungated read here can flip that validate() outcome even though nothing in this read itself + // throws, so `studio.api_url` is gated the same way as `studio.enabled`/`studio.port` above. + const studioApiUrl = remoteWins("studio.api_url") + ? config.studio.api_url + : (legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url); // Go's `Config.Validate` rejects `local_smtp.port === 0`/ // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct // field is still named `Inbucket` for the `[local_smtp]` TOML section // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the // deprecated `inbucket.enabled` alias are the same underlying flag, not two // independent ones. - const mailpitEnabled = legacyEnvOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const mailpitPort = legacyEnvOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `studioEnabled`/`studioPort` above — `local_smtp.enabled`/ + // `local_smtp.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` for the identical reason. + const mailpitEnabled = remoteWins("local_smtp.enabled") + ? config.local_smtp.enabled + : legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = remoteWins("local_smtp.port") + ? config.local_smtp.port + : legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiPort`/`dbPort`/`rootKey` above — `jwtSecret` reaches + // the shadow's own Postgres/fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`). const jwtSecret = resolveJwtSecret( legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + remoteWins("auth.jwt_secret") + ? config.auth.jwt_secret + : legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), projectEnvValues, ), ); - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + // Same remote-over-env precedence as `jwtSecret` above — `signingKeysPath` gates whether + // {@link legacyResolveConfiguredSigningKeys} below produces an asymmetric `signingKey`, which + // feeds `anonKey`/`serviceRoleKey` (already remote-gated fields the shadow's setup consumes). + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a // disabled auth section never opens/parses `signing_keys_path`, even a stale @@ -2817,20 +3467,34 @@ export function legacyResolveLocalConfigValues( // any other field (`config.go:582-586`), so `Validate`'s gate reads the // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); + // Same remote-over-env precedence as every other gated field above — `auth.enabled` IS in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and that reader's own + // resolver already gates it (`remoteOverrideKeys.has("auth.enabled")`); this resolver must + // match, since an ungated `legacyEnvOverrideBool` call THROWS on a malformed + // `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` at + // viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the env var for in + // that case — which would otherwise abort this whole function (and the shadow it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value Go silently ignores + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as // `""` with no schema-level error, same gap as `db.port === 0` above. - const siteUrl = - legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? - config.auth.site_url; + // Same remote-over-env precedence as `jwtSecret` above — `siteUrl` reaches the shadow's own + // fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`'s `authSiteUrl`). + const siteUrl = remoteWins("auth.site_url") + ? config.auth.site_url + : (legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? + config.auth.site_url); // Go's `start.go` built GoTrue's env straight off `utils.Config.Auth.*` // with no local override logic of its own (formerly `internal/start/start.go:1365-1405`, // deleted as unreachable in CLI-1966; last present at commit a253ccba2) — the @@ -2838,70 +3502,92 @@ export function legacyResolveLocalConfigValues( // (`config.go:585-586`), so every flat `auth.*` scalar Go feeds into // GoTrue's env must go through the same override resolution `siteUrl` // above already gets, not just the fields `Validate` happens to check. - const jwtIssuer = legacyEnvOverride( - "SUPABASE_AUTH_JWT_ISSUER", - config.auth.jwt_issuer, - projectEnvValues, - ); - const jwtExpiry = legacyEnvOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ); + // `jwtIssuer` is a plain, non-throwing `legacyEnvOverride` string read, but leaving it ungated + // is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + // `redirect_uri` above — `auth.jwt_issuer` is in `LEGACY_ENV_OVERRIDABLE_KEYS`. + const jwtIssuer = remoteWins("auth.jwt_issuer") + ? config.auth.jwt_issuer + : legacyEnvOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); + // Same remote-over-env precedence as `siteUrl` above — `jwtExpiry` reaches the shadow's own + // Postgres container spec (`legacyBuildLocalDbContainerInputs`'s `authJwtExpiry`). + const jwtExpiry = remoteWins("auth.jwt_expiry") + ? config.auth.jwt_expiry + : legacyEnvOverrideUint( + "SUPABASE_AUTH_JWT_EXPIRY", + "auth.jwt_expiry", + config.auth.jwt_expiry, + projectEnvValues, + ); // Go decodes `additional_redirect_urls` (a `[]string`) through the same // `StringToSliceHookFunc(",")` mapstructure hook as every other Go // string-slice field (`config.go:775-784`) — same comma-split-override - // pattern as `auth.webauthn.rp_origins` below. - const additionalRedirectUrlsOverride = legacyEnvOverride( - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - undefined, - projectEnvValues, - ); + // pattern as `auth.webauthn.rp_origins` below. Same "non-throwing read is still a precedence + // bug" reasoning as `jwtIssuer` above — `auth.additional_redirect_urls` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS`. + const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") + ? undefined + : legacyEnvOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); const additionalRedirectUrls = additionalRedirectUrlsOverride !== undefined ? additionalRedirectUrlsOverride.split(",") : config.auth.additional_redirect_urls; - const enableSignup = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ); - const enableAnonymousSignIns = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ); - const enableRefreshTokenRotation = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ); - const refreshTokenReuseInterval = legacyEnvOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ); - const enableManualLinking = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ); - const minimumPasswordLength = legacyEnvOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ); - const passwordRequirements = legacyEnvOverrideAuthPasswordRequirements( - config.auth.password_requirements, - projectEnvValues, - ); + // Same remote-over-env precedence as `studioEnabled`/`mailpitEnabled` above, for the exact same + // "throws before a value the caller needs is resolved" reason — every field in this group is + // now in `LEGACY_ENV_OVERRIDABLE_KEYS`. + const enableSignup = remoteWins("auth.enable_signup") + ? config.auth.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_SIGNUP", + config.auth.enable_signup, + "auth.enable_signup", + projectEnvValues, + ); + const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") + ? config.auth.enable_anonymous_sign_ins + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + config.auth.enable_anonymous_sign_ins, + "auth.enable_anonymous_sign_ins", + projectEnvValues, + ); + const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") + ? config.auth.enable_refresh_token_rotation + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + config.auth.enable_refresh_token_rotation, + "auth.enable_refresh_token_rotation", + projectEnvValues, + ); + const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") + ? config.auth.refresh_token_reuse_interval + : legacyEnvOverrideUint( + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "auth.refresh_token_reuse_interval", + config.auth.refresh_token_reuse_interval, + projectEnvValues, + ); + const enableManualLinking = remoteWins("auth.enable_manual_linking") + ? config.auth.enable_manual_linking + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + config.auth.enable_manual_linking, + "auth.enable_manual_linking", + projectEnvValues, + ); + const minimumPasswordLength = remoteWins("auth.minimum_password_length") + ? config.auth.minimum_password_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "auth.minimum_password_length", + config.auth.minimum_password_length, + projectEnvValues, + ); + const passwordRequirements = remoteWins("auth.password_requirements") + ? config.auth.password_requirements + : legacyEnvOverrideAuthPasswordRequirements( + config.auth.password_requirements, + projectEnvValues, + ); // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because // the captcha presence check right below needs it too. `undefined` for callers that haven't @@ -2911,6 +3597,7 @@ export function legacyResolveLocalConfigValues( authDocument, config.auth.captcha, projectEnvValues, + remoteOverrideKeys, ); // Go's `generateJWT` (`apikeys.go:77`) signs asymmetrically whenever // `len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0` — NOT gated on `auth.enabled`. Since @@ -2924,9 +3611,12 @@ export function legacyResolveLocalConfigValues( // with the default key, not silently fall back to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (legacyResolveConfiguredSigningKeys(config, workdir, projectEnvValues) ?? [ - LEGACY_DEFAULT_SIGNING_KEY, - ])[0] + ? (legacyResolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + ) ?? [LEGACY_DEFAULT_SIGNING_KEY])[0] : undefined; // Go's `Config.Validate` runs passkey/webauthn validation, then // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then @@ -2962,8 +3652,13 @@ export function legacyResolveLocalConfigValues( // being present (`passkeyDoc`/`webauthnDoc !== undefined`), matching Go's `AutomaticEnv` // (which only intercepts keys already present in the merged config) — an absent // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. - const passkeyEnabled = - passkeyDoc !== undefined + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `auth.passkey.enabled` + // is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated `legacyEnvOverrideBool` call below + // THROWS on a malformed override even when a matched remote block already set it, which would + // abort this whole function (and the shadow it feeds) on an env value Go silently ignores. + const passkeyEnabled = remoteWins("auth.passkey.enabled") + ? legacyRawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") + : passkeyDoc !== undefined ? legacyEnvOverrideBool( "SUPABASE_AUTH_PASSKEY_ENABLED", legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), @@ -2971,19 +3666,23 @@ export function legacyResolveLocalConfigValues( projectEnvValues, ) : false; - const rpId = - webauthnDoc !== undefined - ? legacyEnvOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_ID", - typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined, - projectEnvValues, - ) + // `rp_id`/`rp_origins` are plain, non-throwing `legacyEnvOverride` reads, but leaving them + // ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + // `redirect_uri` above — `auth.webauthn.rp_id`/`.rp_origins` are in + // `LEGACY_ENV_OVERRIDABLE_KEYS`. + const configuredRpId = + typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; + const rpId = remoteWins("auth.webauthn.rp_id") + ? configuredRpId + : webauthnDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) : undefined; // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` // mapstructure hook as every other Go string-slice field (`config.go:775-784`), so a // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. - const rpOriginsOverride = - webauthnDoc !== undefined + const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") + ? undefined + : webauthnDoc !== undefined ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) : undefined; // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` unconditionally to @@ -3008,7 +3707,12 @@ export function legacyResolveLocalConfigValues( // `legacyResolveAuthHooks`'s unfiltered result so this validation path and // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same // per-hook override values (see that function's doc comment). - const resolvedHooks = legacyResolveAuthHooks(authDocument, config.auth.hook, projectEnvValues); + const resolvedHooks = legacyResolveAuthHooks( + authDocument, + config.auth.hook, + projectEnvValues, + remoteOverrideKeys, + ); const hooks: Array = LEGACY_HOOK_TYPE_ORDER.filter( (hookType) => resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]].enabled, ).map((hookType) => { @@ -3019,7 +3723,7 @@ export function legacyResolveLocalConfigValues( // Derived from `legacyResolveAuthMfa`'s unfiltered result so this validation path and // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same per-factor override // values (see that function's doc comment) — same precedent as `hooks` above. - const resolvedMfa = legacyResolveAuthMfa(config.auth.mfa, projectEnvValues); + const resolvedMfa = legacyResolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); const mfa: ReadonlyArray = [ { label: "totp", @@ -3042,13 +3746,17 @@ export function legacyResolveLocalConfigValues( // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read // stays at this exact textual position (see this function's `@throws` doc for why). readAuthEmailTemplateContent( - legacyResolveAuthEmail(config.auth.email, authDocument, projectEnvValues), + legacyResolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), workdir, ); // Go's `[auth.email.smtp]` presence-based `enabled` default — see // {@link legacyResolveAuthEmailSmtp}'s doc comment. - const resolvedSmtp = legacyResolveAuthEmailSmtp(authDocument, projectEnvValues); + const resolvedSmtp = legacyResolveAuthEmailSmtp( + authDocument, + projectEnvValues, + remoteOverrideKeys, + ); const smtp: LegacySmtpInput | undefined = resolvedSmtp === undefined ? undefined @@ -3064,8 +3772,15 @@ export function legacyResolveLocalConfigValues( // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) // — only enabled providers are forwarded, in that order. {@link legacyResolveThirdPartyProviders} // is the SAME hoisted resolver `commands/db/start/start.handler.ts`'s eager pre-probe battery - // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY__*` overrides. - const thirdParty = legacyResolveThirdPartyProviders(config.auth.third_party, projectEnvValues); + // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY__*` overrides — + // `remoteOverrideKeys` is threaded through so a matched remote's `auth.third_party.*` value + // doesn't lose to a malformed `SUPABASE_AUTH_THIRD_PARTY_*` override (review: + // PRRT_kwDOErm0O86W30n6), same reasoning as every other `remoteWins`-gated field above. + const thirdParty = legacyResolveThirdPartyProviders( + config.auth.third_party, + projectEnvValues, + remoteOverrideKeys, + ); authInput = { siteUrl: siteUrl ?? "", @@ -3083,11 +3798,18 @@ export function legacyResolveLocalConfigValues( // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth // block and the functions loop (`pkg/config/config.go:1158-1173`), and — // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no - // `edge_runtime.enabled` gate. - const denoVersion = legacyEnvOverrideDenoVersion( - config.edge_runtime.deno_version, - projectEnvValues, - ); + // `edge_runtime.enabled` gate. `edge_runtime.deno_version` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and + // `legacyEnvOverrideDenoVersion` THROWS on a malformed override — same + // `auth.enabled`/`analytics.enabled` bug class (review: PRRT_kwDOErm0O86W30n6, + // PRRT_kwDOErm0O86W4gCk): an ungated call here would abort this whole + // resolver (and the shadow it feeds) on a malformed `SUPABASE_EDGE_RUNTIME_ + // DENO_VERSION` even when a matched remote block already set + // `edge_runtime.deno_version` at viper's OVERRIDE tier, a value Go's + // `Validate` never evaluates the env var for in that case. + const denoVersion = remoteWins("edge_runtime.deno_version") + ? config.edge_runtime.deno_version + : legacyEnvOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); // Go's `Config.Validate` validates `[analytics]` right after // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when @@ -3098,28 +3820,52 @@ export function legacyResolveLocalConfigValues( // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override // path — see {@link envOverrideAnalyticsBackend} for that case. - const analyticsEnabled = legacyEnvOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", - projectEnvValues, - ); - const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); - const gcpProjectId = legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - config.analytics.gcp_project_id, - projectEnvValues, - ); - const gcpProjectNumber = legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - config.analytics.gcp_project_number, - projectEnvValues, - ); - const gcpJwtPath = legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - config.analytics.gcp_jwt_path, + // `analytics.enabled`/`analytics.backend` are both in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`) and both THROW on a malformed override + // (`LegacyInvalidBoolEnvOverrideError`/`LegacyInvalidAnalyticsBackendEnvOverrideError`) — same + // `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6): an ungated call here would abort + // this whole function (and the shadow it feeds) on a malformed `SUPABASE_ANALYTICS_*` env var + // even when a matched remote block already set the field at viper's OVERRIDE tier, a value + // Go's `Validate` never evaluates the env var for in that case. `gcpProjectId`/ + // `gcpProjectNumber`/`gcpJwtPath` below can't throw either (`legacyEnvOverride` is a plain + // string read), but leaving them ungated is still a precedence bug, same reasoning as + // `auth.external.*`'s `client_id`/`url`/`redirect_uri` — all three are in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and already gated on the `legacy-db-config.toml-read.ts` side + // (`analyticsString`); this resolver's own copy just never got the matching gate. + const analyticsEnabled = remoteWins("analytics.enabled") + ? config.analytics.enabled + : legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend( + config.analytics.backend, projectEnvValues, + remoteWins("analytics.backend"), ); + const gcpProjectId = remoteWins("analytics.gcp_project_id") + ? config.analytics.gcp_project_id + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = remoteWins("analytics.gcp_project_number") + ? config.analytics.gcp_project_number + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") + ? config.analytics.gcp_jwt_path + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); // Go's `Config.Validate` calls `c.Experimental.validate()` right after the // analytics/bigquery block and right before returning. The webhooks check is NOT "the user @@ -3142,18 +3888,34 @@ export function legacyResolveLocalConfigValues( // resolver just never got the equivalent treatment. A malformed JSON override needs no separate // error path here: it flows through unchanged and `legacyValidateResolvedConfig`'s existing // `isValidJson` check reports it the same way it already reports a malformed TOML-sourced value. - const webhooksEnabled = legacyEnvOverrideBool( - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - config.experimental.webhooks?.enabled === true, - "experimental.webhooks.enabled", - projectEnvValues, - ); - const pgdeltaFormatOptions = - legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - config.experimental.pgdelta?.format_options, - projectEnvValues, - ) ?? ""; + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `experimental. + // webhooks.enabled` is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated + // `legacyEnvOverrideBool` call below THROWS on a malformed override even when a matched remote + // block already set it, which would abort this whole function (and the shadow it feeds) on an + // env value Go silently ignores. + const webhooksEnabled = remoteWins("experimental.webhooks.enabled") + ? config.experimental.webhooks?.enabled === true + : legacyEnvOverrideBool( + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + config.experimental.webhooks?.enabled === true, + "experimental.webhooks.enabled", + projectEnvValues, + ); + // `experimental.pgdelta.format_options` is ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`), which already gates its OWN `format_options` read the + // same way (`remoteOverrideKeys.has("experimental.pgdelta.format_options")`) — this resolver's + // copy just never got the matching gate: an ungated `legacyEnvOverride` here let ambient + // `SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` beat a matched remote's own `format_options`, + // the opposite of Go's `mergeRemoteConfig`, which installs the remote leaf with `v.Set` ABOVE + // `AutomaticEnv` (`config.go:724`) — same remote-over-env precedence as `webhooksEnabled` + // immediately above. + const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") + ? (config.experimental.pgdelta?.format_options ?? "") + : (legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", + config.experimental.pgdelta?.format_options, + projectEnvValues, + ) ?? ""); // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is // deferred to this single call, positioned here (where the last of those checks ran until @@ -3215,18 +3977,30 @@ export function legacyResolveLocalConfigValues( // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline // in D") — this is L's port of D's identical inline block. if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues); - validateAuthExternalProviders(authDocument, config.auth.external, projectEnvValues); + validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); + validateAuthExternalProviders( + authDocument, + config.auth.external, + projectEnvValues, + remoteOverrideKeys, + ); } - const openaiApiKey = legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_STUDIO_OPENAI_API_KEY", - config.studio.openai_api_key, - projectEnvValues, - ), - projectEnvValues, - ); + // `studio.openai_api_key` is a `config.Secret` (`pkg/config/config.go:264`), decrypted the same + // way `auth.email.smtp.pass`/`auth.captcha.secret` are — same remote-over-env precedence: an + // ungated `legacyEnvOverride` here could let a malformed ambient `SUPABASE_STUDIO_OPENAI_API_KEY` + // outrank a matched remote's own valid value and throw during decryption, aborting the whole + // call (and the shadow it feeds) on a value Go's `v.Set` (override tier) silently ignores. + const openaiApiKey = remoteWins("studio.openai_api_key") + ? legacyDecryptAuthSecret(config.studio.openai_api_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_STUDIO_OPENAI_API_KEY", + config.studio.openai_api_key, + projectEnvValues, + ), + projectEnvValues, + ); return { apiUrl: apiExternalUrl, @@ -3253,28 +4027,42 @@ export function legacyResolveLocalConfigValues( studioUrl: `http://${hostname}:${studioPort}`, mailpitUrl: `http://${hostname}:${mailpitPort}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + // `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) are + // `config.Secret`-typed exactly like `anon_key`/`service_role_key` below — same + // remote-over-env precedence: an ungated `legacyEnvOverride` here could let a malformed + // ambient `SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY` outrank a matched + // remote's own valid value and throw during decryption, aborting the whole call. publishableKey: resolveOpaqueKey( - legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_PUBLISHABLE_KEY", - config.auth.publishable_key, - projectEnvValues, - ), - projectEnvValues, - ), + remoteWins("auth.publishable_key") + ? legacyDecryptAuthSecret(config.auth.publishable_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_PUBLISHABLE_KEY", + config.auth.publishable_key, + projectEnvValues, + ), + projectEnvValues, + ), defaultPublishableKey, ), secretKey: resolveOpaqueKey( - legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), - projectEnvValues, - ), + remoteWins("auth.secret_key") + ? legacyDecryptAuthSecret(config.auth.secret_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + projectEnvValues, + ), defaultSecretKey, ), jwtSecret, + // Same remote-over-env precedence as `jwtSecret`/`siteUrl` above — `anonKey`/ + // `serviceRoleKey` reach the shadow's own fresh-DB-setup spec + // (`legacyBuildLocalDbContainerInputs`). anonKey: resolveSignedKey( legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + remoteWins("auth.anon_key") + ? config.auth.anon_key + : legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), projectEnvValues, ), jwtSecret, @@ -3283,11 +4071,13 @@ export function legacyResolveLocalConfigValues( ), serviceRoleKey: resolveSignedKey( legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - config.auth.service_role_key, - projectEnvValues, - ), + remoteWins("auth.service_role_key") + ? config.auth.service_role_key + : legacyEnvOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + config.auth.service_role_key, + projectEnvValues, + ), projectEnvValues, ), jwtSecret, @@ -3303,6 +4093,12 @@ export function legacyResolveLocalConfigValues( gcpProjectId: gcpProjectId ?? "", gcpProjectNumber: gcpProjectNumber ?? "", gcpJwtPath: gcpJwtPath ?? "", + // Sanitized here (not above, in `input.projectId`) — `legacyValidateResolvedConfig`'s check is + // presence-only and must see the raw value to reject an explicit `project_id = ""` before any + // fallback; every OTHER reader of `Config.ProjectId` (Docker resource naming, labels) needs Go's + // post-`Validate` sanitized singleton (`config.go:938-944`). + projectId: legacySanitizeProjectId(resolvedProjectId ?? ""), + edgeRuntimeDenoVersion: denoVersion, }; } @@ -3354,18 +4150,30 @@ export function legacyResolveLocalConfigValues( * enabled, an enabled provider is missing a required field, or the remote JWKS fetch (OIDC * discovery or the JWKS document itself) fails — matching Go's `ResolveJWKS` returning that error * outright, propagated here as this file's own error type rather than a bare `Error`. + * + * `remoteOverrideKeys` (default empty, so `start.handler.ts`'s `supabase start` caller sees + * exactly the same behavior as before): every `auth.signing_keys_path`/`auth.third_party.*` + * field a matched `[remotes.]` block set at viper's OVERRIDE tier + * (`apps/cli-go/pkg/config/config.go:718-730`) must win over a conflicting `SUPABASE_AUTH_*` + * value — this function feeds the shadow's PG15+ one-shot auth-migration job's `jwks` input on + * the `db diff --linked`/`db pull` path (CLI-1956), via `legacyBuildLocalDbContainerInputs` + * (review: PRRT_kwDOErm0O86W3Ox_). */ export async function legacyResolveLocalJwks( config: ProjectConfig, workdir: string, jwtSecret: string, projectEnvValues: Readonly> | undefined = undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): Promise { - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); // Go's `a.SigningKeys` is UNCONDITIONALLY seeded with the single default ES256 key at // `NewConfig()` time (`pkg/config/config.go:504-515`) — every resolved config carries it, // regardless of `auth.enabled`. It is only ever REPLACED by a configured @@ -3380,6 +4188,7 @@ export async function legacyResolveLocalJwks( config, workdir, projectEnvValues, + remoteOverrideKeys, ) ?? [LEGACY_DEFAULT_SIGNING_KEY]; // Same fixed provider order + `SUPABASE_AUTH_THIRD_PARTY__*` overrides as the @@ -3387,82 +4196,107 @@ export async function legacyResolveLocalJwks( // but built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's // `tenant_region`) rather than `LegacyThirdPartyInput` (a validation-only shape with no // `tenant_region` field) — {@link resolveThirdPartyIssuerUrl} needs the full set to build the - // issuer URL, not just validate presence. + // issuer URL, not just validate presence. Each field below prefers the remote-set value over a + // conflicting env override, same as {@link legacyResolveDbSettingsEnvOverrides}'s per-field gate. const thirdParty: ThirdPartyProvidersLike = { firebase: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.firebase.enabled") + ? config.auth.third_party.firebase.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ), + project_id: remoteWins("auth.third_party.firebase.project_id") + ? config.auth.third_party.firebase.project_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ), }, auth0: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.auth0.enabled") + ? config.auth.third_party.auth0.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ), + tenant: remoteWins("auth.third_party.auth0.tenant") + ? config.auth.third_party.auth0.tenant + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ), + tenant_region: remoteWins("auth.third_party.auth0.tenant_region") + ? config.auth.third_party.auth0.tenant_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", + config.auth.third_party.auth0.tenant_region, + projectEnvValues, + ), }, aws_cognito: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.aws_cognito.enabled") + ? config.auth.third_party.aws_cognito.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ), + user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? config.auth.third_party.aws_cognito.user_pool_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ), + user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? config.auth.third_party.aws_cognito.user_pool_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), }, clerk: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.clerk.enabled") + ? config.auth.third_party.clerk.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ), + domain: remoteWins("auth.third_party.clerk.domain") + ? config.auth.third_party.clerk.domain + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ), }, workos: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.workos.enabled") + ? config.auth.third_party.workos.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ), + issuer_url: remoteWins("auth.third_party.workos.issuer_url") + ? config.auth.third_party.workos.issuer_url + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ), }, }; @@ -3476,12 +4310,22 @@ export async function legacyResolveLocalJwks( // resolver here is safe/redundant-but-harmless. When auth is disabled, that earlier validation // is (correctly) skipped, so this function must NOT re-introduce it — using the unchecked, // no-throw `IssuerURL()`-only builder instead, matching Go exactly. - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); + // Same remote-over-env precedence as every other field above — `auth.enabled` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and an ungated + // `legacyEnvOverrideBool` call THROWS on a malformed `SUPABASE_AUTH_ENABLED` even when a + // matched remote block already set `auth.enabled` at viper's OVERRIDE tier — a value Go's + // `Validate` never even evaluates the env var for in that case — which would otherwise abort + // this whole function (and the shadow's PG15+ one-shot auth-migration job it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value Go silently ignores + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); let issuerUrl: string | undefined; if (authEnabled) { try { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index f793741024..5b4d0cde6a 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -32,8 +32,11 @@ import { legacyResolveAuthEmail, legacyResolveAuthEmailSmtp, legacyResolveAuthExternalProviders, + legacyResolveAuthExternalUrl, legacyResolveAuthHooks, + legacyResolveAuthMfa, legacyResolveAuthSms, + legacyResolveConfiguredSigningKeys, legacyResolveDbSettingsEnvOverrides, legacyResolveLocalConfigValues, legacyResolveLocalJwks, @@ -942,6 +945,28 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig(); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + + it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { + // Same `experimental.webhooks.enabled` bug class, just for the OTHER Viper-bound + // `[experimental]` leaf this resolver derives: `experimental.pgdelta.format_options` is + // ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS`, so a matched `[remotes.]` block's own valid + // value must win over a malformed ambient env override, matching Go's `mergeRemoteConfig` + // (`v.Set` above `AutomaticEnv`). + process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; + const config = baseConfig({ + experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.pgdelta.format_options"]), + ), + ).not.toThrow(); + }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { @@ -1284,6 +1309,95 @@ describe("legacyResolveLocalConfigValues", () => { expect(resolved?.secret).toBe("value"); delete process.env["DOTENV_PRIVATE_KEY"]; }); + + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G): same "throws before a value the caller + // needs is resolved" bug class as `studio.enabled`/`auth.enabled` above — this function's + // own ungated `legacyEnvOverrideBool` call would abort the whole + // `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a malformed + // override the remote block should have made irrelevant. + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); + + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { + // Regression (review: PRRT_kwDOErm0O86XJ4HR) — same bug class as `auth.email.smtp.pass` + // (review: PRRT_kwDOErm0O86XJYol): this function's own ungated `legacyEnvOverride` call fed + // a malformed ambient override straight into `legacyDecryptAuthSecret`, which throws on an + // undecryptable `encrypted:...` value — aborting the whole `legacyResolveLocalConfigValues` + // caller (and the shadow it feeds) on an env value Go's `v.Set` (override tier, above + // `AutomaticEnv`) never lets reach decryption once a remote block already set the secret. + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { captcha: { enabled: true } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + new Set(["auth.captcha.secret"]), + ); + expect(resolved?.secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { captcha: { enabled: true } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + ), + ).toThrow("failed to parse config: missing private key"); + }); + + it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `provider` can't throw on its own + // (`legacyEnvOverride` is a plain string read), but an ungated override here still let a + // stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER` outrank a matched remote's own + // valid provider — `legacyValidateResolvedConfig`'s enum check downstream then aborts the + // whole `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a value Go's + // `v.Set` (override tier, above `AutomaticEnv`) never lets win. + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.provider"]), + ); + expect(resolved?.provider).toBe("hcaptcha"); + }); + + it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + ); + expect(resolved?.provider).toBe("turnstile"); + }); }); describe("legacyResolveAuthEmail", () => { @@ -1321,6 +1435,31 @@ describe("legacyResolveLocalConfigValues", () => { const resolved = legacyResolveAuthEmail(config.auth.email, authDocument, undefined); expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); }); + + describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; + }); + + it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { + process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; + const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); + const resolved = legacyResolveAuthEmail( + config.auth.email, + undefined, + undefined, + new Set(["auth.email.max_frequency"]), + ); + expect(resolved.max_frequency).toBe("1m"); + }); + + it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; + const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); + const resolved = legacyResolveAuthEmail(config.auth.email, undefined, undefined); + expect(resolved.max_frequency).toBe("5s"); + }); + }); }); describe("legacyResolveAuthHooks", () => { @@ -1337,6 +1476,7 @@ describe("legacyResolveLocalConfigValues", () => { afterEach(() => { delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; + delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; }); it("leaves every hook disabled when nothing is configured or overridden", () => { @@ -1361,6 +1501,280 @@ describe("legacyResolveLocalConfigValues", () => { const resolved = legacyResolveAuthHooks({}, allHooks, undefined); expect(resolved.customAccessToken.enabled).toBe(false); }); + + it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => + legacyResolveAuthHooks( + authDocument, + allHooks, + undefined, + new Set(["auth.hook.custom_access_token.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => legacyResolveAuthHooks(authDocument, allHooks, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { + // Regression (review: PRRT_kwDOErm0O86XGTq5) — Go's `mergeRemoteConfig` flattens the whole + // matched block via `u.AllKeys()` and applies EVERY leaf with `v.Set` + // (`apps/cli-go/pkg/config/config.go:718-724`), not just `enabled`. Leaving `uri` ungated + // let a stale/malformed env var beat a remote's already-merged, valid `uri`. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const hooksWithRemoteUri = { + ...allHooks, + custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteUri, + undefined, + new Set(["auth.hook.custom_access_token.uri"]), + ); + expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); + }); + + it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; + const hooksWithRemoteSecrets = { + ...allHooks, + custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteSecrets, + undefined, + new Set(["auth.hook.custom_access_token.secrets"]), + ); + expect(resolved.customAccessToken.secrets).toBe("remote-secret"); + }); + + it("still applies the env override for uri when no remote block matched that leaf", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; + const hooksWithLocalUri = { + ...allHooks, + custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks(authDocument, hooksWithLocalUri, undefined); + expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); + }); + }); + + describe("legacyResolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above: + // every `auth.mfa.*` leaf here is unconditionally resolved by + // `legacyResolveLocalConfigValues` (inside its `authEnabled` block), so an ungated call + // would abort that whole caller on a malformed override the remote block should have made + // irrelevant. + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const mfa = baseConfig().auth.mfa; + expect(() => + legacyResolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const mfa = baseConfig().auth.mfa; + expect(() => legacyResolveAuthMfa(mfa, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { + process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, + }; + const resolved = legacyResolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"])); + expect(resolved.phone.template).toBe("remote template"); + delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; + }); + + it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { + process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, + }; + const resolved = legacyResolveAuthMfa(mfa, undefined); + expect(resolved.phone.template).toBe("env template"); + delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; + }); + + it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { + process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, + }; + const resolved = legacyResolveAuthMfa( + mfa, + undefined, + new Set(["auth.mfa.phone.max_frequency"]), + ); + expect(resolved.phone.max_frequency).toBe("1m"); + delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; + }); + + it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { + process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, + }; + const resolved = legacyResolveAuthMfa(mfa, undefined); + expect(resolved.phone.max_frequency).toBe("5s"); + delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; + }); + }); + + describe("legacyResolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => + legacyResolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { + // Regression (review: PRRT_kwDOErm0O86XJYol) — same bug class as `.enabled`/`.port` + // above, just for this Secret-typed leaf: an ungated env override reached + // `legacyDecryptAuthSecret` and threw before the remote's own valid `pass` was used. + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.pass"]), + ); + expect(resolved?.pass).toBe("remote-pass"); + }); + + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; + const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.host"]), + ); + expect(resolved?.host).toBe("smtp.remote.example.com"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + }); + + it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; + const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); + expect(resolved?.host).toBe("smtp.env.example.com"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; + }); + + it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; + const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.user"]), + ); + expect(resolved?.user).toBe("remote-user"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + }); + + it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; + const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); + expect(resolved?.user).toBe("env-user"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; + }); + + it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; + const authDocument = { + email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.admin_email"]), + ); + expect(resolved?.adminEmail).toBe("remote@example.com"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + }); + + it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; + const authDocument = { + email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); + expect(resolved?.adminEmail).toBe("env@example.com"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; + }); + + it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; + const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.sender_name"]), + ); + expect(resolved?.senderName).toBe("Remote Sender"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; + }); + + it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; + const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); + expect(resolved?.senderName).toBe("Env Sender"); + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; + }); }); describe("legacyResolveAuthExternalProviders", () => { @@ -1467,6 +1881,83 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("legacyResolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiF): this resolver had no `remoteOverrideKeys` + // parameter at all, so a matched `[remotes.]` block's own valid `auth.external..*` + // value could always lose to a conflicting/malformed ambient `SUPABASE_AUTH_EXTERNAL__*` + // override — `secret`/`enabled`/`skip_nonce_check`/`email_optional` can additionally THROW on + // a malformed override, aborting the whole `legacyResolveLocalConfigValues` caller (and the + // shadow it feeds). + it("prefers a remote-set auth.external..secret over a malformed SUPABASE_AUTH_EXTERNAL__SECRET", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.secret"]), + ); + expect(resolved["my_custom"]?.secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__SECRET when no remote block matched", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow("failed to parse config: missing private key"); + }); + + it("prefers a remote-set auth.external..enabled over a malformed SUPABASE_AUTH_EXTERNAL__ENABLED", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__ENABLED when no remote block matched", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); + + it("prefers a remote-set auth.external..client_id over a conflicting SUPABASE_AUTH_EXTERNAL__CLIENT_ID", () => { + const authDocument = { + external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, + }; + const projectEnvValues = { + SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", + }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.client_id"]), + ); + expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); + }); + }); + describe("legacyRawUnmodeledBool", () => { it("returns false for an absent value, matching Go's zero-value bool default", () => { expect(legacyRawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); @@ -2438,6 +2929,101 @@ describe("legacyResolveLocalConfigValues", () => { legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), ).not.toThrow(); }); + + it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `content_path` is the field that can + // actually abort resolution here — an ungated override let a stale/missing ambient + // `_CONTENT_PATH` outrank a matched remote's own valid path, and the caller-side file read + // (`readAuthEmailTemplateContent`) then threw, aborting the whole + // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's `v.Set` + // (override tier, above `AutomaticEnv`) never lets win. + writeFileSync(join(tempRoot.current, "invite.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.template.invite.content_path"]), + ), + ).not.toThrow(); + }); + + it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.content_path"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { + // `enabled` is a direct `legacyEnvOverrideBool` call, so a malformed ambient override + // throws on its own regardless of the exclusivity/file-read checks above — same bug class + // as `auth.email.enable_signup`/`.enable_confirmations` (review: PRRT_kwDOErm0O86XLAYo). + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: false } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.enabled"]), + ), + ).not.toThrow(); + }); }); // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) @@ -2693,8 +3279,268 @@ describe("legacyResolveLocalConfigValues", () => { }); }); - describe("api.tls (cert/key validation)", () => { - const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); + describe("legacyResolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Regression (review: PRRT_kwDOErm0O86XFmjZ) — a prior review rejected this exact gap as + // "unreachable from the db diff --linked/db pull shadow path," having only grepped direct + // `legacyResolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and + // missed that `legacyResolveLocalConfigValues` (this function's own shadow-consuming caller, + // via `legacyBuildLocalDbContainerInputs`) calls it too, through its own + // `validateAuthSmsProviders` wrapper, whenever `authEnabled`. `enable_signup`/ + // `enable_confirmations`/each provider's `enabled` THROW via `legacyEnvOverrideBool`, and each + // provider's Secret-typed field THROWS via `legacyDecryptAuthSecret` — either can abort the + // whole `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a malformed ambient + // override even when a matched remote block already set that field. + afterEach(() => { + delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; + delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => + legacyResolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => legacyResolveAuthSms(undefined, configured, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => + legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.vonage.enabled"]), + ), + ).not.toThrow(); + }); + + it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, + }; + const resolved = legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), + ); + expect(resolved.vonage.api_secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { + // `vonage` isn't `twilio` (the one provider Go's default template always registers), so the + // env override is only consulted at all when the raw `[auth.sms.vonage]` table is present — + // same presence gate `providerPresent` already applies for the remote-set case above. + process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + const authDocument = { sms: { vonage: {} } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, + }; + expect(() => legacyResolveAuthSms(authDocument, configured, undefined)).toThrow( + "failed to parse config: missing private key", + ); + }); + + // Regression: `resolveField`'s non-secret provider leaves (`account_sid`/`message_service_sid`/ + // `originator`/`sender`/`from`/`api_key`) had no `remoteWins` branch at all — `vonage.api_key` + // sitting right next to the already-gated `vonage.api_secret` was the clearest tell. + it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + const configured = { + ...baseConfig().auth.sms, + twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, + }; + const resolved = legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.twilio.account_sid"]), + ); + expect(resolved.twilio.account_sid).toBe("remote-sid"); + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + }); + + it("still applies SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + const configured = { + ...baseConfig().auth.sms, + twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, + }; + const resolved = legacyResolveAuthSms(undefined, configured, undefined); + expect(resolved.twilio.account_sid).toBe("env-sid"); + delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + }); + + it("prefers a remote-set auth.sms.vonage.from over a conflicting SUPABASE_AUTH_SMS_VONAGE_FROM", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + const authDocument = { sms: { vonage: { from: "remote-from" } } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, + }; + const resolved = legacyResolveAuthSms( + authDocument, + configured, + undefined, + new Set(["auth.sms.vonage.from"]), + ); + expect(resolved.vonage.from).toBe("remote-from"); + delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + }); + + it("still applies SUPABASE_AUTH_SMS_VONAGE_FROM when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + const authDocument = { sms: { vonage: { from: "remote-from" } } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, + }; + const resolved = legacyResolveAuthSms(authDocument, configured, undefined); + expect(resolved.vonage.from).toBe("env-from"); + delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + }); + + it("prefers a remote-set auth.sms.vonage.api_key over a conflicting SUPABASE_AUTH_SMS_VONAGE_API_KEY", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, + }; + const resolved = legacyResolveAuthSms( + authDocument, + configured, + undefined, + new Set(["auth.sms.vonage.api_key"]), + ); + expect(resolved.vonage.api_key).toBe("remote-key"); + delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + }); + + it("still applies SUPABASE_AUTH_SMS_VONAGE_API_KEY when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, + }; + const resolved = legacyResolveAuthSms(authDocument, configured, undefined); + expect(resolved.vonage.api_key).toBe("env-key"); + delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + }); + + it("prefers a remote-set auth.sms.template over a conflicting SUPABASE_AUTH_SMS_TEMPLATE", () => { + process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + const configured = { ...baseConfig().auth.sms, template: "remote template" }; + const resolved = legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.template"]), + ); + expect(resolved.template).toBe("remote template"); + delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + }); + + it("still applies SUPABASE_AUTH_SMS_TEMPLATE when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + const configured = { ...baseConfig().auth.sms, template: "remote template" }; + const resolved = legacyResolveAuthSms(undefined, configured, undefined); + expect(resolved.template).toBe("env template"); + delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + }); + + it("prefers a remote-set auth.sms.max_frequency over a conflicting SUPABASE_AUTH_SMS_MAX_FREQUENCY", () => { + process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; + const resolved = legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.max_frequency"]), + ); + expect(resolved.max_frequency).toBe("1m"); + delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + }); + + it("still applies SUPABASE_AUTH_SMS_MAX_FREQUENCY when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; + const resolved = legacyResolveAuthSms(undefined, configured, undefined); + expect(resolved.max_frequency).toBe("5s"); + delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + }); + + it("still aborts legacyResolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { + // End-to-end proof that the gap is reachable from the exact function this PR's shadow + // provisioning calls (`legacyBuildLocalDbContainerInputs` -> `legacyResolveLocalConfigValues` + // -> `validateAuthSmsProviders` -> `legacyResolveAuthSms`), not just the standalone resolver. + // Built by spreading an already-decoded `baseConfig()` (not re-decoding through + // `ProjectConfigSchema` via `baseConfig({...})`'s shallow-merge overrides) so `vonage`'s + // other schema-required fields (`from`, etc.) keep their valid decoded defaults. + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const base = baseConfig(); + const config: ProjectConfig = { + ...base, + auth: { + ...base.auth, + enabled: true, + sms: { + ...base.auth.sms, + enable_signup: true, + vonage: { + ...base.auth.sms.vonage, + enabled: true, + from: "12345", + api_key: "key", + api_secret: "secret", + }, + }, + }, + }; + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.sms.enable_signup"]), + ), + ).not.toThrow(); + }); + }); + + describe("api.tls (cert/key validation)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); function writeTlsFile(workdir: string, name: string, contents = "dummy") { const supabaseDir = join(workdir, "supabase"); @@ -2702,107 +3548,1014 @@ describe("legacyResolveLocalConfigValues", () => { writeFileSync(join(supabaseDir, name), contents); } - it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { - // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); - // tls.enabled with nothing configured still loads. - const config = baseConfig({ api: { tls: { enabled: true } } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { + // Go's Validate only rejects the "exactly one set" case (config.go:1010-1027); + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // The "exactly one of cert/key set" presence-only assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // the actual file reads below stay here, since I/O is per-caller. + + it("throws a Go-worded error when the configured cert file does not exist", () => { + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + + it("throws a Go-worded error when the configured key file does not exist", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS key: ", + ); + }); + + it("succeeds when both cert_path and key_path are readable", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { + // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` + // (config.go:1006,1010) — a disabled api section never validates cert/key, + // however invalid the pairing. + it("skips TLS validation entirely when api is disabled", () => { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { + afterEach(() => { + delete process.env["SUPABASE_API_ENABLED"]; + delete process.env["SUPABASE_API_TLS_ENABLED"]; + }); + + it("skips TLS validation when api is disabled only via env", () => { + process.env["SUPABASE_API_ENABLED"] = "false"; + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Missing required field in config: api.tls.key_path", + ); + }); + }); + }); +}); + +describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Go's `mergeRemoteConfig` installs every matched `[remotes.]` leaf at viper's OVERRIDE + // tier, above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:718-730`) — so once a remote + // block sets a field, a conflicting `SUPABASE_*` env var must never be consulted for it. + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` already gated this + // (review: PRRT_kwDOErm0O86W2LL4); this covers the remaining leaves this resolver derives + // that the shadow's own container/setup spec also consumes (review: PRRT_kwDOErm0O86W2tRi). + afterEach(() => { + for (const name of [ + "SUPABASE_DB_MAJOR_VERSION", + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_DB_ROOT_KEY", + "SUPABASE_API_PORT", + "SUPABASE_API_TLS_ENABLED", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_DB_PORT", + "SUPABASE_AUTH_SITE_URL", + "SUPABASE_AUTH_JWT_EXPIRY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_STUDIO_API_URL", + "SUPABASE_STUDIO_OPENAI_API_KEY", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + "SUPABASE_AUTH_ENABLED", + "SUPABASE_ANALYTICS_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + "SUPABASE_EDGE_RUNTIME_DENO_VERSION", + "SUPABASE_API_ENABLED", + "SUPABASE_STUDIO_ENABLED", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_ENABLED", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_AUTH_ENABLE_SIGNUP", + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", + "SUPABASE_AUTH_PASSKEY_ENABLED", + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", + ]) { + delete process.env[name]; + } + }); + + const tempRoot = useLegacyTempWorkdir("supabase-remote-signing-keys-test-"); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { + // Regression (review: PRRT_kwDOErm0O86W3Ox_): `legacyResolveConfiguredSigningKeys` — shared + // by this function's own `anonKey`/`serviceRoleKey` asymmetric signing and by + // `legacyResolveLocalJwks` — used to reapply a conflicting env override even when a remote + // block already set `auth.signing_keys_path`, which would have pointed the shadow's + // asymmetric signing at the wrong (env-supplied) file. + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.signing_keys_path"]), + ), + ).not.toThrow(); + }); + + it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { + // Regression (review: PRRT_kwDOErm0O86W2tRi): this function validates `db.major_version` + // early but has no `majorVersion` field on its own return type (the shadow's actually- + // consumed value comes from the already-gated `legacyResolveDbBootstrapConfig`) — before + // this fix, the validate-only read here still decoded a conflicting env var unconditionally, + // so a malformed value the remote block should have made irrelevant failed config loading + // outright instead of the command proceeding on the remote's value, matching Go. + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig({ db: { major_version: 14 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.major_version"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig({ db: { major_version: 14 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid db.major_version: abc", + ); + }); + + it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; + const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.jwt_secret"]), + ); + expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); + }); + + it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { + process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; + const config = baseConfig(); + const document = { db: { root_key: "remote-root-key" } }; + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["db.root_key"]), + ); + expect(values.rootKey).toBe("remote-root-key"); + }); + + it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { + // Regression (review: PRRT_kwDOErm0O86W93Ex): this function's OWN validation-only + // `thirdParty` array used to gate `enabled` on `remoteWins` but leave the sibling + // `requiredField` (domain/tenant/user_pool_id/issuer_url) ungated — even though + // `auth.third_party.clerk.domain` is already tracked in `LEGACY_ENV_OVERRIDABLE_KEYS`. A + // matched remote's valid domain lost to a conflicting, invalid `SUPABASE_AUTH_THIRD_PARTY_ + // CLERK_DOMAIN`, so `legacyValidateResolvedConfig`'s Clerk domain-regex check rejected an + // otherwise-valid, remote-backed configuration before the shadow was ever created — Go's + // `mergeRemoteConfig` sets the whole matched block at viper's OVERRIDE tier, above + // `AutomaticEnv`, so the env var is never even consulted once a remote sets this key. + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + const config = baseConfig({ + auth: { + enabled: true, + third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), + ), + ).not.toThrow(); + }); + + it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + const config = baseConfig({ + auth: { + enabled: true, + third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.clerk has invalid domain", + ); + }); + + describe("api.tls.cert_path/key_path — remoteOverrideKeys (review: PRRT_kwDOErm0O86W8ZYk)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-remote-test-"); + + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } + + afterEach(() => { + delete process.env["SUPABASE_API_TLS_CERT_PATH"]; + delete process.env["SUPABASE_API_TLS_KEY_PATH"]; + }); + + it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { + // The ambient env vars point at files that don't exist — if they won, `readApiTlsFiles` + // would throw. Go's `mergeRemoteConfig` installs the matched remote block's cert/key + // paths at viper's OVERRIDE tier (above `AutomaticEnv`), so they must win instead and the + // load must succeed using the real, remote-supplied paths. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["api.tls.cert_path", "api.tls.key_path"]), + ), + ).not.toThrow(); + }); + + it("still uses the env override when no remote block matched", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + }); + + it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { + process.env["SUPABASE_API_PORT"] = "9999"; + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; + const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.port", "api.tls.enabled", "api.external_url"]), + ); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); + + it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { + process.env["SUPABASE_DB_PORT"] = "9999"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.port"]), + ); + expect(values.dbPort).toBe(54322); + expect(values.dbUrl).toContain(":54322/postgres"); + }); + + it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { + process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; + process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; + const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.site_url", "auth.jwt_expiry"]), + ); + expect(values.authSiteUrl).toBe("https://remote.test"); + expect(values.authJwtExpiry).toBe(3600); + }); + + it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { + process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; + process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; + const config = baseConfig({ + auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, + }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.anon_key", "auth.service_role_key"]), + ); + expect(values.anonKey).toBe("remote-anon-key"); + expect(values.serviceRoleKey).toBe("remote-service-role-key"); + }); + + it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiF's sibling gap): `studio.api_url` feeds + // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check, which throws on a malformed URL + // even though the read itself (`legacyEnvOverride`) never does — same "non-throwing read, + // throwing downstream consumer" bug class as `legacyResolveAuthHooks`'s `uri`/`secrets`. + process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + const config = baseConfig({ studio: { api_url: "http://remote.test" } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.api_url"]), + ), + ).not.toThrow(); + }); + + it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { + // Regression: `studio.openai_api_key` is a `config.Secret` (`pkg/config/config.go:264`), + // decrypted the same way `anon_key`/`service_role_key` above are — an ungated + // `legacyEnvOverride` here could let a malformed ambient override outrank a matched remote's + // own valid value and throw during decryption. + process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.openai_api_key"]), + ); + expect(values.openaiApiKey).toBe("remote-openai-key"); + }); + + it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { + process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { + // Regression: `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) are + // `config.Secret`-typed exactly like `anon_key`/`service_role_key` above, but were missed + // when that sibling pair was gated. + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ + auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, + }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.publishable_key", "auth.secret_key"]), + ); + expect(values.publishableKey).toBe("remote-publishable-key"); + expect(values.secretKey).toBe("remote-secret-key"); + }); + + it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { + process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { + // Same validate-only shape as `db.major_version` above — `legacyResolveDbSettingsEnvOverrides` + // is threaded `remoteOverrideKeys` here too, not just at its OWN (already-gated) call site + // in `legacyResolveDbBootstrapConfig`. + process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; + const config = baseConfig({ db: { settings: { max_connections: 100 } } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.settings.max_connections"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): `auth.enabled` gates the signing-keys file + // read/validate-only auth block below but has no `authEnabled` field on its own return type — + // before this fix, the ungated `legacyEnvOverrideBool` call still decoded a conflicting env + // var unconditionally, so a malformed value the remote block should have made irrelevant + // failed this WHOLE function (and therefore the shadow's `dbPort`/`jwtSecret`/etc. it also + // resolves) instead of the command proceeding on the remote's value, matching Go. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { + // Same class of gap as `auth.enabled` above — `analytics.enabled` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `analyticsEnabled` is never read by the shadow's own + // container inputs, but an ungated `legacyEnvOverrideBool` call still aborts this whole + // function on a malformed override the remote block should have made irrelevant. + process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ analytics: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ analytics: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("prefers a remote-set analytics.gcp_project_id over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_ID", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_project_id"]), + ); + expect(values.gcpProjectId).toBe("remote-project"); + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + }); + + it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_ID when no remote block matched", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.gcpProjectId).toBe("env-project"); + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + }); + + it("prefers a remote-set analytics.gcp_project_number over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + const config = baseConfig({ analytics: { gcp_project_number: "111" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_project_number"]), + ); + expect(values.gcpProjectNumber).toBe("111"); + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + }); + + it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER when no remote block matched", () => { + process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + const config = baseConfig({ analytics: { gcp_project_number: "111" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.gcpProjectNumber).toBe("999"); + delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + }); + + it("prefers a remote-set analytics.gcp_jwt_path over a conflicting SUPABASE_ANALYTICS_GCP_JWT_PATH", () => { + process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.gcp_jwt_path"]), + ); + expect(values.gcpJwtPath).toBe("remote-key.json"); + delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + }); + + it("still applies SUPABASE_ANALYTICS_GCP_JWT_PATH when no remote block matched", () => { + process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.gcpJwtPath).toBe("env-key.json"); + delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + }); + + it("prefers a remote-set auth.jwt_issuer over a conflicting SUPABASE_AUTH_JWT_ISSUER", () => { + process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.jwt_issuer"]), + ); + expect(values.authJwtIssuer).toBe("https://remote.example.com"); + delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + }); + + it("still applies SUPABASE_AUTH_JWT_ISSUER when no remote block matched", () => { + process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.authJwtIssuer).toBe("https://env.example.com"); + delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + }); + + it("prefers a remote-set auth.additional_redirect_urls over a conflicting SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", () => { + process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + const config = baseConfig({ + auth: { additional_redirect_urls: ["https://remote.example.com"] }, + }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.additional_redirect_urls"]), + ); + expect(values.authAdditionalRedirectUrls).toEqual(["https://remote.example.com"]); + delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + }); + + it("still applies SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS when no remote block matched", () => { + process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + const config = baseConfig({ + auth: { additional_redirect_urls: ["https://remote.example.com"] }, + }); + const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.authAdditionalRedirectUrls).toEqual(["https://env.example.com"]); + delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + }); + + describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // `rpId`/`rpOrigins` aren't part of this function's return value (only + // `legacyValidateResolvedConfig`'s passkey step consumes them), so precedence is proven + // through that step's emptiness check: the document deliberately leaves the field EMPTY (a + // real, present-but-empty state, not "absent") while the env var supplies a non-empty value — + // ungated, the non-throwing env value wins and validation passes; gated, the remote's own + // (empty) value wins and validation throws exactly like Go's `Validate` would for a + // `[remotes.*]`-supplied empty field. + afterEach(() => { + delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; + delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + }); + + it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ID when a remote block already set (empty) auth.webauthn.rp_id", () => { + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + const config = baseConfig(); + const document = { + auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, + }; + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.webauthn.rp_id"]), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_id"); + }); + + it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ID when no remote block matched", () => { + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + const config = baseConfig(); + const document = { + auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + + it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when a remote block already set (empty) auth.webauthn.rp_origins", () => { + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + const config = baseConfig(); + const document = { + auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, + }; + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.webauthn.rp_origins"]), + ), + ).toThrow("Missing required field in config: auth.webauthn.rp_origins"); + }); + + it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when no remote block matched", () => { + process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + const config = baseConfig(); + const document = { + auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, + }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); + }); + + it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { + // Same class of gap as `auth.enabled`/`analytics.enabled` above, for this function's OWN + // validation-only `thirdParty` block (distinct from `legacyResolveLocalJwks`'s own, already- + // gated `thirdParty` — see that param's doc comment). Auth must be enabled for this block to + // run at all. + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { + // Regression (review: PRRT_kwDOErm0O86W4gCk): same class of gap as `auth.enabled`/ + // `analytics.enabled` above — `edge_runtime.deno_version` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `denoVersion` is never read by the shadow's own + // container inputs, but an ungated `legacyEnvOverrideDenoVersion` call still aborts this + // whole function on a malformed override the remote block should have made irrelevant. + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["edge_runtime.deno_version"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); + + it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W5UlV): same class of gap as `auth.enabled`/ + // `analytics.enabled`/`edge_runtime.deno_version` above — `api.enabled` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `apiEnabled` is never read by the shadow's own + // container inputs (unlike its siblings `apiTlsEnabled`/`apiPort`, which feed `apiUrl`), + // but an ungated `legacyEnvOverrideBool` call still aborts this whole function — denying + // it `apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc. too — on a malformed override the remote + // block should have made irrelevant. + process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.enabled"]), + ), + ).not.toThrow(); + }); - // The "exactly one of cert/key set" presence-only assertions moved to - // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — - // the actual file reads below stay here, since I/O is per-caller. + it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { + process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', + ); + }); - it("throws a Go-worded error when the configured cert file does not exist", () => { - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); + it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G): the doc comment on this function's + // `remoteOverrideKeys` parameter used to claim `studio`/`local_smtp`/the auth + // enable_signup/-anonymous_sign_ins/refresh-token/manual-linking/password-length/ + // -requirements group/passkey/hooks/mfa/captcha/email.smtp/experimental.webhooks fields could + // stay ungated because their own `legacyEnvOverride*` calls "cannot throw before a value the + // caller needs has already been resolved" — that's false: this function either returns its + // whole object or throws, so ANY unconditional throw anywhere in its body aborts the entire + // call, denying the shadow `dbPort`/`jwtSecret`/etc. too, regardless of textual position. + process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + const config = baseConfig({ studio: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.enabled"]), + ), + ).not.toThrow(); + }); - it("throws a Go-worded error when the configured key file does not exist", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS key: ", - ); - }); + it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { + process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + const config = baseConfig({ studio: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', + ); + }); - it("succeeds when both cert_path and key_path are readable", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { + process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; + const config = baseConfig({ studio: { port: 54323 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.port"]), + ), + ).not.toThrow(); + }); - it("resolves cert_path/key_path against /supabase unconditionally, no isAbsolute guard", () => { - // Go's `path.Join` (config.go:961-965) absorbs a leading "/" — unlike - // signing_keys_path, which Go DOES guard with filepath.IsAbs. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { - tls: { + it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { + process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + const config = baseConfig({ local_smtp: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["local_smtp.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { + process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + const config = baseConfig({ local_smtp: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { + process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + const config = baseConfig({ auth: { enable_signup: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enable_signup"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + const config = baseConfig({ auth: { enable_signup: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { + process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; + const config = baseConfig({ auth: { minimum_password_length: 8 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.minimum_password_length"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { + process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.webhooks.enabled"]), + ), + ).not.toThrow(); + }); + + it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { + // Regression (review: PRRT_kwDOErm0O86XGTq5): the remote can supply a valid `uri` while a + // stale/malformed `SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI` sits in the ambient + // environment. Go's `mergeRemoteConfig` (`config.go:718-724`) sets EVERY matched-block leaf + // above `AutomaticEnv`, so the remote's valid uri must win and validation must pass — before + // this fix, the ungated env read won instead and `legacyValidateResolvedConfig`'s scheme + // check rejected a linked diff/pull that Go would have accepted. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const config = baseConfig({ + auth: { + hook: { + custom_access_token: { enabled: true, - cert_path: "/cert.pem", - key_path: "/key.pem", + uri: "https://example.com/hook", + secrets: `v1,whsec_${"A".repeat(32)}`, }, }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - - // Go's `Validate` nests the whole TLS branch inside `if c.Api.Enabled` - // (config.go:1006,1010) — a disabled api section never validates cert/key, - // however invalid the pairing. - it("skips TLS validation entirely when api is disabled", () => { - const config = baseConfig({ - api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); + }, }); + const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.hook.custom_access_token.uri"]), + ), + ).not.toThrow(); + }); - describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { - afterEach(() => { - delete process.env["SUPABASE_API_ENABLED"]; - delete process.env["SUPABASE_API_TLS_ENABLED"]; - }); - - it("skips TLS validation when api is disabled only via env", () => { - process.env["SUPABASE_API_ENABLED"] = "false"; - const config = baseConfig({ - api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - - it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ - api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.key_path", - ); - }); + it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const config = baseConfig({ + auth: { + hook: { + custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, + }, + }, }); + const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); }); }); @@ -3051,4 +4804,191 @@ describe("legacyResolveLocalJwks", () => { ); }); }); + + describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Go's `mergeRemoteConfig` installs every matched `[remotes.]` leaf at viper's OVERRIDE + // tier, above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:718-730`) — regression + // coverage for review PRRT_kwDOErm0O86W3Ox_, which found `auth.signing_keys_path`/ + // `auth.third_party.*` reapplying a conflicting `SUPABASE_AUTH_*` env value even after a + // matched remote block set them. + afterEach(() => { + for (const name of [ + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + "SUPABASE_AUTH_ENABLED", + ]) { + delete process.env[name]; + } + }); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = await legacyResolveLocalJwks( + config, + tempRoot.current, + "a".repeat(32), + undefined, + new Set(["auth.signing_keys_path"]), + ); + const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); + }); + + it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + await expect( + legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)), + ).rejects.toThrow("failed to read signing keys: "); + }); + + it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://remote-issuer.example/.well-known/openid-configuration") { + return new Response( + JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url === "https://remote-issuer.example/jwks.json") { + return new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch url: ${url}`); + }); + process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = + "https://env-should-not-win.test"; + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, + }, + }); + const jwks = await legacyResolveLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), + ); + const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); + fetchMock.mockRestore(); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function recomputes `authEnabled` + // itself (see its own doc comment) to gate `resolveThirdPartyIssuerUrl`'s throwing validate + // path — before this fix, the ungated `legacyEnvOverrideBool` call still decoded a + // conflicting env var unconditionally, so a malformed value the remote block should have + // made irrelevant failed the shadow's PG15+ one-shot auth-migration job outright. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + await expect( + legacyResolveLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.enabled"]), + ), + ).resolves.toEqual(expect.any(String)); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + }); +}); + +describe("legacyResolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; + }); + + it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { + process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; + const document = { auth: { external_url: "https://remote.test" } }; + expect(legacyResolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( + "https://remote.test", + ); + }); + + it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { + process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; + const document = { auth: { external_url: "https://configured.test" } }; + expect(legacyResolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); + }); +}); + +describe("legacyResolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-configured-signing-keys-test-"); + + afterEach(() => { + delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const keys = legacyResolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.signing_keys_path"]), + ); + expect(keys).toHaveLength(1); + expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); + }); + + it("still reads the env-overridden path when no remote block matched", () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function's own `authEnabled` recompute + // (see its doc comment) used to be ungated, so a malformed override the remote block should + // have made irrelevant aborted the anon/service_role asymmetric-signing path outright. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 37bdfe0314..08f87ea4c8 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -54,6 +54,17 @@ export interface LegacyLocalProjectContext { export const legacyLoadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, + // The resolved `--linked`/`--project-ref` ref, when the caller already has one in scope + // (`db diff`/`db pull`'s shadow-provisioning prelude — CLI-1956 — and the `functions` + // Docker paths' Go-config pipeline — CLI-1963) — threaded straight into + // `loadProjectConfig`'s own `projectRef` option so the matching `[remotes.]` block + // merges over the base config, exactly like `legacyReadDbToml(..., ref)` already does for + // those same commands' OTHER config read. It also supplies Go's `Eject` default + // (`pkg/config/config.go:561-570`): `flags.LoadConfig` pre-sets `Config.ProjectId = + // ProjectRef` before merging the file, so `Eject`'s own basename fallback only triggers + // when that default is itself empty. `db start`/`db reset`/`start`/`stop`/`status` never + // pass this, so it defaults to `undefined` — no remote merge, unchanged from before. + projectRef?: string, ): Effect.Effect => Effect.gen(function* () { // `search: false`: `workdir` already IS Go's fully-resolved chdir target (`legacy-cli-config. @@ -166,16 +177,31 @@ export const legacyLoadLocalProjectContext = ( // `config.toml`. tomlOnly: true, goViperCompat: true, + projectRef, }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); const hostname = legacyGetHostname(); + // `loaded?.appliedRemote !== undefined` means a `[remotes.]` block matched + // `projectRef` above and `loadProjectConfig` merged it over the base document + // (`packages/config/src/io.ts`'s `applyRemoteOverride`) — including that block's OWN + // `project_id` field, which is what selected it (`config.project_id` already equals + // `projectRef`). Go's `mergeRemoteConfig` installs that value at viper's override tier, + // above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:718-724`), so a stale/ + // differently-scoped `SUPABASE_PROJECT_ID` must not win over it here either — otherwise + // this context's `projectId` (network id, container labels — same field + // `legacy-db-config.toml-read.ts`'s own `project_id` gating protects for the pg-delta + // context) resolves the WRONG id for a linked `db diff --linked`/`db pull` shadow + // (review: PRRT_kwDOErm0O86XHGDL). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + loaded?.appliedRemote !== undefined + ? undefined + : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"]), config.project_id, workdir, + projectRef, ), ); diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts index 814408140d..f4b428da01 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts @@ -31,17 +31,67 @@ function writeDotEnv(workdir: string, contents: string): void { writeFileSync(join(workdir, ".env"), contents); } +function writeConfigToml(workdir: string, contents: string): void { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-context-"); describe("legacyLoadLocalProjectContext", () => { const previousDockerHost = process.env[DOCKER_HOST_KEY]; const previousBitbucketCloneDir = process.env[BITBUCKET_CLONE_DIR_KEY]; + const previousProjectId = process.env["SUPABASE_PROJECT_ID"]; afterEach(() => { if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; else process.env[DOCKER_HOST_KEY] = previousDockerHost; if (previousBitbucketCloneDir === undefined) delete process.env[BITBUCKET_CLONE_DIR_KEY]; else process.env[BITBUCKET_CLONE_DIR_KEY] = previousBitbucketCloneDir; + if (previousProjectId === undefined) delete process.env["SUPABASE_PROJECT_ID"]; + else process.env["SUPABASE_PROJECT_ID"] = previousProjectId; + }); + + it.effect( + "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", + () => { + // Regression (review: PRRT_kwDOErm0O86XHGDL) — `loadProjectConfig`'s own remote merge + // (`packages/config/src/io.ts`) already installs the matched block's `project_id` at + // Go's viper override tier before this reads it; letting an unrelated + // `SUPABASE_PROJECT_ID` win here would resolve the WRONG project id for the shadow's + // own network id/container labels on a linked `db diff`/`db pull`. + process.env["SUPABASE_PROJECT_ID"] = "local"; + const ref = "abcdefghijklmnopqrst"; + const workdir = tempRoot.current; + writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( + Effect.map((context) => { + expect(context.loaded?.appliedRemote).toBe("prod"); + expect(context.projectId).toBe(ref); + }), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + process.env["SUPABASE_PROJECT_ID"] = "env-project"; + const ref = "abcdefghijklmnopqrst"; + const workdir = tempRoot.current; + writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( + Effect.map((context) => { + expect(context.loaded?.appliedRemote).toBeUndefined(); + expect(context.projectId).toBe("env-project"); + }), + Effect.provide(BunServices.layer), + ); }); it.effect( diff --git a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts index d40f47524b..abf75d456d 100644 --- a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts @@ -11,6 +11,12 @@ import { LegacyLoginVerificationError } from "../commands/login/login.errors.ts" const POLL_TIMEOUT = "10 seconds"; +// HttpClientError reasons that mean the response arrived but its body could not +// be decoded (including a 2xx whose body isn't valid JSON). These are API +// response problems, not transport ones, so they classify by `decode` rather +// than as a network failure. Mirrors `next/auth/api.layer.ts`. +const BODY_DECODE_REASONS = new Set(["DecodeError", "EmptyBodyError"]); + function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { const value = (obj as Record)[key]; @@ -38,6 +44,7 @@ export const legacyLoginApiLayer = Layer.effect( return yield* Effect.fail( new LegacyLoginVerificationError({ message: `Error status ${response.status}: ${body}`, + statusCode: response.status, }), ); } @@ -51,12 +58,20 @@ export const legacyLoginApiLayer = Layer.effect( }).pipe( // Map transport / JSON-decode failures to the retry-driving error. // The explicit non-200 `LegacyLoginVerificationError` above passes - // through untouched (it is not an `HttpClientError`). + // through untouched (it is not an `HttpClientError`). A body-decode + // reason means the response arrived but its body was unparseable — an + // API response problem (`decode`), not a transport (`network`) one. Effect.catchTag("HttpClientError", (cause) => Effect.fail( - new LegacyLoginVerificationError({ - message: `failed to execute http request: ${cause.message}`, - }), + BODY_DECODE_REASONS.has(cause.reason._tag) + ? new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + decode: true, + }) + : new LegacyLoginVerificationError({ + message: `failed to execute http request: ${cause.message}`, + network: true, + }), ), ), Effect.timeoutOrElse({ @@ -65,6 +80,7 @@ export const legacyLoginApiLayer = Layer.effect( Effect.fail( new LegacyLoginVerificationError({ message: "failed to execute http request: request timed out", + network: true, }), ), }), diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 5f9c3c0683..724765d667 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -1,16 +1,13 @@ -import { Effect, type FileSystem, type Path, Result } from "effect"; +import { Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; -import { legacyBold } from "./legacy-colors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { legacyGlobPattern, legacyResolveUnderWorkdir, legacyWalkSqlFiles } from "./legacy-glob.ts"; import { LegacyMigrationApplyError, legacyApplyMigrationFile, - legacyExecSqlFile, + legacyApplySchemaFiles, } from "./legacy-migration-apply.ts"; import { legacyLoadPartialMigrations } from "./legacy-migration-history.ts"; -import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "./legacy-path-match.ts"; import { legacyApplySeedFiles, type LegacySeedConfig } from "./legacy-seed.ts"; /** Config consumed by `legacyMigrateAndSeed`. */ @@ -33,136 +30,16 @@ export interface LegacyMigrateAndSeedConfig { readonly schemaPaths: ReadonlyArray; } -/** - * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`), - * called with zero `GlobOption`s exactly like `applySchemaFiles`'s own call - * (`internal/migration/apply/apply.go:52`): each `schemaPaths` pattern is glob-matched, in - * declared order, via {@link legacyGlobPattern} — the same `fs.Glob` port `[db.seed] - * sql_paths` already uses. Patterns arrive already resolved to Go's config-load form - * (supabase-prefixed and `path.Clean`-ed when relative) — `legacyCheckDbToml` - * (`legacy-db-config.toml-read.ts`) does that once, at config-load time, the same place - * `db.seed.sql_paths` is resolved, so this function (unlike an earlier version of this - * comment) does no path-shape work of its own. A matched directory is expanded to its - * `.sql` regular files, recursively, sorted; a matched plain file is kept as-is — even a - * non-`.sql` one, since Go's `expandDir` callback only ever runs on `IsDir()` matches, - * never on an explicitly-matched file. Results are deduplicated across ALL patterns - * (first occurrence wins), preserving pattern declaration order. - * - * A pattern matching nothing, a stat failure, or a directory-walk failure is an error, - * but — mirroring `applySchemaFiles`'s `if len(declared) == 0 { return err }` (the error - * `Glob.SQLFiles` returns alongside a non-empty `declared` is joined from every - * problem, including `walkMatchedDir`'s) — every such problem is discarded outright - * whenever the combined result ends up non-empty regardless; they only surface when NO - * pattern matched anything at all. - */ -const legacyResolveSchemaPathFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - patterns: ReadonlyArray, -): Effect.Effect, LegacyMigrationApplyError> => - Effect.gen(function* () { - const seen = new Set(); - const result: Array = []; - const problems: Array = []; - - for (const pattern of patterns) { - if (legacyPathMatch(pattern, "").badPattern) { - problems.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); - continue; - } - const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); - if (matches.length === 0) { - problems.push(`no files matched pattern: ${pattern}`); - continue; - } - for (const match of matches) { - const absMatch = legacyResolveUnderWorkdir(path, workdir, match); - const statResult = yield* fs.stat(absMatch).pipe(Effect.result); - if (Result.isFailure(statResult)) { - problems.push(`failed to stat matched file: ${match}`); - continue; - } - if (statResult.success.type !== "Directory") { - if (!seen.has(match)) { - seen.add(match); - result.push(match); - } - continue; - } - // Go's `walkMatchedDir`: recursively list the matched directory, keep only regular - // `.sql` files, sorted (a global sort over the full relative-to-fsys-root path, not - // per-directory — matches `sort.Strings(files)` running once after the whole walk). - // A read/walk failure is Go's `failed to walk matched directory: %w` — recorded as a - // problem (not silently treated as an empty directory) so it surfaces exactly like - // Go's joined error does whenever nothing else matched anything either. - const namesResult = yield* legacyWalkSqlFiles(fs, absMatch, "").pipe(Effect.result); - if (Result.isFailure(namesResult)) { - problems.push(`failed to walk matched directory: ${match}`); - continue; - } - const sqlRelative = [...namesResult.success].sort(); - for (const relative of sqlRelative) { - const relativeToWorkdir = `${match}/${relative}`; - if (!seen.has(relativeToWorkdir)) { - seen.add(relativeToWorkdir); - result.push(relativeToWorkdir); - } - } - } - } - - if (result.length === 0 && problems.length > 0) { - return yield* Effect.fail(new LegacyMigrationApplyError({ message: problems.join("\n") })); - } - return result; - }); - -/** - * Port of Go's `applySchemaFiles` (`internal/migration/apply/apply.go:50-61`): applies - * every file resolved by {@link legacyResolveSchemaPathFiles} directly, in order, WITHOUT - * inserting a migration-history row (Go sets `schema.Version = ""` before `ExecBatch`) and - * WITHOUT creating the history table or resetting connection state first (`applySchemaFiles` - * calls `ExecBatch` directly on each file, unlike `applyMigrationFiles`'s - * `migration.ApplyMigrations`) — `legacyExecSqlFile` already has exactly this shape. A failed - * `ExecBatch` sets `utils.CmdSuggestion = "See schema file: "` (`apply.go:57`, `fp` bolded) - * immediately, so the failing file is attached as the error's `suggestion` here too — the - * generic `normalizeCliError` fallback (`shared/output/normalize-error.ts`) surfaces any - * error's `suggestion` field verbatim, matching root.go's plain `CmdSuggestion` stderr line. - */ -const legacyApplySchemaFiles = ( - session: LegacyDbSession, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - schemaPaths: ReadonlyArray, -) => - Effect.gen(function* () { - const declared = yield* legacyResolveSchemaPathFiles(fs, path, workdir, schemaPaths); - for (const relativePath of declared) { - const absPath = legacyResolveUnderWorkdir(path, workdir, relativePath); - yield* legacyExecSqlFile( - session, - fs, - path, - absPath, - (message) => - new LegacyMigrationApplyError({ - message, - suggestion: `See schema file: ${legacyBold(relativePath)}`, - }), - ); - } - }); - /** * Reapplies local migrations up to `version`, then runs seed files. Port of Go's * `apply.MigrateAndSeed` (`internal/migration/apply/apply.go:16-26`): when `experimental` is * set, `version` is empty, and `pgDeltaEnabled` is false, the declarative `schemaPaths` - * files are applied INSTEAD of migration files (bypassing `migrationsEnabled` entirely — Go's - * `applySchemaFiles` has no such gate, only `applyMigrationFiles` does); otherwise migration - * apply is gated on `db.migrations.enabled` as before. Seeding (`db.seed.enabled`, inside the - * seed helper) always runs, on either branch. + * files are applied INSTEAD of migration files via the shared {@link legacyApplySchemaFiles} + * (`legacy-migration-apply.ts` — also used by `db reset`'s own `--experimental` remote path, + * so both callers share one Go-quirk-preserving implementation instead of two), bypassing + * `migrationsEnabled` entirely — Go's `applySchemaFiles` has no such gate, only + * `applyMigrationFiles` does; otherwise migration apply is gated on `db.migrations.enabled` as + * before. Seeding (`db.seed.enabled`, inside the seed helper) always runs, on either branch. */ export const legacyMigrateAndSeed = ( session: LegacyDbSession, @@ -175,7 +52,14 @@ export const legacyMigrateAndSeed = ( Effect.gen(function* () { const output = yield* Output; if (config.experimental && version.length === 0 && !config.pgDeltaEnabled) { - yield* legacyApplySchemaFiles(session, fs, path, workdir, config.schemaPaths); + yield* legacyApplySchemaFiles( + session, + fs, + path, + workdir, + config.schemaPaths, + (message, suggestion) => new LegacyMigrationApplyError({ message, suggestion }), + ); } else if (config.migrationsEnabled) { const migrationsDir = path.join(workdir, "supabase", "migrations"); const pending = yield* legacyLoadPartialMigrations(fs, path, migrationsDir, version).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 96d545f10e..701c233b9c 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,14 +1,22 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { legacyBold } from "./legacy-colors.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { legacyErrorMessage, legacyRelativizeErrorMessage } from "./legacy-error-message.ts"; import { INSERT_MIGRATION_VERSION, MIGRATE_FILE_PATTERN, legacyCreateMigrationTable, } from "./legacy-migration-history.ts"; -import { legacySplitAndTrim } from "./legacy-sql-split.ts"; +import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; +import { legacySplitAndTrim, legacySplitSqlTokens } from "./legacy-sql-split.ts"; /** * Applying a migration file failed (Go's `ApplyMigrations` / `ExecBatch` error). @@ -22,7 +30,11 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; export class LegacyMigrationApplyError extends Data.TaggedError("LegacyMigrationApplyError")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} // Byte order mark (U+FEFF) — stripped from the head of a statement like Go does. const BOM_CODE_POINT = 0xfeff; @@ -102,13 +114,299 @@ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } | { readonly kind: "version" }; -const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); - const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length; +// Go's `startBufSize` (`pkg/parser/token.go:15`) — the fixed initial `bufio.Scanner` +// buffer `parser.Split` pre-allocates before applying the configured/default max +// (`scanner.Buffer(buf, maxbuf)` where `buf := make([]byte, startBufSize)`). The +// scanner's buffer therefore starts at exactly this size regardless of how small +// `SUPABASE_SCANNER_BUFFER_SIZE` is set, and `bufio.Scanner`'s too-long check +// (`len(s.buf) >= s.maxTokenSize`, `$GOROOT/src/bufio/scan.go:200`) only fires once +// the buffer is full — so a statement must reach at least this many raw bytes +// before Go can ever raise `bufio.ErrTooLong`, no matter how small the override. +// Verified empirically against `apps/cli-go/pkg/parser` (`parser.SplitAndTrim`): a +// single-statement probe of exactly 4096 raw bytes always succeeds — even with +// `SUPABASE_SCANNER_BUFFER_SIZE` set to 10 bytes — while 4097 bytes always fails; +// with the override set above this floor (e.g. 5000 bytes), the exact same +// pattern repeats at the override's own value (5000 succeeds, 5001 fails). +const GO_SCANNER_START_BUF_SIZE = 4096; + +// Go's `parser.MaxScannerCapacity` (`pkg/parser/token.go:19`) — the hardcoded default +// `parser.Split` falls back to when `viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` +// returns `0`. Reached whenever the env var is SET but resolves to a non-positive size +// — including a value `legacyParseScannerBufferSize` can't parse at all. Verified +// empirically against `apps/cli-go/pkg/parser` + vendored `viper@v1.21.0`: +// `SUPABASE_SCANNER_BUFFER_SIZE=5M` (a bare multiplier suffix with NO trailing `b`/`B`) +// behaves byte-for-byte identically to the var being completely unset from the default +// cap's own first-failure point on — because `parseSizeInBytes` only ever recognizes a +// `k`/`m`/`g` multiplier when it immediately precedes a trailing `b`/`B` +// (`util.go:156-174`); "5M" never strips a suffix, so it falls through to +// `cast.ToInt("5M")`, which fails whole (not a leading-digits prefix parse — unlike +// JS's lenient `Number.parseInt`) and returns `0`. This is NOT the same as truly unset, +// though: `viper.IsSet("SCANNER_BUFFER_SIZE")` is still `true` (the var IS present, just +// unparseable), so `parseFile`'s file-size auto-growth (see `checkScannerBufferSize`'s +// doc comment) never runs — the cap stays pinned at this hardcoded default regardless of +// the real file's size, unlike the genuinely-unset case where it grows to match. +const GO_DEFAULT_MAX_SCANNER_CAPACITY = 256 * 1024; + +/** + * Go's `viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` (env-prefixed + * `SUPABASE_SCANNER_BUFFER_SIZE`, `pkg/parser/token.go:87`): an integer byte count, + * optionally suffixed `k`/`K`/`m`/`M`/`g`/`G` (× 1024/1024²/1024³) plus a trailing + * `b`/`B` (e.g. `"5MB"`, `"256KB"`, or a bare byte count). Ported 1:1 from viper's + * own parser (`parseSizeInBytes`, `github.com/spf13/viper@v1.21.0/util.go:151-179`): + * an unparseable or non-positive result is treated as unset (`0`). + * + * The multiplier is recognized ONLY when the string's LAST character is literally + * `b`/`B` — a bare `"5M"` (no trailing `B`) is NOT 5 MiB in real Go: `sizeStr[lastChar]` + * isn't `b`/`B`, so the multiplier branch never runs and the whole (unstripped) string + * is handed to `cast.ToInt`, which fails on the trailing letter and yields `0`. Do NOT + * special-case a bare `k`/`m`/`g` suffix here; that would make this port accept a value + * real Go rejects. + * + * Go's inner `lastChar > 1` gate (`util.go:158`) means the trailing-`B`-strip ALSO never + * runs for a 2-character value like `"5B"`/`"5b"` — only 3+ characters (an actual + * multiplier letter, or at least one digit, before the `B`) reach the switch at all — so + * `"5B"` is unstripped, `cast.ToInt("5B")` fails, and the whole thing is `0` too, same as + * `"5M"`. `cast.ToInt` (`strconv.ParseInt`) also requires the ENTIRE remaining string to + * be a clean integer — trailing garbage fails the WHOLE parse, unlike JS's lenient + * `Number.parseInt`, which stops at the first non-digit and returns whatever numeric + * prefix it found (`Number.parseInt("5M", 10) === 5`, silently discarding the "M", where + * Go's parse rejects the string outright). `cast.ToInt` does tolerate one decimal point + * via its own `trimDecimal` (keeps only the integer part, e.g. `"5.5"` → `"5"`), so allow + * exactly that one exception. All of the above verified empirically against + * `apps/cli-go/pkg/parser` + vendored `viper@v1.21.0` + * (`"5"→5, "5B"→0, "50B"→50, "5KB"→5120, "5M"→0, "0"→0, "5.5"→5, "5.5MB"→5242880`). + * + * `cast.ToInt`'s underlying `strconv.ParseInt(s, 0, 0)` (review CLI-1958) uses base + * `0`, so the remaining (post multiplier-strip) string is ALSO accepted as a + * `0x`/`0X`-prefixed hex literal, an `0o`/`0O`-prefixed OR bare-leading-zero octal + * literal, or a `0b`/`0B`-prefixed binary literal — handled below by + * {@link parseGoBaseZeroInt}. Verified empirically against vendored `viper@v1.21.0` + * (via `viper.GetSizeInBytes`, since `cast.ToInt` is itself unexported call + * plumbing): `"0x100000"→1048576`, `"0o40000"→16384`, + * `"0b100000000000000000000"→2097152`, `"0755"` (legacy octal, no `"o"`)`→493`, + * `"0x"/"garbage"/"0x1g"→unparseable (0)`. A hex/octal/binary value ending in a + * literal `b`/`B` digit (e.g. `"0x1B"`) still hits the multiplier-strip switch + * ABOVE first, same as any other value — that consumes the trailing `B` before + * base-0 parsing ever sees it (`"0x1B"→1`, not `27`), which is a genuine Go quirk + * this port reproduces automatically by keeping the same two-step order, not a bug. + * Go's base-0 grammar also permits `_` digit separators (e.g. `"1_048_576"`) — see + * {@link parseGoBaseZeroInt}'s own doc comment for the exact placement grammar. + */ +/** + * Go's underscore digit-separator grammar (`go.dev/ref/spec#Integer_literals`, + * reproduced by `strconv.ParseInt`'s base-0 mode, review CLI-1958): a SINGLE `_` + * may sit immediately after a base prefix (explicit `0x`/`0o`/`0b`, or the bare + * leading `"0"` of legacy octal) or between two digits of the same base — never + * doubled, never leading a plain (no-prefix) decimal literal, and never trailing. + * Verified empirically against the real `strconv.ParseInt(s, 0, 64)`: + * `"1_048_576"→1048576`, `"0x_100000"/"0x10_0000"→1048576`, + * `"0o_40000"/"0o4_0000"→16384`, `"0b_100000000000000000000"→1048576`, + * `"0_755"/"07_55"→493` (legacy octal, underscore right after the leading `"0"` + * or between later octal digits); while `"_1048576"`, `"1048576_"`, + * `"1__048576"`, `"0x100000_"`, and `"0_x100000"` (underscore splitting the + * leading `"0"` from the `"x"` — not a real prefix, so it's parsed as legacy + * octal digits `"x100000"`) all fail, matching Go exactly. + */ +// `strconv.ParseInt(s, 0, 0)`'s bitSize-0 mode requires the result to fit in Go's `int` +// — 64 bits on every platform this CLI ships for (amd64/arm64). A magnitude outside +// this range is a range error (`strconv.ErrRange`), and `cast.ToInt` (`spf13/cast@v1.10.0 +// /number.go:407-414`'s `parseInt[T]`) discards ANY `parseFn` error — range or +// syntax — and returns exactly `0`, not the (possibly huge, saturated-to-max-magnitude) +// value `strconv.ParseInt` itself returns alongside that error. Verified empirically +// against the pinned `spf13/cast@v1.10.0`: `cast.ToInt("9223372036854775808")` (one over +// `math.MaxInt64`) → `0`. `Number.parseInt` has no such range check (it silently rounds +// via IEEE-754 double precision instead), so this port must reject the same magnitudes +// Go does, or it would treat an out-of-range override as an enormous-but-finite limit +// instead of falling back to the 256KiB default like Go (review CLI-1958 round 18). +const GO_MAX_INT64 = 9223372036854775807n; +const GO_MIN_INT64 = -9223372036854775808n; + +const parseGoBaseZeroInt = (value: string): number | undefined => { + const negative = value.startsWith("-"); + const unsigned = negative || value.startsWith("+") ? value.slice(1) : value; + if (unsigned.length === 0) return undefined; + + let base = 10; + let digits = unsigned; + const prefix = unsigned.slice(0, 2).toLowerCase(); + if (prefix === "0x") { + base = 16; + digits = unsigned.slice(2); + } else if (prefix === "0o") { + base = 8; + digits = unsigned.slice(2); + } else if (prefix === "0b") { + base = 2; + digits = unsigned.slice(2); + } else if (unsigned.length > 1 && unsigned[0] === "0") { + // Legacy (no "o") leading-zero octal, e.g. "0755". + base = 8; + digits = unsigned.slice(1); + } + if (digits.length === 0) return undefined; + + // Only a real base prefix (or the legacy-octal leading "0") may be followed + // immediately by an underscore; a plain decimal literal has no prefix to + // follow, so a leading underscore there is always invalid (matches Go). + const hadPrefix = base !== 10; + const digitClass = base === 16 ? "0-9a-fA-F" : base === 8 ? "0-7" : base === 2 ? "01" : "0-9"; + const validPattern = new RegExp( + `^${hadPrefix ? "_?" : ""}[${digitClass}](?:_?[${digitClass}])*$`, + ); + if (!validPattern.test(digits)) return undefined; + + const cleanDigits = digits.replace(/_/g, ""); + // Exact-magnitude range check via BigInt — `Number.parseInt` below loses precision + // past 2^53 and never errors, so the int64 bound must be checked independently of it. + const bigPrefix = base === 16 ? "0x" : base === 8 ? "0o" : base === 2 ? "0b" : ""; + const magnitude = BigInt(`${bigPrefix}${cleanDigits}`); + const signedMagnitude = negative ? -magnitude : magnitude; + if (signedMagnitude > GO_MAX_INT64 || signedMagnitude < GO_MIN_INT64) return undefined; + + const n = Number.parseInt(cleanDigits, base); + return negative ? -n : n; +}; + +// `cast.ToInt`'s `trimDecimal` (`spf13/cast@v1.10.0/number.go:507-525`) runs BEFORE +// `strconv.ParseInt`: when the whole string is a sign + plain decimal digits + an +// optional ".digits" tail (`stringNumberRe`, `^([-+]?\d*)(\.\d*)?$` — never matches +// a `0x`/`0o`/`0b` literal, which contains letters), it drops the fractional part +// outright rather than rounding (`"5.5"` → `"5"`). Anything else (including a +// non-decimal-looking string that merely contains a ".") passes through unchanged +// and is left for {@link parseGoBaseZeroInt} to accept or reject. +const trimGoDecimal = (value: string): string => { + if (!value.includes(".")) return value; + const match = /^([+-]?\d*)(?:\.\d*)?$/.exec(value); + if (!match) return value; + const intPart = match[1] ?? ""; + if (intPart === "+" || intPart === "-") return `${intPart}0`; + return intPart === "" ? "0" : intPart; +}; + +const legacyParseScannerBufferSize = (raw: string): number => { + let value = raw.trim(); + let multiplier = 1; + const lastIndex = value.length - 1; + if (lastIndex > 1 && (value[lastIndex] === "b" || value[lastIndex] === "B")) { + switch (value[lastIndex - 1]!.toLowerCase()) { + case "k": + multiplier = 1 << 10; + value = value.slice(0, lastIndex - 1).trim(); + break; + case "m": + multiplier = 1 << 20; + value = value.slice(0, lastIndex - 1).trim(); + break; + case "g": + multiplier = 1 << 30; + value = value.slice(0, lastIndex - 1).trim(); + break; + default: + value = value.slice(0, lastIndex).trim(); + break; + } + } + const size = parseGoBaseZeroInt(trimGoDecimal(value)); + return size !== undefined && Number.isFinite(size) && size > 0 ? size * multiplier : 0; +}; + +/** + * Go's `parser.Split`/`SplitAndTrim` (`pkg/parser/token.go:81-119`) enforces + * `SUPABASE_SCANNER_BUFFER_SIZE` as the `bufio.Scanner`'s max token size — but only + * when the env var is actually SET: `parseFile` (`pkg/migration/file.go:55-70`) + * otherwise grows the package-level `parser.MaxScannerCapacity` to the real file's + * byte length before the scan even starts (`viper.IsSet("SCANNER_BUFFER_SIZE")` + * gates the auto-growth), so the DEFAULT (unset) path can never hit + * `bufio.ErrTooLong` for a file read this way — no single statement can be bigger + * than the whole file. Every caller of `execMigrationBatch` mirrors exactly this + * Go call site (`ApplyMigrations`, `SeedGlobals`, `applySchemaFiles` all read their + * file via `NewMigrationFromFile`/`parseFile`), so this is the correct single home + * for the check (CLI-1958 review) rather than duplicating it per caller. + * + * Fails the same way `parser.Split` does on the FIRST raw (pre-trim) statement + * whose byte length exceeds the effective limit (`Math.max(configured, + * GO_SCANNER_START_BUF_SIZE)` — see that constant's comment). `"After statement + * : …"` reports the count and RAW text of the last statement successfully + * scanned BEFORE the oversized one: Go's loop body (`token = scanner.Text()`) + * never runs for the failing `Scan()` call, so `token` still holds whatever the + * previous iteration left it as (`""` if the very first statement is already + * oversized) — verified empirically against the same `apps/cli-go/pkg/parser` + * probe. This is a "read"-phase failure (`NewMigrationFromFile`/`parseFile` + * returns before `apply.go`'s `CmdSuggestion` is ever set), so it carries no + * suggestion, same as the file-open failure above. + * + * `projectEnv`, when given, is the caller's already-loaded `legacyLoadProjectEnv` map: + * Go's `loadNestedEnv` (`pkg/config/config.go:1220`) `os.Setenv`s every project-`.env` + * key that isn't already in the shell env BEFORE `ParseDatabaseConfig` returns — i.e. + * before ANY command body (including this scan) runs — so `viper.AutomaticEnv()` sees a + * `supabase/.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported + * one. Defaults to `{}` for callers that haven't threaded a project-env map through + * (shell-only, same as before this parameter existed). + */ +export const checkScannerBufferSize = ( + content: string, + mapError: (message: string, phase: "read" | "exec") => E, + projectEnv: Readonly> = {}, +): Effect.Effect => { + const raw = + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; + if (raw === undefined) return Effect.void; + const configuredLimit = legacyParseScannerBufferSize(raw); + // `configuredLimit <= 0` covers both an explicit non-positive size and an unparseable + // value (e.g. a bare "5M", see `GO_DEFAULT_MAX_SCANNER_CAPACITY`'s comment) — Go's + // `viper.GetSizeInBytes` collapses all of these to `0` too, and `parser.Split` then + // falls back to its OWN hardcoded default cap, not to "no limit". + const limit = + configuredLimit > 0 + ? Math.max(configuredLimit, GO_SCANNER_START_BUF_SIZE) + : GO_DEFAULT_MAX_SCANNER_CAPACITY; + // Go's suggestion reports `maxbuf>>10` — the EFFECTIVE cap actually passed to + // `scanner.Buffer` (`pkg/parser/token.go:110`), which is the raw configured value + // (even below the `GO_SCANNER_START_BUF_SIZE` floor — the floor only affects when + // `bufio.ErrTooLong` can fire, never the number Go prints) when positive, or the + // hardcoded default once Go has fallen back to it. + const reportedLimit = configuredLimit > 0 ? configuredLimit : GO_DEFAULT_MAX_SCANNER_CAPACITY; + let emitted = 0; + let lastRaw = ""; + for (const token of legacySplitSqlTokens(content)) { + // A delimiter-terminated token is found (and emitted) by `parser.Split`'s scan in + // the SAME `Scan()` call that fills the buffer to capacity — before Go's too-long + // check is ever reached — so a token exactly AT `limit` still succeeds; only + // strictly-over fails (`>`). The trailing, unterminated token (only ever the LAST + // one `legacySplitSqlTokens` returns, if any — see `LegacySplitSqlToken.terminated`) + // has no delimiter to find: once the buffer fills to `limit` bytes without one, the + // too-long check fires immediately, without Go ever attempting the extra `Read()` + // that would reveal real EOF — so a trailing token AT `limit` already fails (`>=`). + const tooLong = token.terminated + ? utf8ByteLength(token.raw) > limit + : utf8ByteLength(token.raw) >= limit; + if (tooLong) { + const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; + return Effect.fail( + mapError( + `bufio.Scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, + "read", + ), + ); + } + // Go's `token = scanner.Text()` (`pkg/parser/token.go:96`) runs on EVERY successful + // `Scan()` — unconditionally, before the `len(trim) > 0` gate that decides whether to + // `append` to `stats` — so `token` (and therefore the eventual `bufio.ErrTooLong` + // message) reflects the last RAW text scanned even when that statement trimmed to + // empty and was never appended (e.g. a lone `;` immediately before an oversized + // statement reports "After statement N: ;", not a blank token). `emitted` mirrors + // Go's `len(stats)` (append-gated); `lastRaw` must NOT share that gate — verified + // against the Go source directly (review CLI-1958 round 18). + lastRaw = token.raw; + if (token.trimmed.length > 0) { + emitted += 1; + } + } + return Effect.void; +}; + /** * Port of Go's `markError` (`pkg/migration/file.go:117-132`): renders a `^` caret * line under the error position of the failing statement. `pos` is the server's @@ -142,6 +440,43 @@ export const legacyMarkError = (stat: string, pos: number): string => { // matches identically inside the rendered `ERROR: … (SQLSTATE …)` head line. const TYPE_NAME_PATTERN = /type "([^"]+)" does not exist/; +/** + * Mirrors Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go:88-113`): + * on a failed statement, render the `^` caret under the server-reported error + * position, the `Detail` line when present, the SQLSTATE-42704 extension hint, + * then `At statement: ` and the (caret-marked) statement text. The + * structured `detail`/`position` fields are only set by the driver for server + * ErrorResponses, mirroring Go's `errors.As(err, &pgErr)` gate. + * + * Exported so any caller that runs a raw `migration.MigrationFile{Statements: + * [...]}.ExecBatch(...)`-equivalent batch outside a real migration file (e.g. + * `legacyResetRecreateDatabases`'s PG14 `DROP`/`CREATE DATABASE` statements, + * `reset.go:169-172`, which Go itself routes through this exact formatter) gets + * the same rich error context instead of the bare driver error. + */ +export const legacyFormatExecBatchError = ( + e: LegacyDbExecError, + index: number, + stat: string, +): Error => { + const marked = legacyMarkError(stat, e.position ?? 0); + const msg: Array = []; + if (e.detail !== undefined && e.detail.length > 0) { + msg.push(e.detail); + } + // Provide helpful hint for extension type errors (SQLSTATE 42704: undefined_object) + const typeName = TYPE_NAME_PATTERN.exec(e.message)?.[1]; + if (typeName !== undefined && e.code === "42704" && !typeName.includes(".")) { + msg.push(""); + msg.push("Hint: This type may be defined in a schema that's not in your search_path."); + msg.push(" Use schema-qualified type references to avoid this error:"); + msg.push(` CREATE TABLE example (col extensions.${typeName});`); + msg.push(" Learn more: supabase migration new --help"); + } + msg.push(`At statement: ${index}`, marked); + return new Error(`${legacyErrorMessage(e)}\n${msg.join("\n")}`); +}; + /** * Runs a single migration/seed file's statements (plus the optional history insert). * Mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`): statements run @@ -157,101 +492,142 @@ const TYPE_NAME_PATTERN = /type "([^"]+)" does not exist/; * apply.go:65-69), so role/globals files (`legacySeedGlobals`) stay reset-free like Go. * When `forceNoVersion` is set the history insert is skipped regardless of filename * (Go's `SeedGlobals` clears `Version`). + * + * `projectEnv` is forwarded to {@link checkScannerBufferSize} — see its own doc comment + * for why a project-`.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` must be visible here too. */ const execMigrationBatch = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, - mapError: (message: string) => E, + mapError: (message: string, phase: "read" | "exec") => E, forceNoVersion: boolean, + displayPath: string = migrationPath, + projectEnv: Readonly> = {}, ): Effect.Effect => Effect.gen(function* () { - const content = yield* fs.readFileString(migrationPath); - const statements = legacySplitAndTrim(content); - const filename = path.basename(migrationPath); - const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = forceNoVersion ? "" : (matches?.[1] ?? ""); - const name = matches?.[2] ?? ""; - - // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go:88-113`): - // on a failed statement, render the `^` caret under the server-reported error - // position, the `Detail` line when present, the SQLSTATE-42704 extension hint, - // then `At statement: ` and the (caret-marked) statement text. The - // structured `detail`/`position` fields are only set by the driver for server - // ErrorResponses, mirroring Go's `errors.As(err, &pgErr)` gate. - const atStatement = (e: LegacyDbExecError, index: number, stat: string) => { - const marked = legacyMarkError(stat, e.position ?? 0); - const msg: Array = []; - if (e.detail !== undefined && e.detail.length > 0) { - msg.push(e.detail); - } - // Provide helpful hint for extension type errors (SQLSTATE 42704: undefined_object) - const typeName = TYPE_NAME_PATTERN.exec(e.message)?.[1]; - if (typeName !== undefined && e.code === "42704" && !typeName.includes(".")) { - msg.push(""); - msg.push("Hint: This type may be defined in a schema that's not in your search_path."); - msg.push(" Use schema-qualified type references to avoid this error:"); - msg.push(` CREATE TABLE example (col extensions.${typeName});`); - msg.push(" Learn more: supabase migration new --help"); - } - msg.push(`At statement: ${index}`, marked); - return new Error(`${errMessage(e)}\n${msg.join("\n")}`); - }; - - // `executed` is the global statement index of the next statement to run, so the - // error context stays accurate across flushed batches and standalone statements - // (Go threads the same counter through `ExecBatch`). - let pending: ReadonlyArray = []; - let executed = 0; - - const flushBatch = Effect.gen(function* () { - if (pending.length === 0) return; - const items = pending; - pending = []; - const base = executed; - const body = Effect.gen(function* () { - for (const [offset, item] of items.entries()) { - const index = base + offset; - if (item.kind === "version") { - // Go defaults to the version-insert statement when all listed statements succeed. - yield* session - .query(INSERT_MIGRATION_VERSION, [version, name, statements]) - .pipe( - Effect.mapError((cause) => atStatement(cause, index, INSERT_MIGRATION_VERSION)), - ); - } else { - yield* session - .exec(item.sql) - .pipe(Effect.mapError((cause) => atStatement(cause, index, item.sql))); + // Go's `MigrationFile.ExecBatch` receives an already-read/parsed file (the read + // happens earlier, in `NewMigrationFromFile`/`parseFile`, which wraps the open + // failure as `"failed to open migration file: %w"`, `pkg/migration/file.go:57-58`) + // — so a read failure here is a DIFFERENT error class than a statement-execution + // failure below, and needs the same Go prefix so stderr/JSON errors don't surface + // the bare platform error text. Tagged "read" so callers that attach a suggestion + // only around execution failures (`apply.go:61-63`) can tell the two apart. + // + // Go opens `fp` — the workdir-RELATIVE form `[db.migrations].schema_paths`/ + // `[db.seed].sql_paths` already resolved to at config-load time — because Go's + // process cwd is always the workdir (`ChangeWorkDir`, `cmd/root.go:104`). This + // module deliberately never `process.chdir`s (only `bootstrap` does, as its own + // documented one-off), so callers must pass an ABSOLUTE `migrationPath` for the + // real read to work — but that means the platform error's embedded path is + // absolute too. When it differs from `displayPath` (the caller's Go-equivalent + // relative path), substitute it in so the wrapped message still reports the + // relative form Go would, not a leaked local temp/absolute path. + // + // Known residual delta (CLI-1958 review): `readFileString` decodes via `TextDecoder` + // with `fatal: false` (the Effect `FileSystem` default), so an invalid-UTF-8 byte + // sequence in the file is lossily replaced with U+FFFD before it ever reaches + // `legacySplitAndTrim`/`session.exec`. Go's `parseFile` instead scans the raw byte + // stream and preserves those bytes verbatim into the statement strings it sends to + // PostgreSQL. Reading raw bytes here (`fs.readFile`) and mapping them 1:1 into a + // "binary string" would fix the split/parse stage, but the fix dies at the wire: the + // shared `pg`/`pg-protocol` layer this session is built on unconditionally UTF-8- + // encodes query text before writing it (`pg-protocol/dist/serializer.js` — + // `buff.write(string, offset, 'utf-8')`, no raw-byte send API), so ANY string + // representation still gets re-mangled at that boundary, just differently. Faithful + // byte parity would require patching that shared wire-serializer — infrastructure + // every legacy DB command's `session.exec` funnels through, not something scoped to + // this file's read path — so it's flagged here rather than "fixed" underneath it. + const content = yield* fs.readFileString(migrationPath).pipe( + Effect.mapError((error) => { + const message = legacyRelativizeErrorMessage( + legacyErrorMessage(error), + migrationPath, + displayPath, + ); + return mapError(`failed to open migration file: ${message}`, "read"); + }), + ); + + // Still `NewMigrationFromFile`/`parseFile`'s territory (`pkg/migration/file.go:55-70`) — + // `parser.SplitAndTrim` runs INSIDE `parseFile`, before `ExecBatch` ever sees the + // statements, so a `SUPABASE_SCANNER_BUFFER_SIZE` violation is a "read"-phase + // failure like the open failure above, not an "exec"-phase one. See + // `checkScannerBufferSize`'s comment for why this is a no-op unless the env var + // is explicitly set. + yield* checkScannerBufferSize(content, mapError, projectEnv); + + // Everything below mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`), + // which runs against an already-read file — so every failure from here on is an + // execution failure, tagged "exec" (as opposed to the "read" failure above, which + // mirrors `NewMigrationFromFile`). Only execution failures get `CmdSuggestion` + // (`apply.go:61-63`); callers rely on this tag to replicate that split. + yield* Effect.gen(function* () { + const statements = legacySplitAndTrim(content); + const filename = path.basename(migrationPath); + const matches = MIGRATE_FILE_PATTERN.exec(filename); + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); + const name = matches?.[2] ?? ""; + + // `executed` is the global statement index of the next statement to run, so the + // error context stays accurate across flushed batches and standalone statements + // (Go threads the same counter through `ExecBatch`). + let pending: ReadonlyArray = []; + let executed = 0; + + const flushBatch = Effect.gen(function* () { + if (pending.length === 0) return; + const items = pending; + pending = []; + const base = executed; + const body = Effect.gen(function* () { + for (const [offset, item] of items.entries()) { + const index = base + offset; + if (item.kind === "version") { + // Go defaults to the version-insert statement when all listed statements succeed. + yield* session + .query(INSERT_MIGRATION_VERSION, [version, name, statements]) + .pipe( + Effect.mapError((cause) => + legacyFormatExecBatchError(cause, index, INSERT_MIGRATION_VERSION), + ), + ); + } else { + yield* session + .exec(item.sql) + .pipe( + Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, item.sql)), + ); + } } - } - yield* session.exec("COMMIT"); + yield* session.exec("COMMIT"); + }); + yield* session.exec("BEGIN"); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + executed += items.length; }); - yield* session.exec("BEGIN"); - yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); - executed += items.length; - }); - - for (const statement of statements) { - if (legacyIsPipelineIncompatible(statement)) { - // Flush the open batch, then run the incompatible statement on its own (no - // surrounding transaction) so PostgreSQL accepts it. - yield* flushBatch; - const index = executed; - yield* session - .exec(statement) - .pipe(Effect.mapError((cause) => atStatement(cause, index, statement))); - executed += 1; - } else { - pending = [...pending, { kind: "exec", sql: statement }]; + + for (const statement of statements) { + if (legacyIsPipelineIncompatible(statement)) { + // Flush the open batch, then run the incompatible statement on its own (no + // surrounding transaction) so PostgreSQL accepts it. + yield* flushBatch; + const index = executed; + yield* session + .exec(statement) + .pipe(Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement))); + executed += 1; + } else { + pending = [...pending, { kind: "exec", sql: statement }]; + } } - } - if (version.length > 0) { - pending = [...pending, { kind: "version" }]; - } - yield* flushBatch; - }).pipe(Effect.mapError((error) => mapError(errMessage(error)))); + if (version.length > 0) { + pending = [...pending, { kind: "version" }]; + } + yield* flushBatch; + }).pipe(Effect.mapError((error) => mapError(legacyErrorMessage(error), "exec"))); + }); /** * Go's per-migration connection reset (`apply.go:65-69`): `RESET ALL` clears any @@ -264,7 +640,7 @@ const resetConnectionState = ( session: LegacyDbSession, mapError: (message: string) => E, ): Effect.Effect => - session.exec("RESET ALL").pipe(Effect.mapError((e) => mapError(errMessage(e)))); + session.exec("RESET ALL").pipe(Effect.mapError((e) => mapError(legacyErrorMessage(e)))); /** * Applies a single migration file to the connected database and records it in @@ -274,7 +650,7 @@ const resetConnectionState = ( * `SET default_transaction_read_only = on`) before the history-table DDL, then create * the history table, then run the file's statements + the history insert. * - * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + * `mapError` lets the caller tag the failure (e.g. `LegacyPgDeltaDeclarativeApplyError`). */ export const legacyApplyMigrationFile = ( session: LegacyDbSession, @@ -286,7 +662,7 @@ export const legacyApplyMigrationFile = ( Effect.gen(function* () { yield* resetConnectionState(session, mapError); yield* legacyCreateMigrationTable(session).pipe( - Effect.mapError((e) => mapError(errMessage(e))), + Effect.mapError((e) => mapError(legacyErrorMessage(e))), ); yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); }); @@ -308,7 +684,7 @@ export const legacyApplyMigrations = ( const output = yield* Output; if (pending.length === 0) return; yield* legacyCreateMigrationTable(session).pipe( - Effect.mapError((e) => mapError(errMessage(e))), + Effect.mapError((e) => mapError(legacyErrorMessage(e))), ); for (const migrationPath of pending) { yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); @@ -352,11 +728,98 @@ export const legacySeedGlobals = ( * would print an extra line Go never prints. Callers write the in-memory SQL * constant to a temp file first (this module only reads files, like * `execMigrationBatch`'s other callers). + * + * `displayPath`, when given, is the path a read-failure's wrapped message should + * report instead of `filePath` — see `execMigrationBatch`'s comment on why the two + * can differ (an absolute path is required for the real read, but Go's equivalent + * error names the workdir-relative form). `projectEnv`, when given, is forwarded to + * {@link checkScannerBufferSize} via `execMigrationBatch` — see that helper's doc + * comment. */ export const legacyExecSqlFile = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, filePath: string, - mapError: (message: string) => E, -): Effect.Effect => execMigrationBatch(session, fs, path, filePath, mapError, true); + mapError: (message: string, phase: "read" | "exec") => E, + displayPath?: string, + projectEnv?: Readonly>, +): Effect.Effect => + execMigrationBatch(session, fs, path, filePath, mapError, true, displayPath, projectEnv); + +/** + * Applies Go's EXPERIMENTAL declarative schema-files branch of `apply.MigrateAndSeed` + * (`apps/cli-go/internal/migration/apply/apply.go:19,51-68`). Reads `[db.migrations] + * schema_paths` (already resolved to Go's config-load form — supabase-joined when + * relative, verbatim when absolute) via the shared `Glob.SQLFiles` port + * ({@link legacySqlFilesGlob}), then runs each matched file's statements with + * {@link legacyExecSqlFile} in glob order — no history table, no history row, and no + * `RESET ALL` between files, matching Go's `schema.Version = ""` discard (`apply.go:61`) + * and the fact that `ExecBatch` (unlike `ApplyMigrations`) never resets connection state. + * + * Callers gate the call on Go's three-conjunct condition (`--experimental` + no resolved + * version + pg-delta NOT enabled, `apply.go:19`) themselves — this function only performs + * the branch's body, mirroring `applySchemaFiles`'s own signature (it never re-checks the + * gate). It is the caller's responsibility to skip `legacyApplyMigrations` entirely when + * this is called (Go's `if`/`else if` is mutually exclusive, `apply.go:19-27`). + * + * Faithfully reproduces two undocumented, unfixed-upstream Go quirks that are load-bearing + * for the strict 1:1 contract (CLI-1958): + * - **Empty `schema_paths` (the `supabase init` default) silently applies nothing** and + * returns success — `Config.Db.Migrations.SchemaPaths.SQLFiles` returns a `nil` error + * when there are zero patterns to glob (`errors.Join()` with no arguments is `nil`), so + * `applySchemaFiles` returns `nil` too (`apply.go:53-54`). + * - **A PARTIAL glob failure is silently dropped**: per-pattern warnings are only + * surfaced (as the returned failure) when NO pattern matched anything at all + * (`declared` empty, `apply.go:53-55`); once at least one file is found, every other + * pattern's warning is discarded — unlike the seed path's `WARN:` line. + * + * On a per-file EXECUTION failure only, attaches Go's `CmdSuggestion = "See schema file: + * "` (`apply.go:63`) via the optional second argument of `mapError`. A file-READ + * failure (Go's `NewMigrationFromFile`, `apply.go:57-59`) returns before `CmdSuggestion` is + * ever set, so it must NOT carry the suggestion — {@link legacyExecSqlFile}'s `mapError` + * receives the `"read"`/`"exec"` phase precisely so this call site can tell them apart. + * + * `projectEnv` is the caller's already-loaded `legacyLoadProjectEnv` map, forwarded to + * {@link checkScannerBufferSize} (via `legacyExecSqlFile`/`execMigrationBatch`) so a + * `SUPABASE_SCANNER_BUFFER_SIZE` set only in `supabase/.env` is honored here exactly like + * a real Go run — see that helper's doc comment. + */ +export const legacyApplySchemaFiles = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + schemaPaths: ReadonlyArray, + mapError: (message: string, suggestion?: string) => E, + projectEnv: Readonly> = {}, +): Effect.Effect => + Effect.gen(function* () { + const { files, warnings } = yield* legacySqlFilesGlob(fs, path, schemaPaths, workdir); + if (files.length === 0) { + // Go: `if len(declared) == 0 { return err }` — `err` is `nil` when there were no + // patterns to glob at all, and the joined per-pattern warnings otherwise. + if (warnings.length > 0) { + return yield* Effect.fail(mapError(warnings.join("\n"))); + } + return; + } + for (const file of files) { + const absolutePath = path.isAbsolute(file) ? file : path.join(workdir, file); + // `file` is already Go's `fp` form (workdir-relative when the declared pattern + // was relative, verbatim when absolute) — pass it through as the display path so + // a read failure reports it instead of the `absolutePath` the real read needs. + yield* legacyExecSqlFile( + session, + fs, + path, + absolutePath, + (message, phase) => + phase === "exec" + ? mapError(message, `See schema file: ${legacyBold(file)}`) + : mapError(message), + file, + projectEnv, + ); + } + }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 749fb9b70b..c41c48e77c 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -6,9 +6,15 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, + legacyApplySchemaFiles, legacyIsPipelineIncompatible, legacyMarkError, legacySeedGlobals, @@ -21,7 +27,11 @@ class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly code?: string; readonly detail?: string; readonly position?: number; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} function fakeSession( opts: { @@ -131,6 +141,33 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect( + "wraps a read failure with Go's parse-file error text (Go NewMigrationFromFile parity)", + () => { + // Go's `NewMigrationFromFile`/`parseFile` wraps the open failure as + // `"failed to open migration file: %w"` (`pkg/migration/file.go:57-58`) before + // `ApplyMigrations`/`applySchemaFiles` ever get a chance to attach a + // `CmdSuggestion` — a read failure here must carry the same prefix, not the bare + // platform error text. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-read-fail-")); + const missingFile = join(dir, "20240101120000_missing.sql"); + const { session } = fakeSession(); + return run(session, missingFile).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("failed to open migration file: "); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_add_index.sql"); @@ -437,3 +474,574 @@ describe("legacySeedGlobals", () => { ); }); }); + +describe("legacyApplySchemaFiles", () => { + it.effect( + "reports a read failure with the workdir-relative path, not the absolute path used to read it (Go open supabase/... parity)", + () => { + // Go opens the workdir-relative `fp` from `schema_paths` directly (its process + // cwd is always the workdir, `ChangeWorkDir`), so a read failure reports + // `open supabase/unreadable.sql: ...`. This module never `process.chdir`s, so + // the real read needs an absolute path — but the wrapped read-failure message + // must still show the relative `supabase/...` form, not that absolute path. An + // unreadable file (a real permission failure, not a missing-path one) reproduces + // a genuine read failure while still passing the glob's own stat/type check — + // `stat` only needs directory execute permission, not read permission on the + // file itself, so this still resolves as a `"File"` match, unlike a directory + // (which the glob would instead expand via `legacyWalkSqlFiles`). + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-read-fail-")); + const file = join(dir, "supabase", "unreadable.sql"); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(file, "select 1;"); + chmodSync(file, 0o000); + const { session } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/unreadable.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("failed to open migration file: "); + expect(msg).toContain("supabase/unreadable.sql"); + expect(msg).not.toContain(dir); + } + chmodSync(file, 0o644); + rmSync(dir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go bufio.Scanner: token too long parity)", + () => { + // Go's `parser.Split` (`pkg/parser/token.go:81-119`) only enforces + // `SUPABASE_SCANNER_BUFFER_SIZE` when it's explicitly set — `parseFile` + // otherwise auto-grows the scanner to the real file's byte length, so the + // DEFAULT path can never hit `bufio.ErrTooLong`. With it set below a single + // statement's raw byte length, Go fails with `bufio.Scanner: token too long` + // instead of silently applying the oversized statement — verified empirically + // against `apps/cli-go/pkg/parser` (a `parser.SplitAndTrim` scratch probe). + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + // A single, un-splittable statement whose raw text exceeds the 4096-byte floor + // (Go's `bufio.Scanner` starts at that size regardless of the configured limit). + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("After statement 1: SELECT 1;"); + expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "reports the last scanned RAW token in the too-long error even when it trimmed to empty (Go scanner.Text() parity, review CLI-1958)", + () => { + // Go's `token = scanner.Text()` (`pkg/parser/token.go:96`) runs on EVERY + // successful `Scan()`, unconditionally — BEFORE the `len(trim) > 0` gate that + // decides whether to append to `stats`. So when a statement trims to empty + // (a lone ";") immediately before an oversized one, Go's `bufio.ErrTooLong` + // message still reports that lone ";" as the last-scanned text, not a blank + // token — `len(stats)` (this port's `emitted`) stays gated on non-empty trim, + // but the reported RAW text must not share that gate. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-empty-token-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `;\nSELECT '${"a".repeat(5000)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + // 0 statements were EMITTED (the lone ";" trimmed to empty and was never + // appended), but the last scanned RAW token (";") must still show — not a + // blank token, which a trim-gated tracker would wrongly report instead. + expect(msg).toContain("After statement 0: ;"); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (Go's default auto-grows to file size)", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-default-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); + const { session, calls } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ); + expect(calls.some((c) => c.kind === "exec" && c.sql.startsWith("SELECT 'a"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (viper parity, not '5M' == 5MiB)", + () => { + // Verified empirically against `apps/cli-go/pkg/parser` + vendored + // `viper@v1.21.0`: a bare multiplier suffix with NO trailing "b"/"B" (e.g. "5M") + // is NOT 5 MiB in real Go — `parseSizeInBytes` only recognizes a multiplier when + // the string's LAST character is literally "b"/"B", so "5M" never strips a + // suffix and `cast.ToInt("5M")` fails whole, yielding 0. `viper.IsSet` is still + // true (the var IS present), so `parseFile`'s file-size auto-growth never runs + // — `parser.Split` falls back to its OWN hardcoded default cap + // (`MaxScannerCapacity`, 256KiB), not to "no limit" and not to a tiny 5-byte + // limit either. A statement past that hardcoded default must still fail. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-garbage-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5M"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + // 256KiB (Go's `parser.MaxScannerCapacity` default), not "5MB" and not ~0KB. + expect(msg).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", + ); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 parity, review CLI-1958)", + () => { + // `viper.GetSizeInBytes` → `cast.ToInt` → `strconv.ParseInt(s, 0, 0)` parses + // with base 0, so a `0x`-prefixed literal is a valid byte count in real Go: + // "0x1400" is 5120 (5KiB) — verified empirically against vendored + // `viper@v1.21.0` (`viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` with the env + // var set to "0x1400" returns 5120). A decimal-only parser would reject this + // string outright and silently fall back to the 256KiB default instead, so a + // statement between 5120 and 262144 bytes would apply in TS but Go would + // already have failed with "bufio.Scanner: token too long" at 5121 bytes. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-hex-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0x1400"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + // 5KiB (0x1400 bytes), not the 256KiB hardcoded fallback a decimal-only + // parser would have silently used instead. + expect(msg).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 underscore-literal parity, review CLI-1958)", + () => { + // Go's base-0 integer grammar (`go.dev/ref/spec#Integer_literals`, reproduced + // by `strconv.ParseInt`) permits a single `_` between digits: "5_120" is the + // same 5120 (5KiB) byte count as the hex-literal test above's "0x1400" — + // verified empirically against the real `strconv.ParseInt("5_120", 0, 64)`. + // A parser that rejects underscores outright would silently fall back to the + // 256KiB default instead, so a statement between 5120 and 262144 bytes would + // apply in TS but Go would already have failed with "bufio.Scanner: token too + // long" at 5121 bytes. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-underscore-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5_120"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + // 5KiB (5_120 bytes), not the 256KiB hardcoded fallback an + // underscore-rejecting parser would have silently used instead. + expect(msg).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator (Go strconv.ParseInt underscore-grammar parity, review CLI-1958)", + () => { + // Go only permits a SINGLE underscore immediately after a base prefix or + // between two digits — never leading a plain (no-prefix) decimal literal, + // never doubled, never trailing. "_5120" (leading underscore, no prefix) is + // invalid in real Go (`strconv.ParseInt("_5120", 0, 64)` errors), so it falls + // back to the same 256KiB default as a genuinely unset/unparseable value — + // verified empirically against the real Go `strconv.ParseInt`. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-bad-underscore-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "_5120"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + // The 5116-byte statement fits comfortably under the 256KiB default + // fallback, so an invalid underscore placement must NOT fail the apply. + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows Go's signed int range (strconv.ParseInt/cast.ToInt range-error parity, review CLI-1958)", + () => { + // "9223372036854775808" is one more than `math.MaxInt64`. Go's + // `strconv.ParseInt(s, 0, 0)` rejects it with a range error, and `cast.ToInt` + // (`spf13/cast@v1.10.0/number.go:407-414`) discards ANY `parseFn` error — + // range or syntax — returning exactly `0`, never the huge (if imprecise) + // magnitude `Number.parseInt` would otherwise accept. `viper.IsSet` is still + // true, so this falls back to the 256KiB hardcoded default, same as a + // genuinely unparseable value ("5M" above) — NOT to "no limit". Verified + // empirically against the pinned `spf13/cast@v1.10.0` + // (`cast.ToInt("9223372036854775808")` → `0`). + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-int64-overflow-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775808"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + // 256KiB (Go's hardcoded default), not "no limit" — a treat-as-unbounded + // bug would let this 300_000-byte statement apply successfully instead. + expect(msg).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", + ); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt range-boundary parity, review CLI-1958)", + () => { + // `math.MaxInt64` itself ("9223372036854775807", one less than the overflow + // test above) is NOT a range error in Go — only magnitudes strictly beyond it + // are. A range check that's off-by-one in the strict direction would wrongly + // reject this legitimate (if enormous) configured size and fall back to the + // 256KiB default instead of the requested cap. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-int64-boundary-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775807"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + // The 5116-byte statement fits comfortably under the (enormous) configured + // limit, so this must succeed, not fall back to the 256KiB default. + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env (Go loadNestedEnv parity)", + () => { + // Go's `loadNestedEnv` (`pkg/config/config.go:1220`) `os.Setenv`s every + // project-`.env` key that isn't already in the shell env BEFORE the command body + // runs, so `viper.AutomaticEnv()` sees a `supabase/.env`-only + // `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported one. + // `legacyApplySchemaFiles`'s `projectEnv` parameter threads the caller's already + // -loaded `legacyLoadProjectEnv` map through to the same check. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-projectenv-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("bufio.Scanner: token too long"); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE (Go godotenv 'never overrides' parity)", + () => { + // `godotenv.Load`'s `overload=false` never sets a key already present in + // `os.Environ()` (`godotenv@v1.5.1/godotenv.go:184-200`) — the shell value must + // win even when a (different) project-env value is also threaded through. + const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-shellwins-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); + const { session, calls } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + // Shell explicitly unsets enforcement (0 → treated as unset, no check) while the + // project env sets a tiny limit — the shell value must win. + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyApplySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + ); + expect(calls.some((c) => c.kind === "exec" && c.sql.startsWith("SELECT 'a"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 5231def40a..fbf6354776 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -2,6 +2,7 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { legacyListLocalMigrations } from "./legacy-pgdelta.cache.ts"; import { legacyBold } from "./legacy-colors.ts"; +import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { @@ -48,6 +49,10 @@ export const UPSERT_MIGRATION_VERSION = export const DELETE_MIGRATION_VERSION = "DELETE FROM supabase_migrations.schema_migrations WHERE version = ANY($1)"; +/** `DELETE ... WHERE version <= $1` — Go's `DELETE_MIGRATION_BEFORE` (squash baseline). */ +export const LEGACY_DELETE_MIGRATION_BEFORE = + "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1"; + /** `TRUNCATE supabase_migrations.schema_migrations` — Go's repair-all reset. */ export const TRUNCATE_VERSION_TABLE = "TRUNCATE supabase_migrations.schema_migrations"; @@ -421,9 +426,12 @@ export const legacyReadMigrationTable = (session: LegacyDbSession) => /** * Resolves the local migration file for a version by globbing `_*.sql` * against the migrations dir. Mirrors Go's `repair.GetMigrationFile` - * (`internal/migration/repair/repair.go:90`): the lexically-first match, or - * `None` when nothing matches (the caller raises the not-found error so the - * exact Go message can be assembled). A missing directory is treated as no match. + * (`internal/migration/repair/repair.go:90-100`): `afero.Glob` reads the + * directory then byte-sorts entries (`sort.Strings`, `afero/match.go:91`) before + * matching, so ties resolve to the byte-ordered (Go `sort.Strings`) first match, + * not JS's default UTF-16-code-unit order — or `None` when nothing matches (the + * caller raises the not-found error so the exact Go message can be assembled). A + * missing directory is treated as no match. */ export const legacyResolveMigrationFile = ( fs: FileSystem.FileSystem, @@ -445,7 +453,7 @@ export const legacyResolveMigrationFile = ( const prefix = `${version}_`; const matches = names .filter((name) => name.startsWith(prefix) && name.endsWith(".sql")) - .sort(); + .sort(legacyCompareUtf8Bytes); return matches.length > 0 ? Option.some(path.join(migrationsDir, matches[0]!)) : Option.none(); diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index 4b84852b88..8e126aa5e8 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -1,4 +1,4 @@ -import { Effect, Exit } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { describe, expect, it } from "vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; @@ -8,6 +8,7 @@ import { legacyFindPendingMigrations, legacyListRemoteMigrations, legacyReconcileMigrations, + legacyResolveMigrationFile, legacySuggestMigrationRepair, legacySuggestRevertHistory, } from "./legacy-migration-history.ts"; @@ -188,3 +189,41 @@ describe("legacySuggestRevertHistory", () => { expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); }); }); + +describe("legacyResolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { + it("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", async () => { + // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP + // private-use character (U+E000): JS's default `.sort()` (no comparator) ranks the + // surrogate pair FIRST — its leading high-surrogate code unit (0xD83D) is less than + // the private-use code unit (0xE000). Go's `sort.Strings` (UTF-8 byte order) ranks the + // private-use character first instead (0xEE... < 0xF0... in its UTF-8 encoding). + const surrogatePair = "20240101000000_a\u{1f600}.sql"; + const privateUse = "20240101000000_a\u{e000}.sql"; + expect([surrogatePair, privateUse].sort()[0]).toBe(surrogatePair); + + const layer = Layer.mergeAll( + Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => Effect.succeed([surrogatePair, privateUse]), + }), + ), + Path.layer, + ); + const result = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyResolveMigrationFile( + fs, + path, + "/supabase/migrations", + "20240101000000", + ); + }).pipe(Effect.provide(layer)), + ); + expect(Option.isSome(result) ? result.value : undefined).toBe( + `/supabase/migrations/${privateUse}`, + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-migration.errors.ts b/apps/cli/src/legacy/shared/legacy-migration.errors.ts index 1cf0340928..64248c821d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-migration.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Listing or reading migrations failed for a reason other than the directory @@ -13,4 +18,8 @@ import { Data } from "effect"; */ export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-path-match.ts b/apps/cli/src/legacy/shared/legacy-path-match.ts index b7dadac49b..f57f2acd75 100644 --- a/apps/cli/src/legacy/shared/legacy-path-match.ts +++ b/apps/cli/src/legacy/shared/legacy-path-match.ts @@ -1,19 +1,30 @@ /** - * Faithful port of Go's stdlib `path.Match` (`$GOROOT/src/path/match.go`), used - * by the seed-file globber to expand `[db.seed] sql_paths` exactly like the Go + * Faithful, BYTE-level port of Go's stdlib `path.Match` (`$GOROOT/src/path/match.go`), + * used by the seed-file globber to expand `[db.seed] sql_paths` exactly like the Go * CLI's `config.Glob.Files` → `io/fs.Glob` → `path.Match` chain. * - * Why a hand port instead of a JS `RegExp`: Go's glob grammar and JS regex - * character classes diverge — POSIX classes (`[[:alpha:]]`), `\d`/`\w`, and a - * leading `^` mean different things, and Go reports a malformed class as an - * error (`path.ErrBadPattern`) where JS would silently reinterpret it. Compiling - * each segment to a `RegExp` leaked those JS-only semantics; porting the - * algorithm keeps seed globbing byte-compatible with Go, including the - * malformed-pattern handling. + * Why byte-level, not code-point-level: Go strings are raw byte slices — every index, + * slice, and length in `path.Match` operates on UTF-8 BYTES, not decoded characters. + * This matters most in the `*`-retry loop (`legacyPathMatch`'s inner `for` below): + * Go retries the starred chunk at every BYTE offset of `name`, including offsets that + * land in the middle of a multibyte UTF-8 character. When that happens, Go's + * `unicode/utf8.DecodeRuneInString` decodes the LEADING (invalid, mid-character) + * continuation byte as a single-byte `U+FFFD` "rune" — it never throws and never + * consumes more than one byte for invalid input — so a `?` operator in the retried + * chunk can advance past exactly one such byte and let the retry succeed where a + * code-point-stepping port would not. Verified empirically against `apps/cli-go` + * (a `path.Match` scratch probe): `Match("*??.sql", "!.sql")` — a single fullwidth + * exclamation mark, U+FF01, 3 UTF-8 bytes — returns `true`: the second `?` in the + * retried chunk lands on the fullwidth character's 2nd and 3rd bytes (both mid-character + * continuation bytes, each decoded as one `U+FFFD` "rune"), not on a real code point. A + * prior code-point-based port of this file returned `false` for that same case. * - * Pure — no Effect / service dependencies. Operates on code points; Go mixes - * byte and rune indexing, which is equivalent for the BMP characters that occur - * in real seed paths. + * Why a hand port instead of a JS `RegExp`: Go's glob grammar and JS regex character + * classes diverge — POSIX classes (`[[:alpha:]]`), `\d`/`\w`, and a leading `^` mean + * different things, and Go reports a malformed class as an error (`path.ErrBadPattern`) + * where JS would silently reinterpret it. Compiling each segment to a `RegExp` leaked + * those JS-only semantics; porting the algorithm keeps seed globbing byte-compatible + * with Go, including the malformed-pattern handling and the byte-offset retry above. */ /** Mirrors Go's `path.Match` return `(matched bool, err error)`; `badPattern` ↔ `path.ErrBadPattern`. */ @@ -27,106 +38,198 @@ export const LEGACY_BAD_PATTERN_MESSAGE = "syntax error in pattern"; const BAD_PATTERN: LegacyPathMatchResult = { matched: false, badPattern: true }; -/** UTF-16 width (1 or 2 code units) of a code point. */ -const runeWidth = (cp: number): number => (cp > 0xffff ? 2 : 1); +const UTF8_ENCODER = new TextEncoder(); + +/** + * `TextEncoder.encode` returns `Uint8Array` (never a + * `SharedArrayBuffer`-backed view) — naming that explicitly so every `.subarray()` + * slice threaded through this module's helpers keeps that narrower type instead of + * widening to the generic `Uint8Array` default. + */ +type Bytes = Uint8Array; + +const RUNE_ERROR = 0xfffd; +const SLASH = 0x2f; +const STAR = 0x2a; +const QUESTION = 0x3f; +const LBRACKET = 0x5b; +const RBRACKET = 0x5d; +const CARET = 0x5e; +const HYPHEN = 0x2d; +const BACKSLASH = 0x5c; + +interface DecodedRune { + readonly r: number; + readonly size: number; +} + +/** + * Port of Go's `unicode/utf8.DecodeRuneInString`, decoding the rune starting at byte + * offset `i` of `b`. Any invalid or truncated sequence decodes as `(RuneError, 1)` — + * never throws, never consumes more than the single invalid lead byte — matching Go's + * documented behaviour exactly (`$GOROOT/src/unicode/utf8/utf8.go`'s `first` table and + * `acceptRanges`, transcribed here as explicit range checks per lead byte rather than + * the table itself, for readability; verified to agree with the table for every lead + * byte class, including the overlong/surrogate/out-of-range exclusions on `0xE0`, + * `0xED`, `0xF0`, and `0xF4`). + */ +const decodeRune = (b: Bytes, i: number): DecodedRune => { + const n = b.length - i; + if (n <= 0) return { r: RUNE_ERROR, size: 0 }; + const b0 = b[i]!; + if (b0 < 0x80) return { r: b0, size: 1 }; + let size: number; + let lo: number; + let hi: number; + if (b0 >= 0xc2 && b0 <= 0xdf) { + size = 2; + lo = 0x80; + hi = 0xbf; + } else if (b0 === 0xe0) { + size = 3; // Excludes the overlong 3-byte encoding. + lo = 0xa0; + hi = 0xbf; + } else if ((b0 >= 0xe1 && b0 <= 0xec) || b0 === 0xee || b0 === 0xef) { + size = 3; + lo = 0x80; + hi = 0xbf; + } else if (b0 === 0xed) { + size = 3; // Excludes the UTF-16 surrogate range U+D800-U+DFFF. + lo = 0x80; + hi = 0x9f; + } else if (b0 === 0xf0) { + size = 4; // Excludes the overlong 4-byte encoding. + lo = 0x90; + hi = 0xbf; + } else if (b0 >= 0xf1 && b0 <= 0xf3) { + size = 4; + lo = 0x80; + hi = 0xbf; + } else if (b0 === 0xf4) { + size = 4; // Caps the range at U+10FFFF. + lo = 0x80; + hi = 0x8f; + } else { + // 0x80-0xC1: a bare continuation byte or an overlong 2-byte lead. 0xF5-0xFF: past + // the max valid lead byte. Both are invalid lead bytes. + return { r: RUNE_ERROR, size: 1 }; + } + if (n < size) return { r: RUNE_ERROR, size: 1 }; + const b1 = b[i + 1]!; + if (b1 < lo || b1 > hi) return { r: RUNE_ERROR, size: 1 }; + if (size === 2) return { r: ((b0 & 0x1f) << 6) | (b1 & 0x3f), size: 2 }; + const b2 = b[i + 2]!; + if (b2 < 0x80 || b2 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + if (size === 3) return { r: ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f), size: 3 }; + const b3 = b[i + 3]!; + if (b3 < 0x80 || b3 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + return { + r: ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), + size: 4, + }; +}; interface ScanChunk { readonly star: boolean; - readonly chunk: string; - readonly rest: string; + readonly chunk: Bytes; + readonly rest: Bytes; } /** Go's `scanChunk`: the next non-`*` segment, possibly preceded by a `*`. */ -const scanChunk = (pattern: string): ScanChunk => { +const scanChunk = (pattern: Bytes): ScanChunk => { let star = false; let p = pattern; - while (p.length > 0 && p[0] === "*") { - p = p.slice(1); + while (p.length > 0 && p[0] === STAR) { + p = p.subarray(1); star = true; } let inrange = false; for (let i = 0; i < p.length; i++) { - const c = p[i]; - if (c === "\\") { + const c = p[i]!; + if (c === BACKSLASH) { if (i + 1 < p.length) i++; - } else if (c === "[") { + } else if (c === LBRACKET) { inrange = true; - } else if (c === "]") { + } else if (c === RBRACKET) { inrange = false; - } else if (c === "*" && !inrange) { - return { star, chunk: p.slice(0, i), rest: p.slice(i) }; + } else if (c === STAR && !inrange) { + return { star, chunk: p.subarray(0, i), rest: p.subarray(i) }; } } - return { star, chunk: p, rest: "" }; + return { star, chunk: p, rest: p.subarray(p.length) }; }; interface GetEsc { readonly r: number; - readonly rest: string; + readonly rest: Bytes; readonly bad: boolean; } /** Go's `getEsc`: a possibly-escaped character from inside a class. */ -const getEsc = (chunk: string): GetEsc => { - if (chunk.length === 0 || chunk[0] === "-" || chunk[0] === "]") { +const getEsc = (chunk: Bytes): GetEsc => { + if (chunk.length === 0 || chunk[0] === HYPHEN || chunk[0] === RBRACKET) { return { r: 0, rest: chunk, bad: true }; } let c = chunk; - if (c[0] === "\\") { - c = c.slice(1); + if (c[0] === BACKSLASH) { + c = c.subarray(1); if (c.length === 0) return { r: 0, rest: c, bad: true }; } - const r = c.codePointAt(0)!; - const rest = c.slice(runeWidth(r)); - // Go errors when the class has no closing `]` after this character. + const { r, size } = decodeRune(c, 0); + // Go: `if r == utf8.RuneError && n == 1 { err = ErrBadPattern }` — a genuinely + // invalid byte, not a literal (valid, 3-byte-encoded) U+FFFD character. + if (r === RUNE_ERROR && size === 1) return { r, rest: c.subarray(1), bad: true }; + const rest = c.subarray(size); return { r, rest, bad: rest.length === 0 }; }; interface MatchChunk { - readonly rest: string; + readonly rest: Bytes; readonly ok: boolean; readonly bad: boolean; } -const BAD_CHUNK: MatchChunk = { rest: "", ok: false, bad: true }; +const EMPTY_BYTES = new Uint8Array(0); +const BAD_CHUNK: MatchChunk = { rest: EMPTY_BYTES, ok: false, bad: true }; /** * Go's `matchChunk`: match the all-single-char-operators `chunk` against the * start of `s`. Once the match fails the loop keeps walking `chunk` (no longer * reading `s`) so a malformed pattern is still reported. */ -const matchChunk = (chunkIn: string, sIn: string): MatchChunk => { +const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { let chunk = chunkIn; let s = sIn; let failed = false; while (chunk.length > 0) { if (!failed && s.length === 0) failed = true; - const op = chunk[0]; - if (op === "[") { + const op = chunk[0]!; + if (op === LBRACKET) { let r = 0; if (!failed) { - r = s.codePointAt(0)!; - s = s.slice(runeWidth(r)); + const decoded = decodeRune(s, 0); + r = decoded.r; + s = s.subarray(decoded.size); } - chunk = chunk.slice(1); + chunk = chunk.subarray(1); let negated = false; - if (chunk.length > 0 && chunk[0] === "^") { + if (chunk.length > 0 && chunk[0] === CARET) { negated = true; - chunk = chunk.slice(1); + chunk = chunk.subarray(1); } let match = false; let nrange = 0; for (;;) { - if (chunk.length > 0 && chunk[0] === "]" && nrange > 0) { - chunk = chunk.slice(1); + if (chunk.length > 0 && chunk[0] === RBRACKET && nrange > 0) { + chunk = chunk.subarray(1); break; } const lo = getEsc(chunk); if (lo.bad) return BAD_CHUNK; chunk = lo.rest; let hi = lo.r; - if (chunk[0] === "-") { - const hiEsc = getEsc(chunk.slice(1)); + if (chunk.length > 0 && chunk[0] === HYPHEN) { + const hiEsc = getEsc(chunk.subarray(1)); if (hiEsc.bad) return BAD_CHUNK; chunk = hiEsc.rest; hi = hiEsc.r; @@ -135,30 +238,30 @@ const matchChunk = (chunkIn: string, sIn: string): MatchChunk => { nrange++; } if (match === negated) failed = true; - } else if (op === "?") { + } else if (op === QUESTION) { if (!failed) { - const cp = s.codePointAt(0)!; - if (cp === 0x2f) failed = true; // '/' - s = s.slice(runeWidth(cp)); + if (s[0] === SLASH) failed = true; + const { size } = decodeRune(s, 0); + s = s.subarray(size); } - chunk = chunk.slice(1); - } else if (op === "\\") { - chunk = chunk.slice(1); + chunk = chunk.subarray(1); + } else if (op === BACKSLASH) { + chunk = chunk.subarray(1); if (chunk.length === 0) return BAD_CHUNK; if (!failed) { if (chunk[0] !== s[0]) failed = true; - s = s.slice(1); + s = s.subarray(1); } - chunk = chunk.slice(1); + chunk = chunk.subarray(1); } else { if (!failed) { if (chunk[0] !== s[0]) failed = true; - s = s.slice(1); + s = s.subarray(1); } - chunk = chunk.slice(1); + chunk = chunk.subarray(1); } } - return failed ? { rest: "", ok: false, bad: false } : { rest: s, ok: true, bad: false }; + return failed ? { rest: EMPTY_BYTES, ok: false, bad: false } : { rest: s, ok: true, bad: false }; }; /** @@ -167,14 +270,16 @@ const matchChunk = (chunkIn: string, sIn: string): MatchChunk => { * pattern is malformed, mirroring Go's `path.ErrBadPattern`. */ export const legacyPathMatch = (pattern: string, name: string): LegacyPathMatchResult => { - let pat = pattern; - let nm = name; + let pat = UTF8_ENCODER.encode(pattern); + let nm = UTF8_ENCODER.encode(name); while (pat.length > 0) { const scan = scanChunk(pat); pat = scan.rest; - if (scan.star && scan.chunk === "") { - // Trailing `*` matches the rest of the name unless it contains a `/`. - return { matched: !nm.includes("/"), badPattern: false }; + if (scan.star && scan.chunk.length === 0) { + // Trailing `*` matches the rest of the name unless it contains a `/`. `/` is + // never a UTF-8 continuation byte, so a raw byte scan is safe here regardless + // of any multibyte characters elsewhere in `nm`. + return { matched: !nm.includes(SLASH), badPattern: false }; } const m = matchChunk(scan.chunk, nm); if (m.bad) return BAD_PATTERN; @@ -185,10 +290,11 @@ export const legacyPathMatch = (pattern: string, name: string): LegacyPathMatchR continue; } if (scan.star) { - // Look for a match skipping one code point at a time; `*` cannot cross `/`. + // Look for a match skipping one BYTE at a time (see this file's top comment + // for why byte-, not code-point-, stepping matters here); `*` cannot cross `/`. let advanced = false; - for (let i = 0; i < nm.length && nm[i] !== "/"; i++) { - const skip = matchChunk(scan.chunk, nm.slice(i + 1)); + for (let i = 0; i < nm.length && nm[i] !== SLASH; i++) { + const skip = matchChunk(scan.chunk, nm.subarray(i + 1)); if (skip.bad) return BAD_PATTERN; if (skip.ok) { if (pat.length === 0 && skip.rest.length > 0) continue; @@ -203,7 +309,7 @@ export const legacyPathMatch = (pattern: string, name: string): LegacyPathMatchR while (pat.length > 0) { const tail = scanChunk(pat); pat = tail.rest; - if (matchChunk(tail.chunk, "").bad) return BAD_PATTERN; + if (matchChunk(tail.chunk, EMPTY_BYTES).bad) return BAD_PATTERN; } return { matched: false, badPattern: false }; } diff --git a/apps/cli/src/legacy/shared/legacy-path-match.unit.test.ts b/apps/cli/src/legacy/shared/legacy-path-match.unit.test.ts index 66a849d48b..7285f5c3e4 100644 --- a/apps/cli/src/legacy/shared/legacy-path-match.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-path-match.unit.test.ts @@ -49,6 +49,23 @@ describe("legacyPathMatch", () => { }); }); + describe("byte-offset `*` retry against multibyte characters (Go path.Match parity)", () => { + it.each([ + // Go's byte-offset `*`-retry loop can land mid-multibyte-character and have a `?` + // consume the resulting invalid continuation byte as a single-byte `U+FFFD` "rune" + // — producing matches a code-point-stepping port would miss. Verified empirically + // against `apps/cli-go`'s `path.Match` (a fullwidth exclamation mark, U+FF01, is a + // single character but 3 UTF-8 bytes; an emoji, U+1F600, is 4 UTF-8 bytes): + ["*??.sql", "!.sql", true], + ["*??.sql", "😀.sql", true], + ["*?.sql", "!.sql", true], + ["*???.sql", "!.sql", false], + ["schemas/*??.sql", "schemas/!.sql", true], + ] as const)("%s ~ %s => %s", (pattern, name, expected) => { + expect(legacyPathMatch(pattern, name).matched).toBe(expected); + }); + }); + describe("escapes", () => { it.each([ ["\\*.sql", "*.sql", true], diff --git a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts index bb174d7dcf..3b4b3e3bcf 100644 --- a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts +++ b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts @@ -7,6 +7,11 @@ import { } from "../../shared/cli/cobra-flag-groups.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyProfileFilePath } from "../config/legacy-profile-file.ts"; import { legacyLoadProfile, type LegacyLoadedProfile } from "./legacy-profile-load.ts"; import { legacyParseStringSliceFlag } from "./legacy-string-slice-flag.ts"; @@ -37,7 +42,11 @@ import { legacyValidateWorkdirIsDirectory } from "./legacy-workdir-validation.ts */ export class LegacyPflagWorkdirError extends Data.TaggedError("LegacyPflagWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Reconciles an Effect-parsed option flag with pflag semantics diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.env.ts similarity index 96% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.env.ts index 770aca703a..3ca0d24f17 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.env.ts @@ -1,10 +1,12 @@ -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; /** * Pure pg_dump environment builders, ported 1:1 from Go's `pkg/migration/dump.go`. * No Effect or service dependencies, so the schema/role/config lists and the - * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by the - * `db` command family (`db dump`, and `db pull`'s initial-migra schema dump). + * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by `db + * dump`, `db pull`'s initial-migra schema dump, and (CLI-1969) `migration + * squash`'s before/after/full dumps — the third consumer is why this module + * lives in `legacy/shared/` rather than `commands/db/shared/`. */ /** `migration.InternalSchemas` (`pkg/migration/dump.go:18-49`). Used by schema dumps. */ diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts similarity index 97% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts index 1488772664..92957b38a0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { LEGACY_ALLOWED_CONFIGS, LEGACY_EXCLUDED_SCHEMAS, @@ -40,7 +40,7 @@ const baseOpt: LegacyDumpOptions = { // Resolve the Go `.sh` sources relative to this file so the byte-equality // assertion fails loudly if the embedded copies drift from upstream. const goScriptsDir = fileURLToPath( - new URL("../../../../../../cli-go/pkg/migration/scripts/", import.meta.url), + new URL("../../../../cli-go/pkg/migration/scripts/", import.meta.url), ); const readGoScript = (name: string) => readFileSync(`${goScriptsDir}${name}`, "utf8"); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts new file mode 100644 index 0000000000..6afa5c494c --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -0,0 +1,80 @@ +import { Effect, Option } from "effect"; + +import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; +import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; + +/** + * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its + * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit + * code + captured stderr for failure classification. Mirrors Go's `dockerExec` + * (`apps/cli-go/internal/db/dump/dump.go`): host networking by default (overridden + * by the global `--network-id` flag, the ambient `SUPABASE_NETWORK_ID` env var, or + * a project `supabase/.env` value, in that precedence), no security-opt, and the + * Linux-only `host.docker.internal:host-gateway` extra host. + * + * Shared by `db dump` (streams to `--file`/stdout), `db pull`'s initial-migra + * schema dump (streams to the migration file), and (CLI-1969) `migration + * squash`'s three one-shot dumps (before/after `auth`/`storage` diff buffers, + * plus the full dump streamed straight into the target migration file) — the + * third consumer is why this module lives in `legacy/shared/` rather than + * `commands/db/shared/`. The pooler-fallback *decision* stays with the caller — + * this helper runs a single attempt and surfaces its exit/stderr so the caller + * can classify with `legacyIsIPv6ConnectivityError`. + */ +export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { + /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ + readonly image: string; + /** The bash pg_dump/pg_dumpall script (`legacyDump{Schema,Data,Role}Script`). */ + readonly script: string; + readonly env: Readonly>; + /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + /** + * Loaded project `supabase/.env` map, consulted for a `SUPABASE_NETWORK_ID` + * value when neither `--network-id` nor the ambient shell env set one. Omitted + * (or `{}`) by callers that haven't loaded a project env map. + */ + readonly projectEnvValues?: Readonly>; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + + // Go's `dockerExec` sets `NetworkMode` to host (`dump.go:91-93`), but + // `DockerStart` then overrides it with `viper.GetString("network-id")` whenever + // that resolves non-empty (`docker.go:379-380`) — a bound flag/env value wins, + // flag > ambient env > project-`.env` (`legacyViperEnvStringWithProjectFallback` + // precedence). Only when NEITHER the flag nor the env resolves does Go fall back + // to `NetId` (`docker.go:381-382`) — but that branch only fires when the caller + // left `NetworkMode` empty, which the dump path never does, so the effective + // pg_dump fallback is host networking, not the generated `supabase_network_*`. + const networkId = Option.getOrUndefined(networkIdFlag); + const envNetworkId = legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + params.projectEnvValues ?? {}, + ); + const network = + networkId !== undefined && networkId.length > 0 + ? { _tag: "named" as const, name: networkId } + : envNetworkId.length > 0 + ? { _tag: "named" as const, name: envNetworkId } + : { _tag: "host" as const }; + const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + + return yield* docker.runStream( + { + image: legacyGetRegistryImageUrl(params.image), + cmd: ["bash", "-c", params.script, "--"], + env: params.env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network, + }, + { onStdout: params.onStdout, teeStderr: true }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.scripts.ts similarity index 100% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.scripts.ts diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts index d5dca259f5..175f0fcc80 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts @@ -1,4 +1,9 @@ import { Context, Data, type Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A live TLS-capability probe for pg-delta SOURCE/TARGET endpoints, mirroring Go's @@ -33,7 +38,11 @@ export interface LegacyPgDeltaSslProbeShape { export class LegacyPgDeltaSslProbeError extends Data.TaggedError("LegacyPgDeltaSslProbeError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} export class LegacyPgDeltaSslProbe extends Context.Service< LegacyPgDeltaSslProbe, diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index d9f292dd25..848197024c 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -1,12 +1,29 @@ import { createHash } from "node:crypto"; import { Clock, Effect, type FileSystem, Option, type Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; +import { + LegacyNetworkIdFlag, + legacyResolveDebugWithProjectEnv, +} from "../../shared/legacy/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; -import type { LegacyBaselineTomlConfig } from "./legacy-db-config.toml-read.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import type { LegacyBaselineTomlConfig, LegacyDbTomlValues } from "./legacy-db-config.toml-read.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; +import { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "./db-bootstrap/local-container-inputs.ts"; +import { + legacyCreateShadowDatabase, + legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, +} from "./db-bootstrap/shadow-database.ts"; +import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; import { type LegacyPgDeltaContext, legacyExportCatalogPgDelta } from "./legacy-pgdelta.ts"; -import { LegacyDeclarativeSeam } from "../commands/db/shared/legacy-pgdelta.seam.service.ts"; +import { legacyPrepareShadowSource } from "../commands/db/shared/legacy-shadow-source.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported @@ -18,10 +35,12 @@ import { LegacyDeclarativeSeam } from "../commands/db/shared/legacy-pgdelta.seam * Beyond the pure key/path builders, this file also owns the migrations-catalog * RESOLUTION path for both `db diff --from/--to migrations` and `db schema * declarative sync` ({@link legacyResolveMigrationsCatalogRef}, - * {@link legacyGetMigrationsCatalogRef}) — including shadow-database provisioning/ - * removal via `LegacyDeclarativeSeam` (Docker orchestration, unchanged from the Go - * seam) and the "Creating shadow database..." stderr side effect the latter prints - * on a cache miss. It is not a pure module. + * {@link legacyGetMigrationsCatalogRef}) — including NATIVE shadow-database + * provisioning/removal (CLI-1956, {@link exportViaShadowCatalog}, the same + * `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ + * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own + * shadow — no seam/subprocess involved) and the "Creating shadow database..." + * stderr side effect the latter prints on a cache miss. It is not a pure module. */ const CATALOG_PREFIX_PATTERN = /[^a-zA-Z0-9._-]+/g; @@ -195,9 +214,11 @@ export function legacyPgDeltaTempPath(path: Path.Path, workdir: string): string /** * Lists local migration file paths under `migrationsDir`. Mirrors Go's - * `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`. + * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's + * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default + * UTF-16-code-unit `Array.prototype.sort()` — 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 @@ -228,12 +249,32 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( ), ); if (names.length === 0) return [] as ReadonlyArray; - const sorted = [...names].sort(); + // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each + // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code + // units and disagrees with byte/codepoint order for a supplementary-plane filename character + // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, + // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left + // uncorrected, such a migrations directory would replay in a different order than Go, and a + // dependent migration could fail or produce a different shadow schema (review: + // PRRT_kwDOErm0O86W3OyD). + const sorted = [...names].sort(legacyCompareUtf8Bytes); const result: Array = []; for (let index = 0; index < sorted.length; index++) { const name = sorted[index]!; - const stat = yield* fs.stat(path.join(migrationsDir, name)).pipe(Effect.option); - if (Option.isSome(stat) && stat.value.type === "Directory") continue; + const entryPath = path.join(migrationsDir, name); + // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a + // directory entry from its own type without following symlinks (verified empirically: + // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — + // so a symlinked migration is never skipped as a directory in Go, only later, when + // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows + // symlinks, so it would misclassify a symlink-to-directory as a plain directory and + // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) + // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. + const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); + if (!isSymlink) { + const stat = yield* fs.stat(entryPath).pipe(Effect.option); + 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) { @@ -251,7 +292,7 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( ); continue; } - result.push(path.join(migrationsDir, name)); + result.push(entryPath); } return result as ReadonlyArray; }); @@ -332,12 +373,30 @@ const parseCatalogTimestamp = (name: string): Option.Option => { return Number.isInteger(ts) ? Option.some(ts) : Option.none(); }; +/** + * Mirrors Go's `ensureTempDir` + `ReadDir` pairing (`pgcache/cache.go`, + * `declarative.go`): the temp dir's existence is already guaranteed by the + * `MkdirAll` that runs before every write into it, so Go's `ReadDir` only ever + * needs to tolerate a genuinely missing directory (a cache that was never + * written to) — every OTHER read failure (e.g. permission denied) propagates, + * same as {@link legacyListLocalMigrations} above. Swallowing every failure + * (as an earlier version of this did) let a real read error silently look like + * "no cached catalogs", which both bypasses catalog resolution's cache HIT and + * — for cleanup's caller — bypasses the retention limit indefinitely, since + * the caller's own warning path never fires without a propagated failure. + */ const listJsonEntries = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, tempDir: string) { - const exists = yield* fs.exists(tempDir).pipe(Effect.orElseSucceed(() => false)); - if (!exists) return [] as ReadonlyArray; - return yield* fs - .readDirectory(tempDir) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + return yield* fs.readDirectory(tempDir).pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed([] as ReadonlyArray) + : Effect.fail( + new LegacyMigrationsReadError({ + message: `failed to read directory: ${error.message}`, + }), + ), + ), + ); }); /** @@ -511,6 +570,20 @@ export const legacyWriteMigrationCatalogSnapshot = Effect.fnUntraced(function* ( * `diff/pgdelta.go` `ExportCatalogPgDelta`) rather than porting a second copy, * so this can't reintroduce the `/workspace` mount bug `pgcache/cache.go` had * (supabase/cli#5921). + * + * The snapshot's timestamp is read from `Clock` HERE — after `legacyHashMigrations` + * and `legacyExportCatalogPgDelta` (the network round-trip) have both resolved, + * immediately before the write — never accepted as a caller-supplied parameter. + * This mirrors Go's own call order exactly: `TryCacheMigrationsCatalog` + * (`pgcache/cache.go:71-91`) resolves `hash` and `snapshot` FIRST, and only THEN + * calls `WriteMigrationCatalogSnapshot`, which itself reads `time.Now().UTC()` + * (`pgcache/cache.go:151-163`) — i.e. Go's clock read happens LAST, right before + * the file write, not before the export. A caller capturing the timestamp before + * calling this function (review CLI-1958) would race a concurrent cache write + * from another process: Go would order the two snapshots by real write-time, but + * the early-captured timestamp could sort the wrong one as "latest" during + * catalog resolution/retention (`legacyResolveMigrationCatalogPath`, + * `legacyCleanupOldMigrationCatalogs`). */ export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -527,7 +600,6 @@ export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( }; readonly isLocal: boolean; readonly migrationsDir: string; - readonly nowMillis: number; }, ) { if (!params.enabled) return; @@ -537,6 +609,7 @@ export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( targetRef: params.targetUrl, role: "postgres", }); + const nowMillis = yield* Clock.currentTimeMillis; yield* legacyWriteMigrationCatalogSnapshot( fs, path, @@ -544,57 +617,147 @@ export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( prefix, hash, snapshot, - params.nowMillis, + nowMillis, ); }); +/** The spawner + already-built local container inputs {@link exportViaShadowCatalog} needs. */ +interface LegacyShadowCatalogInputs { + readonly spawner: ChildProcessSpawnerType["Service"]; + readonly localInputs: LegacyLocalDbContainerInputs; +} + +/** + * Builds the {@link LegacyShadowCatalogInputs} {@link exportViaShadowCatalog} needs — the SAME + * second `@supabase/config` load (`legacyBuildLocalDbContainerInputs`) `db diff`/`db pull` run + * before their own "Creating shadow database..." banner (`diff.handler.ts`'s `localInputs` + * build, see that call site's doc comment). Split out from `exportViaShadowCatalog` itself so + * {@link legacyGetMigrationsCatalogRef} can run it BEFORE printing its own banner: this load can + * fail on its own (e.g. an enabled API TLS's unreadable cert/key files, which `toml` never + * reads), and Go's config loading — ALL of it, including this validation — runs once in the + * root `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` ever + * prints "Creating shadow database..." (`declarative.go:490`). Building it as an implicit side + * effect of `exportViaShadowCatalog` (called only after the banner already printed) would + * surface that failure AFTER the banner instead, unlike Go. {@link legacyResolveMigrationsCatalogRef} + * has no such banner, so calling this immediately before `exportViaShadowCatalog` on its own + * cache-miss path is harmless there too — it only ever changes when a pre-existing, + * unconditional build runs relative to a print that never happens on that path. + */ +const legacyBuildShadowCatalogInputs = Effect.fnUntraced(function* ( + ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, + provisionParams: { readonly projectRef?: string }, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Go's equivalent stderr writer for the shadow's one-shot setup jobs is + // `utils.GetDebugLogger()` = `viper.GetBool("DEBUG")` (`internal/utils/logger.go:11`), + // which also honors `SUPABASE_DEBUG` via `AutomaticEnv` — NOT the bare `--debug` pflag + // value. `legacyResolveDebugWithProjectEnv` reproduces that (plus the project `.env` + // Go's `loadNestedEnv` has already `os.Setenv`'d into the process by this point). + const debug = yield* legacyResolveDebugWithProjectEnv(toml.projectEnv); + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + ctx.cwd, + networkIdFlag, + runtimeInfo.platform, + debug, + provisionParams.projectRef, + toml.remoteOverrideKeys, + ); + return { spawner, localInputs } satisfies LegacyShadowCatalogInputs; +}); + /** * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind * both {@link legacyResolveMigrationsCatalogRef} and {@link legacyGetMigrationsCatalogRef} - * on a cache miss: provisions the shadow via the EXISTING - * `LegacyDeclarativeSeam.provisionShadow` (Go's `db __shadow --mode diff`, unchanged - * / out of scope for CLI-1959 — `CreateShadowDatabase` + `MigrateShadowDatabase` are - * the exact same Go primitives both callers' Go counterparts call directly, - * `internal/db/diff/shadow.go:37-53` with `targetLocal=false` skipping its only - * extra branch), exports its catalog via the already-native - * {@link legacyExportCatalogPgDelta} (the same edge-runtime script Go's own - * `ExportCatalogPgDelta` runs), hands the snapshot to `persist` to decide where it - * lands on disk, then ALWAYS removes the shadow container (`Effect.ensuring`, - * success or failure) before returning. The persisted path is made relative to - * `ctx.cwd` before returning: every caller feeds this ref into pg-delta's - * edge-runtime scripts as SOURCE/TARGET, which prefix a bare (non-postgres://) ref - * with `/workspace/` — matching the container bind `${ctx.cwd}:/workspace` - * (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts:100-103`). Go's equivalent - * (`pgcache.WriteMigrationCatalogSnapshot`) is only ever built from `utils.TempDir`, - * a workdir-RELATIVE constant (Go chdirs into the workdir first), so the ref it - * returns is relative too; return the same shape here rather than the absolute host - * path `persist` builds internally. The two public functions differ only in their - * cache-decision and `persist`'s cache-write logic, not in this mechanics. + * on a cache miss. Provisions the shadow via the SAME native primitives `db + * diff`/`db pull` use for their own diff-source shadow (CLI-1956, + * `legacyCreateShadowDatabase` + `legacyPrepareShadowSource` + + * `legacyRemoveShadowDatabase`, `commands/db/shared/legacy-shadow-source.ts`) — + * NOT the retired `db __shadow` hidden CLI subcommand, which was only ever a + * TS-facing IPC shim over these same Go functions. This is in fact TRUER Go + * parity than the shim it replaces: Go's own two callers of this mechanics — + * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) + * and `getMigrationsCatalogRef`'s `createShadow`/`createShadowContainer` + * (`apps/cli-go/internal/db/declarative/declarative.go:368-430,487-506`) — both + * call `diff.CreateShadowDatabase` + `diff.MigrateShadowDatabase` (via + * `start.WaitForHealthyService`) DIRECTLY, in-process, never through a CLI + * subcommand. `legacyPrepareShadowSource` is called with `targetLocal: false` + + * `usePgDelta: false`, which skips its ENTIRE declarative-schema-override branch + * (Go's local-target `PrepareShadowSource`/`shadow.go:37-91` branch) — neither Go + * function above ever takes that branch either, since neither has a "target" at + * all; they only ever provision + migrate + export. + * + * Exports the shadow's catalog via the already-native {@link legacyExportCatalogPgDelta} + * (the same edge-runtime script Go's own `ExportCatalogPgDelta` runs), hands the + * snapshot to `persist` to decide where it lands on disk, then removes the shadow + * (`Effect.acquireUseRelease`'s release phase, once the `use` phase below has run — + * success or failure alike) — matching Go's `defer utils.DockerRemove(shadow)` + * immediately after creation, and `diff.handler.ts`/`pull.handler.ts`'s own + * `acquire`=create/`use`=prepare+diff/`release`=remove shape for the exact same + * interruptibility reason (see `legacyPrepareShadowSource`'s own doc comment: + * creation runs inside `acquireUseRelease`'s uninterruptible `acquire`, while the + * health-wait/migrate sequence stays in the interruptible `use` phase, so a SIGINT + * during either can still land while the shadow is still reliably torn down). This + * is NOT an unconditional guarantee, though — see `legacyCreateShadowDatabase`'s own + * doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity + * leak window when `acquire` itself (container creation) fails partway through. + * + * The persisted path is made relative to `ctx.cwd` before returning: every caller + * feeds this ref into pg-delta's edge-runtime scripts as SOURCE/TARGET, which + * prefix a bare (non-postgres://) ref with `/workspace/` — matching the container + * bind `${ctx.cwd}:/workspace` (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts: + * 100-103`). Go's equivalent (`pgcache.WriteMigrationCatalogSnapshot`) is only + * ever built from `utils.TempDir`, a workdir-RELATIVE constant (Go chdirs into the + * workdir first), so the ref it returns is relative too; return the same shape + * here rather than the absolute host path `persist` builds internally. The two + * public functions differ only in their cache-decision and `persist`'s + * cache-write logic, not in this mechanics. + * + * `toml` is the caller's own already-loaded/remote-merged `config.toml` read + * (`legacyReadDbToml`'s result) — used, together with the caller-supplied + * {@link LegacyShadowCatalogInputs} (built by {@link legacyBuildShadowCatalogInputs}), + * to derive the shadow's own container spec (image, JWT secret, root key, + * `db.settings`, service enabled-for-setup flags) exactly like `db diff`/`db pull` + * do for their own shadow. The build is NOT performed in here — see + * {@link legacyBuildShadowCatalogInputs}'s own doc comment for why a caller that + * prints a "Creating shadow database..." banner first must build it BEFORE that + * print, not have it built implicitly as a side effect of calling this function. */ const exportViaShadowCatalog = ( + fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, - provisionParams: { readonly projectRef?: string }, + toml: LegacyDbTomlValues, + built: LegacyShadowCatalogInputs, persist: (snapshot: string) => Effect.Effect, ) => Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const shadow = yield* seam.provisionShadow({ - mode: "diff", + const { spawner, localInputs } = built; + const resolvedImage = yield* localInputs.resolvePostgresImage; + const shadowInput = { + ...legacyShadowRunInputFromLocalContainerInputs(localInputs, resolvedImage, toml, fs, path), targetLocal: false, usePgDelta: false, - schema: [], - ...(provisionParams.projectRef !== undefined - ? { projectRef: provisionParams.projectRef } - : {}), - }); - const written = yield* Effect.gen(function* () { - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }); - return yield* persist(snapshot); - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + schemaPaths: toml.schemaPathPatterns, + pgDelta: toml.pgDelta, + ctx, + }; + const written = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const snapshot = yield* legacyExportCatalogPgDelta(ctx, { + targetRef: shadow.sourceUrl, + role: "postgres", + }); + return yield* persist(snapshot); + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); return path.relative(ctx.cwd, written); }); @@ -616,12 +779,16 @@ const exportViaShadowCatalog = ( * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are * shared with {@link legacyGetMigrationsCatalogRef} via {@link exportViaShadowCatalog} * — see its doc comment. The catalog is cached with - * {@link legacyWriteMigrationCatalogSnapshot}. + * {@link legacyWriteMigrationCatalogSnapshot}. `toml` is the caller's own + * already-loaded/remote-merged `config.toml` read, threaded through to + * {@link exportViaShadowCatalog} for the shadow's own container spec (CLI-1956) — + * see that function's doc comment. */ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, params: { readonly projectRef?: string }, ) { const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); @@ -630,7 +797,8 @@ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - return yield* exportViaShadowCatalog(path, ctx, params, (snapshot) => + const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); + return yield* exportViaShadowCatalog(fs, path, ctx, toml, built, (snapshot) => Effect.gen(function* () { const timestamp = yield* Clock.currentTimeMillis; return yield* legacyWriteMigrationCatalogSnapshot( @@ -665,12 +833,16 @@ const NO_CACHE_MIGRATIONS_CATALOG_NAME = "catalog-nocache-migrations.json"; * * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are * shared with {@link legacyResolveMigrationsCatalogRef} via - * {@link exportViaShadowCatalog} — see its doc comment. + * {@link exportViaShadowCatalog} — see its doc comment. `toml` is the caller's + * own already-loaded/remote-merged `config.toml` read, threaded through for the + * shadow's own container spec (CLI-1956) — distinct from `setupInputs`, which is + * only the cache-key/baseline-setup subset. */ export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, setupInputs: LegacySetupInputs, params: { readonly noCache: boolean; readonly projectRef?: string }, ) { @@ -701,8 +873,16 @@ export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); } + // Built BEFORE the banner below, not after: this is a SECOND `@supabase/config` load + // (`legacyBuildLocalDbContainerInputs`) whose failure (e.g. an enabled API TLS's + // unreadable cert/key files) must surface before "Creating shadow database..." prints, + // matching Go's config loading (all of it) running once in the root `PersistentPreRunE`, + // strictly before `declarative.go`'s `createShadowContainer` ever prints that banner + // (`declarative.go:490`) — see `legacyBuildShadowCatalogInputs`'s own doc comment, and + // `diff.handler.ts`'s identical `localInputs` build for the same reasoning. + const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog(path, ctx, params, (snapshot) => + return yield* exportViaShadowCatalog(fs, path, ctx, toml, built, (snapshot) => Effect.gen(function* () { if (params.noCache) { yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts index fc32436de8..43d6007540 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts @@ -1,13 +1,16 @@ import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; 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, Layer, Option, Path } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; +import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; import { type LegacySetupInputs, legacyBaselineCatalogFileName, @@ -28,6 +31,7 @@ import { legacyResolveSetupInputs, legacySanitizedCatalogPrefix, legacySetupInputsToken, + legacyTryCacheMigrationsCatalog, legacyWriteMigrationCatalogSnapshot, } from "./legacy-pgdelta.cache.ts"; @@ -199,6 +203,66 @@ describe("legacyListLocalMigrations", () => { }, ); + it.effect( + "includes a validly-named .sql symlink to a directory, matching Go's IsDir() (no follow)", + () => { + // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a + // directory entry from its own type without following symlinks, so a `.sql` symlink + // whose target is a directory is NOT skipped as a directory — it is only ever dropped + // later, if something actually tries to read it as a file. A naive `fs.stat`-based + // directory check (which follows symlinks) would misclassify it and silently skip it. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const targetDir = join(dir, "outside-target"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([ + "20240101120000_create.sql", + "20240102000000_link.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "sorts by UTF-8 byte order, matching Go's fs.ReadDir, not JS's default UTF-16 code-unit order", + () => { + // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) sorts entries byte-wise over each name's + // UTF-8 encoding. A BMP private-use character (U+E000, single UTF-16 code unit `0xE000`) + // and a supplementary-plane character (U+1F600, a surrogate pair starting `0xD83D`) reverse + // order between the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate + // pair first (`0xD83D < 0xE000`), while Go's byte order — which preserves codepoint order — + // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such filenames must + // replay in Go's order, not JS's default, or a dependent migration could apply out of order. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const privateUseFile = "20240101120000_z\uE000.sql"; + const supplementaryFile = "20240101120000_z\u{1F600}.sql"; + writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); + writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([ + privateUseFile, + supplementaryFile, + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("returns [] when the migrations dir is absent", () => { const dir = withTemp(); return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( @@ -518,6 +582,72 @@ describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { }); }); +describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-1958)", () => { + // `it.live` (not `it.effect`): the mocked export below uses a real `Effect.sleep` + // to create a measurable time gap, which needs the real wall clock, not + // `it.effect`'s virtual `TestClock` (which never auto-advances and would hang). + it.live( + "reads the clock AFTER the pg-delta export resolves, matching Go's WriteMigrationCatalogSnapshot ordering", + () => { + // Go's `TryCacheMigrationsCatalog` (`pgcache/cache.go:71-91`) resolves `hash` + // and `snapshot` FIRST and only THEN calls `WriteMigrationCatalogSnapshot`, + // which itself reads `time.Now().UTC()` (`pgcache/cache.go:151-163`) — i.e. + // Go's clock read happens LAST, right before the file write. The mocked + // edge-runtime export below sleeps for a real, measurable interval before + // resolving; the written snapshot's embedded timestamp must reflect a moment + // AFTER that sleep, proving the clock was read after the export — not + // captured up front by a caller before this function even started (the + // pre-fix bug). + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + // Mirrors `legacyPgDeltaTempPath` (`/supabase/.temp/pgdelta`). + const tempDir = join(dir, "supabase", ".temp", "pgdelta"); + const beforeCallMillis = Date.now(); + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: () => + Effect.gen(function* () { + yield* Effect.sleep("30 millis"); + return { stdout: "{}", stderr: "" }; + }), + }); + const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }); + const ctx: LegacyPgDeltaContext = { + projectId: "test", + cwd: dir, + npmVersion: undefined, + denoVersion: 1, + projectEnv: {}, + }; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacyTryCacheMigrationsCatalog(fs, path, ctx, { + enabled: true, + targetUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + conn: { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }, + isLocal: true, + migrationsDir, + }); + const names = (yield* fs.readDirectory(tempDir)).filter((n) => + n.startsWith("catalog-local-migrations-"), + ); + expect(names.length).toBe(1); + const match = /-(\d+)\.json$/.exec(names[0]!); + expect(match).not.toBeNull(); + const embeddedMillis = Number(match![1]); + expect(embeddedMillis).toBeGreaterThanOrEqual(beforeCallMillis + 25); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer, edge, sslProbe)), + Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }, + ); +}); + describe("legacyCleanupOldMigrationCatalogs", () => { it.effect("only prunes files matching the given prefix's family", () => { const dir = withTemp(); @@ -539,4 +669,32 @@ describe("legacyCleanupOldMigrationCatalogs", () => { }), ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); }); + + it.effect( + "propagates a permission-denied directory read instead of treating it as empty (Go ReadDir parity)", + () => { + // Go's CleanupOldMigrationCatalogs only tolerates a genuinely MISSING temp dir + // (ensureTempDir already created it before ReadDir runs) — any other ReadDir + // failure propagates, so a permission-denied listing must fail here too rather + // than silently look like "no cached catalogs" (which would bypass retention + // indefinitely, since the caller's own best-effort warning never fires without + // a propagated failure). + const dir = withTemp(); + const tempDir = join(dir, "pgdelta"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "catalog-local-migrations-h-100.json"), "{}"); + chmodSync(tempDir, 0o000); + return withServices((fs, path) => + legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local").pipe(Effect.exit), + ).pipe( + Effect.tap((exit) => + Effect.sync(() => { + chmodSync(tempDir, 0o755); + expect(Exit.isFailure(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts index b1f28744aa..2028a30d92 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts @@ -25,15 +25,28 @@ const CTX: LegacyPgDeltaContext = { cwd: "/proj", npmVersion: undefined, denoVersion: 2, + projectEnv: {}, }; -function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { +function fakeEdgeRuntime( + outcome: { + stdout?: string; + stderr?: string; + fail?: string; + docker?: "daemon" | "inspect" | "pull"; + } = {}, +) { const calls: LegacyEdgeRuntimeRunOpts[] = []; const layer = Layer.succeed(LegacyEdgeRuntimeScript, { run: (opts: LegacyEdgeRuntimeRunOpts) => { calls.push(opts); if (outcome.fail !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ + message: outcome.fail, + ...(outcome.docker !== undefined ? { docker: outcome.docker } : {}), + }), + ); } return Effect.succeed({ stdout: outcome.stdout ?? "", @@ -156,6 +169,30 @@ describe("legacyDiffPgDelta", () => { ); }); + it.effect("preserves docker failure classification through the pg-delta wrapper", () => { + const edge = fakeEdgeRuntime({ + fail: "error diffing schema: docker unavailable", + docker: "daemon", + }); + return legacyDiffPgDelta(CTX, { + targetRef: "postgresql://t", + sourceRef: "", + schema: [], + formatOptions: "", + }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeEdgeRuntimeError", + docker: "daemon", + }); + }), + ), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + ); + }); + it.effect("fails with LegacyPgDeltaDiffParseError on a malformed envelope", () => { const edge = fakeEdgeRuntime({ stdout: "not json{", stderr: "boom" }); return legacyDiffPgDelta(CTX, { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index d181e4deb0..3d1657dd4c 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -1,9 +1,11 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { type LegacyEdgeRuntimeFile, LegacyEdgeRuntimeScript, } from "./legacy-edge-runtime-script.service.ts"; +import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { LEGACY_PG_DELTA_SOURCE_SSL_ENV, LEGACY_PG_DELTA_TARGET_SSL_ENV, @@ -84,6 +86,49 @@ export interface LegacyPgDeltaContext { * config the command operates on rather than the base `config.toml`. */ readonly denoVersion: number; + /** + * The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`), so + * {@link legacyPgDeltaNpmRegistryOption}'s `PGDELTA_NPM_REGISTRY` read matches Go's + * `os.Getenv`, which already observes `.env`-loaded values by this point (see that + * function's doc comment). + */ + readonly projectEnv: Readonly>; +} + +/** + * Resolves {@link LegacyPgDeltaContext.projectId}: Go's `Config.ProjectId` singleton + * (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir basename, + * `pkg/config/config.go:563-570` + `Validate` :989-996), sanitized the same way + * `UpdateDockerIds` derives `EdgeRuntimeId` from it (`internal/utils/config.go:57-76`) — + * NOT `LegacyCliConfig.projectId` alone, which is env-only and resolves to `""` for a + * project that relies on config.toml's `project_id` or the workdir-basename default, + * mounting the WRONG `supabase_edge_runtime_` Deno-cache volume (review: + * PRRT_kwDOErm0O86XAlIw). Hoisted here — the single home for every pg-delta context + * builder (`db diff`, `db pull`, `db schema declarative generate`/`sync`) — per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * + * `toml.appliedRemote !== undefined` suppresses the raw `cliProjectId` argument entirely: + * `toml.projectId` already reflects the matched `[remotes.]` block's own `project_id` + * at viper's override tier (`legacyReadDbToml`'s `remoteOverrideKeys.has("project_id")` + * gate, review: PRRT_kwDOErm0O86XHGDL) — but `legacyResolveLocalProjectId` tries its FIRST + * argument before its second, so passing the raw, ungated `cliProjectId` through would let + * an unrelated ambient `SUPABASE_PROJECT_ID` win back over the matched remote's own id, + * mounting the wrong Deno-cache volume for a linked pg-delta run. Mirrors the same + * suppression `legacy-local-project-context.ts`'s own `legacyLoadLocalProjectContext` + * already applies (review: PRRT_kwDOErm0O86XI1w8). + */ +export function legacyResolvePgDeltaProjectId( + cliProjectId: Option.Option, + toml: { readonly projectId: Option.Option; readonly appliedRemote: string | undefined }, + workdir: string, +): string { + return legacySanitizeProjectId( + legacyResolveLocalProjectId( + toml.appliedRemote !== undefined ? undefined : Option.getOrUndefined(cliProjectId), + Option.getOrUndefined(toml.projectId), + workdir, + ), + ); } /** Mirrors Go's `isPostgresURL` (`internal/db/diff/pgdelta.go:46`). */ @@ -127,13 +172,26 @@ export function legacyIsPgDeltaDebugEnabled(): boolean { * Mirrors Go's `PgDeltaNpmRegistryOption` (`internal/utils/pgdelta_local.go:30`): * when `PGDELTA_NPM_REGISTRY` is set, drop a project-local `.npmrc` scoping the * `@supabase` registry and forward both `PGDELTA_NPM_REGISTRY` and the universal - * `NPM_CONFIG_REGISTRY` into the container. + * `NPM_CONFIG_REGISTRY` into the container. Exported so `legacy-pgdelta.apply.ts`'s + * declarative-apply runner (CLI-1956) can reuse the same option, matching every other + * pg-delta edge-runtime invocation in this file. + * + * `PGDELTA_NPM_REGISTRY` is a bare `os.Getenv` read in Go (`pgdelta_local.go:30`), not a + * viper-bound flag — but by the time Go reaches it, `config.Load`'s `loadNestedEnv` has + * already run `godotenv.Load` on the project's `supabase/.env`, which calls `os.Setenv` for + * every key not already present in the real process env (`godotenv@v1.5.1/godotenv.go:184- + * 200`). So a project `.env`-only `PGDELTA_NPM_REGISTRY` is visible to this exact `os.Getenv` + * call in Go. `projectEnv` reproduces that merge with the same shell-presence-wins semantics + * (review: PRRT_kwDOErm0O86XFmjf). */ -function legacyPgDeltaNpmRegistryOption(): { +export function legacyPgDeltaNpmRegistryOption(projectEnv: Readonly>): { readonly extraFiles?: ReadonlyArray; readonly extraEnv?: Readonly>; } { - const registry = (process.env[PG_DELTA_NPM_REGISTRY_ENV] ?? "").trim(); + const registry = legacyViperEnvStringWithProjectFallback( + PG_DELTA_NPM_REGISTRY_ENV, + projectEnv, + ).trim(); if (registry.length === 0) return {}; return { extraFiles: [{ name: ".npmrc", content: `@supabase:registry=${registry}\n` }], @@ -179,8 +237,14 @@ const buildDiffEnv = Effect.fnUntraced(function* ( return env; }); -const toDeclarativeEdgeRuntimeError = (error: { readonly message: string }) => - new LegacyDeclarativeEdgeRuntimeError({ message: error.message }); +const toDeclarativeEdgeRuntimeError = (error: { + readonly message: string; + readonly docker?: "daemon" | "inspect" | "pull"; +}) => + new LegacyDeclarativeEdgeRuntimeError({ + message: error.message, + ...(error.docker !== undefined ? { docker: error.docker } : {}), + }); /** * Diffs SOURCE → TARGET via the pg-delta diff script. Mirrors Go's @@ -201,7 +265,7 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, ctx.npmVersion), @@ -255,7 +319,7 @@ export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeExportScript, ctx.npmVersion), @@ -304,7 +368,7 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( const env: Record = {}; yield* appendRefEnv(fs, path, ctx.cwd, env, "TARGET", params.targetRef); if (params.role.length > 0) env["ROLE"] = params.role; - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, ctx.npmVersion), diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts index 0ea876c5ad..f012934ce2 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts @@ -6,6 +6,7 @@ import { legacyIsPostgresURL, legacyPgDeltaBinds, legacyPgDeltaContainerRef, + legacyPgDeltaNpmRegistryOption, } from "./legacy-pgdelta.ts"; describe("legacyIsPostgresURL", () => { @@ -74,3 +75,43 @@ describe("legacyIsPgDeltaDebugEnabled", () => { expect(legacyIsPgDeltaDebugEnabled()).toBe(false); }); }); + +describe("legacyPgDeltaNpmRegistryOption", () => { + const prev = process.env["PGDELTA_NPM_REGISTRY"]; + afterEach(() => { + if (prev === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; + else process.env["PGDELTA_NPM_REGISTRY"] = prev; + }); + + it("returns no option when unset in both the shell and the project .env", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + expect(legacyPgDeltaNpmRegistryOption({})).toEqual({}); + }); + + it("falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + const npm = legacyPgDeltaNpmRegistryOption({ + PGDELTA_NPM_REGISTRY: "https://registry.example.com", + }); + expect(npm.extraFiles).toEqual([ + { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, + ]); + expect(npm.extraEnv).toEqual({ + PGDELTA_NPM_REGISTRY: "https://registry.example.com", + NPM_CONFIG_REGISTRY: "https://registry.example.com", + }); + }); + + it("prefers the shell env over the project .env (shell presence wins)", () => { + process.env["PGDELTA_NPM_REGISTRY"] = "https://shell.example.com"; + const npm = legacyPgDeltaNpmRegistryOption({ + PGDELTA_NPM_REGISTRY: "https://dotenv.example.com", + }); + expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); + }); + + it("treats a whitespace-only value as unset", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + expect(legacyPgDeltaNpmRegistryOption({ PGDELTA_NPM_REGISTRY: " " })).toEqual({}); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-profile-load.ts b/apps/cli/src/legacy/shared/legacy-profile-load.ts index c09745ed19..cc6cdc9978 100644 --- a/apps/cli/src/legacy/shared/legacy-profile-load.ts +++ b/apps/cli/src/legacy/shared/legacy-profile-load.ts @@ -1,6 +1,11 @@ import { Data, Effect, FileSystem } from "effect"; import { parse as parseYaml } from "yaml"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyApiUrl, legacyDashboardUrl, @@ -19,7 +24,11 @@ import { // classes. export class LegacyProfileLoadError extends Data.TaggedError("LegacyProfileLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Emulates Go's `LoadProfile` (`apps/cli-go/internal/utils/profile.go:94-118`) diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts index bbea4d4fcc..2690f5fcf9 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts @@ -1,11 +1,12 @@ import { createHash } from "node:crypto"; -import { Effect, type FileSystem, Option, type Path } from "effect"; +import { Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { checkScannerBufferSize } from "./legacy-migration-apply.ts"; import { legacyCreateSeedTable } from "./legacy-migration-history.ts"; -import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "./legacy-path-match.ts"; +import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; import { legacySplitAndTrim } from "./legacy-sql-split.ts"; /** @@ -26,171 +27,6 @@ export interface LegacySeedFile { readonly dirty: boolean; } -const META_CHARS = /[*?[\\]/u; - -/** Result of resolving `[db.seed].sql_paths` against the workspace. */ -interface LegacyGlobResult { - /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ - readonly files: ReadonlyArray; - /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ - readonly warning: Option.Option; -} - -/** - * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` - * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined - * under the `supabase/` directory (Go resolves `sql_paths` at config load, - * `config.go:884`). Matches per pattern are sorted; the overall result preserves - * first-seen order across patterns. A pattern that matches nothing, or is malformed - * (Go's `path.ErrBadPattern`, e.g. an unterminated `[` class), contributes a warning - * but is not fatal — mirroring `fs.Glob`'s up-front `Match(pattern, "")` validation - * (`io/fs/glob.go`) and the sibling seed pipeline's `legacy-seed.ts:resolveSeedFiles`. - */ -const legacyGlobSeedFiles = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - patterns: ReadonlyArray, - workdir: string, -) { - const seen = new Set(); - const files: Array = []; - const errors: Array = []; - - for (const rawPattern of patterns) { - // Patterns arrive already resolved to Go's config-load form (relative entries - // supabase/-joined, absolute preserved) via `legacyResolveSeedSqlPath` — the reader - // for `[db.seed].sql_paths`, the caller for `--sql-paths`. Go's `config.Glob.Files` - // globs those resolved paths without re-prefixing (`config.go:102-124`), so only - // normalize separators here; re-joining `supabase/` would double-prefix. - const pattern = toSlash(rawPattern); - // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a - // malformed glob is reported as `failed to glob files: ` and - // contributes no matches, rather than the misleading "no files matched" below. - if (legacyPathMatch(pattern, "").badPattern) { - errors.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); - continue; - } - const matches = yield* globOne(fs, path, workdir, pattern); - if (matches.length === 0) { - errors.push(`no files matched pattern: ${pattern}`); - continue; - } - for (const match of [...matches].sort()) { - const fp = toSlash(match); - // Go's `GetPendingSeeds` globs via `Glob.SQLFiles`, which `Stat`s each match: a - // directory is expanded to its regular `.sql` files recursively (`walkMatchedDir`, - // sorted) while a file match is kept verbatim (`config.go:157-183`). Without this a - // directory `sql_paths` entry (e.g. `["seeds"]`) would flow into - // `readFileString()` and fail — Go's `db push --include-seed` / remote reset - // seed the directory's SQL children instead. - const matchType = yield* fs.stat(path.isAbsolute(fp) ? fp : path.join(workdir, fp)).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => "File" as const), - ); - if (matchType === "Directory") { - for (const file of yield* legacyWalkSeedSqlFiles(fs, path, workdir, fp)) { - if (!seen.has(file)) { - seen.add(file); - files.push(file); - } - } - continue; - } - if (!seen.has(fp)) { - seen.add(fp); - files.push(fp); - } - } - } - - return { - files, - warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), - } satisfies LegacyGlobResult; -}); - -const toSlash = (p: string): string => p.replaceAll("\\", "/"); - -/** Splits a forward-slashed path into its directory prefix and final element. */ -const splitPath = (p: string): { readonly dir: string; readonly file: string } => { - const slash = p.lastIndexOf("/"); - return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; -}; - -/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ -const globOne = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - pattern: string, -): Effect.Effect, never> => - Effect.gen(function* () { - // Absolute patterns resolve against the filesystem root (Go preserves absolute - // seed paths); relative ones are rooted at the workdir. - const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); - // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). - if (!META_CHARS.test(pattern)) { - const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); - return exists ? [pattern] : []; - } - const { dir, file } = splitPath(pattern); - // Resolve the directory level first (recursively if it, too, is a glob). - const dirs = - dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); - const result: Array = []; - for (const d of dirs) { - const absDir = d === "" ? workdir : resolve(d); - const names = yield* fs - .readDirectory(absDir) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - for (const name of names) { - if (legacyPathMatch(file, name).matched) { - result.push(d === "" ? name : `${d}/${name}`); - } - } - } - return result; - }); - -/** - * Recursively collects the regular `.sql` files under a matched seed directory, porting - * Go's `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && - * filepath.Ext(path) == ".sql"`, `config.go:126-131,194-211`). Paths are workdir-relative - * (matching the glob output), forward-slashed, and sorted for deterministic application. - */ -const legacyWalkSeedSqlFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - dir: string, -): Effect.Effect, never> => - Effect.gen(function* () { - const collected: Array = []; - const walk = (rel: string): Effect.Effect => - Effect.gen(function* () { - const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); - const names = yield* fs - .readDirectory(absDir) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - for (const name of names) { - const childRel = `${rel}/${name}`; - const childType = yield* fs - .stat(path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel)) - .pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => "Unknown" as const), - ); - if (childType === "Directory") { - yield* walk(childRel); - } else if (childType === "File" && childRel.endsWith(".sql")) { - collected.push(toSlash(childRel)); - } - } - }); - yield* walk(dir); - return collected.sort(); - }); - /** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ const readRemoteSeeds = (session: LegacyDbSession) => session.query(SELECT_SEED_TABLE).pipe( @@ -212,10 +48,16 @@ const isUndefinedTable = (error: LegacyDbExecError): boolean => /** * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's - * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths - * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, - * and emit each local file that is new (`dirty=false`) or hash-changed - * (`dirty=true`); files whose hash already matches are skipped. + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths via + * the shared {@link legacySqlFilesGlob} traversal (also used by `[db.migrations]. + * schema_paths`, `legacy-migration-apply.ts`, and by `legacy-seed.ts`'s own + * `resolveSeedFiles` for the `migration down`/`start` seed step), warn — don't fail — + * on empty patterns, read the remote `seed_files` hashes, and emit each local file + * that is new (`dirty=false`) or hash-changed (`dirty=true`); files whose hash + * already matches are skipped. Per-pattern warnings are joined with Go's `errors.Join` + * newline semantics and surfaced unconditionally (`seed.go:36-38`) — unlike the + * schema-files apply path (see `legacyApplySchemaFiles`), which only surfaces a + * warning when it is the ONLY outcome. */ export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( session: LegacyDbSession, @@ -225,9 +67,9 @@ export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( workdir: string, ) { const output = yield* Output; - const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); - if (Option.isSome(warning)) { - yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + const { files, warnings } = yield* legacySqlFilesGlob(fs, path, patterns, workdir); + if (warnings.length > 0) { + yield* output.raw(`WARN: ${warnings.join("\n")}\n`, "stderr"); } const pending: Array = []; if (files.length === 0) return pending; @@ -285,12 +127,16 @@ export const legacySeedData = ( // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed // that is unreadable or contains malformed SQL still fails and leaves the - // previous hash — only the queueing of statements is gated on `Dirty`. - const lines = legacySplitAndTrim( - yield* fs.readFileString( - path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), - ), + // previous hash — only the queueing of statements is gated on `Dirty`. Parsing + // includes the same `SUPABASE_SCANNER_BUFFER_SIZE` enforcement every other + // `parseFile` caller gets (`checkScannerBufferSize`'s own doc comment) — Go's + // `SeedFile.ExecBatchWithCache` runs through the identical `parseFile`, so an + // oversized seed statement must fail here too, not execute silently. + const content = yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), ); + yield* checkScannerBufferSize(content, (message) => new Error(message)); + const lines = legacySplitAndTrim(content); const statements = seed.dirty ? [] : lines; yield* session.exec("BEGIN"); const body = Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts index 2282ea54ef..72fd1800f6 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts @@ -122,6 +122,35 @@ describe("legacySeedData (dirty parse)", () => { ); }); + it.effect( + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + () => { + // Go's SeedFile.ExecBatchWithCache parses through the same parseFile every + // other file type does, so an oversized statement must abort the seed run — + // same as legacy-migration-apply.unit.test.ts's equivalent case for migrations. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-scanner-")); + // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at + // regardless of the configured limit (see legacy-migration-apply.unit.test.ts's + // equivalent case for the exact same 4096-byte floor). + writeFileSync(join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); + const { session, calls } = fakeSeedSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; + return runSeed(session, dir, [{ path: "big.sql", hash: "newhash", dirty: false }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((c) => c.sql.includes("select"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + }), + ), + ); + }, + ); + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index 53247f0dff..edcde08e7e 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -1,21 +1,31 @@ import { createHash } from "node:crypto"; -import { Data, Effect, FileSystem, Path, Result } from "effect"; +import { Data, Effect, FileSystem, Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { legacyGlobPattern, legacyResolveUnderWorkdir, legacyWalkSqlFiles } from "./legacy-glob.ts"; +import { legacyResolveUnderWorkdir } from "./legacy-glob.ts"; +import { checkScannerBufferSize } from "./legacy-migration-apply.ts"; import { legacyCreateSeedTable, legacyReadSeedTable, UPSERT_SEED_FILE, } from "./legacy-migration-history.ts"; -import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "./legacy-path-match.ts"; +import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; import { legacySplitAndTrim } from "./legacy-sql-split.ts"; /** Applying a seed file failed (Go's `SeedData` / `ExecBatchWithCache` errors). */ export class LegacyMigrationSeedError extends Data.TaggedError("LegacyMigrationSeedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `[db.seed]` config: `enabled` + the (supabase-prefixed) `sql_paths` glob list. */ export interface LegacySeedConfig { @@ -35,20 +45,13 @@ interface LegacyPendingSeed { } /** - * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`) as - * called by `GetPendingSeeds` (`locals.SQLFiles(fsys)`, `pkg/migration/seed.go:35`) — the SAME - * method `db.migrations.schema_paths` resolves through (`legacyResolveSchemaPathFiles` in - * `legacy-migrate-and-seed.ts`), not the plainer `Glob.Files`: each pattern is glob-matched via - * {@link legacyGlobPattern}, and a matched DIRECTORY is expanded to its sorted, regular `.sql` - * files, recursively (via the shared {@link legacyWalkSqlFiles}), rather than kept as-is — a - * plain glob match (e.g. `[db.seed] sql_paths = ["./seeds"]` with no metacharacters) previously - * resolved a directory entry to itself, which then failed reading it as a seed file. A matched - * plain file is kept as-is, even a non-`.sql` one, matching `expandDir`'s `IsDir()`-only gate. - * - * Unlike `legacyResolveSchemaPathFiles`, a bad pattern, an empty match, or a directory-walk - * failure is NEVER a hard failure here — `GetPendingSeeds` only ever warns - * (`fmt.Fprintln(os.Stderr, "WARN:", err)`) and proceeds with whatever it already collected, - * even if that ends up empty (`len(locals) == 0` just means no pending seeds, not an error). + * Resolves `[db.seed].sql_paths` to existing files, porting Go's `config.Glob.SQLFiles` + * (`pkg/migration/seed.go:35`, via the shared {@link legacySqlFilesGlob} traversal — + * also used by `legacyGetPendingSeeds` (`legacy-seed-ops.ts`) for the same Go field on + * the `db push`/`db reset` path, and by `legacyApplySchemaFiles` (`legacy-migration-apply.ts`) + * for `[db.migrations].schema_paths`). Go's `GetPendingSeeds` prints a single unconditional + * `WARN: ` line for any glob problem (`seed.go:36-38`) — unlike the schema-files + * apply path, which only warns when NO pattern matched anything at all. */ const resolveSeedFiles = ( fs: FileSystem.FileSystem, @@ -58,55 +61,9 @@ const resolveSeedFiles = ( ) => Effect.gen(function* () { const output = yield* Output; - const seen = new Set(); - const result: Array = []; - const unmatched: Array = []; - for (const pattern of patterns) { - // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); - // a malformed glob is reported as `failed to glob files: ` and - // contributes no matches, exactly like `Glob.Files`'s error branch. - if (legacyPathMatch(pattern, "").badPattern) { - unmatched.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); - continue; - } - const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); - if (matches.length === 0) unmatched.push(`no files matched pattern: ${pattern}`); - for (const match of matches) { - const absMatch = legacyResolveUnderWorkdir(path, workdir, match); - const statResult = yield* fs.stat(absMatch).pipe(Effect.result); - if (Result.isFailure(statResult)) { - unmatched.push(`failed to stat matched file: ${match}`); - continue; - } - if (statResult.success.type !== "Directory") { - if (!seen.has(match)) { - seen.add(match); - result.push(match); - } - continue; - } - // Go's `walkMatchedDir`: recursively list the matched directory, keep only regular - // `.sql` files, sorted (a global sort over the full relative-to-fsys-root path, not - // per-directory — matches `sort.Strings(files)` running once after the whole walk). - const namesResult = yield* legacyWalkSqlFiles(fs, absMatch, "").pipe(Effect.result); - if (Result.isFailure(namesResult)) { - unmatched.push(`failed to walk matched directory: ${match}`); - continue; - } - const sqlRelative = [...namesResult.success].sort(); - for (const relative of sqlRelative) { - const relativeToWorkdir = `${match}/${relative}`; - if (!seen.has(relativeToWorkdir)) { - seen.add(relativeToWorkdir); - result.push(relativeToWorkdir); - } - } - } - } - // Go collects all glob/walk errors into one `errors.Join` and prints a single - // `WARN: ` line (`Glob.SQLFiles` → `seed.go:35-36`), not one per pattern. - if (unmatched.length > 0) yield* output.raw(`WARN: ${unmatched.join("\n")}\n`, "stderr"); - return result; + const { files, warnings } = yield* legacySqlFilesGlob(fs, path, patterns, workdir); + if (warnings.length > 0) yield* output.raw(`WARN: ${warnings.join("\n")}\n`, "stderr"); + return files; }); /** @@ -182,20 +139,27 @@ export const legacyApplySeedFiles = ( // statements are in memory at a time, matching Go's `ExecBatchWithCache` → // `parseFile` inside the apply loop (`file.go:198-203`). A dirty seed only // updates its recorded hash, so Go never re-reads it — skip the read. - const statements = seed.dirty - ? [] - : legacySplitAndTrim( - new TextDecoder().decode( - yield* fs.readFile(legacyResolveUnderWorkdir(path, workdir, seed.path)).pipe( - Effect.mapError( - (cause) => - new LegacyMigrationSeedError({ - message: `failed to open seed file: ${cause.message}`, - }), - ), - ), + let statements: ReadonlyArray = []; + if (!seed.dirty) { + const content = new TextDecoder().decode( + yield* fs.readFile(legacyResolveUnderWorkdir(path, workdir, seed.path)).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSeedError({ + message: `failed to open seed file: ${cause.message}`, + }), ), - ); + ), + ); + // Go's `SeedFile.ExecBatchWithCache` parses through the same `parseFile` every + // other caller does, so it enforces `SUPABASE_SCANNER_BUFFER_SIZE` here too — + // see `checkScannerBufferSize`'s own doc comment. + yield* checkScannerBufferSize( + content, + (message) => new LegacyMigrationSeedError({ message }), + ); + statements = legacySplitAndTrim(content); + } const txn = Effect.gen(function* () { yield* session.exec("BEGIN"); if (!seed.dirty) { diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts index e3f672ef51..177084c845 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.unit.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 { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; @@ -111,3 +111,35 @@ describe("legacyApplySeedFiles seed glob", () => { }, ); }); + +describe("legacyApplySeedFiles scanner buffer size", () => { + it.effect( + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + () => { + // Ports the same `parseFile` every migration/globals/schema-file caller goes + // through (see `checkScannerBufferSize`'s doc comment), so an oversized + // statement must abort here too, not execute silently. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-scanner-")); + // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at + // regardless of the configured limit (see legacy-migration-apply.unit.test.ts's + // equivalent case for the exact same 4096-byte floor). + writeFileSync(join(dir, "big.sql"), `insert into t values ('${"x".repeat(5000)}');`); + const { session, queries } = fakeSession(); + const out = mockOutput(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; + return run(session, dir, ["big.sql"], out).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + }), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-sql-files-glob.ts b/apps/cli/src/legacy/shared/legacy-sql-files-glob.ts new file mode 100644 index 0000000000..dba95222a6 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-sql-files-glob.ts @@ -0,0 +1,506 @@ +import { Effect, type FileSystem, type Path, Result } from "effect"; + +import { legacyErrorMessage, legacyRelativizeErrorMessage } from "./legacy-error-message.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "./legacy-path-match.ts"; + +const META_CHARS = /[*?[\\]/u; + +// Go's `config.hasGlobMeta` (`apps/cli-go/pkg/config/config.go:211-213`) — a DIFFERENT, +// narrower set than `META_CHARS` above (which mirrors `io/fs.hasMeta`'s `path.Match` +// escape handling and includes `\`). Only used to gate `WithSkipEmptyGlobs()` below. +const GLOB_META_CHARS = /[*?[]/u; + +// Go's `filepath.ToSlash` replaces `os.PathSeparator` with `/` — a no-op on the +// non-Windows platforms this shell mostly runs on, since their separator already IS +// `/`. Gating on `win32` (rather than converting unconditionally) matters: on +// non-Windows a `\` in a pattern is never a path separator, only a `path.Match` +// escape (`foo\.sql`, `seed\*.sql`), and unconditionally slashing it would corrupt +// that escape — see `legacyPathMatch`'s escape handling below. +const toSlash = (p: string): string => (process.platform === "win32" ? p.replaceAll("\\", "/") : p); + +// Go's `sort.Strings` (used by both `Glob.SQLFiles`, `config.go:155`, and +// `walkMatchedDir`, `config.go:207`) orders strings by their raw UTF-8 BYTES — +// Go strings are just byte slices, so `strings.Compare` never decodes runes. JS's +// default `Array.prototype.sort()` instead compares UTF-16 CODE UNITS, which +// disagrees with UTF-8 byte order for any character outside the Basic Multilingual +// Plane: a supplementary-plane code point (`U+10000`+, a UTF-16 surrogate PAIR +// starting `0xD800`-`0xDBFF`) always UTF-8-encodes to 4 bytes leading `0xF0`-`0xF4`, +// while every 3-byte-encoded BMP character (`U+0800`-`U+FFFF`, UTF-8 lead byte +// `0xE0`-`0xEF`) is numerically SMALLER as a lead byte but can have a LARGER lone +// UTF-16 code unit than the surrogate pair's lead unit — so the two orderings can +// disagree. Verified empirically: sorting a 4-byte emoji filename against a +// 3-byte fullwidth-exclamation filename, Go's `sort.Strings` places the fullwidth +// exclamation FIRST, while JS's default `.sort()` places the emoji first. +const UTF8_ENCODER = new TextEncoder(); +const utf8Compare = (a: string, b: string): number => { + const bytesA = UTF8_ENCODER.encode(a); + const bytesB = UTF8_ENCODER.encode(b); + const len = Math.min(bytesA.length, bytesB.length); + for (let i = 0; i < len; i++) { + const diff = bytesA[i]! - bytesB[i]!; + if (diff !== 0) return diff; + } + return bytesA.length - bytesB.length; +}; + +// Joins a matched directory (or glob-split directory prefix) with a child/entry name, +// delegating to the injected `Path.Path` service for Go's `path.Join`-equivalent +// cleaning. Two Go call sites build a path exactly this way, and both need the same +// cleaning: +// +// - Direct glob-match construction (`globOne`, below): the real runtime glob path — +// `config.Glob.SQLFiles`'s `fs.Glob(fsys, pattern)` call (`apps/cli-go/pkg/config/ +// config.go:145`) resolves to `afero.IOFS.Glob` (it implements `fs.GlobFS`), which +// delegates to `afero.Glob` (`github.com/spf13/afero@v1.15.0/iofs.go:56-65`). Its +// `glob()` helper appends each match as `filepath.Join(dir, n)` +// (`match.go:99`), so a glob whose directory portion has a cleanable segment +// (`/tmp/./schemas/*.sql`, `/tmp/x/../schemas/*.sql`, a doubled `/tmp/schemas//*.sql`) +// still records the CLEANED path, not a raw concatenation. Verified empirically: a +// scratch `afero.Glob` probe against all three shapes above returns +// `.../tmp/schemas/a.sql` in every case. +// - Walked-child construction (`legacyWalkSqlFiles`, below): Go's `fs.WalkDir` builds +// each child path via `path.Join(dirname, name)` (`io/fs/walk.go`). +// +// `path.Join`/`filepath.Join` both run Clean on the joined result — collapsing doubled +// slashes, dropping a bare `.` root, and lexically resolving `.`/`..` segments anywhere +// else in the path. Node's `path.join` (via this module's injected `Path.Path` service, +// backed by `node:path`) runs the same POSIX lexical-cleaning algorithm and was verified +// empirically to match byte-for-byte across every case either call site can hit — +// dot-root, trailing slash, embedded `.`/`..`, and doubled slashes: +// +// Go: path.Join(".", "foo.sql") = "foo.sql" +// filepath.Join("/tmp/schemas/", "a.sql") = "/tmp/schemas/a.sql" +// filepath.Join("/tmp/./schemas", "a.sql") = "/tmp/schemas/a.sql" +// filepath.Join("/tmp/x/../schemas", "a.sql") = "/tmp/schemas/a.sql" +// path.Join("..", "foo.sql") = "../foo.sql" +// Node: path.join(".", "foo.sql") = "foo.sql" +// path.join("/tmp/schemas/", "a.sql") = "/tmp/schemas/a.sql" +// path.join("/tmp/./schemas", "a.sql") = "/tmp/schemas/a.sql" +// path.join("/tmp/x/../schemas", "a.sql") = "/tmp/schemas/a.sql" +// path.join("..", "foo.sql") = "../foo.sql" +// +// For seeds, the resulting path becomes the `supabase_migrations.seed_files.path` hash +// key, so any of these cleaning differences would make a TS-resolved path fail to match +// an already-recorded Go-CLI key and re-run/re-record the seed; for schema files it +// changes what path is suggested on an apply failure. +const joinRelChild = (path: Path.Path, rel: string, name: string): string => path.join(rel, name); + +/** + * Splits a forward-slashed path into its directory prefix and final element. + * + * A bare root prefix is kept as `"/"`, never chopped to `""`. This mirrors + * the real runtime glob path — `config.Glob.SQLFiles`'s `fs.Glob` call + * resolves to `afero.IOFS.Glob` (it implements `fs.GlobFS`), which delegates + * to `afero.Glob`/`match.go`'s `filepath.Split` followed by a switch on + * `dir` that leaves a bare `filepath.Separator` alone — every OTHER trailing + * separator is chopped, but the root one is deliberately preserved. Verified + * empirically against `apps/cli-go`: with cwd elsewhere, a pattern rooted at + * `/`, with a metacharacter in the FIRST component after the root slash + * (e.g. `tmp` + wildcard + `probe-dir` + wildcard + `.sql`), still resolves + * that first component against the filesystem ROOT, not cwd. Collapsing this + * to `dir: ""` would make `globOne` below treat such an absolute root-level + * pattern as relative to the workdir instead of the filesystem root. + * + * On Windows, a bare drive-root prefix (`"C:/"`) needs the exact same + * preservation, for the same reason but a different mechanism: Go's + * `filepath.Split` treats `"C:"` as the volume name (`volumeNameLen`, + * `internal/filepathlite/path_windows.go`) and always keeps the following + * separator attached to `dir` — verified against that source directly, since + * there is no Windows machine available to run the compiled stdlib on: + * `Split("C:/*.sql")` returns `dir: "C:/"`, not `dir: "C:"`. Chopping the + * separator here would matter downstream: Node's `path.isAbsolute("C:")` is + * `false` (a bare drive letter is a *drive-relative* path in Windows + * semantics, not absolute), so `globOne`'s `resolve()` would wrongly `join` + * it under the workdir instead of resolving the real drive root, while + * `path.isAbsolute("C:/")` is `true`. + */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + if (slash === -1) return { dir: "", file: p }; + if (slash === 0) return { dir: "/", file: p.slice(1) }; + if (process.platform === "win32" && slash === 2 && p.charAt(1) === ":") { + return { dir: p.slice(0, 3), file: p.slice(3) }; + } + return { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // Go's `fs.Glob`/`afero.Glob` resolve a literal (no-metacharacter) pattern via + // `Lstat`, which errors on an empty path — so `""` always yields no matches. An + // unguarded `path.join(workdir, "")` resolves to `workdir` itself, which would + // wrongly report the workdir as a match for an empty `schema_paths`/`sql_paths` + // entry (e.g. `schema_paths = [""]`). + if (pattern.length === 0) { + return []; + } + // Absolute patterns resolve against the filesystem root; relative ones are + // rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); + // No metacharacters: Go's `fs.Glob`/`afero.Glob` fast path (`match.go:34-40`) probes + // via `Lstat` (`OsFs.LstatIfPossible` → `os.Lstat`, verified empirically against + // `afero@v1.15.0`), which does NOT follow a symlink — so a literal pattern naming a + // BROKEN symlink still Lstat-succeeds (the link itself exists) and is reported as a + // match; the follow-up `fs.Stat(fsys, fp)` in `legacySqlFilesGlob` below (which DOES + // follow it) is what fails, with `failed to stat matched file: ...`. `fs.exists` here + // is Effect's `access`-based check (Node's `fs.access`), which follows the symlink + // like a normal `Stat` and would wrongly report "no files matched pattern" instead — + // verified empirically: `fs.access` on a broken symlink resolves ENOENT while + // `fs.lstat` on the same path succeeds. Probe for the entry itself the same + // no-follow way `legacyWalkSqlFiles` below already does for a walked child: `readLink` + // succeeds only for a symlink (broken or not), so treat that as an Lstat success + // before falling back to the normal existence check for everything else. + if (!META_CHARS.test(pattern)) { + const resolved = resolve(pattern); + const isSymlink = yield* fs.readLink(resolved).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + const exists = + isSymlink || (yield* fs.exists(resolved).pipe(Effect.orElseSucceed(() => false))); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : resolve(d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const name of names) { + if (legacyPathMatch(file, name).matched) { + // `joinRelChild` (above) is the same Path-service clean-join the walked-child + // path below uses — see its comment for why a raw `${d}/${name}` concatenation + // isn't enough here (Go's `afero.Glob` cleans via `filepath.Join(dir, n)`, so + // `d === "/"`, `d === ""`, and a `dir` containing `.`/`..`/doubled-slash + // segments must all clean the same way as the walked-child case does). + // + // Deliberately NOT `toSlash`'d here, on Windows: Go's `config.Glob.SQLFiles` + // sorts the RAW backslash-joined matches from `fs.Glob`/`afero.Glob` (built via + // `filepath.Join`, `config.go:145-155`) and only converts each surviving match + // to forward slash AFTER that sort (`fp := filepath.ToSlash(item)`, + // `config.go:156`). This function's own caller (`legacySqlFilesGlob`) already + // sorts the array `globOne` returns and slashes each item only after — slashing + // here too would sort forward-slash-joined strings instead of the raw + // backslash-joined ones, which can disagree: comparing `a\x.sql` vs `a0\x.sql` + // byte-for-byte puts `a0\x.sql` first (`\` is `0x5C`, greater than `0`'s + // `0x30`), while `a/x.sql` vs `a0/x.sql` puts `a/x.sql` first (`/` is `0x2F`, + // less than `0x30`) — a different order for the same two matches. + result.push(joinRelChild(path, d, name)); + } + } + } + return result; + }); + +/** + * Recursively collects the regular `.sql` files under a matched directory, porting + * Go's `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && + * filepath.Ext(path) == ".sql"`, `apps/cli-go/pkg/config/config.go:126-131,194-211`). + * Paths are workdir-relative (matching the glob output), forward-slashed, and sorted + * for deterministic application. + * + * A `ReadDir` failure anywhere in the tree (e.g. a permissions error on a nested + * directory) fails the WHOLE walk with `failed to walk matched directory: `, + * discarding every file collected so far — Go's `fs.WalkDir` callback returns that + * `err` unchanged, which stops the traversal immediately and makes `walkMatchedDir` + * return `(nil, err)` rather than the partial list (`config.go:196-208`; verified + * empirically: an unreadable matched directory makes `Glob.SQLFiles` return a + * `failed to walk matched directory: ...` error with zero files, not an empty match). + */ +const legacyWalkSqlFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dir: string, +): Effect.Effect, string> => + Effect.gen(function* () { + const collected: Array = []; + const walk = (rel: string): Effect.Effect => + Effect.gen(function* () { + const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); + // Go's `fs.WalkDir` runs against `afero.OsFs` with the process cwd already the + // workdir (`ChangeWorkDir`, `cmd/root.go`), so a `ReadDir` failure — on the + // matched root `dir` itself, or on any nested directory the walk descends into — + // embeds the workdir-relative `rel` in its error text, never an absolute path. + // This module never `process.chdir`s, so the real read needs `absDir`, but the + // wrapped warning must still report `rel` (same substitution pattern as the + // matched-file stat failure below and `legacyApplySchemaFiles`'s read errors). + const names = yield* fs + .readDirectory(absDir) + .pipe( + Effect.mapError( + (error) => + `failed to walk matched directory: ${legacyRelativizeErrorMessage(legacyErrorMessage(error), absDir, rel)}`, + ), + ); + // Go's `fs.WalkDir` visits directory entries in lexical byte order — its own + // `ReadDir` (`os.ReadDir`/`afero.OsFs`) contract guarantees results "sorted by + // filename" before `walkDir` ever iterates them. This FileSystem service's + // `readDirectory` makes no such promise (raw OS enumeration order), so when a + // directory contains MULTIPLE problematic children (e.g. two unreadable + // subdirectories), which one's failure short-circuits this loop — and therefore + // which single error message this walk fails with, or which `WARN:`/fatal text a + // caller (the experimental schema branch, or seed-path globbing) surfaces — + // would otherwise depend on filesystem enumeration order instead of matching + // Go's deterministic choice (review CLI-1958). Sort with the same UTF-8 + // byte-order comparator `globOne`/the top-level match list already use above. + for (const name of [...names].sort(utf8Compare)) { + const childRel = joinRelChild(path, rel, name); + const childAbs = path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel); + // Go's `fs.WalkDir` types each child from the parent's `ReadDir` entry + // (`os.ReadDir`'s Lstat-based `DirEntry`) and never re-`Stat`s through it — + // so a symlinked file or subdirectory found below the matched root is + // neither included nor recursed into, regardless of what it points to + // (`io/fs/walk.go:114-115`: only the matched root itself, resolved once by + // the caller before reaching this walk, may be a symlink). `readLink` + // succeeds only for symlinks, so use it as the no-follow probe in place of + // the `Lstat` this FileSystem service doesn't expose. + const isSymlink = yield* fs.readLink(childAbs).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + if (isSymlink) { + continue; + } + const statResult = yield* fs.stat(childAbs).pipe(Effect.result); + if (Result.isFailure(statResult)) { + // TOCTOU: this child existed a moment ago in `names` (this directory's + // `readDirectory` snapshot) but is gone by the time we stat it here — e.g. removed + // by a concurrent process. Go never hits this window for the child's TYPE: `fs.WalkDir` + // decides file-vs-directory from the SAME `DirEntry` its parent `ReadDir` already + // returned and never re-`Stat`s a child, so a `.sql` file that disappears here + // stays in Go's declared file list, and only the later, real file-open fails + // loudly. Verified empirically: a scratch `filepath.WalkDir` probe that deletes a + // sibling `.sql` file between `ReadDir` and that file's own visit still reports + // it `IsRegular` from the cached entry, keeps it in `declared`, and the + // subsequent `os.Open` on it fails with "no such file or directory" — never a + // silent drop. Losing the stat here must not silently drop the file and let the + // walk "succeed" having applied nothing — best-effort include it, matching Go's + // outcome, and let the real downstream read surface the failure. + if (childRel.endsWith(".sql")) { + collected.push(toSlash(childRel)); + continue; + } + // TOCTOU, directory variant: the vanished entry could equally have been a + // SUBDIRECTORY of the matched tree, not a harmless non-`.sql` file — and Go's + // outcome for those two cases is NOT the same. For a directory `DirEntry`, + // `fs.WalkDir` unconditionally attempts a second `ReadDir` to recurse into it + // (`io/fs/walk.go`'s `walkDir`); when the child has since been removed, that + // second `ReadDir` fails, and `walkMatchedDir`'s callback propagates the error + // unchanged (`if err != nil { return err }`, `config.go:198-199`) — `fs.WalkDir` + // returns it, and `walkMatchedDir` wraps it as `failed to walk matched + // directory: `, discarding every file already collected + // (`config.go:205-206`). Verified empirically: a scratch `fs.WalkDir` probe that + // removes a nested subdirectory between the parent's `ReadDir` and the + // subdirectory's own `ReadDir` reproduces exactly this — zero files, error + // `failed to walk matched directory: open .../nested: no such file or + // directory` — never a silent skip, unlike the vanished-`.sql`-file case above + // (round 6). This FileSystem service can't recover the lost entry's type after + // the fact — Go's `DirEntry` type came for free from the same `ReadDir` syscall + // as the listing, whereas this port's `stat` is a second, separate syscall, so a + // raced disappearance here always loses the type along with the entry. Treat any + // non-`.sql` disappearance as a potential directory and fail the whole walk the + // same way an unreadable still-present directory does (the `readDirectory` + // failure above) — matching Go's fail-loud design intent (never silently apply a + // partial schema/seed set) rather than risk silently dropping an entire nested + // subtree of schema files. This `stat` stands in for the second `ReadDir` Go + // itself would issue on the vanished directory, so its display path needs the + // same absolute-to-relative substitution as the `readDirectory` failure above — + // `childAbs` is what the real (stand-in) syscall needed, `childRel` is what Go's + // own `ReadDir` error would embed. + return yield* Effect.fail( + `failed to walk matched directory: ${legacyRelativizeErrorMessage(legacyErrorMessage(statResult.failure), childAbs, childRel)}`, + ); + } + const childType = statResult.success.type; + if (childType === "Directory") { + yield* walk(childRel); + } else if (childType === "File" && childRel.endsWith(".sql")) { + collected.push(toSlash(childRel)); + } + } + }); + yield* walk(dir); + return collected.sort(utf8Compare); + }); + +/** Result of resolving SQL-file glob patterns against the workspace. */ +interface LegacySqlFilesGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in first-seen order across patterns. */ + readonly files: ReadonlyArray; + /** + * Per-pattern/per-match problems (`no files matched pattern: …` / `failed to glob + * files: …` / `failed to walk matched directory: …`), in pattern order. Never fatal + * by itself — callers decide when a warning matters + * (e.g. the seed path always surfaces it; the schema-files apply path only surfaces + * it when NO pattern matched anything at all, mirroring Go's `apply.go:53-55`). + */ + readonly warnings: ReadonlyArray; +} + +/** + * Mirrors Go's `GlobOption`s (`apps/cli-go/pkg/config/config.go:100-117`). Neither + * caller in this shared module needs them today (`legacyApplySchemaFiles`'s + * `[db.migrations].schema_paths`, and the two `[db.seed].sql_paths` callers — + * `legacyGetPendingSeeds` and `legacy-seed.ts`'s `resolveSeedFiles` — all pass none, + * matching Go's own zero-option call sites, `pkg/migration/seed.go:35` and + * `internal/migration/apply/apply.go:52`). Added for `db diff`'s declarative path, + * which calls `Glob.files` `WithSkipEmptyGlobs()` + `WithErrorOnAllSkippedGlobs()`. + */ +export interface LegacySqlFilesGlobOptions { + /** + * Go's `WithSkipEmptyGlobs()`: a pattern that contains a glob metacharacter + * (`config.hasGlobMeta` — `*`, `?`, `[`; NOT `\`, unlike `META_CHARS` above) and + * matches nothing is silently skipped — no "no files matched pattern" warning — + * unless `errorOnAllSkipped` retroactively un-skips it (below). A LITERAL pattern + * (no glob metacharacter) that doesn't exist always warns, regardless of this flag. + */ + readonly skipEmptyGlobs?: boolean; + /** + * Go's `WithErrorOnAllSkippedGlobs()`: only meaningful together with + * `skipEmptyGlobs`. If the overall result ends up empty AND at least one pattern + * was silently skipped, every skipped pattern's silence is retroactively turned + * back into a "no files matched pattern" warning — so a `skipEmptyGlobs` caller + * can still detect the "nothing matched anything" case. + */ + readonly errorOnAllSkipped?: boolean; +} + +/** + * Resolves SQL-file glob patterns to existing files, porting Go's `config.Glob.SQLFiles` + * over `fs.Glob` (`apps/cli-go/pkg/config/config.go:123-211`). Shared by + * `[db.seed].sql_paths` (via `legacyGetPendingSeeds`, `legacy-seed-ops.ts`, and + * `legacy-seed.ts`'s `resolveSeedFiles`) and `[db.migrations].schema_paths` (via + * `legacyApplySchemaFiles`, `legacy-migration-apply.ts`) — all three Go call sites + * resolve through the exact same `Glob` type and `SQLFiles` method, so the traversal + * logic lives here once. + * + * Each pattern is matched independently: matches are sorted per-pattern (Go's + * `sort.Strings`, `config.go:155`), but the overall result preserves cross-pattern + * DECLARATION order (no global re-sort), with first-seen dedup. A directory match is + * expanded to its regular `.sql` files, recursively, sorted by full path + * (`walkMatchedDir`); a non-directory match is kept verbatim regardless of extension. A + * pattern that matches nothing, or is malformed (Go's `path.ErrBadPattern`, e.g. an + * unterminated `[` class), contributes a warning but does not stop the loop — mirroring + * `fs.Glob`'s up-front `Match(pattern, "")` validation (`io/fs/glob.go`). + * + * Patterns are assumed already resolved to Go's config-load form (a relative entry + * `supabase/`-joined, an absolute entry verbatim) — callers resolve that once at + * config-read time (`legacyResolveSeedSqlPath`), matching Go's `config.resolve` step, + * which runs once at config load, before any glob. + */ +export const legacySqlFilesGlob = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, + options?: LegacySqlFilesGlobOptions, +) { + const skipEmptyGlobs = options?.skipEmptyGlobs ?? false; + const errorOnAllSkipped = options?.errorOnAllSkipped ?? false; + const seen = new Set(); + const files: Array = []; + const warnings: Array = []; + const skipped: Array = []; + + for (const rawPattern of patterns) { + // Go's `filepath.ToSlash(pattern)` (`config.go:145`) is passed only as an ARGUMENT + // to `fs.Glob` — the loop's own `pattern` variable (Go's range variable) is never + // reassigned, so every later reference to it in THIS iteration (`hasGlobMeta`, the + // skipped-pattern list, and the "no files matched pattern" warning, all below) + // still reports the ORIGINAL, un-slashed pattern. On Windows, an absolute pattern + // with backslashes (`C:\schemas\*.sql`) must therefore warn with that raw backslash + // form, even though matching itself runs against the slashed form. + const pattern = toSlash(rawPattern); + // Go's `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a + // malformed glob is reported as `failed to glob files: ` and + // contributes no matches, rather than the misleading "no files matched" below. + if (legacyPathMatch(pattern, "").badPattern) { + warnings.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + if (skipEmptyGlobs && GLOB_META_CHARS.test(rawPattern)) { + skipped.push(rawPattern); + } else { + warnings.push(`no files matched pattern: ${rawPattern}`); + } + continue; + } + for (const match of [...matches].sort(utf8Compare)) { + const fp = toSlash(match); + // A directory match is expanded to its regular `.sql` files recursively + // (`walkMatchedDir`, sorted); a file match is kept verbatim (`config.go:157-183`). + // Go: `if err != nil { allErrors = append(allErrors, errors.Errorf("failed to + // stat matched file: %w", err)); continue }` (`config.go:157-161`) — a match + // that disappears (or is a broken symlink) between the glob and this stat + // becomes a warning and is skipped, same as a walk failure below. Falling back + // to treating it as a regular file (the previous behaviour here) would instead + // hand a nonexistent path to the caller's later read, turning a warned-but- + // otherwise-successful reset into a hard apply error. + // + // Go's `fs.Stat(fsys, fp)` (`config.go:157`) runs with its process cwd already + // the workdir (`ChangeWorkDir`, `cmd/root.go`), so `fp` IS the exact string the + // real syscall sees and the resulting error's path is that workdir-relative + // `fp`. This module never `process.chdir`s, so the real stat needs an absolute + // path here — but the wrapped message must still report the relative `fp`, not + // the absolute path used to make the syscall work. Same substitution pattern as + // `legacyApplySchemaFiles`'s read-error display path (`legacy-migration-apply.ts`). + const absoluteFp = path.isAbsolute(fp) ? fp : path.join(workdir, fp); + const statResult = yield* fs.stat(absoluteFp).pipe(Effect.result); + if (Result.isFailure(statResult)) { + const message = legacyRelativizeErrorMessage( + legacyErrorMessage(statResult.failure), + absoluteFp, + fp, + ); + warnings.push(`failed to stat matched file: ${message}`); + continue; + } + const matchType = statResult.success.type; + if (matchType === "Directory") { + // Go: `if err != nil { allErrors = append(allErrors, err); continue }` — a walk + // failure on this match becomes a warning (never a hard Effect failure, like + // every other per-match/per-pattern problem here) and the loop moves on to the + // next match, exactly like a malformed pattern or a "no files matched" miss. + const walked = yield* legacyWalkSqlFiles(fs, path, workdir, fp).pipe(Effect.result); + if (Result.isFailure(walked)) { + warnings.push(walked.failure); + continue; + } + for (const file of walked.success) { + if (!seen.has(file)) { + seen.add(file); + files.push(file); + } + } + continue; + } + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + // Go: `if opts.errorOnAllSkipped && len(result) == 0 && len(skipped) > 0` — only + // escalate silently-skipped patterns back into warnings when NOTHING matched at all. + if (errorOnAllSkipped && files.length === 0 && skipped.length > 0) { + for (const pattern of skipped) { + warnings.push(`no files matched pattern: ${pattern}`); + } + } + + return { files, warnings } satisfies LegacySqlFilesGlobResult; +}); diff --git a/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts b/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts new file mode 100644 index 0000000000..a40acc4cc5 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts @@ -0,0 +1,828 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; + +const run = (patterns: ReadonlyArray, workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySqlFilesGlob(fs, path, patterns, workdir); + }).pipe(Effect.provide(BunServices.layer)); + +describe("legacySqlFilesGlob", () => { + it.effect( + "treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", + () => { + // Go's `fs.Glob`/`afero.Glob` resolve a no-metacharacter pattern via `Lstat`, which + // errors on an empty path — an empty `schema_paths`/`sql_paths` entry (e.g. + // `schema_paths = [""]`) always yields no matches, never the workdir itself. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-empty-")); + return run([""], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: "]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "does not follow a symlinked .sql file below a matched directory (Go WalkDir parity)", + () => { + // Go's `Glob.SQLFiles` expands a matched directory with `fs.WalkDir`, which types + // each child from its parent's `ReadDir` entry (`os.ReadDir`'s Lstat-based + // `DirEntry`) and never re-`Stat`s through it — so a symlinked `.sql` file is + // never included, regardless of what it points to. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-symlink-file-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "real.sql"), "select 1;"); + const outsideDir = join(dir, "outside"); + mkdirSync(outsideDir); + writeFileSync(join(outsideDir, "evil.sql"), "select 2;"); + symlinkSync(join(outsideDir, "evil.sql"), join(schemasDir, "linked.sql")); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "does not recurse into a symlinked subdirectory below a matched directory (Go WalkDir parity)", + () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-symlink-dir-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "real.sql"), "select 1;"); + const outsideSubdir = join(dir, "outside-subdir"); + mkdirSync(outsideSubdir); + writeFileSync(join(outsideSubdir, "nested.sql"), "select 3;"); + symlinkSync(outsideSubdir, join(schemasDir, "linked-dir")); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file (Go parity)", + () => { + // Go: `if info, err := fs.Stat(fsys, fp); err != nil { allErrors = append(allErrors, + // errors.Errorf("failed to stat matched file: %w", err)); continue }` (config.go:157-161) + // — a match that disappears (or is a broken symlink) between the glob and this stat + // becomes a warning and is skipped entirely, never silently treated as a regular file. + // + // Go's `fsys` here is always `afero.NewOsFs()` with the process cwd already the + // workdir (`ChangeWorkDir`, `cmd/root.go`), so `fs.Stat(fsys, fp)`'s embedded path + // in the resulting error is the workdir-RELATIVE `fp` (verified directly against + // `os.Stat`/`afero.OsFs.Stat`, which pass the name through to `os.Stat` unchanged). + // This module never `process.chdir`s, so the real stat needs an absolute path — but + // the warning must still report the relative form, not that absolute (temp-dir) + // path, or it would leak a local filesystem path Go never would. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-stat-fail-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "good.sql"), "select 1;"); + symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); + return run(["schemas/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/good.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + expect(result.warnings[0]).not.toContain(dir); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match (Go afero.Glob Lstat parity)", + () => { + // Go's `fs.Glob`/`afero.Glob` no-metacharacter fast path (`match.go:34-40`) probes + // via `Lstat` (`OsFs.LstatIfPossible` → `os.Lstat`), which does NOT follow a + // symlink — so a LITERAL pattern naming a broken symlink still Lstat-succeeds (the + // link itself exists) and is reported as a match; the follow-up `fs.Stat` above is + // what then fails with `failed to stat matched file: ...`, exactly like the + // wildcard-pattern case the previous test covers. Verified empirically against + // `apps/cli-go` (`afero.Glob`/`fs.Stat` scratch probe): a literal broken-symlink + // pattern always Globs to a match and always fails the follow-up Stat — never + // "no files matched pattern". + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-literal-symlink-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); + return run(["schemas/broken.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component (Go afero.Glob parity)", + () => { + // Go's real runtime glob path — `config.Glob.SQLFiles`'s `fs.Glob` call resolves to + // `afero.IOFS.Glob` (it implements `fs.GlobFS`), which delegates to `afero.Glob` + // (`match.go`): `filepath.Split` followed by a switch that leaves a bare + // `filepath.Separator` alone — every OTHER trailing separator is chopped, but the + // root one is deliberately preserved. Verified empirically against `apps/cli-go`: + // with cwd elsewhere, a pattern rooted at "/" with a metacharacter in the first + // component after the root slash still resolves against the filesystem ROOT, not + // cwd. A canary file placed in the WORKDIR (never the real "/") proves this native + // port does not fall back to treating the root component as workdir-relative. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-abs-root-")); + writeFileSync(join(dir, "__legacy_sql_glob_canary__.sql"), "select 1;"); + return run(["/*__legacy_sql_glob_canary__*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /*__legacy_sql_glob_canary__*.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "recurses through a root-anchored directory component without falling back to the workdir (Go afero.Glob parity)", + () => { + // Same bug as above, but for a two-level pattern (`/foo*/*.sql`) — the recursive + // call that resolves the "foo*" directory component must also treat "/" as the + // real filesystem root, not "" (which `globOne` maps to the workdir). The workdir + // here contains a subdirectory that WOULD match "foo*" if (and only if) the + // recursive call incorrectly fell back to reading the workdir instead of "/". + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-abs-root-nested-")); + const canaryDir = join(dir, "__legacy_sql_glob_root_canary_dir__"); + mkdirSync(canaryDir); + writeFileSync(join(canaryDir, "a.sql"), "select 1;"); + return run(["/__legacy_sql_glob_root_canary_dir__*/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /__legacy_sql_glob_root_canary_dir__*/*.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern (Go filepath.Split parity)", + () => { + // Go's `filepath.Split` treats `"C:"` as the volume name on Windows + // (`volumeNameLen`, `internal/filepathlite/path_windows.go`) and always + // keeps the following separator attached to `dir` — so `Split("C:/*.sql")` + // returns `dir: "C:/"`, not `dir: "C:"` (verified directly against that + // stdlib source; there is no Windows machine available to run the + // compiled binary on). Losing the trailing slash matters: Node's + // `path.isAbsolute("C:")` is `false` (a bare drive letter is + // *drive-relative*, not absolute, in Windows semantics), so `globOne`'s + // `resolve()` would wrongly `join` it under the workdir instead of + // resolving the real drive root. Force win32 path semantics + // (`BunPath.layerWin32`) and this module's own `process.platform` gate + // so the test exercises the same branch a real Windows install takes. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-drive-root-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // A "C:/" drive root doesn't exist on this (non-Windows) test host, so + // fake just the two calls that must resolve against it, reusing a real + // file's stat info to avoid hand-rolling a `File.Info`. + const realFileInfo = yield* fs.stat(join(dir, "a.sql")); + const driveRootFs: FileSystem.FileSystem = { + ...fs, + readDirectory: (p: string) => + p === "C:/" ? Effect.succeed(["a.sql"]) : fs.readDirectory(p), + stat: (p: string) => (p === "C:/a.sql" ? Effect.succeed(realFileInfo) : fs.stat(p)), + }; + return yield* legacySqlFilesGlob(driveRootFs, path, ["C:/*.sql"], dir); + }).pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["C:/a.sql"]); + expect(result.warnings).toEqual([]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching (Go filepath.ToSlash parity)", + () => { + // Go passes `filepath.ToSlash(pattern)` only as an ARGUMENT to `fs.Glob` + // (`config.go:145`) — the loop's own `pattern` variable (Go's range variable) is + // never reassigned, so the "no files matched pattern: %s" warning (`config.go:155`) + // still reports the ORIGINAL backslash form. An absolute Windows pattern with + // backslashes that matches nothing must therefore warn with that backslash form, + // not the slashed one used internally to glob. Force win32 path semantics + // (`BunPath.layerWin32`) and this module's own `process.platform` gate, same as + // the drive-root test above. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-win-warn-")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySqlFilesGlob(fs, path, ["C:\\schemas\\*.sql"], dir); + }).pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: C:\\schemas\\*.sql"]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "sorts raw backslash-joined Windows matches BEFORE slashing, not after (Go afero.Glob/filepath.ToSlash ordering parity)", + () => { + // Go's `config.Glob.SQLFiles` (`config.go:145-156`) calls `fs.Glob`, which resolves + // to `afero.Glob`'s `glob()` helper — it builds each match with `filepath.Join(dir, + // n)` (OS-separator-joined, backslash on Windows) and NEVER slashes it. Only back in + // `SQLFiles`, AFTER `sort.Strings(matches)` sorts those raw backslash matches, does + // each surviving item get `filepath.ToSlash`'d. For a pattern like `a*/x.sql` + // matching both `a\x.sql` and `a0\x.sql`, sorting the RAW backslash strings byte-for + // -byte puts `a0\x.sql` first (`\` is `0x5C`, greater than `0`'s `0x30`) — but + // sorting the SLASHED strings instead would put `a/x.sql` first (`/` is `0x2F`, less + // than `0x30`), a different order for the same two matches. `globOne` must therefore + // push the raw joined match (slashing only happens in the caller's post-sort loop), + // matching Go's real order exactly. Fully faked filesystem (no real Windows host + // available): `readDirectory`/`stat` return canned results keyed by the exact + // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + const scratchDir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-win-sort-")); + const canaryFile = join(scratchDir, "canary.sql"); + writeFileSync(canaryFile, "select 1;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fileInfo = yield* fs.stat(canaryFile); + const workdir = "/workdir"; + const winFs: FileSystem.FileSystem = { + ...fs, + readDirectory: (p: string) => { + if (p === workdir) return Effect.succeed(["a", "a0"]); + if (p === "\\workdir\\a" || p === "\\workdir\\a0") return Effect.succeed(["x.sql"]); + return fs.readDirectory(p); + }, + stat: (p: string) => + p === "\\workdir\\a\\x.sql" || p === "\\workdir\\a0\\x.sql" + ? Effect.succeed(fileInfo) + : fs.stat(p), + }; + return yield* legacySqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); + }).pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); + expect(result.warnings).toEqual([]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + rmSync(scratchDir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "normalizes a doubled slash when the matched directory itself has a trailing slash (Go path.Join parity)", + () => { + // Go's `fs.WalkDir` builds each child path via `path.Join(dirname, name)` + // (`io/fs/walk.go`), and `path.Join` runs `path.Clean` on the result, collapsing a + // doubled `/`. A literal (no-metacharacter) `schema_paths`/`sql_paths` entry like + // `"schemas/"` resolves via `fs.Glob`'s fast path to the pattern VERBATIM, trailing + // slash and all — so the walk over its children must not produce `schemas//a.sql`. + // Verified empirically against `apps/cli-go`: a scratch probe calling + // `config.Glob{"/"}.SQLFiles(...)` on a real trailing-slash directory returns + // the single-slash path, not a doubled one. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-trailing-slash-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["schemas/"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "drops the './' prefix when the matched directory cleans to '.' (Go path.Join parity)", + () => { + // Go's `fs.WalkDir` builds each child path via `path.Join(dirname, name)`, and + // `path.Join` runs `path.Clean`, which drops a bare `.` root entirely rather than + // joining it as a prefix. A matched directory can clean to exactly `.` — e.g. + // `[db.migrations].schema_paths = [".."]`/`[db.seed].sql_paths = [".."]`, which + // `baseConfig.resolve`'s own `path.Join(builder.SupabaseDirPath, pattern)` collapses + // to `.` (`apps/cli-go/pkg/config/config.go:969-980`) — so the walk over its children + // must record `a.sql`, not `./a.sql`. This matters beyond cosmetics: for seeds, the + // walked path becomes the `supabase_migrations.seed_files.path` hash key, so a + // `./`-prefixed path would never match an already-recorded Go-CLI key. Verified + // empirically: `path.Join(".", "foo.sql")` and a real `fs.WalkDir` rooted at `.` both + // drop the `./` prefix entirely. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dot-root-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + const nestedDir = join(dir, "nested"); + mkdirSync(nestedDir); + writeFileSync(join(nestedDir, "b.sql"), "select 2;"); + return run(["."], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["a.sql", "nested/b.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "cleans a '..'-segment matched directory when walking its children (Go path.Join parity)", + () => { + // Go's `fs.WalkDir` builds each child path via `path.Join(dirname, name)`, and + // `path.Join` runs `path.Clean`, which lexically resolves an embedded `..` segment — + // not just a bare `.` root or a trailing slash. A matched directory can contain a + // `..` anywhere, e.g. `[db.migrations].schema_paths = ["nested/../schemas"]`, and the + // walk over its children must record `schemas/a.sql`, not `nested/../schemas/a.sql`. + // Verified empirically: `path.Join("/tmp/x/../schemas", "a.sql")` and Node's + // `path.join("/tmp/x/../schemas", "a.sql")` both clean to `/tmp/schemas/a.sql`. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dotdot-segment-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["nested/../schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "cleans a direct glob match whose directory portion has a '.' segment (Go afero.Glob parity)", + () => { + // Distinct from the walked-child cleaning above: this pattern's glob metacharacter + // (`*`) is in the FINAL component, so `globOne` matches `a.sql` directly against + // the directory entries of `schemas/.` — it never goes through + // `legacyWalkSqlFiles`. Go's real runtime glob path resolves through + // `afero.IOFS.Glob` -> `afero.Glob`'s `glob()` helper, which appends each match as + // `filepath.Join(dir, n)` (`match.go:99`) — so the recorded match is the CLEANED + // `schemas/a.sql`, not a raw `schemas/./a.sql` concatenation. Verified empirically: + // a scratch `afero.Glob(fs, ".../tmp/./schemas/*.sql")` probe against a real + // filesystem returns the cleaned path. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-dot-segment-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["schemas/./*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "cleans a direct glob match whose directory portion has a '..' segment (Go afero.Glob parity)", + () => { + // Same distinction as above (a direct match via `globOne`, not a walked directory + // expansion), but for an embedded `..` rather than a `.` segment — e.g. an absolute + // `schema_paths`/`sql_paths` entry like `/tmp/x/../schemas/*.sql`. `filepath.Join` + // lexically resolves `..` the same way it drops a bare `.` root, so Go still records + // the cleaned `schemas/a.sql`, not `nested/../schemas/a.sql`. For seed files, that + // recorded path is the `supabase_migrations.seed_files.path` hash key, so leaving it + // uncleaned would make a TS-resolved match fail to line up with an already-recorded + // Go-CLI key and re-run/re-record the seed. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-dotdot-segment-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["nested/../schemas/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "normalizes a doubled slash for a direct glob match under a trailing-slash directory component (Go afero.Glob parity)", + () => { + // Same distinction again: `splitPath` on `"schemas//*.sql"` yields a `dir` of + // `"schemas/"` (a single trailing slash survives the split), so the old raw + // `` `${d}/${name}` `` concatenation inserted a SECOND slash on top of it + // (`"schemas//a.sql"`). `filepath.Join`/`path.join` collapse doubled slashes + // regardless of where they came from, so the recorded match must be the + // single-slash `schemas/a.sql`. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-doubled-slash-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["schemas//*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "still includes a '.sql' child whose stat fails after it's already listed (Go WalkDir parity)", + () => { + // Go's `fs.WalkDir` types each child from the parent's `ReadDir`-returned `DirEntry` + // and never re-`Stat`s through it, so a `.sql` file that disappears between `ReadDir` + // and its own visit stays in Go's declared file list — only the later, real file-open + // fails. Simulate the stat failure directly (mocking a real race is flaky) by pointing + // the matched directory at one that lists a child but whose child path is unreadable: + // a broken symlink target used as a bare filename via a `readLink` failure isn't + // enough here (that's the earlier symlink test), so exercise the `fs.stat` failure + // path itself by removing the file the instant after `readDirectory` returns it, via + // a `FileSystem` layer that deletes on first `stat` call for that path. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-stat-race-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + const racyFile = join(schemasDir, "racy.sql"); + writeFileSync(racyFile, "select 1;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const racyFs: FileSystem.FileSystem = { + ...fs, + stat: (p: string) => + p === racyFile + ? Effect.sync(() => rmSync(racyFile)).pipe(Effect.andThen(fs.stat(p))) + : fs.stat(p), + }; + return yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); + }).pipe( + Effect.provide(BunServices.layer), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/racy.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory (Go WalkDir parity)", + () => { + // Round 6 (test above) established that a `.sql` FILE child racing away between + // `readDirectory` and this port's own `stat` call is best-effort included, matching + // Go's cached-`DirEntry` behaviour for regular files. But Go's `fs.WalkDir` does NOT + // treat every vanished child the same way: for a DIRECTORY `DirEntry`, it unconditionally + // attempts a second `ReadDir` to recurse into it; when that child is gone, the second + // `ReadDir` fails, and `walkMatchedDir`'s callback propagates the error unchanged + // (`if err != nil { return err }`, `config.go:198-199`) — `fs.WalkDir` returns it, and + // `walkMatchedDir` wraps it as `failed to walk matched directory: `, discarding + // every file already collected (`config.go:205-206`). Verified empirically with a + // scratch `fs.WalkDir` probe against `apps/cli-go`'s real `walkMatchedDir`: removing a + // nested subdirectory between the parent's `ReadDir` and the subdirectory's own `ReadDir` + // reproduces exactly this — zero files, `failed to walk matched directory: open + // .../nested: no such file or directory` — never a silent skip. This port's `stat` call + // is a second, separate syscall from the parent's `readDirectory` (unlike Go, which gets + // the child's type for free from the SAME syscall as the listing), so a raced + // disappearance here always loses the type along with the entry — a non-`.sql` name + // could equally have been the now-missing subdirectory, and must fail the same way an + // unreadable still-present directory does, not silently vanish along with the files it + // may have held. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dir-race-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + const nestedDir = join(schemasDir, "nested"); + mkdirSync(nestedDir); + writeFileSync(join(nestedDir, "b.sql"), "select 2;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const racyFs: FileSystem.FileSystem = { + ...fs, + stat: (p: string) => + p === nestedDir + ? Effect.sync(() => rmSync(nestedDir, { recursive: true })).pipe( + Effect.andThen(fs.stat(p)), + ) + : fs.stat(p), + }; + return yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); + }).pipe( + Effect.provide(BunServices.layer), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + // This `stat` failure stands in for the second `ReadDir` Go's own `fs.WalkDir` + // would issue on the vanished directory — whose error, like every other Go + // filesystem error here, embeds the workdir-relative path, not this port's + // absolute stand-in syscall path. + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("still expands a real (non-symlinked) nested directory recursively", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-nested-")); + const schemasDir = join(dir, "schemas"); + const nestedDir = join(schemasDir, "nested"); + mkdirSync(nestedDir, { recursive: true }); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + writeFileSync(join(nestedDir, "b.sql"), "select 2;"); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql", "schemas/nested/b.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + + it.effect.skipIf(isRoot)( + "surfaces a directory-read failure during walk as a warning instead of an empty match (Go WalkDir parity)", + () => { + // Go's `fs.WalkDir` returns the `ReadDir` error from its walkFn unchanged, which + // stops the walk immediately; `walkMatchedDir` then wraps it as `failed to walk + // matched directory: ...` and discards every file already found — never an empty + // (successful) match. Verified empirically against `apps/cli-go`: an unreadable + // matched directory makes `Glob.SQLFiles` return that error with zero files. + // + // Go's `fsys` here is always `afero.OsFs` with the process cwd already the workdir + // (`ChangeWorkDir`, `cmd/root.go`), so the `ReadDir` error's embedded path is the + // workdir-relative matched directory (`schemas`), never an absolute one. This + // module never `process.chdir`s, so the real read needs an absolute path, but the + // warning must still report the relative form. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + chmodSync(schemasDir, 0o000); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas"); + expect(result.warnings[0]).not.toContain(dir); + }), + ), + Effect.ensuring( + Effect.sync(() => { + chmodSync(schemasDir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect.skipIf(isRoot)( + "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's (Go WalkDir parity)", + () => { + // Same leak as the matched-root-directory case above, but for a failure during the + // RECURSIVE walk of an already-descended subdirectory — a distinct code path inside + // Go's `fs.WalkDir` callback (it recurses via the SAME `ReadDir` call the matched + // root used, `io/fs/walk.go`), and this port's `walk()` closure recurses the same + // way. The matched root ("schemas") itself is readable; only "schemas/nested" is + // not, so the warning must report "schemas/nested", never the workdir's absolute + // temp-dir path. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-nested-")); + const schemasDir = join(dir, "schemas"); + const nestedDir = join(schemasDir, "nested"); + mkdirSync(nestedDir, { recursive: true }); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + writeFileSync(join(nestedDir, "b.sql"), "select 2;"); + chmodSync(nestedDir, 0o000); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + }), + ), + Effect.ensuring( + Effect.sync(() => { + chmodSync(nestedDir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect.skipIf(isRoot)( + "keeps files from a sibling pattern when only one matched directory fails to walk", + () => { + // Go: `if err != nil { allErrors = append(allErrors, err); continue }` — a walk + // failure on one match doesn't stop the loop over the REST of the matches/patterns; + // whether it's ultimately fatal is the caller's decision (`legacyApplySchemaFiles`'s + // `len(declared) == 0` gate), not this function's. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-partial-")); + const goodDir = join(dir, "good"); + const badDir = join(dir, "bad"); + mkdirSync(goodDir); + mkdirSync(badDir); + writeFileSync(join(goodDir, "a.sql"), "select 1;"); + writeFileSync(join(badDir, "b.sql"), "select 2;"); + chmodSync(badDir, 0o000); + return run(["good", "bad"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["good/a.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + }), + ), + Effect.ensuring( + Effect.sync(() => { + chmodSync(badDir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect.skipIf(isRoot)( + "picks the lexically-first failing subdirectory as the fatal error, matching Go's fs.WalkDir sorted-visit order (review CLI-1958)", + () => { + // Go's `fs.WalkDir` visits directory entries in lexical byte order — its + // `ReadDir` (`os.ReadDir`/`afero.OsFs`) contract guarantees results "sorted by + // filename" before `walkDir` ever iterates them, so when a matched directory + // has MULTIPLE unreadable subdirectories, Go deterministically fails on the + // FIRST one lexically ("aaa" before "bbb") and never even attempts the second. + // This module's own `readDirectory` makes no such ordering promise, so this + // test provides a fake `FileSystem` whose `readDirectory` deliberately returns + // "schemas"'s children in REVERSE order ("bbb" before "aaa") — the opposite of + // Go's guaranteed order — to prove the walk sorts them back (`utf8Compare`) + // before iterating, rather than trusting raw (here: adversarial) enumeration + // order. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-order-")); + const schemasDir = join(dir, "schemas"); + const aaaDir = join(schemasDir, "aaa"); + const bbbDir = join(schemasDir, "bbb"); + mkdirSync(aaaDir, { recursive: true }); + mkdirSync(bbbDir, { recursive: true }); + chmodSync(aaaDir, 0o000); + chmodSync(bbbDir, 0o000); + return Effect.gen(function* () { + const realFs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const reorderedFs: FileSystem.FileSystem = { + ...realFs, + readDirectory: (p, opts) => + realFs + .readDirectory(p, opts) + .pipe( + Effect.map((names) => (p === schemasDir ? [...names].sort().reverse() : names)), + ), + }; + const result = yield* legacySqlFilesGlob(reorderedFs, path, ["schemas"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + // Go descends into "aaa" first (lexical order), fails reading it, and stops — + // "bbb" is never even attempted. + expect(result.warnings[0]).toContain("schemas/aaa"); + expect(result.warnings[0]).not.toContain("schemas/bbb"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring( + Effect.sync(() => { + chmodSync(aaaDir, 0o755); + chmodSync(bbbDir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units (Go sort.Strings parity)", + () => { + // Go's `sort.Strings` (`Glob.SQLFiles`, `config.go:155`) orders the raw UTF-8 bytes + // of each match. A supplementary-plane character (here, an emoji — 4-byte UTF-8, + // lead byte 0xF0) always sorts AFTER a 3-byte-encoded BMP character (here, a + // fullwidth exclamation mark — lead byte 0xEF) in Go, because 0xF0 > 0xEF. JS's + // default `Array.prototype.sort()` instead compares UTF-16 code units, under which + // the emoji's surrogate-pair lead unit (0xD83D) sorts BEFORE the fullwidth + // exclamation mark's code unit (0xFF01) — the opposite order. Verified empirically + // against a real Go `sort.Strings` call: it places the fullwidth-exclamation file + // first. + const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-utf8-sort-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 + writeFileSync(join(schemasDir, "!.sql"), "select 2;"); // ! + return run(["schemas/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-sql-split.ts b/apps/cli/src/legacy/shared/legacy-sql-split.ts index 4eec9072a3..a2205847f0 100644 --- a/apps/cli/src/legacy/shared/legacy-sql-split.ts +++ b/apps/cli/src/legacy/shared/legacy-sql-split.ts @@ -19,7 +19,11 @@ interface State { const BEGIN_ATOMIC = "ATOMIC"; const END_ATOMIC = "END"; -const isIdentifierRune = (rune: string): boolean => /[\p{L}\p{N}_$]/u.test(rune); +// `\p{Nd}` (decimal digits only), not `\p{N}` (all Unicode numbers): Go's +// `unicode.IsDigit` — what `isIdentifierRune`/`TagState.next` port — is an alias for +// category `Nd` alone, so it rejects `No`/`Nl` runes like superscript-2 (`²`) that +// `\p{N}` would wrongly accept as a valid identifier/dollar-tag character. +const isIdentifierRune = (rune: string): boolean => /[\p{L}\p{Nd}_$]/u.test(rune); function isBeginAtomic(data: string): boolean { let offset = data.length - BEGIN_ATOMIC.length; @@ -114,8 +118,9 @@ class TagState implements State { constructor(private readonly offset: number) {} next(rune: string, data: string): State | null { if (rune === "$") return new DollarState(data.slice(this.offset)); - // Valid dollar-tag characters. - if (/[\p{L}\p{N}_]/u.test(rune)) return this; + // Valid dollar-tag characters — see `isIdentifierRune`'s comment on why `\p{Nd}`, + // not `\p{N}`. + if (/[\p{L}\p{Nd}_]/u.test(rune)) return this; return new ReadyState().next(rune, data); } } @@ -144,23 +149,28 @@ class AtomicState implements State { } /** - * Splits `sql` into raw statements (comments/whitespace preserved), then applies - * the optional transforms to each. Mirrors Go's `parser.Split`. + * One raw token from {@link splitRaw}. `terminated` is `false` only for a + * trailing statement emitted at EOF with no closing delimiter (the + * `acc.length > 0` fallback below) — every other token was emitted because the + * FSM itself found a boundary (a bare `;` in `ReadyState`, or `AtomicState` + * closing). Only ever `false` on the LAST element `splitRaw` returns, since + * that fallback fires at most once, after the main loop. */ -export function legacySplitSql( - sql: string, - ...transform: ReadonlyArray<(s: string) => string> -): string[] { +interface RawToken { + readonly text: string; + readonly terminated: boolean; +} + +/** The FSM traversal shared by every `legacySplitSql*` entry point below. */ +function splitRaw(sql: string): RawToken[] { let state: State = new ReadyState(); - const statements: string[] = []; + const tokens: RawToken[] = []; let acc = ""; for (const rune of Array.from(sql)) { acc += rune; const next = state.next(rune, acc); if (next === null) { - let token = acc; - for (const apply of transform) token = apply(token); - if (token.length > 0) statements.push(token); + tokens.push({ text: acc, terminated: true }); acc = ""; state = new ReadyState(); } else { @@ -168,21 +178,85 @@ export function legacySplitSql( } } // Trailing non-terminated statement at EOF. - if (acc.length > 0) { - let token = acc; + if (acc.length > 0) tokens.push({ text: acc, terminated: false }); + return tokens; +} + +/** + * Splits `sql` into raw statements (comments/whitespace preserved), then applies + * the optional transforms to each. Mirrors Go's `parser.Split`. + */ +export function legacySplitSql( + sql: string, + ...transform: ReadonlyArray<(s: string) => string> +): string[] { + const statements: string[] = []; + for (const { text: raw } of splitRaw(sql)) { + let token = raw; for (const apply of transform) token = apply(token); if (token.length > 0) statements.push(token); } return statements; } +/** Go's `parser.SplitAndTrim`'s per-token transform: trim trailing `;` then surrounding whitespace. */ +const legacyTrimStatement = (token: string): string => token.replace(/;+$/u, "").trim(); + /** Mirrors Go's `parser.SplitAndTrim`: trim trailing `;` then surrounding whitespace. */ export function legacySplitAndTrim(sql: string): string[] { - return legacySplitSql( - sql, - (token) => token.replace(/;+$/u, ""), - (token) => token.trim(), - ); + return legacySplitSql(sql, legacyTrimStatement); +} + +/** One statement, paired with both its RAW and trimmed forms. */ +export interface LegacySplitSqlToken { + /** The exact text `legacySplitSql(sql)` (no transforms) would emit for this statement. */ + readonly raw: string; + /** `legacyTrimStatement(raw)` — what `legacySplitAndTrim` emits, including when empty. */ + readonly trimmed: string; + /** + * `false` only for a trailing statement with no closing delimiter, emitted at + * real EOF (`splitRaw`'s `acc.length > 0` fallback) — see {@link RawToken}. + * `checkScannerBufferSize` (`legacy-migration-apply.ts`) needs this to decide + * `>` vs `>=` against the effective buffer limit: Go's `bufio.Scanner` can + * only apply its too-long check (`len(s.buf) >= s.maxTokenSize`) once it has + * given up looking for a delimiter and still needs more data — for a + * delimiter-terminated token the delimiter is found (and the token emitted) + * in the SAME `Scan()` call that fills the buffer to capacity, before that + * check is ever reached, so a token exactly AT the limit still succeeds. An + * unterminated trailing token has no delimiter to find: once the buffer + * fills to the effective limit without one, the too-long check fires + * immediately — Go never gets to attempt the extra `Read()` that would + * reveal real EOF and let the split function emit the trailing token + * instead. Verified empirically against `apps/cli-go/pkg/parser.Split`: a + * single terminated statement of exactly `maxbuf` bytes always succeeds, + * while an unterminated one of exactly `maxbuf` bytes always fails with + * `bufio.ErrTooLong` (one byte under still succeeds; one byte over always + * fails either way). + */ + readonly terminated: boolean; +} + +/** + * Same FSM traversal as {@link legacySplitAndTrim}, but pairs each statement's RAW + * (pre-trim) text with its trimmed form instead of discarding the raw text once + * emitted. Go's `bufio.Scanner`-based `parser.Split` (`pkg/parser/token.go:81-119`) + * enforces `SUPABASE_SCANNER_BUFFER_SIZE` against the untransformed + * `scanner.Text()` — the RAW form — and its `bufio.ErrTooLong` message reports + * that same raw text for the LAST successfully scanned statement, so a caller + * replicating that check (`legacy-migration-apply.ts`'s `execMigrationBatch`) + * needs both forms, not just the trimmed one `legacySplitAndTrim` returns. + * + * Unlike `legacySplitSql`/`legacySplitAndTrim`, this does NOT drop a statement + * whose trimmed form is empty — callers that replicate Go's `len(stats)` counter + * (which only increments for a non-empty trimmed statement) need to see every raw + * token, including the ones `legacySplitAndTrim` itself would filter out. + */ +export function legacySplitSqlTokens(sql: string): ReadonlyArray { + return splitRaw(sql).map(({ text: raw, terminated }) => ({ + raw, + trimmed: legacyTrimStatement(raw), + terminated, + })); } // `(?i)drop\s+` — Go's `dropStatementPattern` (`internal/db/diff/diff.go:100`, diff --git a/apps/cli/src/legacy/shared/legacy-sql-split.unit.test.ts b/apps/cli/src/legacy/shared/legacy-sql-split.unit.test.ts index b04a4793a0..6acf7cc5b1 100644 --- a/apps/cli/src/legacy/shared/legacy-sql-split.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-sql-split.unit.test.ts @@ -39,6 +39,17 @@ describe("legacySplitAndTrim", () => { ]); }); + it("treats a non-decimal Unicode digit as an invalid dollar-tag character, like Go's unicode.IsDigit", () => { + // Go's TagState.Next gates on unicode.IsDigit (category Nd only), which is false + // for superscript-2 (U+00B2, category No) — the tag "a²" is therefore invalid, + // Go falls back out of the tag and the embedded `;` becomes a real boundary. + const sql = "CREATE FUNCTION f() AS $a²$foo; bar$a²$ LANGUAGE sql;"; + expect(legacySplitAndTrim(sql)).toEqual([ + "CREATE FUNCTION f() AS $a²$foo", + "bar$a²$ LANGUAGE sql", + ]); + }); + it("respects named dollar tags", () => { const sql = "CREATE FUNCTION f() AS $body$ SELECT ';'; $body$ LANGUAGE sql; SELECT 2;"; expect(legacySplitAndTrim(sql)).toEqual([ diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index 90dbec8e0c..367d73b2d4 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -1,5 +1,5 @@ import { rm } from "node:fs/promises"; -import { join } from "node:path"; +import { resolve, sep } from "node:path"; import { Effect } from "effect"; @@ -62,6 +62,16 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * Never fails: a directory that was never staged (every service besides Edge * Runtime) is a harmless no-op, and a real deletion error is not worth * failing `stop`/rollback over. + * + * `container.name` is a `docker ps` field value read back off whatever containers matched + * the caller's label filter (`legacyListContainerIdsAndNames`) — external metadata, not + * something this function generated itself, so it cannot be trusted as a bare path segment + * without a defence-in-depth check. Resolve the candidate and require it to be a direct + * child of the staging root before deleting it — same defence-in-depth shape as + * `bootstrap.templates.ts`'s identical guard against a GitHub-supplied path escaping its + * target directory. This also covers the degenerate case where `container.name` ends up + * empty (would otherwise resolve to the staging root itself and wipe every project's + * secrets). */ export function legacyCleanupStartSecrets( containers: ReadonlyArray, @@ -71,7 +81,12 @@ export function legacyCleanupStartSecrets( Promise.all( containers.map((container) => { const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; - return rm(join(workdir, "supabase", ".temp", "start-secrets", container.name), { + const stagingRoot = resolve(workdir, "supabase", ".temp", "start-secrets"); + const target = resolve(stagingRoot, container.name); + if (target === stagingRoot || !target.startsWith(stagingRoot + sep)) { + return Promise.resolve(); + } + return rm(target, { recursive: true, force: true, }); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts new file mode 100644 index 0000000000..5b934925c1 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, rmSync } from "node:fs"; +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, Path } from "effect"; + +import { legacyCleanupStartSecrets } from "./legacy-start-secrets-cleanup.ts"; + +describe("legacyCleanupStartSecrets", () => { + it.effect("removes a NAMED container's secret directory keyed off container.name", () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", + () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("prefers a container's OWN com.supabase.cli.workdir label over fallbackWorkdir", () => { + const ownWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-own-")); + const otherWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-other-")); + const secretDir = join(ownWorkdir, "supabase", ".temp", "start-secrets", "supabase_db_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir }], + otherWorkdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(ownWorkdir, { recursive: true, force: true }); + rmSync(otherWorkdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("never fails when nothing was ever staged for a container", () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + return Effect.gen(function* () { + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_realtime_demo", workdir: "" }], + workdir, + ); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "refuses to delete outside the staging root when container.name contains path-traversal segments", + () => { + // `container.name` is a `docker ps` field value read back off whatever containers + // matched the caller's project-label filter — external metadata, not something this + // process generated. A crafted name containing `..` segments must never be able to walk + // `rm -rf` outside `start-secrets/` and onto an unrelated host directory. + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const canary = join(workdir, "important"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(canary, { recursive: true }); + yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "../../important", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(canary)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("refuses to delete the whole staging root when container.name is empty", () => { + // Degenerate case: an empty `name` would otherwise resolve to the staging root itself + // (`/supabase/.temp/start-secrets`) and wipe every project's staged secrets in + // one call, not just this one container's. + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); + const otherProjectSecretDir = join(stagingRoot, "supabase_kong_other"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); + yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets([{ id: "abc123", name: "", workdir: "" }], workdir); + expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-status-errors.ts b/apps/cli/src/legacy/shared/legacy-status-errors.ts index 9e72e14fbb..8db4a316e3 100644 --- a/apps/cli/src/legacy/shared/legacy-status-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-status-errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,41 +15,76 @@ import { Data } from "effect"; */ export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ export class LegacyStatusOverrideParseError extends Data.TaggedError( "LegacyStatusOverrideParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -/** Inspecting the db container failed for a reason other than "not found". */ +/** + * Inspecting the db container failed for a reason other than "not found" — + * except Go's `assertContainerHealthy` never special-cases a missing + * container (see `status.handler.ts`'s step-5 comment): an absent container + * is just another non-zero inspect exit, so the dominant real trigger of this + * error is "the local stack was never started", same fix as + * {@link LegacyStatusDbNotRunningError}. + */ export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is absent or present but not in the `running` state. */ export class LegacyStatusDbNotRunningError extends Data.TaggedError( "LegacyStatusDbNotRunningError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is running but its Docker health check is not `healthy`. */ export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** Listing running containers by label failed. */ export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** * `config.toml` resolved to a value `Config.Validate` would reject before status @@ -55,4 +95,8 @@ export class LegacyStatusInvalidConfigError extends Data.TaggedError( "LegacyStatusInvalidConfigError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index f6f36d126c..be55551fb9 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -1,5 +1,12 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Errors raised while deriving Storage connection credentials, shared by * `seed buckets` and `storage ls/cp/mv/rm`. @@ -11,7 +18,11 @@ import { Data } from "effect"; */ export class LegacyStorageConfigError extends Data.TaggedError("LegacyStorageConfigError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised on `--linked` when the project's api-keys response yields no keys, @@ -23,14 +34,27 @@ export class LegacyStorageMissingApiKeyError extends Data.TaggedError( "LegacyStorageMissingApiKeyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 200 api-keys response with no usable key — an API response problem, not + // a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} /** Transport failure fetching the project's api-keys (`failed to get api keys: `). */ export class LegacyStorageApiKeysNetworkError extends Data.TaggedError( "LegacyStorageApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 on a @@ -42,4 +66,11 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The shared mapper wraps any non-200 in this tag; the status policy maps + // 401 → re-login, 404 → user-supplied ref not found, everything else → + // API status. + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index 1c1d041319..d17751160f 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * Errors for the Supabase Storage **service gateway** (Kong), shared by every @@ -17,7 +23,19 @@ export class LegacyStorageGatewayNetworkError extends Data.TaggedError( "LegacyStorageGatewayNetworkError", )<{ readonly message: string; -}> {} + /** + * Set when this failure is a 200-response body that failed to decode + * (`failParse`) rather than a transport failure — so a malformed-body decode + * classifies as an API response problem instead of a network problem. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyStorageGatewayStatusError extends Data.TaggedError( "LegacyStorageGatewayStatusError", @@ -25,7 +43,20 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The tenant Storage gateway is not the Management API: a 401/403 here + // means stale local service keys, which `supabase login` cannot fix, so + // the Management-API auth/permission policy must not apply. This tag also + // spans collection and capability-probe routes (including unsupported + // vector routes returning 404), so it cannot safely opt every 404 into the + // named-resource policy. + if (this.status === 401 || this.status === 403) { + return { ...actionability.apiStatus, fingerprint_suffix: "gateway_auth" }; + } + return statusCodeActionability(this.status); + } +} export type LegacyStorageGatewayError = | LegacyStorageGatewayNetworkError diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts index 6b57ec8a3f..3f581e52e2 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts @@ -136,6 +136,7 @@ export interface LegacyStorageGateway { function failParse(detail: string): LegacyStorageGatewayNetworkError { return new LegacyStorageGatewayNetworkError({ message: `failed to parse response body: ${detail}`, + decode: true, }); } diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts index 4d4b604e1c..8d9a8e1b80 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts @@ -5,6 +5,8 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { LegacyStorageGatewayStatusError } from "./legacy-storage-gateway.errors.ts"; import { legacyBucketBody, legacyMakeStorageGateway } from "./legacy-storage-gateway.ts"; describe("legacyBucketBody", () => { @@ -37,6 +39,17 @@ describe("legacyBucketBody", () => { }); }); +describe("LegacyStorageGatewayStatusError actionability", () => { + it("keeps a shared gateway 404 on API status because the tag also wraps capability probes", () => { + const result = classifyCliErrorActionability( + new LegacyStorageGatewayStatusError({ status: 404, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyStorageGatewayStatusError:api_status"); + }); +}); + interface Recorded { method: string; url: string; diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 9a4cce090f..c2198926c2 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -1,3 +1,10 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Storage URL parsing, ported 1:1 from Go's `internal/storage/client/scheme.go` * plus the slices of `net/url` that `url.Parse` exercises for the `ss://` scheme. @@ -28,10 +35,15 @@ const LEGACY_STORAGE_INVALID_URL_MESSAGE = "URL must match pattern ss:///bucket/ * `errors.Errorf("failed to parse … url: %w", err)`. */ export class LegacyGoUrlParseError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyGoUrlParseError"; constructor(rawURL: string, inner: string) { super(`parse "${rawURL}": ${inner}`); this.name = "LegacyGoUrlParseError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -41,10 +53,15 @@ export class LegacyGoUrlParseError extends Error { * parse-error tagged error. */ export class LegacyStorageUrlPatternError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyStorageUrlPatternError"; constructor() { super(LEGACY_STORAGE_INVALID_URL_MESSAGE); this.name = "LegacyStorageUrlPatternError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export interface LegacyGoUrl { diff --git a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts index 12cd3051f8..95e5eda86e 100644 --- a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts @@ -1,3 +1,10 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence via * `encoding/csv` and accumulates across repeats, matching `readAsCSV` in @@ -32,6 +39,7 @@ const lengthNL = (b: Uint8Array): number => (b.length > 0 && b[b.length - 1] === * `readAsCSV` propagates `csv.Reader.Read`'s `io.EOF` unchanged). */ export class LegacyStringSliceFlagParseError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "LegacyStringSliceFlagParseError"; readonly value: string; private constructor(value: string, message: string) { super(message); @@ -56,6 +64,10 @@ export class LegacyStringSliceFlagParseError extends Error { static eof(value: string): LegacyStringSliceFlagParseError { return new LegacyStringSliceFlagParseError(value, "EOF"); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts index aaa14167e9..95845ee21a 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -1,4 +1,9 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A real failure reading `/supabase/.temp/project-ref` (e.g. the path is a @@ -9,7 +14,11 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; */ export class LegacyProjectRefReadError extends Data.TaggedError("LegacyProjectRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Absolute paths to the files the Go CLI writes under `/supabase/.temp/`. diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts index f8139ab260..7455d95fbe 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts @@ -5,7 +5,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Option, Path } from "effect"; -import { legacyReadProjectRefFile, legacyTempPaths } from "./legacy-temp-paths.ts"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + LegacyProjectRefReadError, + legacyReadProjectRefFile, + legacyTempPaths, +} from "./legacy-temp-paths.ts"; const readRef = (workdir: string) => Effect.gen(function* () { @@ -112,4 +117,15 @@ describe("legacyReadProjectRefFile", () => { ), ); }); + + it("classifies an unreadable ref file as permission without an unrelated command", () => { + const result = classifyCliErrorActionability( + new LegacyProjectRefReadError({ message: "failed to load project ref: permission denied" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("permission"); + expect(result.has_suggestion).toBe(false); + expect(result.suggestion_type).toBe("none"); + expect(result.suggested_command).toBeUndefined(); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts index a49ec1b27d..151144595f 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts @@ -71,6 +71,11 @@ export const legacyTestDbConfig = { local: Flag.boolean("local").pipe( Flag.withDescription("Runs pgTAP tests on the local database."), ), + // TS-only override of the linked project ref — see push.command.ts (db push). + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), } as const; export interface LegacyTestDbFlags { @@ -78,6 +83,7 @@ export interface LegacyTestDbFlags { readonly dbUrl: Option.Option; readonly linked: boolean; readonly local: boolean; + readonly projectRef: Option.Option; } /** @@ -95,9 +101,18 @@ export function legacyRunTestDbCommand( dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, + projectRef: flags.projectRef, }).pipe( withLegacyCommandInstrumentation({ - flags: { "db-url": flags.dbUrl, linked: flags.linked, local: flags.local }, + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "project-ref": flags.projectRef, + }, + // TS-only flag with no Go telemetry-safety baseline; Go's nearest + // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most + // others) are unmarked, so it stays redacted. }), // Run failures (failing tests) must not corrupt the TAP stream on stdout in // machine modes; other errors (pre-stream) still get the JSON envelope. diff --git a/apps/cli/src/legacy/shared/legacy-test-db.errors.ts b/apps/cli/src/legacy/shared/legacy-test-db.errors.ts index 63e3c87d7b..4fdf2cc112 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.errors.ts @@ -1,12 +1,22 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * `create extension if not exists pgtap` failed. Byte-matches Go's * `"failed to enable pgTAP: " + err` (`apps/cli-go/internal/db/test/test.go:70`). */ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDbEnablePgtapError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * `pg_prove` exited non-zero (test failures or a container error). Byte-matches @@ -15,7 +25,11 @@ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDb */ export class LegacyTestDbRunError extends Data.TaggedError("LegacyTestDbRunError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -26,4 +40,8 @@ export class LegacyTestDbMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyTestDbMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-test-db.handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.handler.ts index 4f6bbb6f68..19a6d0fa1a 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.handler.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.handler.ts @@ -74,10 +74,25 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy ); } + const connType = target.connType ?? "local"; + + // `--project-ref` never implies `--linked` and must not be silently + // discarded on a non-linked target — see push.handler.ts's identical guard + // (db push) for the full TS-only rationale. + if (Option.isSome(flags.projectRef) && connType !== "linked") { + return yield* Effect.fail( + new LegacyTestDbMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }), + ); + } + const { conn, isLocal } = yield* resolver.resolve({ dbUrl: flags.dbUrl, - connType: target.connType ?? "local", + connType, dnsResolver, + linkedProjectRef: flags.projectRef, }); const args = buildLegacyPgProveArgs({ diff --git a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts index 4339f5aa7b..48209a3629 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts @@ -44,10 +44,26 @@ const REMOTE_CONN: LegacyPgConnInput = { }; function mockResolver(opts: { conn?: LegacyPgConnInput; isLocal?: boolean } = {}) { - return Layer.succeed(LegacyDbConfigResolver, { - resolve: () => Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }), + const calls: Array<{ + readonly connType: string; + readonly linkedProjectRef: Option.Option; + }> = []; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags) => { + calls.push({ + connType: flags.connType ?? "", + linkedProjectRef: flags.linkedProjectRef ?? Option.none(), + }); + return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }); + }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); + return { + layer, + get calls() { + return calls; + }, + }; } function mockDbConnection(opts: { @@ -105,19 +121,37 @@ function mockDockerRun(opts: { exitCode?: number; runFails?: boolean }) { run: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed(opts.exitCode ?? 0); }, runCapture: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stdout: new Uint8Array(0), stderr: "" }); }, runStream: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }); }, }); @@ -166,7 +200,7 @@ function setup(opts: SetupOpts = {}) { const docker = mockDockerRun(opts); const layer = Layer.mergeAll( out.layer, - resolver, + resolver.layer, connection.layer, docker.layer, mockLegacyCliConfig({ workdir: opts.workdir ?? "/work/project", projectId: Option.none() }), @@ -181,7 +215,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(CliArgs, { args: opts.args ?? [] }), BunServices.layer, ); - return { layer, out, telemetry, connection, docker }; + return { layer, out, telemetry, connection, docker, resolver }; } const flags = (over: Partial[0]> = {}) => ({ @@ -189,6 +223,7 @@ const flags = (over: Partial[0]> = {}) => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, local: over.local ?? true, + projectRef: over.projectRef ?? Option.none(), }); describe("legacy test db integration", () => { @@ -441,6 +476,38 @@ describe("legacy test db integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("tests the project given via --project-ref --linked", () => { + // test db defaults to local; only with --linked does the flag reach the + // resolver as `linkedProjectRef`. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false, args: ["--linked"] }); + return Effect.gen(function* () { + yield* legacyTestDb(flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) })); + expect(resolver.calls[0]?.connType).toBe("linked"); + expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects --project-ref on the default local target", () => { + // test db defaults to local when no target flag is set — the guard must + // fire from the flag alone, with no explicit --local/--db-url needed. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, connection, docker, resolver } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags({ projectRef: Option.some(FLAG_REF) }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } + // The guard fires before any connection resolution or container run. + expect(resolver.calls).toEqual([]); + expect(connection.execCalls).toEqual([]); + expect(docker.lastOpts).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("honors --network-id, overriding the generated local network name", () => { const { layer, docker } = setup({ networkId: "my-custom-net" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts index e348ebf3b3..928806e5cc 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts @@ -24,13 +24,14 @@ import { mockAnalytics, mockOutput, mockProcessControl, - mockRuntimeInfo, mockTelemetryRuntime, mockTty, } from "../../../tests/helpers/mocks.ts"; import { + legacyIsolatedHomeLayer, mockLegacyCliConfig, mockLegacyTelemetryStateLayer, + useLegacyTempWorkdir, } from "../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; @@ -48,6 +49,8 @@ import { LegacyIdentityStitch } from "./legacy-identity-stitch.ts"; import { legacyTestDbRuntimeLayer } from "./legacy-test-db.layers.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-test-db-layers-"); + /** * Builds a stub ambient layer that satisfies every external service required by * `legacyTestDbRuntimeLayer` from the root runtime. Services whose logic is not @@ -82,7 +85,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - mockRuntimeInfo(), + // The runtime layer under test builds the REAL legacyCliConfigLayer against + // the real filesystem — see legacyIsolatedHomeLayer's docs. + legacyIsolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, analytics.layer, diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index da13bad0b4..ef6e7e0a8b 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -59,18 +59,18 @@ export function legacyGateResponse( export const legacyGateMapError = ( opts: { readonly projectRef: string; readonly featureKey?: string }, - mapError: (cause: SupabaseApiError) => Effect.Effect, + mapError: (cause: SupabaseApiError, upgradeSuggested: boolean) => Effect.Effect, ) => (cause: SupabaseApiError) => Effect.gen(function* () { const response = legacyGateResponse(cause); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: opts.projectRef, featureKey: opts.featureKey, statusCode: response?.status ?? 0, response, }); - return yield* mapError(cause); + return yield* mapError(cause, upgradeSuggested); }); /** @@ -79,6 +79,8 @@ export const legacyGateMapError = * The fallback bypasses the typed API client: its strict response schemas * reject the cli-e2e replay fixtures' placeholder refs (same workaround as * `legacy-linked-project-cache.layer.ts`). + * Returns whether the feature was confirmed plan-gated so callers can carry + * that typed result into their error classification. */ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; @@ -117,7 +119,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { - return; + return false; } const output = yield* Output; @@ -143,7 +145,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { if (gate === undefined) { if (opts.featureKey === undefined || opts.featureKey === "") { - return; + return false; } const tokenOpt = opts.accessToken ?? (yield* resolveLegacyAccessToken); @@ -160,15 +162,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ); const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; + return false; } const projectBody = yield* projectResp.value.json.pipe(Effect.option); if (projectBody._tag === "None") { - return; + return false; } const orgSlug = readString(projectBody.value, "organization_slug"); if (orgSlug.length === 0) { - return; + return false; } const entReq = HttpClientRequest.get(`${apiUrl}/v1/organizations/${orgSlug}/entitlements`).pipe( @@ -177,15 +179,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ); const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); if (entResp._tag === "None" || entResp.value.status !== 200) { - return; + return false; } const entBody = yield* entResp.value.json.pipe(Effect.option); if (entBody._tag === "None") { - return; + return false; } const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; if (!Array.isArray(entitlements)) { - return; + return false; } const gated = entitlements.some((entry: unknown) => { @@ -197,7 +199,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { return key === opts.featureKey && hasAccess === false; }); if (!gated) { - return; + return false; } gate = { @@ -219,4 +221,6 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { [PropOrgSlug]: gate.orgSlug, }); } + + return true; }); diff --git a/apps/cli/src/legacy/shared/legacy-vault.ts b/apps/cli/src/legacy/shared/legacy-vault.ts index 16534f8227..b45128e7f3 100644 --- a/apps/cli/src/legacy/shared/legacy-vault.ts +++ b/apps/cli/src/legacy/shared/legacy-vault.ts @@ -1,12 +1,21 @@ import { Data, Effect } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Reading or updating `vault.secrets` failed (Go's `UpsertVaultSecrets` errors). */ export class LegacyMigrationVaultError extends Data.TaggedError("LegacyMigrationVaultError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** A resolved `[db.vault]` secret. `resolved` mirrors Go's `len(SHA256) > 0` gate. */ export interface LegacyVaultSecret { diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts index 40e8a5976f..6731c06923 100644 --- a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -1,13 +1,27 @@ import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path * doesn't exist or isn't a directory. Callers map this into their own - * command-specific error type. + * command-specific error type. Only reachable when the user explicitly set + * `--workdir`/`SUPABASE_WORKDIR` to a bad path — the default walk-up + * resolution can never fail this check (see the doc comment on + * {@link legacyValidateWorkdirIsDirectory} below) — so the fix is always + * "pass a different `--workdir`/`SUPABASE_WORKDIR`". */ export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Validates that `workdir` exists and is a directory, the way Go's diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index bd349ff18b..8f2dced990 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, Exit, Option, Stdio } from "effect"; +import { Cause, Clock, Effect, Exit, Option, Stdio } from "effect"; import { Param } from "effect/unstable/cli"; import { CommandRuntime, @@ -14,12 +14,23 @@ import { import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; import { withAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { + type CliErrorActionability, + classifyCliErrorActionability, + unknownProcessControlledFailureActionability, +} from "../../shared/telemetry/error-actionability.ts"; +import { LegacyDbAdvisorsFailOnError } from "../commands/db/advisors/advisors.errors.ts"; +import { LegacyDbLintFailOnError } from "../commands/db/lint/lint.errors.ts"; import { EventCommandExecuted, PropDurationMs, PropExitCode, PropOutputFormat, } from "../../shared/telemetry/event-catalog.ts"; +import { + failureTelemetryPropertiesForCause, + toFailureTelemetryProperties, +} from "../../shared/telemetry/failure-metadata.ts"; import { LEGACY_RESOURCE_OUTPUT_FORMATS, LegacyInvalidOutputFormatError, @@ -33,6 +44,23 @@ import { } from "../shared/legacy-db-target-flags.ts"; import { legacyUnwrapToSingleParam } from "../shared/legacy-param-introspection.ts"; +/** + * Classifies a command that succeeded its Effect but recorded a nonzero exit + * code through ProcessControl. `db lint`/`db advisors` do this deliberately in + * machine mode after a `--fail-on` trigger (to keep the JSON payload on stdout + * intact), so their telemetry derives from the same typed error their text + * mode raises — the classification stays declared on the error class itself. + */ +function processControlledFailureActionability(command: string): CliErrorActionability { + if (command === "db lint") { + return classifyCliErrorActionability(new LegacyDbLintFailOnError({ message: "" })); + } + if (command === "db advisors") { + return classifyCliErrorActionability(new LegacyDbAdvisorsFailOnError({ message: "" })); + } + return unknownProcessControlledFailureActionability; +} + interface LegacyCommandInstrumentationOptions = never> { readonly analytics?: boolean; readonly flags?: Flags; @@ -441,6 +469,11 @@ function withLegacyCommandAnalyticsImplementation analyticsContext, onSome: (distinct_id) => ({ ...analyticsContext, distinct_id }), }); + const failureMetadata = Exit.isFailure(exit) + ? failureTelemetryPropertiesForCause(exit.cause) + : recordedExitCode === 1 + ? toFailureTelemetryProperties(processControlledFailureActionability(command)) + : {}; yield* analytics .capture(EventCommandExecuted, { @@ -449,8 +482,17 @@ function withLegacyCommandAnalyticsImplementation + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.void, + ), + ); if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts index eb5be95e3d..d376e93200 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; import { Flag } from "effect/unstable/cli"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { @@ -11,7 +11,17 @@ import { } from "../../shared/legacy/global-flags.ts"; import { CurrentAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; +import { + PropErrorCategory, + PropErrorFingerprint, + PropErrorKind, + PropHasSuggestion, + PropSuggestedCommand, + PropSuggestionType, + PropWorkflow, +} from "../../shared/telemetry/event-catalog.ts"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; +import { LegacyDbDumpRunError } from "../commands/db/dump/dump.errors.ts"; import { LegacyIdentityStitch } from "../shared/legacy-identity-stitch.ts"; import { withLegacyCommandInstrumentation } from "./legacy-command-instrumentation.ts"; import { @@ -20,6 +30,16 @@ import { } from "../shared/legacy-go-output-flag.ts"; import { mockOutput, mockProcessControl } from "../../../tests/helpers/mocks.ts"; +const FAILURE_PROPERTY_NAMES = [ + PropErrorKind, + PropErrorCategory, + PropErrorFingerprint, + PropHasSuggestion, + PropSuggestionType, + PropSuggestedCommand, + PropWorkflow, +] as const; + function mockLegacyIdentityStitch(opts: { stitchedDistinctId?: string }) { return { layer: Layer.succeed( @@ -61,6 +81,30 @@ function mockContextualAnalytics() { return { layer, captured }; } +function failingAnalytics(defect: unknown) { + return Layer.succeed( + Analytics, + Analytics.of({ + capture: () => Effect.die(defect), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); +} + +function interruptingAnalytics() { + return Layer.succeed( + Analytics, + Analytics.of({ + capture: () => Effect.interrupt, + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); +} + describe("withLegacyCommandInstrumentation", () => { it.live("annotates the command span and emits cli_command_executed", () => { const analytics = mockContextualAnalytics(); @@ -90,6 +134,9 @@ describe("withLegacyCommandInstrumentation", () => { expect(event?.properties.exit_code).toBe(0); expect(typeof event?.properties.duration_ms).toBe("number"); expect(event?.properties.output_format).toBe("text"); + for (const property of FAILURE_PROPERTY_NAMES) { + expect(event?.properties).not.toHaveProperty(property); + } }), ), ); @@ -615,10 +662,13 @@ describe("withLegacyCommandInstrumentation", () => { ); }); - it.live("captures failed commands with exit_code=1", () => { + it.live("adds sanitized metadata from the original typed failure", () => { const analytics = mockContextualAnalytics(); + const secret = "dump failed for postgres://customer.internal/private"; - return withLegacyCommandInstrumentation()(Effect.fail(new Error("boom"))).pipe( + return withLegacyCommandInstrumentation()( + Effect.fail(new LegacyDbDumpRunError({ message: secret })), + ).pipe( Effect.provide(analytics.layer), Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), @@ -628,14 +678,72 @@ describe("withLegacyCommandInstrumentation", () => { Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); - expect(analytics.captured[0]?.properties.exit_code).toBe(1); + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbDumpRunError", + has_suggestion: true, + suggestion_type: "update_config", + }); + expect(analytics.captured[0]?.properties).not.toHaveProperty(PropSuggestedCommand); + expect(analytics.captured[0]?.properties).not.toHaveProperty(PropWorkflow); + expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, ); }); - it.live("records exit_code=1 when a handler set a non-zero exit code without failing", () => { + it.live("preserves the command failure when telemetry capture defects", () => { + const failure = new LegacyDbDumpRunError({ message: "command failure" }); + + return Effect.fail(failure).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(failingAnalytics(new Error("telemetry defect"))), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), + Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(failure); + expect(Cause.hasDies(exit.cause)).toBe(false); + } + }), + ), + Effect.asVoid, + ); + }); + + it.live("propagates fiber interruption from telemetry capture", () => { + // A capture failure or defect is swallowed (best-effort telemetry), but an + // interruption landing during the trailing capture must not be — the fiber + // is being cancelled and swallowing would fight the cancellation. + return Effect.void.pipe( + withLegacyCommandInstrumentation(), + Effect.provide(interruptingAnalytics()), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), + Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }), + ), + Effect.asVoid, + ); + }); + + it.live("classifies db lint machine-mode fail-on like its typed text-mode error", () => { // Go records the telemetry exit code from the real process exit code // (`cmd/root.go:177` -> `exitCode(err)` = 1). `db lint`/`db advisors` set // ProcessControl's exit code in json/stream-json mode after a --fail-on @@ -657,7 +765,110 @@ describe("withLegacyCommandInstrumentation", () => { Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); - expect(analytics.captured[0]?.properties.exit_code).toBe(1); + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyDbLintFailOnError", + has_suggestion: false, + suggestion_type: "none", + }); + }), + ), + ); + }); + + it.live("classifies db advisors machine-mode fail-on like its typed text-mode error", () => { + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + + return Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(1); + }).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(processControl.layer), + Effect.provide(mockOutput({ format: "json" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) })), + Effect.provide(commandRuntimeLayer(["db", "advisors"])), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "tag:LegacyDbAdvisorsFailOnError", + has_suggestion: false, + suggestion_type: "none", + }); + }), + ), + ); + }); + + it.live("classifies a command that sets its exit code outside the instrumentation", () => { + // `db dump` converts its run failure into an exit code in the command pipe + // (`Effect.catchTag(...)` applied AFTER this wrapper), not inside the + // handler like `db lint`/`db advisors`. Instrumentation is the innermost + // wrapper, so it still sees the typed failure and must classify it rather + // than fall back to the process-controlled `unknown` bucket. Reordering + // that pipe would silently degrade this command's telemetry. + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const failure = new LegacyDbDumpRunError({ message: "container exited 1" }); + + return Effect.fail(failure).pipe( + withLegacyCommandInstrumentation(), + Effect.catchTag("LegacyDbDumpRunError", () => + Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(1); + }), + ), + Effect.provide(analytics.layer), + Effect.provide(processControl.layer), + Effect.provide(mockOutput({ format: "json" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), + Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbDumpRunError", + }); + }), + ), + Effect.asVoid, + ); + }); + + it.live("uses a static unknown fallback for other process-controlled failures", () => { + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + + return Effect.gen(function* () { + const pc = yield* ProcessControl; + yield* pc.setExitCode(2); + }).pipe( + withLegacyCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(processControl.layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) })), + Effect.provide(commandRuntimeLayer(["unknown", "command"])), + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "unknown", + error_category: "unknown", + error_fingerprint: "error:ProcessControlledFailure", + has_suggestion: false, + suggestion_type: "none", + }); }), ), ); diff --git a/apps/cli/src/next/auth/api.layer.ts b/apps/cli/src/next/auth/api.layer.ts index 5e37fa2bc1..fc2eb1627f 100644 --- a/apps/cli/src/next/auth/api.layer.ts +++ b/apps/cli/src/next/auth/api.layer.ts @@ -10,18 +10,42 @@ import { import { ApiError } from "./errors.ts"; import { Api, type LoginSessionResponse } from "./api.service.ts"; -function mapHttpClientError( - error: HttpClientError.HttpClientError, -): Effect.Effect { - if (error.response !== undefined) { - return Effect.fail( - new ApiError({ - statusCode: error.response.status, - detail: `${error.response.status} ${error.message}`, - }), - ); +// HttpClientError reasons that mean the response arrived but its body could not +// be decoded (including a 2xx whose body isn't valid JSON). These are API +// response problems, not transport ones, so they classify by `decode` rather +// than a status code. +const BODY_DECODE_REASONS = new Set(["DecodeError", "EmptyBodyError"]); + +/** + * Maps any fetcher failure to an {@link ApiError}, preserving the classification + * signal: + * - a received status → `statusCode` (transport error → status-less, classified + * as network); + * - a body/schema decode failure — whether an `HttpClientError` decode reason or + * a non-`HttpClientError` thrown while decoding — → `decode: true`, classified + * as an API response problem instead of network. + */ +function mapToApiError(error: unknown): Effect.Effect { + if (HttpClientError.isHttpClientError(error)) { + if (BODY_DECODE_REASONS.has(error.reason._tag)) { + return Effect.fail(new ApiError({ detail: error.message, decode: true })); + } + if (error.response !== undefined) { + return Effect.fail( + new ApiError({ + statusCode: error.response.status, + detail: `${error.response.status} ${error.message}`, + }), + ); + } + return Effect.fail(new ApiError({ detail: error.message })); } - return Effect.fail(new ApiError({ detail: error.message })); + return Effect.fail( + new ApiError({ + detail: error instanceof Error ? error.message : String(error), + decode: true, + }), + ); } export const makeApi = Effect.gen(function* () { @@ -36,7 +60,7 @@ export const makeApi = Effect.gen(function* () { const response = yield* httpClient.execute(HttpClientRequest.get(url)); return (yield* response.json) as LoginSessionResponse; }, - (effect) => effect.pipe(Effect.catch(mapHttpClientError)), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), fetchProfile: Effect.fnUntraced( function* (apiUrl, accessToken) { @@ -47,19 +71,7 @@ export const makeApi = Effect.gen(function* () { }).pipe(Effect.provide(httpClientLayer)); return yield* api.v1.getProfile(); }, - (effect) => - effect.pipe( - Effect.catch((error) => { - if (HttpClientError.isHttpClientError(error)) { - return mapHttpClientError(error); - } - return Effect.fail( - new ApiError({ - detail: error instanceof Error ? error.message : String(error), - }), - ); - }), - ), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), }); }); diff --git a/apps/cli/src/next/auth/errors.ts b/apps/cli/src/next/auth/errors.ts index 246cf1368f..34625dac5c 100644 --- a/apps/cli/src/next/auth/errors.ts +++ b/apps/cli/src/next/auth/errors.ts @@ -1,25 +1,58 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; -function CliError(tag: Tag) { - return class extends Data.TaggedError(tag)<{ - readonly detail: string; - readonly suggestion: string; - }> { - override get message() { - return `${this.detail}\n Suggestion: ${this.suggestion}`; - } - }; +export class InvalidTokenError extends Data.TaggedError("InvalidTokenError")<{ + readonly detail: string; + readonly suggestion: string; + /** + * Where the malformed token came from. Direct-input tokens (`--token` flag, + * `SUPABASE_ACCESS_TOKEN`, piped stdin) cannot be fixed by `supabase login`, + * so their remediation is to correct that input. A token from the browser + * flow (no source) is fixable by logging in again. + */ + readonly source?: "env" | "flag" | "stdin"; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.source === undefined ? actionability.authLogin : actionability.authToken; + } } -export class InvalidTokenError extends CliError("InvalidTokenError") {} - export class ApiError extends Data.TaggedError("ApiError")<{ readonly statusCode?: number; readonly detail: string; -}> {} + /** + * Set when this error represents a body/schema decode failure on an + * otherwise-successful response (no status code to classify by), rather + * than a transport failure — so it classifies as an API response problem + * instead of a network problem. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.statusCode !== undefined) { + return statusCodeActionability(this.statusCode); + } + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return statusCodeActionability(undefined); + } +} export class PlatformAuthRequiredError extends Data.TaggedError("PlatformAuthRequiredError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/next/auth/token.ts b/apps/cli/src/next/auth/token.ts index 631a8aef08..00f959149d 100644 --- a/apps/cli/src/next/auth/token.ts +++ b/apps/cli/src/next/auth/token.ts @@ -3,11 +3,15 @@ import { InvalidTokenError } from "./errors.ts"; const TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; -export const validateToken = Effect.fnUntraced(function* (token: string) { +export const validateToken = Effect.fnUntraced(function* ( + token: string, + source?: "env" | "flag" | "stdin", +) { if (!TOKEN_PATTERN.test(token)) { return yield* new InvalidTokenError({ detail: "Invalid access token format", suggestion: "Generate a token at https://supabase.com/dashboard/account/tokens", + source, }); } }); diff --git a/apps/cli/src/next/commands/branches/create/create.handler.ts b/apps/cli/src/next/commands/branches/create/create.handler.ts index 2f42731fd7..beb445dcbc 100644 --- a/apps/cli/src/next/commands/branches/create/create.handler.ts +++ b/apps/cli/src/next/commands/branches/create/create.handler.ts @@ -13,6 +13,12 @@ import { BranchAlreadyExistsError, NoBranchNameError } from "../errors.ts"; const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option) { if (Option.isSome(nameOpt)) { + if (nameOpt.value.length === 0) { + return yield* new NoBranchNameError({ + detail: "Branch name cannot be empty.", + suggestion: "Provide a branch name: `supabase branches create `", + }); + } return { branchName: nameOpt.value, gitBranch: Option.none() }; } @@ -50,6 +56,7 @@ const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option`", + cancelled: true, }), ); } diff --git a/apps/cli/src/next/commands/branches/create/create.integration.test.ts b/apps/cli/src/next/commands/branches/create/create.integration.test.ts index 710fe61b30..dae785c7df 100644 --- a/apps/cli/src/next/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/next/commands/branches/create/create.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, V1CreateABranchOutput } from "@supabase/api/effect"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option } 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"; @@ -8,6 +8,7 @@ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { emptyEnv, mockOutput, @@ -195,6 +196,26 @@ describe("branches create handler", () => { }), ); + it.live("rejects an explicit empty name before contacting the API", () => + Effect.gen(function* () { + const { layer, api } = setup({ env: { GITHUB_HEAD_REF: "fallback-branch" } }); + const exit = yield* create({ ...BASE_FLAGS, name: Option.some("") }).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + suggestion_type: "provide_flags", + }); + } + expect(api.capturedInput).toBeUndefined(); + }), + ); + it.live("prompts for git branch confirmation when no name is provided (interactive)", () => Effect.gen(function* () { const branch = makeCreatedBranch({ diff --git a/apps/cli/src/next/commands/branches/errors.ts b/apps/cli/src/next/commands/branches/errors.ts index 0c0dada1aa..0fa2b64681 100644 --- a/apps/cli/src/next/commands/branches/errors.ts +++ b/apps/cli/src/next/commands/branches/errors.ts @@ -1,16 +1,42 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class BranchNotFoundError extends Data.TaggedError("BranchNotFoundError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoBranchNameError extends Data.TaggedError("NoBranchNameError")<{ readonly detail: string; readonly suggestion: string; -}> {} + /** + * Set when the user declined the "create branch named …?" prompt: the + * failure is a deliberate cancellation, not missing input. Left unset for + * the genuine "no name and no way to obtain one" paths, which stay + * `provideFlags`. + */ + readonly cancelled?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.cancelled === true + ? { ...actionability.cancelled, fingerprint_suffix: "cancelled" } + : actionability.provideFlags; + } +} export class BranchAlreadyExistsError extends Data.TaggedError("BranchAlreadyExistsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/branches/errors.unit.test.ts b/apps/cli/src/next/commands/branches/errors.unit.test.ts new file mode 100644 index 0000000000..3c11157787 --- /dev/null +++ b/apps/cli/src/next/commands/branches/errors.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { NoBranchNameError } from "./errors.ts"; + +describe("NoBranchNameError actionability", () => { + it("classifies a declined-prompt cancellation as user-cancelled", () => { + const result = classifyCliErrorActionability( + new NoBranchNameError({ + detail: "Branch creation cancelled.", + suggestion: "Provide a branch name: `supabase branches create `", + cancelled: true, + }), + ); + expect(result.error_kind).toBe("user_cancelled"); + expect(result.error_category).toBe("cancelled"); + expect(result.error_fingerprint).toBe("tag:NoBranchNameError:cancelled"); + }); + + it("classifies a genuinely missing branch name as provide-flags", () => { + const result = classifyCliErrorActionability( + new NoBranchNameError({ + detail: "No branch name provided and no git branch detected.", + suggestion: "Provide a branch name: `supabase branches create `", + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.suggestion_type).toBe("provide_flags"); + expect(result.error_fingerprint).toBe("tag:NoBranchNameError"); + }); +}); diff --git a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts index 6914188219..d37fb7cdf9 100644 --- a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { makeApiClient } from "@supabase/api/effect"; +import { makeApiClient, SupabaseApiInputError } from "@supabase/api/effect"; import { Effect, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -20,6 +20,7 @@ import { InvalidFunctionSlugError, } from "../../../../shared/functions/delete.errors.ts"; import { functionsDelete } from "./delete.handler.ts"; +import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; const PROJECT_REF = "abcdefghijklmnopqrst"; const BRANCH_REF = "branchrefabcdefghij"; @@ -190,6 +191,29 @@ describe("functions delete", () => { }), ); + it.live("marks a rejected project ref as user input without sending a request", () => + Effect.gen(function* () { + const { layer, api } = setup({ linked: false }); + + const error = yield* functionsDelete({ + slug: "hello-world", + projectRef: Option.some("invalid-ref"), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(SupabaseApiInputError); + if (!(error instanceof SupabaseApiInputError)) { + return yield* Effect.die("expected SupabaseApiInputError"); + } + expect(error.source).toBe("user_input"); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:SupabaseApiInputError:request_input", + }); + expect(api.requests).toHaveLength(0); + }), + ); + it.live("maps API 404 responses to FunctionNotFoundError", () => Effect.gen(function* () { const { layer } = setup({ apiStatus: 404, apiBody: "not found" }); diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts index acd17b7a1b..300403b634 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.handler.ts @@ -1,12 +1,10 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { Effect, Stdio } from "effect"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { resolveProjectRef } from "../functions.shared.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; @@ -19,13 +17,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( const runtimeInfo = yield* RuntimeInfo; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; - const edgeRuntimeVersion = yield* Effect.tryPromise(() => - readFile(join(projectHome.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((version) => version.trim()), - Effect.catch(() => Effect.succeed("")), - Effect.map((version) => version || DEFAULT_VERSIONS["edge-runtime"]), - ); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin(projectHome.supabaseDir); yield* deployFunctions(flags, { api, @@ -34,7 +26,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( projectRoot: projectHome.projectRoot, supabaseDir: projectHome.supabaseDir, dashboardUrl: cliConfig.dashboardUrl, - goViperCompat: false, + goConfigCompat: undefined, yes: flags.yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index ea8fe70489..221e4abf7a 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { BunServices } from "@effect/platform-bun"; import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; @@ -1331,6 +1332,49 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(Effect.all([cleanupTempDir(tempDir), cleanupTempDir(outsideDir)]))); }); + it.live( + "skips an unreferenced `/`-suffixed import-map target that resolves through a file, on the default API deploy path", + () => { + // Regression: a spec-valid `/`-suffixed import-map value (which SHOULD + // end in "/") pointing at a real FILE used to crash `uploadScopeTarget` + // with a raw, unhandled ENOTDIR — independent of whether the + // entrypoint even references the key, since `forEachLocalImportMapTarget` + // walks every import-map value unconditionally. + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* Effect.promise(() => + writeFile( + join(tempDir, "supabase", "functions", "hello-world", "vendor.mjs"), + "export const x = 1;\n", + ), + ); + yield* Effect.promise(() => + writeFile( + join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + JSON.stringify({ imports: { "@x/": "./vendor.mjs/" } }), + ), + ); + + const { out, api, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(api.multiparts).toHaveLength(1); + expect(out.stderrText).toContain( + "WARN: Skipping import map target that is not a directory:", + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }, + ); + it.live( "rejects a git-root workspace import that escapes the workdir with a `..` segment", () => { @@ -1669,16 +1713,23 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); expect(child.spawned[0]).toEqual({ command: "docker", args: ["info"], }); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` — resolved + // before the network/volume ensure; `deno_version = 1` pins + // `DENO1_EDGE_RUNTIME_VERSION` ("1.68.4"). expect(child.spawned[1]).toEqual({ command: "docker", - args: ["network", "inspect", "supabase_network_test-project"], + args: ["image", "inspect", "public.ecr.aws/supabase/edge-runtime:v1.68.4"], }); expect(child.spawned[2]).toEqual({ + command: "docker", + args: ["network", "inspect", "supabase_network_test-project"], + }); + expect(child.spawned[3]).toEqual({ command: "docker", args: [ "volume", @@ -1957,7 +2008,7 @@ describe("functions deploy", () => { path: `/v1/projects/${PROJECT_REF}/functions/hello-world`, }); expect(api.requests[1]?.urlParams).not.toContain("name="); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -2128,7 +2179,10 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v9.9.9"); + // The pin's content is applied VERBATIM as the tag (Go's + // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare `9.9.9` pin + // stays bare, with no `v` synthesized. + expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:9.9.9"); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -2174,7 +2228,7 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned).toHaveLength(4); + expect(child.spawned).toHaveLength(5); expect(child.spawned.at(-1)?.args).toContain( yield* Effect.promise(() => expectedDockerBind(staticFile)), ); @@ -2569,4 +2623,116 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); + + describe("Go's Config.Validate/env-override parity is legacy-only (CLI-1963)", () => { + it.live( + "does not fail on an explicit empty project_id, unlike the legacy shell's Config.Validate", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir, 'project_id = ""\n')); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { out, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain( + `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }, + ); + + it.live( + "does not fail on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.promise(() => + writeProjectConfig( + tempDir, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { out, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain( + `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }, + ); + + it.live( + "ignores SUPABASE_EDGE_RUNTIME_DENO_VERSION and resolves the default edge-runtime image tag", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ + exitCode: 0, + onSpawn: (record) => { + if (record.command !== "docker" || record.args[0] !== "run") { + return; + } + const outputPath = resolveDockerOutputPath(record.args); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, "eszip-test-output"); + }, + }); + + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + + const { layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + useDocker: true, + }).pipe(Effect.provide(layer)); + + // `docker info` is spawned[0]; the bundler's first image-inspect + // candidate (a cache hit here) is spawned[1]. + expect(child.spawned[1]).toEqual({ + command: "docker", + args: ["image", "inspect", `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`], + }); + }).pipe( + Effect.ensuring(cleanupTempDir(tempDir)), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts index b13a63f0fc..8b19629c01 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts @@ -7,6 +7,11 @@ import { import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; import { Data, Effect, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; type ResolvedSecretValue = string | Redacted.Redacted; type EdgeRuntimePolicy = "oneshot" | "per_worker"; @@ -27,6 +32,10 @@ export class FunctionsDevEdgeRuntimeDisabledError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } export interface ResolvedFunctionsDevEdgeRuntimeConfig { diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts index 4432db7c0b..aecd8adc61 100644 --- a/apps/cli/src/next/commands/functions/download/download.command.ts +++ b/apps/cli/src/next/commands/functions/download/download.command.ts @@ -25,6 +25,7 @@ const config = { ), useDocker: Flag.boolean("use-docker").pipe( Flag.withDescription("Use Docker to unbundle functions client-side."), + Flag.withDefault(true), Flag.withHidden, ), legacyBundle: Flag.boolean("legacy-bundle").pipe( diff --git a/apps/cli/src/next/commands/functions/download/download.handler.ts b/apps/cli/src/next/commands/functions/download/download.handler.ts index 15c6238e84..129f1e2609 100644 --- a/apps/cli/src/next/commands/functions/download/download.handler.ts +++ b/apps/cli/src/next/commands/functions/download/download.handler.ts @@ -3,8 +3,9 @@ import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { downloadFunctions, - makeGoProxyDownloadArgs, + makeGoProxyLegacyBundleArgs, } from "../../../../shared/functions/download.ts"; +import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { resolveProjectRef } from "../functions.shared.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; @@ -15,21 +16,26 @@ export const functionsDownload = Effect.fnUntraced(function* (flags: FunctionsDo const proxy = yield* LegacyGoProxy; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin(projectHome.supabaseDir); yield* downloadFunctions(flags, { api, projectRoot: projectHome.projectRoot, rawArgs, + goConfigCompat: undefined, + edgeRuntimeVersion, resolveProjectRef, // In machine-output mode the child's stdout is captured and discarded // instead of inherited (CLI-1546: stdout is payload-only in machine // mode) — `downloadFunctions` emits the `Output` envelope itself. proxyDownload: (proxyFlags, projectRef, captureOutput) => { - const args = makeGoProxyDownloadArgs(proxyFlags, projectRef); + const args = makeGoProxyLegacyBundleArgs(proxyFlags.functionName, projectRef); const cwd = projectHome.projectRoot; return captureOutput - ? Effect.asVoid(proxy.execCapture(args, { cwd, stdin: "ignore" })) - : proxy.exec(args, { cwd }); + ? Effect.asVoid( + proxy.execCapture(args, { cwd, stdin: "ignore", suppressChildTelemetry: true }), + ) + : proxy.exec(args, { cwd, suppressChildTelemetry: true }); }, }); }); diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index 9c1fa1a75d..e43f3cdd15 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse, makeApiClient } from "@supabase/api/effect"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -23,6 +24,7 @@ import { mockProjectLinkState, mockRuntimeInfo, } from "../../../../../tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; import { ConflictingFunctionDownloadFlagsError, @@ -30,6 +32,7 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "../../../../shared/functions/download.errors.ts"; +import { invalidFunctionSlugDetail } from "../../../../shared/functions/functions.shared.ts"; import { functionsDownload } from "./download.handler.ts"; const PROJECT_REF = "abcdefghijklmnopqrst"; @@ -74,6 +77,7 @@ function textResponse( status: number, body: ResponseBody = "", contentType = "text/plain", + extraHeaders: Readonly> = {}, ): HttpClientResponse.HttpClientResponse { return HttpClientResponse.fromWeb( request, @@ -81,6 +85,7 @@ function textResponse( status, headers: { "content-type": contentType, + ...extraHeaders, }, }), ); @@ -182,7 +187,15 @@ function mockDownloadApi(opts: { functionStatusBySlug?: Readonly>; functionBodyBySlug?: Readonly>; bodyBySlug?: Readonly< - Record + Record< + string, + { + status?: number; + body: ResponseBody; + contentType: string; + headers?: Readonly>; + } + > >; bodyErrorBySlug?: Readonly>; }) { @@ -233,6 +246,7 @@ function mockDownloadApi(opts: { response?.status ?? 200, response?.body ?? "", response?.contentType ?? "multipart/form-data; boundary=missing", + response?.headers ?? {}, ), ); } @@ -277,6 +291,7 @@ function setup( linked?: boolean; projectRoot?: string; rawArgs?: ReadonlyArray; + childLayer?: ReturnType["layer"]; } = {}, ) { const out = mockOutput({ format: opts.format ?? "text", interactive: false }); @@ -293,6 +308,10 @@ function setup( Stdio.layerTest({ args: Effect.succeed(opts.rawArgs ?? ["functions", "download"]), }), + // Overrides `emptyEnv()`'s real `ChildProcessSpawner` (via `BunServices`) + // so `--use-docker`'s now-default-true native path never spawns a real + // `docker` process — CLI-1963. + opts.childLayer ?? mockChildProcessSpawner({ exitCode: 0 }).layer, ); return { out, api, layer, proxy }; @@ -709,48 +728,88 @@ describe("functions download", () => { ); }); - it.live("downloads remote slugs from download-all without local slug validation", () => { + it.live("rejects a malicious remote slug from download-all before any per-slug work", () => { const tempDir = makeTempDir(); - const multipart = multipartBody([ - { - headers: { - "Content-Disposition": 'form-data; name="metadata"', - "Content-Type": "application/json", - }, - body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), - }, - { - headers: { - "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', - }, - body: "console.log('remote')", - }, - ]); + // Mirrors Go's own `TestDownloadAllRejectsMaliciousSlug` regression test + // (`apps/cli-go/internal/functions/download/download_test.go`) — a + // path-traversal-shaped slug returned by the (untrusted) list endpoint. + const maliciousSlug = "../../../../../poc-escaped-outside-project"; return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { layer } = setup(tempDir, { - list: [makeFunction({ slug: "1remote" })], - bodyBySlug: { - "1remote": multipart, - }, + const { api, layer } = setup(tempDir, { + list: [makeFunction({ slug: maliciousSlug })], }); - yield* functionsDownload({ + // CLI-1891 (Go parity): every slug sourced from the Management API's + // function list must be validated before any per-slug network or + // filesystem work — not just user-supplied CLI arguments. + const error = yield* functionsDownload({ ...BASE_FLAGS, functionName: Option.none(), - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer), Effect.flip); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "1remote", "index.ts"), "utf8"), - ), - ).toBe("console.log('remote')"); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `failed to download function ${maliciousSlug}: ${invalidFunctionSlugDetail}`, + ); + expect((error as Error & { suggestion?: string }).suggestion).toBe( + `The Supabase API returned an unexpected function slug (${maliciousSlug}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, + ); + // Only the list call happened — no GET to the malicious slug's own + // body/metadata endpoints, and nothing was written to disk. + expect(api.requests).toEqual([ + `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, + ]); + expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + it.live( + "fails the whole list before downloading anything when a slug is typed as a non-string", + () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + // Go's generated client unmarshals the whole `[]FunctionResponse` + // array in one `json.Unmarshal` call + // (`apps/cli-go/pkg/api/client.gen.go:22186-22208`); a type mismatch + // on any single element's `slug` (a required `string` field) fails + // that call outright, so `downloadAll` fails with "failed to list + // functions: ..." before downloading anything — including the + // earlier, well-formed "ok" entry. Confirmed empirically: + // `json.Unmarshal([]byte(`[{"slug":"ok"},{"slug":123}]`), &dest)` + // returns a `*json.UnmarshalTypeError`, and the generated parser + // returns before ever assigning `response.JSON200`. + const { api, layer } = setup(tempDir, { + listBody: [{ slug: "ok" }, { slug: 123 }], + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); + expect((error as Error).message).toBe( + "failed to read functions list: expected function slug to be a string, got number", + ); + // Only the list call happened — "ok" was never downloaded, matching + // Go's atomic list-decode failure instead of downloading it before + // hitting the later entry's error. + expect(api.requests).toEqual([ + `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, + ]); + expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + it.live("prints the download-all success line when the project has one function", () => { const tempDir = makeTempDir(); const multipart = multipartBody([ @@ -851,56 +910,125 @@ describe("functions download", () => { ); }); - it.live("delegates --use-docker with the linked project ref to the Go proxy", () => { + it.live( + "runs the native Docker unbundle path for --use-docker with the linked project ref", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path + // instead of delegating to the Go proxy. + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + const runCommand = child.spawned.find( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ); + expect(runCommand?.args).toContain("unbundle"); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + // No `--debug` — the temp eszip file is removed after the run. + expect(existsSync(join(tempDir, "supabase", ".temp", "output_hello-world.eszip"))).toBe( + false, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("runs the native Docker path and emits a JSON envelope in machine mode", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { layer, proxy } = setup(tempDir, { + const { out, layer, proxy } = setup(tempDir, { + format: "json", + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, }); + // CLI-1963: `--use-docker` now runs the native Docker-unbundle path; + // this asserts the JSON envelope this command emits itself still + // shows up correctly once the native path is exercised in machine mode. yield* functionsDownload({ ...BASE_FLAGS, useDocker: true, }).pipe(Effect.provide(layer)); - expect(proxy.calls).toEqual([ - ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--use-docker"], - ]); + expect(proxy.calls).toEqual([]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.some((spawned) => spawned.command === "docker" && spawned.args[0] === "run"), + ).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Downloaded Edge Function source.", + data: { + function_slugs: ["hello-world"], + project_ref: PROJECT_REF, + }, + }), + ); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); - it.live("captures the Go proxy's output and emits a JSON envelope in machine mode", () => { + it.live("lists remote functions and downloads each natively via Docker in machine mode", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); const { out, layer, proxy } = setup(tempDir, { format: "json", - rawArgs: ["functions", "download", "hello-world", "--use-docker"], + list: [makeFunction({ slug: "hello-world" }), makeFunction({ slug: "goodbye-world" })], + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + "goodbye-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "--use-docker"], + childLayer: child.layer, }); - // CLI-1546: stdout is payload-only in machine mode, so the delegated - // Go child's raw output must be captured/discarded (not inherited), - // and this command must emit the `Output` envelope itself. yield* functionsDownload({ ...BASE_FLAGS, + functionName: Option.none(), useDocker: true, }).pipe(Effect.provide(layer)); expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--use-docker"], - ]); + expect(proxy.captureCalls).toEqual([]); + expect( + child.spawned.filter( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toHaveLength(2); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Downloaded Edge Function source.", data: { - function_slugs: ["hello-world"], + function_slugs: ["hello-world", "goodbye-world"], project_ref: PROJECT_REF, }, }), @@ -911,38 +1039,130 @@ describe("functions download", () => { }); it.live( - "lists remote functions before delegating when no function name is given in machine mode", + "defaults --use-docker to true so a bare invocation still runs the native Docker path", () => { const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - const { out, layer, proxy } = setup(tempDir, { - format: "json", - list: [makeFunction({ slug: "hello-world" }), makeFunction({ slug: "goodbye-world" })], - rawArgs: ["functions", "download", "--use-docker"], + const { layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + // No `--use-docker` at all — mirrors a bare `supabase functions + // download hello-world` invocation relying on the flag's default. + rawArgs: ["functions", "download", "hello-world"], + childLayer: child.layer, }); + // `useDocker: true` is what `download.command.ts`'s + // `Flag.withDefault(true)` resolves to when the flag is omitted + // (CLI-1963 parity fix — `next` was previously missing this default, + // unlike the legacy shell's equivalent command). yield* functionsDownload({ ...BASE_FLAGS, - functionName: Option.none(), useDocker: true, }).pipe(Effect.provide(layer)); expect(proxy.calls).toEqual([]); - expect(proxy.captureCalls).toEqual([ - ["functions", "download", "--project-ref", PROJECT_REF, "--use-docker"], - ]); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Downloaded Edge Function source.", - data: { - function_slugs: ["hello-world", "goodbye-world"], - project_ref: PROJECT_REF, + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "falls back to the native server-side path with a warning when Docker is not running", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 1 }); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('fallback')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + bodyBySlug: { "hello-world": multipart }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); + expect(out.stderrText).toContain("WARNING: Docker is not running\n"); + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('fallback')"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "writes the eszip response body to disk exactly as received, regardless of Content-Encoding", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + // Arbitrary binary bytes, not valid brotli — this mocked `Response` (a + // hand-built `new Response(body, {...})`, unlike a real `fetch()`) + // never applies transport-level content-decoding, so a + // `Content-Encoding: br` header here must have zero effect on what + // `downloadEszipBody` does with it. If production code ever tried to + // brotli-decompress this body again, decompression itself would throw + // on these bytes, failing this test. + const rawEszipBytes = new Uint8Array([0, 1, 2, 253, 254, 255, 10, 13, 0, 128, 200]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + body: new Blob([rawEszipBytes]), + contentType: "application/octet-stream", + headers: { "content-encoding": "br" }, }, - }), + }, + // `--debug` keeps the temp eszip file on disk after a successful + // run so this test can inspect the exact bytes that were written. + rawArgs: ["functions", "download", "hello-world", "--use-docker", "--debug"], + childLayer: child.layer, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + const written = yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", ".temp", "output_hello-world.eszip")), ); + expect(new Uint8Array(written)).toEqual(rawEszipBytes); }).pipe( Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); @@ -1346,6 +1566,65 @@ describe("functions download", () => { ); }); + it.live("maps eszip body transport errors with Go-style wording (Docker path)", () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyErrorBySlug: { + "hello-world": new Error("network error"), + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + // `downloadEszipBody` (the Docker path's own GET) uses a distinct + // error prefix ("failed to get function body") from the server-side + // `downloadBody`'s ("failed to download function") — Go parity. + const error = yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("failed to get function body: network error"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps unexpected eszip body statuses with Go-style wording (Docker path)", () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + status: 503, + body: "unavailable", + contentType: "text/plain", + }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Error status 503: unavailable"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + it.live("maps metadata fallback transport errors with Go-style wording", () => { const tempDir = makeTempDir(); const multipart = multipartBody([ @@ -1625,4 +1904,119 @@ describe("functions download", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + describe("Go's Config.Validate/env-override parity is legacy-only (CLI-1963)", () => { + it.live( + "does not fail on an explicit empty project_id, unlike the legacy shell's Config.Validate", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(tempDir, "supabase", "config.toml"), 'project_id = ""\n'), + ); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "does not fail on an unrelated Config.Validate branch (unsupported Postgres major version)", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); + yield* Effect.tryPromise(() => + writeFile( + join(tempDir, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), + ), + ); + const { out, layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + expect( + child.spawned.some( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ), + ).toBe(true); + expect(out.stderrText).toContain("Downloading function: hello-world\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live( + "ignores SUPABASE_EDGE_RUNTIME_DENO_VERSION and resolves the default edge-runtime image tag", + () => { + const tempDir = makeTempDir(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, + }, + rawArgs: ["functions", "download", "hello-world", "--use-docker"], + childLayer: child.layer, + }); + + yield* functionsDownload({ ...BASE_FLAGS, useDocker: true }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + const runCommand = child.spawned.find( + (spawned) => spawned.command === "docker" && spawned.args[0] === "run", + ); + expect(runCommand?.args).toContain( + `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; + } else { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + } + }), + ), + ); + }, + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/new/new.errors.ts b/apps/cli/src/next/commands/functions/new/new.errors.ts index 4da86bf457..060f83bd15 100644 --- a/apps/cli/src/next/commands/functions/new/new.errors.ts +++ b/apps/cli/src/next/commands/functions/new/new.errors.ts @@ -1,18 +1,35 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class MissingFunctionSlugError extends Data.TaggedError("MissingFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionEntrypointExistsError extends Data.TaggedError( "FunctionEntrypointExistsError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/init/init.errors.ts b/apps/cli/src/next/commands/init/init.errors.ts index 5f14c6e0a0..452f82fafb 100644 --- a/apps/cli/src/next/commands/init/init.errors.ts +++ b/apps/cli/src/next/commands/init/init.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * `--use-orioledb` without `--experimental`. The next shell deliberately keeps * this friendlier wording; the legacy shell byte-matches Go's cobra @@ -15,4 +21,8 @@ export class InitExperimentalRequiredError extends Data.TaggedError( override get message() { return "The --use-orioledb flag requires --experimental."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/next/commands/link/link.errors.ts b/apps/cli/src/next/commands/link/link.errors.ts index 06e8ff24d2..21d3f9c3ff 100644 --- a/apps/cli/src/next/commands/link/link.errors.ts +++ b/apps/cli/src/next/commands/link/link.errors.ts @@ -1,11 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class ProjectRefRequiredError extends Data.TaggedError("ProjectRefRequiredError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} export class NoAccessibleProjectsError extends Data.TaggedError("NoAccessibleProjectsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.accountAccess; + } +} diff --git a/apps/cli/src/next/commands/login/login.errors.ts b/apps/cli/src/next/commands/login/login.errors.ts index a1bd067583..fb129f979c 100644 --- a/apps/cli/src/next/commands/login/login.errors.ts +++ b/apps/cli/src/next/commands/login/login.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; function LoginError(tag: Tag) { return class extends Data.TaggedError(tag)<{ @@ -11,5 +16,42 @@ function LoginError(tag: Tag) { }; } -export class NoTtyError extends LoginError("NoTtyError") {} -export class LoginFailedError extends LoginError("LoginFailedError") {} +export class NoTtyError extends LoginError("NoTtyError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} +/** + * All browser-login verification retries exhausted. Carries the LAST poll + * failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (a pending 4xx, or no signal) from a genuine + * platform problem (5xx / transport). + */ +export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ + readonly detail: string; + readonly suggestion: string; + readonly statusCode?: number; + readonly network?: boolean; + /** + * Set when the last poll failed decoding an otherwise-received response body + * (no status code to classify by) — an API response problem rather than a + * transport (network) one or an incomplete browser flow. + */ + readonly decode?: boolean; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return actionability.authLogin; + } +} diff --git a/apps/cli/src/next/commands/login/login.errors.unit.test.ts b/apps/cli/src/next/commands/login/login.errors.unit.test.ts new file mode 100644 index 0000000000..2ead78424e --- /dev/null +++ b/apps/cli/src/next/commands/login/login.errors.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { LoginFailedError } from "./login.errors.ts"; + +const base = { detail: "Login failed after maximum retries", suggestion: "Try again" }; + +describe("LoginFailedError actionability", () => { + it("classifies a decoded-body poll failure as an API response problem", () => { + const result = classifyCliErrorActionability(new LoginFailedError({ ...base, decode: true })); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_response"); + }); + + it("prefers the decode signal over a transport one", () => { + const result = classifyCliErrorActionability( + new LoginFailedError({ ...base, network: true, decode: true }), + ); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_response"); + }); + + it("classifies a transport failure as network", () => { + const result = classifyCliErrorActionability(new LoginFailedError({ ...base, network: true })); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:network"); + }); + + it("classifies a 5xx poll status as an API status problem", () => { + const result = classifyCliErrorActionability( + new LoginFailedError({ ...base, statusCode: 502 }), + ); + expect(result.error_fingerprint).toBe("tag:LoginFailedError:api_status"); + }); + + it("treats an incomplete browser flow (no signal / pending 4xx) as auth login", () => { + expect(classifyCliErrorActionability(new LoginFailedError(base)).error_category).toBe("auth"); + expect( + classifyCliErrorActionability(new LoginFailedError({ ...base, statusCode: 400 })) + .error_category, + ).toBe("auth"); + }); +}); diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index a0006a25cd..07058958ee 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -18,14 +18,23 @@ import { } from "../../../shared/telemetry/identity.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import type { NonInteractiveError } from "../../../shared/output/errors.ts"; import { LoginFailedError, NoTtyError } from "./login.errors.ts"; import type { LoginFlags } from "./login.command.ts"; -class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ +export class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ cause: ApiError; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} const MAX_LOGIN_VERIFICATION_RETRIES = 2; @@ -89,34 +98,48 @@ const captureLoginCompleted = Effect.fnUntraced(function* ( ); }); -const saveDirectToken = Effect.fnUntraced(function* (token: Redacted.Redacted) { +const saveDirectToken = Effect.fnUntraced(function* ( + token: Redacted.Redacted, + source: "env" | "flag" | "stdin", +) { const credentials = yield* Credentials; const output = yield* Output; - yield* validateToken(revealToken(token)); + yield* validateToken(revealToken(token), source); yield* credentials.saveAccessToken(token); const distinctId = yield* resolveAuthenticatedDistinctId(token); yield* output.success("Logged in successfully.", { command: "login" }); yield* captureLoginCompleted({ login_method: "token" }, distinctId); }); +interface ResolvedToken { + readonly token: Redacted.Redacted; + readonly source: "env" | "flag" | "stdin"; +} + // Token resolution priority: --token flag > SUPABASE_ACCESS_TOKEN env > piped stdin > interactive browser flow const resolveToken = Effect.fnUntraced(function* (tokenFlag: Option.Option) { - if (Option.isSome(tokenFlag)) return Option.some(Redacted.make(tokenFlag.value)); + if (Option.isSome(tokenFlag)) { + return Option.some({ token: Redacted.make(tokenFlag.value), source: "flag" }); + } const cliConfig = yield* CliConfig; - if (Option.isSome(cliConfig.accessToken)) return cliConfig.accessToken; + if (Option.isSome(cliConfig.accessToken)) { + return Option.some({ token: cliConfig.accessToken.value, source: "env" }); + } const stdin = yield* Stdin; if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; - if (Option.isSome(piped)) return Option.some(Redacted.make(piped.value)); + if (Option.isSome(piped)) { + return Option.some({ token: Redacted.make(piped.value), source: "stdin" }); + } return yield* new NoTtyError({ detail: "Cannot prompt for token in non-interactive mode", suggestion: "Pass --token or set SUPABASE_ACCESS_TOKEN", }); } - return Option.none(); + return Option.none(); }); // --------------------------------------------------------------------------- @@ -187,14 +210,23 @@ const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) { remainingRetries: number, ): Effect.Effect => verifyCode.pipe( - Effect.catchTag("LoginVerificationError", () => + Effect.catchTag("LoginVerificationError", (err) => Effect.gen(function* () { yield* output.error("Verification failed"); if (remainingRetries <= 0) { + // Thread the last poll failure's discriminant: a received status + // classifies by code (5xx → platform outage), a status-less/decode + // failure means transport (network) unless it carried a decoded + // body, and a pending 4xx / no signal stays "run supabase login". + const { statusCode, decode } = err.cause; + const network = statusCode === undefined && decode !== true; return yield* Effect.fail( new LoginFailedError({ detail: "Login failed after maximum retries", suggestion: "Try running `supabase login` again", + statusCode, + network, + decode, }), ); } @@ -239,7 +271,7 @@ export const login = Effect.fnUntraced(function* (flags: LoginFlags) { const resolved = yield* resolveToken(flags.token); if (Option.isSome(resolved)) { - return yield* saveDirectToken(resolved.value); + return yield* saveDirectToken(resolved.value.token, resolved.value.source); } return yield* browserOAuthFlow(flags); }); diff --git a/apps/cli/src/next/commands/logs/logs.errors.ts b/apps/cli/src/next/commands/logs/logs.errors.ts index 2fc9866f60..f2e4ea812c 100644 --- a/apps/cli/src/next/commands/logs/logs.errors.ts +++ b/apps/cli/src/next/commands/logs/logs.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class UnsupportedLogsOutputFormatError extends Data.TaggedError( "UnsupportedLogsOutputFormatError", @@ -9,4 +14,8 @@ export class UnsupportedLogsOutputFormatError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/next/commands/platform/platform.errors.ts b/apps/cli/src/next/commands/platform/platform.errors.ts index cd049e7987..d0372c4604 100644 --- a/apps/cli/src/next/commands/platform/platform.errors.ts +++ b/apps/cli/src/next/commands/platform/platform.errors.ts @@ -1,24 +1,46 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + export class PlatformInputError extends Data.TaggedError("PlatformInputError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMetadataError extends Data.TaggedError("PlatformMetadataError")<{ readonly message: string; readonly detail?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} export class PlatformRouteNotFoundError extends Data.TaggedError("PlatformRouteNotFoundError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMethodSelectionError extends Data.TaggedError("PlatformMethodSelectionError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/config/project-home.layer.ts b/apps/cli/src/next/config/project-home.layer.ts index 1af2f096b1..fcff73c99a 100644 --- a/apps/cli/src/next/config/project-home.layer.ts +++ b/apps/cli/src/next/config/project-home.layer.ts @@ -1,6 +1,6 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { ProjectContext } from "./project-context.service.ts"; -import { ProjectHome } from "./project-home.service.ts"; +import { ProjectHome, ProjectHomeNotDirectoryError } from "./project-home.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; const PROJECT_HOME_DIR_NAME = ".supabase"; @@ -19,7 +19,9 @@ const findProjectRootFromRepoState = ( while (true) { const projectLinkPath = path.join(current, PROJECT_HOME_DIR_NAME, PROJECT_LINK_FILE_NAME); - if (yield* fs.exists(projectLinkPath).pipe(Effect.orDie)) { + // A FILE named `.supabase` along the ancestor walk reads as "no link + // here" rather than crashing the boot (fs.exists only maps NotFound). + if (yield* fs.exists(projectLinkPath).pipe(Effect.orElseSucceed(() => false))) { return current; } if (current === root) { @@ -43,9 +45,19 @@ const makeProjectHome = Effect.gen(function* () { const projectLinkPath = path.join(projectHomeDir, "project.json"); const projectLocalVersionsPath = path.join(projectHomeDir, "local-versions.json"); - const ensureProjectHomeDir = Effect.gen(function* () { - yield* fs.makeDirectory(projectHomeDir, { recursive: true, mode: 0o700 }); - }).pipe(Effect.orDie); + const ensureProjectHomeDir = fs + .makeDirectory(projectHomeDir, { recursive: true, mode: 0o700 }) + .pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "AlreadyExists" || error.reason._tag === "BadResource" + ? Effect.die( + new ProjectHomeNotDirectoryError({ + message: `${projectHomeDir} could not be created: a file (or a symlink loop) exists at that path or on one of its parent directories. Remove or rename it so the Supabase CLI can store project state there.`, + }), + ) + : Effect.die(error), + ), + ); const stackDir = (name: string) => path.join(projectHomeDir, "stacks", name); diff --git a/apps/cli/src/next/config/project-home.layer.unit.test.ts b/apps/cli/src/next/config/project-home.layer.unit.test.ts index 8ddb14f82e..b6d50efc8c 100644 --- a/apps/cli/src/next/config/project-home.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-home.layer.unit.test.ts @@ -4,13 +4,13 @@ import { mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Result } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { projectHomeLayer } from "./project-home.layer.ts"; import { ProjectContext } from "./project-context.service.ts"; -import { ProjectHome } from "./project-home.service.ts"; +import { ProjectHome, ProjectHomeNotDirectoryError } from "./project-home.service.ts"; function makeTempDir(): string { return mkdtempSync(join(tmpdir(), "supabase-project-home-")); @@ -155,4 +155,82 @@ describe("projectHomeLayer", () => { Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), ); }); + + it.live("dies with ProjectHomeNotDirectoryError when a FILE occupies the .supabase path", () => { + const tempDir = makeTempDir(); + const projectRoot = join(tempDir, "repo"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(projectRoot, { recursive: true })); + yield* Effect.tryPromise(() => + writeFile(join(projectRoot, ".supabase"), "not a directory\n"), + ); + + const layer = buildLayer({ + cwd: projectRoot, + env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + }); + const projectHome = yield* Effect.gen(function* () { + return yield* ProjectHome; + }).pipe(Effect.provide(layer)); + + const exit = yield* projectHome.ensureProjectHomeDir.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const defect = Cause.findDefect(exit.cause); + expect(Result.isSuccess(defect)).toBe(true); + if (Result.isSuccess(defect)) { + expect(defect.success).toBeInstanceOf(ProjectHomeNotDirectoryError); + expect(defect.success).toMatchObject({ _tag: "ProjectHomeNotDirectoryError" }); + expect((defect.success as ProjectHomeNotDirectoryError).message).toContain( + "could not be created", + ); + } + } + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live( + "dies with ProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", + () => { + // Distinct from the AlreadyExists case above: here `.supabase` itself + // doesn't exist, but a FILE sits on one of ITS OWN parent directories + // (`/proj`), so `mkdir(..., { recursive: true })` fails with + // ENOTDIR (-> PlatformError reason "BadResource") while trying to + // traverse through it, rather than EEXIST on the leaf itself. + const tempDir = makeTempDir(); + const fileAsDir = join(tempDir, "proj"); + const cwd = join(fileAsDir, "child"); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeFile(fileAsDir, "not a directory\n")); + + const layer = buildLayer({ + cwd, + env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + }); + const projectHome = yield* Effect.gen(function* () { + return yield* ProjectHome; + }).pipe(Effect.provide(layer)); + + const exit = yield* projectHome.ensureProjectHomeDir.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const defect = Cause.findDefect(exit.cause); + expect(Result.isSuccess(defect)).toBe(true); + if (Result.isSuccess(defect)) { + expect(defect.success).toBeInstanceOf(ProjectHomeNotDirectoryError); + expect(defect.success).toMatchObject({ _tag: "ProjectHomeNotDirectoryError" }); + expect((defect.success as ProjectHomeNotDirectoryError).message).toContain( + "could not be created", + ); + } + } + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); }); diff --git a/apps/cli/src/next/config/project-home.service.ts b/apps/cli/src/next/config/project-home.service.ts index 2536af7256..227e567a7c 100644 --- a/apps/cli/src/next/config/project-home.service.ts +++ b/apps/cli/src/next/config/project-home.service.ts @@ -1,5 +1,18 @@ import type { Effect } from "effect"; -import { Context } from "effect"; +import { Context, Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +export class ProjectHomeNotDirectoryError extends Data.TaggedError("ProjectHomeNotDirectoryError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} interface ProjectHomeShape { readonly projectRoot: string; diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index cd444e93cd..a054fa984a 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -1,5 +1,10 @@ import { Data, Duration, Effect, Exit, Layer } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { PlatformApi } from "../auth/platform-api.service.ts"; import { CliConfig } from "./cli-config.service.ts"; import { @@ -10,13 +15,23 @@ import { } from "./project-link-remote.service.ts"; import type { LinkedServiceVersions } from "./project-link-state.service.ts"; -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ +export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ readonly projectRef: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A successful api-keys response with no usable key — an API response + // problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} type ProjectApiKey = { readonly name: string; diff --git a/apps/cli/src/next/config/project-link-state.service.ts b/apps/cli/src/next/config/project-link-state.service.ts index 5e5ea316a5..04cb75fa81 100644 --- a/apps/cli/src/next/config/project-link-state.service.ts +++ b/apps/cli/src/next/config/project-link-state.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LinkedServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -37,12 +42,20 @@ export type ProjectLinkStateValue = Schema.Schema.Type {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} export class ProjectNotLinkedError extends Data.TaggedError("ProjectNotLinkedError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} interface ProjectLinkStateShape { readonly load: Effect.Effect, InvalidProjectLinkStateError>; diff --git a/apps/cli/src/next/config/project-local-service-versions.layer.ts b/apps/cli/src/next/config/project-local-service-versions.layer.ts index 05e7dfa3ee..86389b9ca5 100644 --- a/apps/cli/src/next/config/project-local-service-versions.layer.ts +++ b/apps/cli/src/next/config/project-local-service-versions.layer.ts @@ -18,7 +18,9 @@ const makeProjectLocalServiceVersions = Effect.gen(function* () { const loadFromPath = (filePath: string) => Effect.gen(function* () { - const exists = yield* fs.exists(filePath).pipe(Effect.orDie); + // A FILE named `.supabase` reads as "no saved local versions" rather + // than a defect (same bug family as the boot fix; reachable via `supabase services`). + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { return Option.none(); } diff --git a/apps/cli/src/next/config/project-local-service-versions.service.ts b/apps/cli/src/next/config/project-local-service-versions.service.ts index 77168189df..59b7dd2083 100644 --- a/apps/cli/src/next/config/project-local-service-versions.service.ts +++ b/apps/cli/src/next/config/project-local-service-versions.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LocalServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -28,7 +33,11 @@ export class InvalidLocalServiceVersionsStateError extends Data.TaggedError( )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} interface ProjectLocalServiceVersionsShape { readonly load: Effect.Effect< diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts index 62f9aedb82..dccd3321c3 100644 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ b/apps/cli/src/next/config/service-version-resolution.ts @@ -6,17 +6,26 @@ import { SERVICE_NAMES, } from "@supabase/stack/effect"; import { Data, Effect, Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { ProjectLocalServiceVersions } from "./project-local-service-versions.service.ts"; import { ProjectLinkState } from "./project-link-state.service.ts"; export type ResolvedServiceVersionContext = StackVersionPlan; -class InvalidServiceVersionOverrideError extends Data.TaggedError( +export class InvalidServiceVersionOverrideError extends Data.TaggedError( "InvalidServiceVersionOverrideError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function isServiceName(value: string): value is ServiceName { return (SERVICE_NAMES as ReadonlyArray).includes(value); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 19b7da1b2d..0d305fcec1 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,6 +28,42 @@ export function hasExplicitLongFlag( return false; } +const PFLAG_BOOLEAN_FALSE_VALUES: ReadonlySet = new Set([ + "0", + "f", + "F", + "false", + "FALSE", + "False", +]); + +/** + * Last explicit `--`/`--=` boolean occurrence in + * argv, or `undefined` when the flag never appears — matching pflag/viper's + * shared-variable last-`Set()`-wins semantics (mirrors + * `legacyExperimentalFlagFromArgs`, `shared/legacy/global-flags.ts`). A bare + * `--` records pflag's bool `NoOptDefVal` (`true`); an inline value + * is parsed through pflag's `strconv.ParseBool` false set — anything else + * (including garbage) is truthy, same as `cast.ToBool`'s permissive default. + * Unlike a bare presence scan, this distinguishes `--=false` + * from presence alone, which matters for Go call sites gated on + * `viper.GetBool` rather than "was the flag passed at all". + */ +export function explicitBooleanLongFlag( + rawArgs: ReadonlyArray, + flagName: string, +): boolean | undefined { + let result: boolean | undefined; + for (const token of rawArgs) { + if (token === `--${flagName}`) { + result = true; + } else if (token.startsWith(`--${flagName}=`)) { + result = !PFLAG_BOOLEAN_FALSE_VALUES.has(token.slice(flagName.length + 3)); + } + } + return result; +} + /** * The LAST explicit `--` occurrence's value in raw argv, matching * pflag's last-wins resolution (`--profile a --profile b` → `b`), or diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index 03e5e08dd6..a502e5df3d 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, hasExplicitLongFlag, lastExplicitLongFlagValue, PERSISTENT_VALUE_FLAG_NAMES, @@ -70,6 +71,40 @@ describe("lastExplicitLongFlagValue", () => { }); }); +describe("explicitBooleanLongFlag", () => { + test("a bare flag records pflag's NoOptDefVal true", () => { + expect(explicitBooleanLongFlag(["--debug"], "debug")).toBe(true); + }); + + test("an inline =false records false", () => { + expect(explicitBooleanLongFlag(["--debug=false"], "debug")).toBe(false); + }); + + test("an inline =0 records false, matching pflag's ParseBool false set", () => { + expect(explicitBooleanLongFlag(["--debug=0"], "debug")).toBe(false); + }); + + test("an inline =F records false, matching pflag's ParseBool false set", () => { + expect(explicitBooleanLongFlag(["--debug=F"], "debug")).toBe(false); + }); + + test("an inline =true records true", () => { + expect(explicitBooleanLongFlag(["--debug=true"], "debug")).toBe(true); + }); + + test("a garbage inline value is truthy, matching pflag's permissive cast", () => { + expect(explicitBooleanLongFlag(["--debug=yes"], "debug")).toBe(true); + }); + + test("repeated occurrences resolve last-wins", () => { + expect(explicitBooleanLongFlag(["--debug", "--debug=false"], "debug")).toBe(false); + }); + + test("returns undefined when the flag never appears", () => { + expect(explicitBooleanLongFlag(["--other"], "debug")).toBeUndefined(); + }); +}); + describe("pflagArgvScan", () => { const SSO_UPDATE_PATH = ["sso", "update"] as const; const SPEC = { diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 9171d54aee..13d81e6ae5 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -137,13 +137,34 @@ describe("native hidden flags", () => { "--backup=false", ]).pipe(Effect.exit); expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); + // `functions download --use-docker` now runs the native Docker-unbundle + // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — the + // deliberately-invalid slug makes it fail at `validateSlug` + // (`download.ts`, checked BEFORE `isDockerRunning`/any image pull), + // so the invocation stays fast and side-effect-free even on a CI + // runner with a live Docker daemon (a valid slug here triggered a + // real multi-second `docker pull` and timed this test out), while + // still proving the hidden flag parses by exact name. + // `--legacy-bundle` is the one remaining case that still forwards to the + // proxy, asserted below. + const downloadUseDockerExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })([ + "functions", + "download", + "Not_A_Valid-Slug!", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ]).pipe(Effect.exit); + expect(JSON.stringify(downloadUseDockerExit)).not.toContain("UnrecognizedFlag"); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", - "--use-docker", + "--legacy-bundle", ]); const useDockerExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test", @@ -171,7 +192,14 @@ describe("native hidden flags", () => { ); expect(proxy.calls).toEqual([ - ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], + [ + "functions", + "download", + "hello", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", + ], ]); }); diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 2333e4f63b..2824d04e24 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -10,9 +10,12 @@ import { join } from "node:path"; * This is the single source of truth for the `SUPABASE_HOME` contract in the * TypeScript CLI. It is a pure function: callers pass their own environment and * home directory so it stays trivially testable and free of global state. The - * legacy and next shells both resolve through it; libraries such as - * `@supabase/stack` never read `SUPABASE_HOME` themselves and instead receive - * the resolved path from the CLI. + * legacy and next shells both resolve through it, and every CLI call into + * `@supabase/stack` passes the root resolved here explicitly, so this stays the + * authoritative resolution for anything the CLI drives. Library-side fallbacks + * do exist for non-CLI embedders — the managed layer's `resolveManagedStateRoot` + * reads `SUPABASE_HOME` itself when no root is supplied (CLI-2106) — but the + * CLI never relies on them. */ export const resolveSupabaseHome = ( env: Readonly>, diff --git a/apps/cli/src/shared/functions/delete.errors.ts b/apps/cli/src/shared/functions/delete.errors.ts index ba0ee0a761..b7357ee9de 100644 --- a/apps/cli/src/shared/functions/delete.errors.ts +++ b/apps/cli/src/shared/functions/delete.errors.ts @@ -1,19 +1,42 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class DeleteFunctionNetworkError extends Data.TaggedError("DeleteFunctionNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class DeleteFunctionUnexpectedStatusError extends Data.TaggedError( "DeleteFunctionUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 810428a682..fe71911ef9 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -1,4 +1,9 @@ -import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { + markSupabaseApiInputErrorAsUserInput, + operationDefinitions, + SupabaseApiInputError, + type ApiClient, +} from "@supabase/api/effect"; import { Effect, type Option } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { Output } from "../output/output.service.ts"; @@ -54,6 +59,11 @@ export function deleteFunction( }) .pipe( Effect.mapError((error) => { + if (error instanceof SupabaseApiInputError) { + // This operation's complete input is the resolved ref and the + // prevalidated slug, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } if (HttpClientError.isHttpClientError(error)) { const description = error.reason.description ?? error.reason._tag; return new DeleteFunctionNetworkError({ @@ -79,6 +89,7 @@ export function deleteFunction( const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( new DeleteFunctionUnexpectedStatusError({ + status: response.status, message: `unexpected delete function status ${response.status}: ${body}`, }), ); diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index b1ac44b74e..6b9198ca21 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -1,21 +1,52 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class ConflictingFunctionDeployFlagsError extends Data.TaggedError( "ConflictingFunctionDeployFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDeploySlugError extends Data.TaggedError( "InvalidFunctionDeploySlugError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoFunctionsToDeployError extends Data.TaggedError("NoFunctionsToDeployError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDeployCancelledError extends Data.TaggedError("FunctionDeployCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} + +export class FunctionImportNotDirectoryError extends Data.TaggedError( + "FunctionImportNotDirectoryError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 63eaa05b33..d72335141d 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2,27 +2,33 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { URL } from "node:url"; -import { FunctionResponse, operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { + FunctionResponse, + operationDefinitions, + SupabaseApiInputError, + type ApiClient, +} from "@supabase/api/effect"; import { inferFunctionsManifest, - loadProjectConfig, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; -import { Duration, Effect, Option, Schema, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Duration, Effect, Option, Schema } from "effect"; +import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; -import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; -import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, hasExplicitLongFlag, + lastExplicitLongFlagValue, } from "../cli/cobra-flag-groups.ts"; import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -30,17 +36,30 @@ import { import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, + FunctionImportNotDirectoryError, InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; +import { + buildFunctionsDockerRunArgs, + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveDockerNetworkMode, + resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, + runChildProcess, + toDockerPath, + toSlash, +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; -const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; const SUPABASE_FUNCTIONS_DIR = "supabase/functions"; const IMPORT_MAP_GUIDE_URL = "https://supabase.com/docs/guides/functions/import-maps"; -const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; -const MAX_PROJECT_ID_LENGTH = 40; const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:\//; const importPathPattern = /(?:import|export)\s+(?:type\s+)?(?:{[^{}]+}|.*?)\s*(?:from)?\s*['"](.*?)['"]|import\(\s*['"](.*?)['"]\)/gi; @@ -68,7 +87,12 @@ interface DeployFunctionsDependencies { readonly projectRoot: string; readonly supabaseDir: string; readonly dashboardUrl: string; - readonly goViperCompat: boolean; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. + */ + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; @@ -82,9 +106,12 @@ interface DeployFunctionsDependencies { * - `styleIdentifier`: the project ref in the stdout success line. * - `styleEmphasis`: the slug in the stderr `Bundling Function:` line and * the functions dir in the no-functions error. + * - `styleWarning`: the `WARNING:` token on the "Docker is not running" + * fallback line. Go: `utils.Yellow("WARNING:")` (`deploy.go:60`). */ readonly styleIdentifier?: (text: string) => string; readonly styleEmphasis?: (text: string) => string; + readonly styleWarning?: (text: string) => string; } export interface ResolvedDeployFunctionConfig { @@ -171,17 +198,39 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray> { @@ -217,6 +266,18 @@ function validateDeploySlug(slug: string): Effect.Effect` was passed + * explicitly after `commandPath`, matching cobra's `Changed()`; + * `Option.none()` otherwise. Used only by `deployFunctions`'s + * `--no-verify-jwt` override below — kept private per this file's own + * "used by one command only -> keep it in the command's own directory" rule. + */ function explicitBooleanFlag( rawArgs: ReadonlyArray, commandPath: ReadonlyArray, @@ -226,45 +287,6 @@ function explicitBooleanFlag( return hasExplicitLongFlag(rawArgs, commandPath, flagName) ? Option.some(value) : Option.none(); } -function explicitStringFlag(rawArgs: ReadonlyArray, flagName: string) { - for (let index = 0; index < rawArgs.length; index += 1) { - const token = rawArgs[index]; - if (token === `--${flagName}`) { - return rawArgs[index + 1]; - } - if (token?.startsWith(`--${flagName}=`)) { - return token.slice(flagName.length + 3); - } - } - return undefined; -} - -function hasGlobalLongFlag(rawArgs: ReadonlyArray, flagName: string) { - return rawArgs.some((token) => token === `--${flagName}` || token.startsWith(`--${flagName}=`)); -} - -function isDenoConfigFile(pathname: string) { - const name = basename(pathname).toLowerCase(); - return name === "deno.json" || name === "deno.jsonc"; -} - -function toSlash(pathname: string) { - return pathname.replaceAll("\\", "/"); -} - -export function normalizeProjectId(source: string) { - const sanitized = source.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); - return sanitized.length > MAX_PROJECT_ID_LENGTH - ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) - : sanitized; -} - -export function localDockerId(name: string, projectId: string) { - return `supabase_${name}_${normalizeProjectId(projectId)}`; -} - -const dockerCliProjectLabel = "com.supabase.cli.project"; -const dockerComposeProjectLabel = "com.docker.compose.project"; /** * Must stay in sync with `LEGACY_CLI_WORKDIR_LABEL` * (`legacy/shared/legacy-docker-ids.ts:95`) — same string literal, kept as a @@ -284,18 +306,6 @@ export const dockerWorkdirLabel = "com.supabase.cli.workdir"; */ const dockerNpmEnvNames = ["NPM_CONFIG_REGISTRY"] as const; -export function dockerProjectLabels(projectId: string) { - return { - [dockerCliProjectLabel]: projectId, - [dockerComposeProjectLabel]: projectId, - }; -} - -export function toDockerPath(hostPath: string) { - const normalized = toSlash(resolve(hostPath)); - return normalized.replace(/^[A-Za-z]:/, ""); -} - function toBundledFileUrl(hostPath: string) { const url = new URL("file:///"); url.pathname = toDockerPath(hostPath).replaceAll("%", "%25"); @@ -365,7 +375,12 @@ async function realpathIfExists(pathname: string) { try { return await realpath(resolve(pathname)); } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { return resolve(pathname); } throw error; @@ -627,8 +642,22 @@ function substituteImportMapValue( ): string | undefined { let match: [string, string] | undefined; for (const entry of Object.entries(mappings)) { - const [prefix] = entry; - if (!specifier.startsWith(prefix)) { + const [prefix, value] = entry; + if (prefix.length === 0) { + continue; + } + // Import-maps spec (implemented by Deno): a key matches exactly, or as a + // prefix only when it ends with "/". Go's walker prefix-matches every key + // (pkg/function/deno.go:150-155) — intentional divergence, see + // go-cli-porting-status.md: the lax match fabricates paths the runtime + // can never resolve (the ENOTDIR family this PR fixes). + if (prefix.endsWith("/")) { + // Spec normalization: a `/`-suffixed key whose address lacks a trailing + // `/` is an invalid mapping — dropped, not concatenated. + if (!value.endsWith("/") || !specifier.startsWith(prefix)) { + continue; + } + } else if (specifier !== prefix) { continue; } if (match === undefined || prefix.length > match[0].length) { @@ -652,7 +681,11 @@ function resolveImportSpecifier( let scopedMappings: Readonly> | undefined; let scopedPrefixLength = -1; for (const [scopeName, scopeValue] of Object.entries(importMap.scopes)) { - if (!currentPath.startsWith(scopeName) || scopeName.length <= scopedPrefixLength) { + // Same import-maps spec rule as key matching: a scope matches exactly, or + // as a prefix only when it ends with "/". + const scopeMatches = + scopeName === currentPath || (scopeName.endsWith("/") && currentPath.startsWith(scopeName)); + if (!scopeMatches || scopeName.length <= scopedPrefixLength) { continue; } scopedMappings = scopeValue; @@ -705,12 +738,21 @@ async function walkImportPaths( } contents = await readFile(resolvedCurrent); } catch (error) { - if (error instanceof Error) { - if ("code" in error && error.code === "ENOENT") { + if (error instanceof Error && "code" in error) { + if (error.code === "ENOENT") { const message = `failed to read file: open ${toApiRelativePath(displayRoot, current)}: no such file or directory`; await onWarning(`WARN: ${message}\n`); continue; } + // Go aborts on any other read error (pkg/function/deno.go:131-136); an + // ENOTDIR (import path routed through a file) gets Go's message instead + // of an unhandled raw Node error, via a classified error so telemetry + // books it as user-fixable config instead of a panic. + if (error.code === "ENOTDIR") { + throw new FunctionImportNotDirectoryError({ + message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, + }); + } } throw error; } @@ -732,7 +774,12 @@ async function walkImportPaths( ); modulePath = toSlash(modulePath); - if (!modulePath.includes(".")) { + // A module file needs a dot in the FINAL path segment (Go's path.Ext + // semantics): a dot earlier in the path (`dist/index.mjs/core`) is a + // directory-shaped path, not a module file. Not basename(): a + // trailing-slash directory import must yield an empty final segment here. + const finalSegment = modulePath.slice(modulePath.lastIndexOf("/") + 1); + if (!finalSegment.includes(".")) { continue; } if ( @@ -994,14 +1041,26 @@ async function writeSourceDeployForm( }; const uploadScopeTarget = async (pathname: string) => { - const resolvedPath = await realpath(pathname); + let resolvedPath: string; + let pathInfo: Awaited>; + try { + resolvedPath = await realpath(pathname); + pathInfo = await stat(pathname); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOTDIR") { + await Effect.runPromise( + outputRaw(`WARN: Skipping import map target that is not a directory: ${pathname}\n`), + ); + return; + } + throw error; + } if (!isContainedInAnyPath(importMapAllowedRoots, resolvedPath)) { await Effect.runPromise( outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), ); return; } - const pathInfo = await stat(pathname); if (!pathInfo.isDirectory()) { await uploadImportMapTargetAsset(pathname, await readFile(pathname)); await walkLocalImportMapTargetImports( @@ -1111,15 +1170,6 @@ function createBundledMetadata( }; } -function collectByteStream(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); -} - function sanitizeDockerBinds( binds: ReadonlyArray, functionsDir: string, @@ -1229,16 +1279,23 @@ export async function buildDockerBinds( async () => {}, ); } catch (error) { - if ( - options.skipMissingImportMapTargets === true && - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - await (options.onWarning ?? (async () => {}))( - `WARN: Skipping missing import map target: ${target}\n`, - ); - return; + if (error instanceof Error && "code" in error) { + // ENOTDIR (a trailing-slash value routed through a file) is never a + // walkable target regardless of caller: an import that actually + // reaches through that file still fails via the walker's + // FunctionImportNotDirectoryError. + if (error.code === "ENOTDIR") { + await (options.onWarning ?? (async () => {}))( + `WARN: Skipping import map target that is not a directory: ${target}\n`, + ); + return; + } + if (options.skipMissingImportMapTargets === true && error.code === "ENOENT") { + await (options.onWarning ?? (async () => {}))( + `WARN: Skipping missing import map target: ${target}\n`, + ); + return; + } } throw error; } @@ -1265,84 +1322,6 @@ function shouldUseDenoJsonDiscovery(entrypoint: string, importMap: string) { return isDenoConfigFile(importMap) && dirname(importMap) === dirname(entrypoint); } -export function isUserDefinedDockerNetwork(networkMode: string) { - return ( - networkMode.length > 0 && - networkMode !== "default" && - networkMode !== "bridge" && - networkMode !== "host" && - networkMode !== "none" - ); -} - -export const ensureDockerNetwork = Effect.fnUntraced(function* ( - networkMode: string, - projectId: string, -) { - if (!isUserDefinedDockerNetwork(networkMode)) { - return; - } - - const inspect = yield* runChildProcess("docker", ["network", "inspect", networkMode], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - if (inspect.exitCode === 0) { - return; - } - - const labels = dockerProjectLabels(projectId); - const create = yield* runChildProcess( - "docker", - [ - "network", - "create", - "--label", - `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, - "--label", - `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, - networkMode, - ], - { - stdout: "ignore", - stderr: "pipe", - }, - ); - if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); - } -}); - -export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( - volumeName: string, - projectId: string, -) { - if (process.env["BITBUCKET_CLONE_DIR"] !== undefined) { - return; - } - - const labels = dockerProjectLabels(projectId); - const create = yield* runChildProcess( - "docker", - [ - "volume", - "create", - "--label", - `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, - "--label", - `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, - volumeName, - ], - { - stdout: "ignore", - stderr: "pipe", - }, - ); - if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); - } -}); - async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { if (importMap.length > 0) { return false; @@ -1355,57 +1334,31 @@ async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: stri } } -// Runs a container CLI command and collects its output. Every caller runs -// `docker`, so the spawn goes through `spawnContainerCli` to fall back to -// `podman` on Docker-less hosts. `command` is retained for the extendEnv -// default and the `functions serve` dependency-injection seam. -export const runChildProcess = Effect.fnUntraced(function* ( - command: string, - args: ReadonlyArray, - opts: { - readonly stdout?: "pipe" | "ignore"; - readonly stderr?: "pipe" | "ignore"; - readonly env?: Readonly>; - readonly extendEnv?: boolean; - } = {}, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawnContainerCli(spawner, [...args], { - stdin: "ignore", - stdout: opts.stdout ?? "pipe", - stderr: opts.stderr ?? "pipe", - env: opts.env, - extendEnv: opts.extendEnv ?? command === "docker", - }); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - opts.stdout === "ignore" ? Effect.succeed("") : collectByteStream(child.stdout), - opts.stderr === "ignore" ? Effect.succeed("") : collectByteStream(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - return { exitCode, stdout, stderr }; -}); - -const isDockerRunning = Effect.fnUntraced(function* () { - const result = yield* runChildProcess("docker", ["info"], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - return result.exitCode === 0; -}); +interface BundleFunctionWithDockerOptions { + readonly projectId: string; + readonly edgeRuntimeVersion: string; + readonly functionsDir: string; + readonly config: ResolvedDeployFunctionConfig; + /** Already resolved (explicit flag > `SUPABASE_NETWORK_ID` > generated) — see the caller. */ + readonly networkMode: string; + readonly verbose?: boolean; + readonly styleEmphasis?: (text: string) => string; + readonly projectEnvValues?: Readonly>; +} const bundleFunctionWithDocker = Effect.fnUntraced(function* ( - projectId: string, - edgeRuntimeVersion: string, - functionsDir: string, - config: ResolvedDeployFunctionConfig, - dockerNetworkId?: string, - verbose = false, - styleEmphasis: (text: string) => string = (text) => text, + options: BundleFunctionWithDockerOptions, ) { + const { + projectId, + edgeRuntimeVersion, + functionsDir, + config, + networkMode, + verbose = false, + styleEmphasis = (text: string) => text, + projectEnvValues, + } = options; const output = yield* Output; // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` // (`internal/functions/deploy/bundle.go:30`) — the legacy handler injects @@ -1428,56 +1381,80 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( } const outputPath = join(outputDir, "output.eszip"); const binds = yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, outputDir, config), + buildDockerBinds(projectId, functionsDir, outputDir, config, { + onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), + }), + ); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolves ECR->GHCR->Docker-Hub candidates and pulls with retry, per + // container, before ever touching the network/volume. Deliberately NOT + // hoisted out of the per-function loop the way `download.ts`'s + // `PulledEdgeRuntimeImage` is: per-slug matches Go's per-container + // `DockerStart` exactly, and the first resolve failure aborts the loop, + // so the only cost is one cached `docker image inspect` per function. + const image = yield* resolveFunctionsDockerImage( + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) + // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` + // prefix or not (see the helper's doc in `functions.shared.ts`). + edgeRuntimeImage(edgeRuntimeVersion), + projectEnvValues, ); - const networkMode = dockerNetworkId ?? localDockerId("network", projectId); yield* ensureDockerNetwork(networkMode, projectId); yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); - const command = ["run", "--rm", ...binds.flatMap((bind) => ["-v", bind])]; - command.push("--network", networkMode); - if (process.platform === "linux") { - command.push("--add-host", "host.docker.internal:host-gateway"); - } + const env: Array = []; if ( !(yield* Effect.promise(() => shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap), )) ) { - command.push("-e", "DENO_NO_PACKAGE_JSON=1"); - } - for (const env of dockerNpmEnv()) { - command.push("-e", env); + env.push("DENO_NO_PACKAGE_JSON=1"); } + env.push(...dockerNpmEnv()); - command.push( - legacyGetRegistryImageUrl(`supabase/edge-runtime:v${edgeRuntimeVersion}`), + const containerArgs = [ "bundle", "--entrypoint", toDockerPath(config.entrypoint), "--output", toDockerPath(outputPath), - ); + ]; if ( config.importMap.length > 0 && !shouldUseDenoJsonDiscovery(config.entrypoint, config.importMap) ) { - command.push("--import-map", toDockerPath(config.importMap)); + containerArgs.push("--import-map", toDockerPath(config.importMap)); } for (const staticFile of config.staticFiles) { - command.push("--static", toDockerPath(staticFile)); + containerArgs.push("--static", toDockerPath(staticFile)); } if (verbose || process.env["DEBUG"] === "true") { - command.push("--verbose"); + containerArgs.push("--verbose"); } - const result = yield* runChildProcess("docker", command, { stdout: "pipe", stderr: "pipe" }); - if (result.stdout.length > 0) { - yield* output.raw(result.stdout, output.format === "text" ? "stdout" : "stderr"); - } - if (result.stderr.length > 0) { - yield* output.raw(result.stderr, "stderr"); - } + const command = buildFunctionsDockerRunArgs({ + image, + projectId, + networkMode, + binds, + env, + // Go: `WorkingDir: utils.ToDockerPath(cwd)` (`bundle.go:79`), where + // `cwd` is the post-`ChangeWorkDir` workdir — `functionsDir` is + // `/supabase/functions`, same derivation as `deployViaApi`'s + // own `projectRoot`. + workingDir: toDockerPath(resolve(functionsDir, "..", "..")), + containerArgs, + }); + + // Live-tees each chunk to `output.raw` as it arrives (Go's + // `DockerRunOnceWithConfig` copies the container's log stream live) + // rather than buffering the whole run until exit. + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }); if (result.exitCode !== 0) { return yield* Effect.fail(new Error(`failed to bundle function: exit ${result.exitCode}`)); } @@ -1514,7 +1491,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) @@ -1531,15 +1508,23 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project if (result.success) { const body = yield* result.response.text.pipe(Effect.orElseSucceed(() => "")); if (result.response.status === 200) { + // A 200 whose body is not the expected JSON is an API-response problem, + // not a transport failure — surface it via FunctionsApiStatusError so it + // classifies as api_status rather than network. return yield* Effect.try({ try: () => decodeFunctionListResponse(JSON.parse(body)), catch: (error) => - new Error( - `failed to read functions list: ${error instanceof Error ? error.message : String(error)}`, - ), + new FunctionsApiStatusError({ + status: result.response.status, + message: `failed to read functions list: ${error instanceof Error ? error.message : String(error)}`, + decode: true, + }), }); } - lastError = new Error(`unexpected list functions status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected list functions status ${result.response.status}: ${body}`, + }); if (result.response.status < 500 && result.response.status !== 429) { return yield* Effect.fail(lastError); } @@ -1645,12 +1630,14 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( }, }) .pipe( + // Read the body as text (never failing) so the status check below wins: + // a non-201 with a non-JSON body, or a 201 with malformed JSON, must + // classify as a status/response problem — not fall through + // `mapTransportError` as a network failure. Effect.map((raw) => ({ status: raw.status, headers: raw.headers, - body: raw.json.pipe( - Effect.mapError((error) => mapTransportError("failed to deploy function", error)), - ), + body: raw.text.pipe(Effect.orElseSucceed(() => "")), })), Effect.mapError((error) => mapTransportError("failed to deploy function", error)), ), @@ -1658,15 +1645,23 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( const body = yield* response.body; if (response.status !== 201) { return yield* Effect.fail( - new Error(`unexpected deploy status ${response.status}: ${JSON.stringify(body)}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, + }), ); } + // A 201 whose body is not the expected JSON is an API-response problem, not a + // transport failure — surface it via FunctionsApiStatusError so it classifies + // as api_status rather than network. return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(body), + try: () => decodeDeployFunctionResponse(JSON.parse(body)), catch: (error) => - new Error( - `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, - ), + new FunctionsApiStatusError({ + status: response.status, + message: `failed to read deploy response: ${error instanceof Error ? error.message : String(error)}`, + decode: true, + }), }); }); @@ -1691,7 +1686,7 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( projectRef: string, functions: ReadonlyArray, ) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api @@ -1700,12 +1695,12 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( body: functions.map(toBulkUpdateItem), }) .pipe( + // Read the body as text (never failing) so the status check wins even + // if the body cannot be read. Effect.map((raw) => ({ status: raw.status, headers: raw.headers, - body: raw.text.pipe( - Effect.mapError((error) => mapTransportError("failed to bulk update", error)), - ), + body: raw.text.pipe(Effect.orElseSucceed(() => "")), })), Effect.mapError((error) => mapTransportError("failed to bulk update", error)), ), @@ -1724,7 +1719,10 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( if (result.response.status === 200) { return; } - lastError = new Error(`unexpected bulk update status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected bulk update status ${result.response.status}: ${body}`, + }); if (result.response.status < 500) { return yield* Effect.fail(lastError); } @@ -1746,7 +1744,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( exists: boolean, ) { let shouldUpdate = exists; - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const action = shouldUpdate ? "update" : "create"; @@ -1786,19 +1784,30 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( if (response.success) { const expectedStatus = shouldUpdate ? 200 : 201; if (response.value.status === expectedStatus) { - const body = yield* response.value.json.pipe( - Effect.mapError((error) => mapTransportError("failed to read function response", error)), - ); - return decodeDeployFunctionResponse(body); + // A success status with a malformed / unexpected JSON body is an + // API-response problem, not a transport failure — surface it via + // FunctionsApiStatusError so it classifies as api_status not network. + const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.try({ + try: () => decodeDeployFunctionResponse(JSON.parse(body)), + catch: (error) => + new FunctionsApiStatusError({ + status: response.value.status, + message: `failed to read function response: ${error instanceof Error ? error.message : String(error)}`, + decode: true, + }), + }); } const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); if (!shouldUpdate && body.includes("Duplicated function slug")) { shouldUpdate = true; } - lastError = new Error( - `unexpected ${action} function status ${response.value.status}: ${body}`, - ); + lastError = new FunctionsApiStatusError({ + status: response.value.status, + message: `unexpected ${action} function status ${response.value.status}: ${body}`, + notFoundIsInvalidInput: shouldUpdate, + }); } else { lastError = response.error; } @@ -1828,7 +1837,10 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( - new Error(`unexpected delete function status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }), ); }); @@ -2115,17 +2127,33 @@ const deployViaApi = Effect.fnUntraced(function* ( } }); -const deployViaDocker = Effect.fnUntraced(function* ( - projectId: string, - projectRef: string, - edgeRuntimeVersion: string, - functionsDir: string, - configs: ReadonlyArray, - api: ApiClient, - dockerNetworkId?: string, - verbose = false, - styleEmphasis: (text: string) => string = (text) => text, -) { +interface DeployViaDockerOptions { + readonly projectId: string; + readonly projectRef: string; + readonly edgeRuntimeVersion: string; + readonly functionsDir: string; + readonly configs: ReadonlyArray; + readonly api: ApiClient; + /** Already resolved (explicit flag > `SUPABASE_NETWORK_ID` > generated) — see the caller. */ + readonly networkMode: string; + readonly verbose?: boolean; + readonly styleEmphasis?: (text: string) => string; + readonly projectEnvValues?: Readonly>; +} + +const deployViaDocker = Effect.fnUntraced(function* (options: DeployViaDockerOptions) { + const { + projectId, + projectRef, + edgeRuntimeVersion, + functionsDir, + configs, + api, + networkMode, + verbose = false, + styleEmphasis = (text: string) => text, + projectEnvValues, + } = options; const output = yield* Output; const remoteFunctions = yield* listRemoteFunctions(api, projectRef); const remoteBySlug = new Map(remoteFunctions.map((fn) => [fn.slug, fn])); @@ -2137,15 +2165,16 @@ const deployViaDocker = Effect.fnUntraced(function* ( continue; } - const bundled = yield* bundleFunctionWithDocker( + const bundled = yield* bundleFunctionWithDocker({ projectId, edgeRuntimeVersion, functionsDir, config, - dockerNetworkId, + networkMode, verbose, styleEmphasis, - ); + projectEnvValues, + }); const current = remoteBySlug.get(config.slug); if ( current?.ezbr_sha256 === bundled.metadata.sha256 && @@ -2172,21 +2201,6 @@ const deployViaDocker = Effect.fnUntraced(function* ( } }); -export function resolveEdgeRuntimeVersion( - denoVersion: number | undefined, - defaultVersion: string, -): Effect.Effect { - if (denoVersion === undefined || denoVersion === 2) { - return Effect.succeed(defaultVersion); - } - if (denoVersion === 1) { - return Effect.succeed(DENO1_EDGE_RUNTIME_VERSION); - } - return Effect.fail( - new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), - ); -} - const pruneFunctions = Effect.fnUntraced(function* ( projectRef: string, configs: ReadonlyArray, @@ -2276,10 +2290,21 @@ export function deployFunctions( return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); } - const preResolvedProjectRef = - flags.functionNames.length > 0 - ? yield* dependencies.resolveProjectRef(flags.projectRef) - : undefined; + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + // `@supabase/config` merges the matching `[remotes.*]` block over the base + // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved + // config already reflects any remote function/edge_runtime overrides. + // In the legacy shell this also runs the same `Config.Validate`/dotenv/ + // env-override pipeline `start`/`stop`/`status` already go through — see + // `functions-config.ts`. Go: `flags.LoadConfig` runs before validating any + // slug (`deploy.go:22-28`), so this must precede the loop below too — an + // invalid `config.toml` is reported ahead of a malformed slug when both + // are wrong (review round on CLI-1963). + const context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); if (flags.functionNames.length > 0) { for (const slug of flags.functionNames) { @@ -2293,19 +2318,15 @@ export function deployFunctions( "no-verify-jwt", flags.noVerifyJwt, ); - const debugEnabled = hasGlobalLongFlag(dependencies.rawArgs, "debug"); - const projectRef = - preResolvedProjectRef ?? (yield* dependencies.resolveProjectRef(flags.projectRef)); - // `@supabase/config` merges the matching `[remotes.*]` block over the base - // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved - // config already reflects any remote function/edge_runtime overrides. - const loadedConfig = yield* loadProjectConfig(dependencies.projectRoot, { - projectRef, - goViperCompat: dependencies.goViperCompat, - }); - const deployConfig = loadedConfig?.config; + // Go gates the bundler's `--verbose` on `viper.GetBool("DEBUG")` + // (`bundle.go:59`), so `--debug=false` must resolve to `false` — a plain + // presence check would get that backwards (same rule as `download.ts`'s + // own `--debug` read; the `SUPABASE_DEBUG` env fallback is deferred + // there too). + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; + const deployConfig = context.loaded?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - deployConfig?.edge_runtime.deno_version, + context.denoVersion, dependencies.edgeRuntimeVersion, ); const configFunctions = yield* inferFunctionsManifest({ @@ -2313,7 +2334,7 @@ export function deployFunctions( config: deployConfig, }); const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(loadedConfig?.document); + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); yield* validateConfigFunctionSlugs(configDeclaredFunctions); const slugs = flags.functionNames.length > 0 @@ -2373,25 +2394,43 @@ export function deployFunctions( ), ); + const styleWarning = dependencies.styleWarning ?? ((text: string) => text); const deployed = useLocalBundler ? yield* Effect.gen(function* () { if (!(yield* isDockerRunning())) { - yield* output.raw("WARNING: Docker is not running\n", "stderr"); + yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); return yield* deployWithApi; } - const projectId = deployConfig?.project_id ?? projectRef; - yield* deployViaDocker( - projectId, + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs + // "never touched" distinction `resolveDockerNetworkMode` needs to + // decide whether `SUPABASE_NETWORK_ID` applies — see that + // function's own doc comment. `SUPABASE_NETWORK_ID` (env or + // project dotenv) is legacy-shell-only — same Go-viper-parity gate + // as `context.projectEnvValues` itself (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), + envOverride: + context.projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + context.projectEnvValues, + ), + projectId: context.projectId, + }); + yield* deployViaDocker({ + projectId: context.projectId, projectRef, edgeRuntimeVersion, - join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), + functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), configs, - dependencies.api, - explicitStringFlag(dependencies.rawArgs, "network-id"), - debugEnabled, + api: dependencies.api, + networkMode, + verbose: debugEnabled, styleEmphasis, - ); + projectEnvValues: context.projectEnvValues, + }); return true; }) : yield* deployWithApi; diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts new file mode 100644 index 0000000000..eaf81c3274 --- /dev/null +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -0,0 +1,509 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + buildDockerBinds, + dockerBindHostPath, + type ResolvedDeployFunctionConfig, +} from "./deploy.ts"; +import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; + +/** + * `../../` from `/supabase/functions/hello/deno.json`'s directory + * lands at `/supabase/_vendor/package/dist/index.mjs` — deliberately + * OUTSIDE `functionsDir` (`/supabase/functions`) so a bind for it + * survives `sanitizeDockerBinds`, which strips every bind under + * `functionsDir`/`outputDir`. That makes bind-list assertions observable + * instead of vacuously true. + */ +const VENDOR_TARGET_RELATIVE = "../../_vendor/package/dist/index.mjs"; +/** Import-maps spec: a value for a "/"-suffixed key should itself end in "/". */ +const VENDOR_TARGET_RELATIVE_SLASH = "../../_vendor/package/dist/index.mjs/"; + +async function createFunctionProjectWithDenoJson( + denoJson: Readonly>, + indexTsContents: string, +) { + // realpath the temp dir up front: on macOS `TMPDIR` resolves through a + // `/var` -> `/private/var` symlink, and `buildDockerBinds` compares + // realpath'd module roots against a non-realpath'd fallback path for a + // dotted-but-nonexistent specifier — an unresolved symlink prefix would + // make every path below "outside the source root" and mask the real + // assertions this file is testing. + const root = await realpath(await mkdtemp(join(tmpdir(), "deploy-import-scanner-"))); + const functionsDir = join(root, "supabase", "functions"); + const functionDir = join(functionsDir, "hello"); + const outputDir = join(root, "out"); + + await mkdir(functionDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); + + const entrypoint = join(functionDir, "index.ts"); + const importMap = join(functionDir, "deno.json"); + await writeFile(entrypoint, indexTsContents); + await writeFile(importMap, JSON.stringify(denoJson)); + + const config: ResolvedDeployFunctionConfig = { + slug: "hello", + enabled: true, + entrypoint, + importMap, + staticFiles: [], + env: {}, + }; + + return { root, functionsDir, functionDir, outputDir, config }; +} + +async function createHelloFunctionProject( + denoJsonImports: Record, + indexTsContents: string, +) { + return createFunctionProjectWithDenoJson({ imports: denoJsonImports }, indexTsContents); +} + +async function writeVendorIndexFile(root: string) { + const vendorDir = join(root, "supabase", "_vendor", "package", "dist"); + await mkdir(vendorDir, { recursive: true }); + const vendorIndexPath = join(vendorDir, "index.mjs"); + await writeFile(vendorIndexPath, "export const core = 1;\n"); + return vendorIndexPath; +} + +async function createVendoredFunctionProject(indexTsContents: string) { + const project = await createHelloFunctionProject( + { "@supabase/server": VENDOR_TARGET_RELATIVE }, + indexTsContents, + ); + const vendorIndexPath = await writeVendorIndexFile(project.root); + return { ...project, vendorIndexPath }; +} + +async function createSlashVendoredFunctionProject(indexTsContents: string) { + const project = await createHelloFunctionProject( + { "@supabase/server/": VENDOR_TARGET_RELATIVE_SLASH }, + indexTsContents, + ); + const vendorIndexPath = await writeVendorIndexFile(project.root); + return { ...project, vendorIndexPath }; +} + +describe("buildDockerBinds — import-map key matching (spec-strict) and the file-mapped-key guard", () => { + it("drops a specifier reachable only through a JSDoc comment, now via a no-match on the unqualified bare key (not the extension guard)", async () => { + // Import-maps spec: a bare key ("@supabase/server", no trailing slash) + // matches only exactly, so "@supabase/server/core" no longer substitutes + // at all here — it is dropped as an unresolvable bare specifier before + // the final-segment guard ever runs. Kept as its own test because it + // pins the exact field-reported shape; see the "final-segment guard" + // test below for the guard itself under a spec-valid `/`-suffixed key. + const { root, functionsDir, outputDir, config, vendorIndexPath } = + await createVendoredFunctionProject( + [ + "/**", + " * @example", + ' * import { core } from "@supabase/server/core";', + " */", + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + // The vendor file is still bound via the import-map target walk + // (independent of whether the entrypoint's own specifier matched). + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects with a FunctionImportNotDirectoryError carrying a clean 'not a directory' message (not a raw ENOTDIR) for a real import reaching a dotted final segment through a `/`-suffixed file-mapped key", async () => { + const { root, functionsDir, outputDir, config } = await createSlashVendoredFunctionProject( + [ + 'import { extra } from "@supabase/server/extra.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + + try { + let caught: unknown; + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async () => {}, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(FunctionImportNotDirectoryError); + expect((caught as FunctionImportNotDirectoryError)._tag).toBe( + "FunctionImportNotDirectoryError", + ); + expect((caught as FunctionImportNotDirectoryError).message).toBe( + "failed to read file: open supabase/_vendor/package/dist/index.mjs/extra.ts: not a directory", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("no longer prefix-matches a bare (non-`/`-suffixed) key: a longer specifier stays bare and is skipped without a warning", async () => { + const { root, functionsDir, outputDir, config } = await createVendoredFunctionProject( + [ + 'import { extra } from "@supabase/server/extra.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(binds.some((bind) => bind.includes("extra.ts"))).toBe(false); + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("still substitutes on an exact match against a bare key", async () => { + const { root, functionsDir, outputDir, config, vendorIndexPath } = + await createVendoredFunctionProject( + [ + 'import { server } from "@supabase/server";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("warns ENOENT-style for a genuinely missing relative import, unaffected by the file-mapped-key guard", async () => { + const { root, functionsDir, outputDir, config } = await createVendoredFunctionProject( + ['import { missing } from "./missing.ts";', 'Deno.serve(() => new Response("ok"));', ""].join( + "\n", + ), + ); + const warnings: Array = []; + + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + const matches = warnings.filter( + (warning) => + warning.includes("failed to read file: open ") && + warning.includes(": no such file or directory"), + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toContain("missing.ts"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("the final-segment guard still covers the original crash shape under a spec-valid `/`-suffixed map: a JSDoc-only mention is dropped silently", async () => { + const { root, functionsDir, outputDir, config } = await createSlashVendoredFunctionProject( + [ + "/**", + " * @example", + ' * import { core } from "@supabase/server/core";', + " */", + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); + expect(warnings.some((warning) => warning.includes("index.mjs/core"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not crash when an unreferenced `/`-suffixed import-map target resolves through a file, with no options passed", async () => { + // Regression for a bug found while writing the test above: + // `forEachLocalImportMapTarget` enumerates every import-map VALUE + // unconditionally (regardless of whether the entrypoint references it), + // and Bun's `realpath` — unlike Node's — throws ENOTDIR on a + // trailing-slash path through a file. A spec-valid `/`-suffixed value + // (which SHOULD end in "/") pointing at a real file used to crash + // `buildDockerBinds` with a raw ENOTDIR here, with no options passed — + // exactly how the real `functions deploy` bundling call site invokes it. + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + { "@x/": VENDOR_TARGET_RELATIVE_SLASH }, + 'Deno.serve(() => new Response("ok"));\n', + ); + await writeVendorIndexFile(root); + + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("skips an unreferenced import-map target that resolves through a file, regardless of skipMissingImportMapTargets", async () => { + // ENOTDIR (a target routed through a file) is now always skippable, with + // its own wording distinct from the ENOENT "missing" case below — see + // "skips a genuinely missing import-map target" for the option's actual + // gate. + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + { "@x": `${VENDOR_TARGET_RELATIVE}/sub.ts` }, + 'Deno.serve(() => new Response("ok"));\n', + ); + await writeVendorIndexFile(root); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(binds.some((bind) => bind.includes("index.mjs"))).toBe(false); + expect( + warnings.some((warning) => + warning.includes("Skipping import map target that is not a directory"), + ), + ).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("skips a genuinely missing import-map target only when skipMissingImportMapTargets is set", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + { "@missing": "../../does-not-exist.ts" }, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + let threwWithoutOption = false; + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config); + } catch { + threwWithoutOption = true; + } + expect(threwWithoutOption).toBe(true); + + const warnings: Array = []; + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + skipMissingImportMapTargets: true, + }); + + expect(binds.some((bind) => bind.includes("does-not-exist"))).toBe(false); + expect( + warnings.some((warning) => warning.includes("Skipping missing import map target")), + ).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("drops a `/`-suffixed key whose value lacks a trailing slash (spec-invalid mapping), instead of fabricating a concatenated path", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + { "pkg/": VENDOR_TARGET_RELATIVE }, + [ + 'import { core } from "pkg/core.ts";', + 'import { core2 } from "pkg//core.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + await writeVendorIndexFile(root); + const warnings: Array = []; + + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + // Pre-fix, "pkg/core.ts" fabricated "/index.mjscore.ts" (no + // separator) and "pkg//core.ts" fabricated "/index.mjs/core.ts" + // (a genuine through-a-file crash shape) — both warned or threw. + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("ignores an empty-string import-map key (spec) without crashing; other mappings still resolve", async () => { + const { root, functionsDir, functionDir, outputDir, config } = await createHelloFunctionProject( + { "": "./x.ts", "@supabase/server": VENDOR_TARGET_RELATIVE }, + [ + 'import { server } from "@supabase/server";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + await writeFile(join(functionDir, "x.ts"), "export const x = 1;\n"); + const vendorIndexPath = await writeVendorIndexFile(root); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("resolves via the longest matching `/`-suffixed key when two keys compete", async () => { + const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( + { + "@v/": "../../../dirA/", + "@v/deep/": "../../../dirB/", + }, + ['import { mod } from "@v/deep/mod.ts";', 'Deno.serve(() => new Response("ok"));', ""].join( + "\n", + ), + ); + await mkdir(join(root, "dirA"), { recursive: true }); + await mkdir(join(root, "dirB"), { recursive: true }); + const modPath = join(root, "dirB", "mod.ts"); + await writeFile(modPath, "export const mod = 2;\n"); + const warnings: Array = []; + + try { + const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + // Proves the LONGER key ("@v/deep/") won: the walker followed + // "@v/deep/mod.ts" through dirB and bound the resolved FILE. Had the + // shorter key incorrectly won, the walker would have tried + // "/deep/mod.ts" instead (which does not exist). + expect(binds.some((bind) => dockerBindHostPath(bind) === modPath)).toBe(true); + expect(binds.some((bind) => bind.includes(join("dirA", "deep")))).toBe(false); + expect(warnings).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("no longer applies a scope whose name coincidentally shares a string prefix with the current file's directory (spec-strict scope matching)", async () => { + const { root, functionsDir, outputDir, config } = await createFunctionProjectWithDenoJson( + { + imports: { "@lib": "../../../scoped-test/fallback-lib.ts" }, + scopes: { + "../hell": { "@lib": "../../../scoped-test/definitely-not-real.ts" }, + }, + }, + ['import { lib } from "@lib";', 'Deno.serve(() => new Response("ok"));', ""].join("\n"), + ); + await mkdir(join(root, "scoped-test"), { recursive: true }); + await writeFile(join(root, "scoped-test", "fallback-lib.ts"), "export const lib = 1;\n"); + const warnings: Array = []; + + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + skipMissingImportMapTargets: true, + }); + + // Scope name "../hell" resolves to ".../functions/hell" — the OLD bare + // `startsWith` rule let that match the entrypoint's OWN directory + // (".../functions/hello") purely as a string prefix ("hello" starts + // with "hell" as characters, not as a path segment). If that scope + // incorrectly applied, "@lib" would resolve to the scoped (nonexistent) + // target and the walker itself would emit a "failed to read file" + // warning for it — distinct from the constant "Skipping missing import + // map target" warning that the independent, unconditional + // target-enumeration walk always emits for that same value regardless + // of whether its scope matches anything. + expect( + warnings.some( + (warning) => warning.includes("failed to read file") && warning.includes("not-real"), + ), + ).toBe(false); + expect( + warnings.some( + (warning) => + warning.includes("Skipping missing import map target") && warning.includes("not-real"), + ), + ).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("silently drops a trailing-slash directory-shaped specifier instead of crashing", async () => { + const { root, functionsDir, functionDir, outputDir, config } = await createHelloFunctionProject( + { "@dir/": "./sub/" }, + 'import "@dir/nested/";\nDeno.serve(() => new Response("ok"));\n', + ); + await mkdir(join(functionDir, "sub"), { recursive: true }); + const warnings: Array = []; + + try { + await buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: async (message) => { + warnings.push(message); + }, + }); + + expect(warnings.some((warning) => warning.includes("nested"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index b12aeca7af..2883d0b782 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -1,29 +1,66 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class ConflictingFunctionDownloadFlagsError extends Data.TaggedError( "ConflictingFunctionDownloadFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDownloadNotFoundError extends Data.TaggedError( "FunctionDownloadNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDownloadResponseError extends Data.TaggedError( "InvalidFunctionDownloadResponseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Every construction is a 200-response whose multipart/metadata/list body + // failed to decode — an API response problem, not a raw status failure. + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} export class UnsafeFunctionDownloadPathError extends Data.TaggedError( "UnsafeFunctionDownloadPathError", )<{ readonly message: string; -}> {} + /** + * True when a 200 response's multipart filename/metadata entrypoint + * resolved outside `supabase/functions` — an API response problem, not a + * local write/rename failure (the other construction sites, which keep the + * default `permission` classification). + */ + readonly unsafeResponsePath?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.unsafeResponsePath === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return actionability.permission; + } +} diff --git a/apps/cli/src/shared/functions/download.errors.unit.test.ts b/apps/cli/src/shared/functions/download.errors.unit.test.ts new file mode 100644 index 0000000000..8b69507070 --- /dev/null +++ b/apps/cli/src/shared/functions/download.errors.unit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; +import { UnsafeFunctionDownloadPathError } from "./download.errors.ts"; + +describe("UnsafeFunctionDownloadPathError actionability", () => { + it("classifies a response-derived unsafe path as an API response problem", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "refusing to extract Function file outside supabase/functions: ../evil", + unsafeResponsePath: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:UnsafeFunctionDownloadPathError:api_response"); + }); + + it("keeps a local temp-file write/rename failure on the permission policy", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "failed to write Function file: index.ts: EACCES", + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("permission"); + expect(result.error_fingerprint).toBe("tag:UnsafeFunctionDownloadPathError"); + }); + + it("keeps an explicitly-false unsafeResponsePath on the permission policy", () => { + const result = classifyCliErrorActionability( + new UnsafeFunctionDownloadPathError({ + message: "failed to create temporary Function file while extracting index.ts: EACCES", + unsafeResponsePath: false, + }), + ); + expect(result.error_category).toBe("permission"); + }); +}); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 7b9882f6dd..d6beca0b38 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,17 +1,35 @@ -import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; import { randomUUID } from "node:crypto"; -import { open, rename, rm } from "node:fs/promises"; +import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { Effect, FileSystem, Option } from "effect"; +import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { Output } from "../output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, + explicitBooleanLongFlag, + lastExplicitLongFlagValue, hasExplicitLongFlag, } from "../cli/cobra-flag-groups.ts"; +import { legacyDescribeContainerCliFailure } from "../../legacy/shared/legacy-container-cli.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { + buildFunctionsDockerRunArgs, + ensureDockerNamedVolume, + ensureDockerNetwork, + isDockerRunning, + localDockerId, + resolveDockerNetworkMode, + resolveEdgeRuntimeVersion, + resolveFunctionsDockerImage, + runChildProcess, +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { + edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, invalidFunctionSlugDetail, validateFunctionSlugMessage, @@ -23,8 +41,14 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "./download.errors.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; +// Go: `utils.DockerDenoDir`/`utils.DockerEszipDir` (`internal/utils/deno.go:34-35`) +// — fixed container-side paths for the docker-unbundle path, unrelated to +// deploy's `toDockerPath` host-mirroring scheme. +const DOCKER_DENO_DIR = "/home/deno"; +const DOCKER_ESZIP_DIR = "/root/eszips"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option; @@ -34,15 +58,68 @@ export interface DownloadFunctionsOptions { readonly legacyBundle: boolean; } +interface DownloadRuntimeDependencies { + readonly api: ApiClient; + readonly projectRoot: string; +} + +/** Adds what the Docker-unbundle path needs beyond the server-side path. */ +interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies { + readonly rawArgs: ReadonlyArray; + /** + * Optional shell-specific styling hook for the `Downloading function:` + * progress line — mirrors `deploy.ts`'s `DeployFunctionsDependencies.styleEmphasis`. + * Defaults to identity (plain text); the legacy shell injects Go's bold + * styling here so the next shell stays isolated from `legacy/`-specific + * rendering. Go: `utils.Bold(slug)` (`downloadOne`, `download.go:219`). + */ + readonly styleEmphasis?: (text: string) => string; + /** + * Optional shell-specific styling hook for the `--legacy-bundle` command + * suggested inside {@link suggestLegacyBundle} — same isolation rationale + * as {@link styleEmphasis}, just a different Go colour. Go: + * `utils.Aqua("supabase functions download --legacy-bundle "+slug)` + * (`suggestLegacyBundle`, `download.go:315`). + */ + readonly styleAqua?: (text: string) => string; + /** + * Optional shell-specific styling hook for the `WARNING:` token on the + * "Docker is not running" fallback line — same isolation rationale as + * {@link styleEmphasis}. Go: `utils.Yellow("WARNING:")` (`download.go:146`). + */ + readonly styleWarning?: (text: string) => string; +} + +/** + * What {@link resolveEdgeRuntimeImage} needs to resolve the Docker + * edge-runtime image tag — split out so it's declared once instead of + * duplicated across `DownloadFunctionsDependencies`'s fields. + */ +interface EdgeRuntimeImageDependencies { + readonly projectRoot: string; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. + */ + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; + /** + * Fallback edge-runtime image tag used when the project config doesn't pin + * `edge_runtime.deno_version` to `1` (which forces the older + * `DENO1_EDGE_RUNTIME_VERSION`) — mirrors `deploy.ts`'s own + * `edgeRuntimeVersion` dependency, read via + * `resolveEdgeRuntimeVersionPin` by the shell-specific handler. + */ + readonly edgeRuntimeVersion: string; +} + export interface DownloadFunctionsDependencies< ResolveError, ResolveRequirements, ProxyError, ProxyRequirements, -> { - readonly api: ApiClient; - readonly projectRoot: string; - readonly rawArgs: ReadonlyArray; +> + extends DownloadDockerRuntimeDependencies, EdgeRuntimeImageDependencies { readonly resolveProjectRef: ( projectRef: Option.Option, ) => Effect.Effect; @@ -51,7 +128,8 @@ export interface DownloadFunctionsDependencies< * child's raw stdout must not reach the terminal (it would corrupt the * JSON/NDJSON envelope, CLI-1546's "stdout is payload-only in machine * mode" invariant), so the dependency must capture/discard it (e.g. via - * `LegacyGoProxy.execCapture`) instead of inheriting stdio. + * `LegacyGoProxy.execCapture`) instead of inheriting stdio. Only invoked + * for `--legacy-bundle` today — `--use-docker` now runs natively (CLI-1963). */ readonly proxyDownload: ( flags: DownloadFunctionsOptions, @@ -60,29 +138,18 @@ export interface DownloadFunctionsDependencies< ) => Effect.Effect; } -interface DownloadRuntimeDependencies { - readonly api: ApiClient; - readonly projectRoot: string; -} - -export function makeGoProxyDownloadArgs( - flags: DownloadFunctionsOptions, +// `--legacy-bundle` is the only case `downloadFunctions()` still delegates to +// the Go binary for (CLI-1963) — `functionName` is the one remaining piece of +// user input the delegating branch needs to forward. +export function makeGoProxyLegacyBundleArgs( + functionName: Option.Option, projectRef: string, ): ReadonlyArray { const args: string[] = ["functions", "download"]; - if (Option.isSome(flags.functionName)) { - args.push(flags.functionName.value); - } - args.push("--project-ref", projectRef); - // At most one of these may reach the Go binary — it re-parses this argv - // fresh and enforces the same mutual exclusivity itself. `legacyBundle` - // takes priority since `useDocker` now defaults to `true` (CLI-1862) and - // would otherwise ride along on every `--legacy-bundle` invocation. - if (flags.legacyBundle) { - args.push("--legacy-bundle"); - } else if (flags.useDocker) { - args.push("--use-docker"); + if (Option.isSome(functionName)) { + args.push(functionName.value); } + args.push("--project-ref", projectRef, "--legacy-bundle"); return args; } @@ -127,6 +194,35 @@ function validateSlug(slug: string): Effect.Effect` argument), which fails with a plain `InvalidFunctionSlugError` and no + * "failed to download function" prefix or suggestion. + */ +function validateRemoteSlug( + slug: string, + styleAqua: (text: string) => string = (text) => text, +): Effect.Effect { + if (validateFunctionSlugMessage(slug) === undefined) { + return Effect.void; + } + + return Effect.fail( + Object.assign(new Error(`failed to download function ${slug}: ${invalidFunctionSlugDetail}`), { + // Go: `utils.Aqua(f.Slug)` (`download.go:185`). + suggestion: `The Supabase API returned an unexpected function slug (${styleAqua(slug)}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, + }), + ); +} + const downloadCommandPath = ["functions", "download"] as const; function validateDownloadFlags( @@ -149,17 +245,26 @@ function validateDownloadFlags( ); } -function mapTransportError(prefix: string, error: unknown): Error { +function mapTransportError( + prefix: string, + error: unknown, +): FunctionsApiTransportError | SupabaseApiInputError | HttpBody.HttpBodyError { + // This mapper is shared by requests with different input ownership. Preserve + // validation/build failures so their provenance is not inferred from text. + if (error instanceof SupabaseApiInputError || error instanceof HttpBody.HttpBodyError) { + return error; + } + if (HttpClientError.isHttpClientError(error)) { const description = error.reason.description ?? error.reason._tag; - return new Error(`${prefix}: ${description}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${description}` }); } if (error instanceof Error) { - return new Error(`${prefix}: ${error.message}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${error.message}` }); } - return new Error(`${prefix}: ${String(error)}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${String(error)}` }); } function hasEntrypointPath(metadata: DownloadMetadata | undefined): metadata is { @@ -523,6 +628,7 @@ function resolveDownloadDestination( return Effect.fail( new UnsafeFunctionDownloadPathError({ message: `refusing to extract Function file outside ${functionsRoot}: ${partPath}`, + unsafeResponsePath: true, }), ); } @@ -535,6 +641,7 @@ function ensureContainedPath(root: string, candidate: string, sourcePath: string return Effect.fail( new UnsafeFunctionDownloadPathError({ message: `refusing to extract Function file outside ${root}: ${sourcePath}`, + unsafeResponsePath: true, }), ); } @@ -589,7 +696,10 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { return yield* Effect.fail( - new Error(`unexpected list functions status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected list functions status ${response.status}: ${body}`, + }), ); } @@ -599,9 +709,42 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro if (!Array.isArray(parsed)) { throw new Error("expected functions list response to be an array"); } - return parsed.flatMap((value) => { + // Go: `FunctionResponse.Slug` (`apps/cli-go/pkg/api/types.gen.go:6465`) + // is a required, non-pointer `string` — a list entry with a missing or + // `null` "slug" decodes to the zero value `""` rather than erroring + // (`encoding/json`'s documented null-into-non-pointer no-op), and that + // empty slug then fails loudly downstream (`validateRemoteSlug`, + // matching Go's own per-item `ValidateFunctionSlug` in `downloadAll`, + // `download.go:182-188`) instead of silently vanishing from the list. + // Coercing here (rather than filtering the entry out, as before) + // preserves that "always surface an unexpected API response, never + // silently download fewer functions than requested" invariant — the + // exact CLI-1891 threat model `validateRemoteSlug` exists for (review + // round on CLI-1963's `functions download` port). + // + // A "slug" present but typed as something other than string/null is a + // different case: Go's generated client decodes the *entire* array in + // one `json.Unmarshal` call (`ParseV1ListAllFunctionsResponse`, + // `apps/cli-go/pkg/api/client.gen.go:22186-22208`), and a type mismatch + // on any single element fails that whole call — confirmed empirically + // (`json.Unmarshal([]byte(`+"`"+`[{"slug":"ok"},{"slug":123}]`+"`"+`), &dest)` + // returns a `*json.UnmarshalTypeError`; `dest` is partially populated in + // memory, but `ParseV1ListAllFunctionsResponse` returns before ever + // assigning `response.JSON200`, discarding it), so `V1ListAllFunctionsWithResponse` + // returns an error and `downloadAll` fails with "failed to list + // functions: ..." before downloading anything — not after downloading + // the earlier, well-formed entries. Throwing here (rather than + // coercing to `""` like the missing/null case above) preserves that + // same fail-before-any-download ordering. + return parsed.map((value) => { const slug = getObjectProperty(value, "slug"); - return typeof slug === "string" ? [slug] : []; + if (slug === null || slug === undefined) { + return ""; + } + if (typeof slug !== "string") { + throw new Error(`expected function slug to be a string, got ${typeof slug}`); + } + return slug; }); }, catch: (cause) => @@ -635,7 +778,10 @@ const getRemoteFunction = Effect.fnUntraced(function* ( ); default: return yield* Effect.fail( - new Error(`Failed to download Function ${slug} on the Supabase project: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `Failed to download Function ${slug} on the Supabase project: ${body}`, + }), ); } @@ -675,7 +821,353 @@ const downloadBody = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + return yield* Effect.fail( + new FunctionsApiStatusError({ + status: response.status, + message: `Error status ${response.status}: ${body}`, + notFoundIsInvalidInput: true, + }), + ); +}); + +// Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) +// sends this request with no `Accept` header set at all (contrast +// `downloadBody` above, which requests `multipart/form-data` for the +// server-side path). This operation's generated contract marks its response +// `kind: "json"` (`packages/api/src/generated/contracts.ts`), so +// `executeRaw` would otherwise default to `Accept: application/json` here +// (`buildRequest`'s unconditional `acceptJson` for json-kind operations, +// `packages/api/src/internal/client.ts`) and risk a negotiated JSON response +// instead of the raw eszip body — overriding to `*/*` (no preference) is the +// closest equivalent this API surface has to Go sending no header at all. +// Go explicitly decodes a brotli `Content-Encoding` itself because Go's +// `http.Transport` only auto-decodes `gzip`; this TS CLI's transport +// (`effect/unstable/http`'s `FetchHttpClient`, backed by the platform +// `fetch`) already transparently decodes `br` per the Fetch spec — while +// still reporting `Content-Encoding: br` on the exposed `Response.headers` +// (confirmed empirically: a `fetch()` against a real `Content-Encoding: br` +// response returns already-decompressed bytes from `arrayBuffer()`). +// Re-running `brotliDecompressSync` here would therefore throw on +// already-decoded bytes, so this reads the body as-is and does not +// re-implement Go's manual decode step. Error prefix ("failed to get +// function body") is deliberately distinct from `downloadBody`'s ("failed to +// download function") — the two Go call sites use different wording. +const downloadEszipBody = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + slug: string, +) { + const response = yield* api + .executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: projectRef, + function_slug: slug, + }, + { Accept: "*/*" }, + ) + .pipe(Effect.mapError((error) => mapTransportError("failed to get function body", error))); + + if (response.status !== 200) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + } + + return new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError( + (cause) => + new Error( + `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ), + ), + ); +}); + +function suggestLegacyBundle( + slug: string, + styleAqua: (text: string) => string = (text) => text, +): string { + // Go: `suggestLegacyBundle` (`download.go:314-316`) — verbatim, including + // the source's own "trying running" wording and its leading newline. Go + // wraps only the suggested command itself in `utils.Aqua`, not the whole + // sentence — `styleAqua` mirrors that scope exactly. + return `\nIf your function is deployed using CLI < 1.120.0, trying running ${styleAqua(`supabase functions download --legacy-bundle ${slug}`)} instead.`; +} + +function suggestDenoV2(styleEmphasis: (text: string) => string = (text) => text): string { + // Go: `suggestDenoV2` (`download.go:306-312`), verbatim including its + // trailing newline. Go bolds `utils.ConfigPath` via `utils.Bold` — the + // same hook `styleEmphasis` already covers for the slug above + // (`downloadOne`, `download.go:219`). + return `Please use deno v2 in ${styleEmphasis("supabase/config.toml")} to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n`; +} + +/** + * Attaches Go's `suggestLegacyBundle` hint to any Docker-extraction failure — + * matches `downloadWithDockerUnbundle`'s `CmdSuggestion += + * suggestLegacyBundle(slug)` (`download.go:211-214`), which runs whenever + * `extractOne` fails for *any* reason (network/volume creation, container + * create/start, log streaming, container inspect), not just a non-zero exit + * code. `ensureDockerNetwork`/`ensureDockerNamedVolume` already prefix their + * own "failed to create docker network/volume: ..." context on the failures + * they raise themselves (`functions-docker.ts`), so this only normalizes + * (never re-prefixes) whatever `legacyDescribeContainerCliFailure` reports. + */ +function withLegacyBundleSuggestion(slug: string, styleAqua?: (text: string) => string) { + return (cause: unknown): Error => + Object.assign(new Error(legacyDescribeContainerCliFailure(cause)), { + suggestion: suggestLegacyBundle(slug, styleAqua), + }); +} + +/** + * Same as {@link withLegacyBundleSuggestion}, plus a `step` prefix — for + * `runChildProcess` itself, whose own failure (a spawn error, or the + * `PlatformError` `functions-docker.ts`'s hoisted `collectByteStream` erases + * to `unknown`) carries no context of its own about which command was + * running, unlike `ensureDockerNetwork`/`ensureDockerNamedVolume`'s + * self-describing errors. + */ +function withDockerStepFailure(step: string, slug: string, styleAqua?: (text: string) => string) { + return (cause: unknown): Error => + Object.assign(new Error(`${step}: ${legacyDescribeContainerCliFailure(cause)}`), { + suggestion: suggestLegacyBundle(slug, styleAqua), + }); +} + +// Go: `Config.EdgeRuntime.Image` (`extractOne`, `download.go:271`) resolves +// from `edge_runtime.deno_version` — `1` pins the older +// `DENO1_EDGE_RUNTIME_VERSION`, anything else (including unset) uses the +// project's configured/default tag (`resolveEdgeRuntimeVersion`, shared with +// `deploy.ts`). Resolved once per invocation by the caller +// (`downloadFunctions`), not once per slug — Go's `Config` is likewise loaded +// once, before any per-function work. `loadFunctionsProjectConfig` (legacy +// shell only) runs the same `Config.Validate`/dotenv/env-override pipeline +// `start`/`stop`/`status` already go through — see `functions-config.ts`. +const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( + dependencies: EdgeRuntimeImageDependencies, + projectRef: string, +) { + const context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + context.denoVersion, + dependencies.edgeRuntimeVersion, + ); + return { + projectId: context.projectId, + denoVersion: context.denoVersion, + // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) — + // a `.temp/edge-runtime-version` pin flows through unmodified, `v` prefix + // or not (see the helper's doc in `functions.shared.ts`). Registry + // mapping + pull-with-retry happens per-container, right before + // `ensureDockerNetwork`, matching Go's `DockerStart` (see the caller). + rawImage: edgeRuntimeImage(edgeRuntimeVersion), + projectEnvValues: context.projectEnvValues, + }; +}); + +interface EdgeRuntimeImage { + readonly projectId: string; + readonly denoVersion: number | undefined; + /** Not yet registry-mapped/pull-resolved — see {@link resolveFunctionsDockerImage}. */ + readonly rawImage: string; + readonly projectEnvValues: Readonly> | undefined; +} + +/** + * `EdgeRuntimeImage` plus the pull-resolved reference, once per invocation + * (not once per slug — see {@link downloadFunctions}'s own resolve site): + * the image is identical for every function being downloaded, so resolving + * it inside the per-slug loop would multiply both the cache-check subprocess + * count and, on a registry outage, the retry-backoff sleep (up to ~36s) by + * the function count. Go's own `DockerStart` DOES run per-container (once + * per `extractOne`), but its image-cache check is an in-process Engine API + * call, not a fork+exec — the per-slug cost that justifies hoisting here has + * no Go equivalent to stay faithful to. + */ +interface PulledEdgeRuntimeImage extends EdgeRuntimeImage { + readonly image: string; +} + +// Go: `downloadWithDockerUnbundle`/`extractOne` +// (`download.go:198-282`) — downloads the function body as an eszip, writes +// it to a temp file, then runs the edge-runtime image's `unbundle` +// subcommand against it, mounting the *shared* `supabase/functions` +// directory (not the slug's own subdirectory — `download_test.go:267-271` +// asserts this explicitly). +const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( + dependencies: DownloadDockerRuntimeDependencies, + edgeRuntimeImage: PulledEdgeRuntimeImage, + projectRef: string, + slug: string, +) { + const output = yield* Output; + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); + const styleAqua = dependencies.styleAqua ?? ((text: string) => text); + + // Go: `downloadOne` (`download.go:219`) — lowercase "function", distinct + // from the server-side path's "Downloading Function:" (capital F, + // `downloadWithServerSideUnbundle`, `download.go:329`). Both Go call sites + // bold the slug (`utils.Bold`); this path is new in CLI-1963, so it picks + // up the styling hook now. `downloadSingle`'s server-side path below has + // the identical gap, but predates this PR (#5527) — left as-is here. + yield* output.raw(`Downloading function: ${styleEmphasis(slug)}\n`, "stderr"); + + const eszip = yield* downloadEszipBody(dependencies.api, projectRef, slug); + + const tempDir = join(dependencies.projectRoot, "supabase", ".temp"); + yield* Effect.tryPromise({ + try: () => mkdir(tempDir, { recursive: true }), + catch: (cause) => + new Error(`failed to mkdir: ${cause instanceof Error ? cause.message : String(cause)}`), + }); + const eszipFileName = `output_${slug}.eszip`; + const eszipPath = join(tempDir, eszipFileName); + yield* Effect.tryPromise({ + try: () => writeFile(eszipPath, eszip), + catch: (cause) => + new Error( + `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + + // Go: the `defer fsys.Remove(eszipPath)` cleanup is registered right after + // the write and covers the whole of `extractOne`, including the container + // run — it fires on every return path, success or failure + // (`download.go:203-209`). `Effect.ensuring` below is the equivalent: it + // wraps every step from here on so a failure resolving the network/volume, + // spawning Docker, or a non-zero container exit all still clean up the + // temp eszip, matching Go instead of only doing so on the happy path. + // + // Go gates this on `viper.GetBool("DEBUG")` (`download.go:203`), which + // resolves an explicit `--debug=false` to `false` (cleanup runs) — a plain + // presence check would get that backwards, so this reads the last explicit + // occurrence's boolean value instead (`explicitBooleanLongFlag`), falling + // back to `false` (cleanup runs) when `--debug` never appears. `SUPABASE_DEBUG` + // env-var fallback is a separate, pre-existing gap shared with every other + // presence-only `--debug` read this file family used to have + // (e.g. `deploy.ts`) and the legacy debug logger itself, none of which + // currently honor it either — left open rather than fixed piecemeal here. + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; + const cleanupEszip = debugEnabled + ? Effect.void + : Effect.tryPromise({ + try: () => rm(eszipPath, { force: true }), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe(Effect.catch((message) => output.raw(`${message}\n`, "stderr"))); + + const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; + const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); + const hostEszipPath = resolve(eszipPath); + const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); + const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); + + // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — + // `--network-id` is a persistent root flag (`cmd/root.go:328`), not + // registered on `functions download` itself. `lastExplicitLongFlagValue` + // preserves the "explicitly cleared" vs "never touched" distinction + // `resolveDockerNetworkMode` needs to decide whether `SUPABASE_NETWORK_ID` + // applies — see that function's own doc comment. `SUPABASE_NETWORK_ID` + // (env or project dotenv) is legacy-shell-only — same Go-viper-parity gate + // as `projectEnvValues` itself (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), + envOverride: + projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + projectId, + }); + + const extract = Effect.gen(function* () { + // `image` is already pull-resolved once for the whole invocation — see + // `downloadFunctions`'s own resolve site — not re-resolved per slug. + yield* ensureDockerNetwork(networkMode, projectId).pipe( + Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), + ); + yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( + Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), + ); + + // Bind order matches `extractOne` (`download.go:260-266`) exactly. Go's + // `DockerStart` drops the named-volume bind entirely on Bitbucket + // (`internal/utils/docker.go:400-405`) rather than just skipping its + // explicit creation — `docker run -v :...` would otherwise still + // implicitly create the named volume, which Bitbucket's restricted Docker + // environment doesn't allow, same carve-out as `deploy.ts`'s + // `buildDockerBinds`. + const binds = [ + ...(process.env["BITBUCKET_CLONE_DIR"] === undefined + ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] + : []), + `${hostEszipPath}:${dockerEszipPath}:ro`, + `${functionsDir}:${DOCKER_DENO_DIR}:rw`, + ]; + const command = buildFunctionsDockerRunArgs({ + image, + projectId, + networkMode, + binds, + containerArgs: ["unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath], + }); + + // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` + // while the container runs (`DockerRunOnceWithConfig`, copied live via the + // log stream) — `runChildProcess`'s `onStdout`/`onStderr` tee each chunk + // to `output.raw` as it arrives instead of buffering the whole run. + // Go pipes the container's stdout straight to `os.Stdout` + // (`download.go:279`); machine-output modes must keep stdout + // payload-only (CLI-1546), so this mirrors `deploy.ts`'s own + // `bundleFunctionWithDocker` routing. + const result = yield* runChildProcess("docker", command, { + stdout: "pipe", + stderr: "pipe", + onStdout: (chunk) => output.raw(chunk, output.format === "text" ? "stdout" : "stderr"), + onStderr: (chunk) => output.raw(chunk, "stderr"), + }).pipe( + Effect.mapError( + withDockerStepFailure("failed to run the edge-runtime unbundle container", slug, styleAqua), + ), + ); + + if (result.exitCode !== 0) { + // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = + // suggestDenoV2()` (assignment) as soon as a full stderr line reads + // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` + // appends `suggestLegacyBundle` (`+=`) once extraction has failed + // (`download.go:213,284-304`). Go's own implementation races these two + // goroutines (the pipe writer is never closed) — this resolves that + // race deterministically to the common (non-race) ordering instead of + // reproducing the nondeterminism. The line match is exact (not a + // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. + const invalidEszipV2 = + denoVersion === 1 && + result.stderr + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); + const suggestion = + (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); + return yield* Effect.fail( + Object.assign(new Error(`error running container: exit ${result.exitCode}`), { + suggestion, + }), + ); + } + + // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." + // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only + // stdout/stderr text is "Downloading function: ..." above plus whatever + // the `unbundle` container itself wrote. + return slug; + }); + + return yield* extract.pipe(Effect.ensuring(cleanupEszip)); }); const downloadSingle = Effect.fnUntraced(function* ( @@ -764,11 +1256,17 @@ export function downloadFunctions text); + const edgeRuntimeImage: EdgeRuntimeImage | undefined = + !flags.useApi && flags.useDocker + ? (yield* isDockerRunning()) + ? resolvedEdgeRuntimeImage + : yield* output + .raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr") + .pipe(Effect.as(undefined)) + : undefined; + const slugs = Option.isSome(flags.functionName) ? [flags.functionName.value] : yield* listRemoteFunctionSlugs(dependencies.api, projectRef); @@ -831,9 +1366,47 @@ export function downloadFunctions `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolved ONCE here, for the whole invocation, not once per slug + // inside the loop below: the image is identical for every function, so + // per-slug resolution would multiply both the cache-check subprocess + // count and, on a registry outage, the retry-backoff sleep (up to ~36s) + // by the function count — see `PulledEdgeRuntimeImage`'s own doc comment + // for why this diverges from Go's per-container `DockerStart` without + // losing parity (Go's cache check is in-process, not a fork+exec). The + // `--legacy-bundle` suggestion on a resolve failure uses the first slug + // as a representative example, since no single slug is "the" one being + // processed yet at this point. + const styleAqua = dependencies.styleAqua ?? ((text: string) => text); + const pulledEdgeRuntimeImage: PulledEdgeRuntimeImage | undefined = + edgeRuntimeImage === undefined + ? undefined + : { + ...edgeRuntimeImage, + image: yield* resolveFunctionsDockerImage( + edgeRuntimeImage.rawImage, + edgeRuntimeImage.projectEnvValues, + ).pipe(Effect.mapError(withLegacyBundleSuggestion(slugs[0] ?? "", styleAqua))), + }; + const downloaded: string[] = []; for (const slug of slugs) { - downloaded.push(yield* downloadSingle(dependencies, projectRef, slug)); + // Go: CLI-1891, `downloadAll`'s per-item validation runs before any + // per-slug network/filesystem work (`download.go:182-188`). A + // user-supplied slug is already validated above (`validateSlug`); this + // covers slugs sourced from the Management API's function list, which + // this threat model treats as untrusted (a malicious/compromised + // response, or a MITM). + if (Option.isNone(flags.functionName)) { + yield* validateRemoteSlug(slug, styleAqua); + } + if (pulledEdgeRuntimeImage !== undefined) { + downloaded.push( + yield* downloadWithDockerUnbundle(dependencies, pulledEdgeRuntimeImage, projectRef, slug), + ); + } else { + downloaded.push(yield* downloadSingle(dependencies, projectRef, slug)); + } } if (output.format !== "text") { diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts new file mode 100644 index 0000000000..25d1357bd1 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -0,0 +1,55 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../telemetry/error-actionability.ts"; + +/** + * Shared error for a non-OK Management API response where an HTTP status + * code is available, used by both `deploy.ts` and `download.ts`. Keeping one + * class here (rather than duplicating it per file) lets both call sites + * classify identically via {@link statusCodeActionability} instead of + * falling back to a plain `Error` (which reports as `unknown` in the error + * actionability KPI). + */ +export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatusError")<{ + readonly status: number; + readonly message: string; + /** The request path names a user-selected function slug. */ + readonly notFoundIsInvalidInput?: boolean; + /** + * Set when the failure is a successful-status response whose body could not + * be decoded (status is 200/201 but the JSON is malformed / unexpected). + * That is an API-response problem, not a raw status failure, so it classifies + * as `api_status` with the `api_response` fingerprint ahead of the + * status-code policy. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return statusCodeActionability(this.status, { + notFoundIsInvalidInput: this.notFoundIsInvalidInput, + }); + } +} + +/** + * Shared error for a Management API request that failed before a response + * was received (DNS, connection reset, timeout, ...), used by both + * `deploy.ts` and `download.ts`'s `mapTransportError`. Keeping one class here + * (rather than duplicating it per file) lets both call sites classify + * identically as a network failure instead of falling back to a plain + * `Error` (which reports as `unknown` in the error actionability KPI). + */ +export class FunctionsApiTransportError extends Data.TaggedError("FunctionsApiTransportError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } +} diff --git a/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts new file mode 100644 index 0000000000..7ee13800d0 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-api.errors.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; +import { FunctionsApiStatusError } from "./functions-api.errors.ts"; + +describe("FunctionsApiStatusError actionability", () => { + it("keeps a collection-level 404 on the API-status policy", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 404, message: "not found" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_status"); + }); + + it("classifies a user-selected function slug 404 as invalid input", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ + status: 404, + message: "not found", + notFoundIsInvalidInput: true, + }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:not_found"); + }); + + it("keeps a 401 on the auth-login policy", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 401, message: "unauthorized" }), + ); + expect(result.error_category).toBe("auth"); + }); + + it("keeps a 5xx on the API status policy", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ status: 500, message: "server error" }), + ); + expect(result.error_category).toBe("api_status"); + }); + + it("classifies a successful-status decode failure as an api-response problem", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ + status: 201, + message: "failed to read deploy response: unexpected token", + decode: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_response"); + }); + + it("classifies a 200 list-functions decode failure as an api-response problem", () => { + const result = classifyCliErrorActionability( + new FunctionsApiStatusError({ + status: 200, + message: "failed to read functions list: unexpected token", + decode: true, + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:FunctionsApiStatusError:api_response"); + }); +}); diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts new file mode 100644 index 0000000000..7202e6af6a --- /dev/null +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -0,0 +1,100 @@ +import { basename } from "node:path"; +import { Effect, type FileSystem, type Path } from "effect"; +import { loadProjectConfig, type LoadedProjectConfig } from "@supabase/config"; +import { normalizeProjectId } from "./functions-docker.ts"; + +/** + * Everything the native `functions` Docker paths (`deploy`/`download`/`serve`) + * need from project config resolution, unified across both shells. In the + * legacy shell this also runs the same `Config.Validate`/dotenv pipeline + * `start`/`stop`/`status` already go through — see {@link FunctionsGoConfigCompat}. + * `next` keeps its existing plain `loadProjectConfig` behavior exactly (no + * Go-parity claim there). + */ +interface FunctionsProjectConfigContext { + readonly loaded: LoadedProjectConfig | null; + /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` in `next`. */ + readonly projectEnvValues: Readonly> | undefined; + /** Go's `Config.ProjectId`, sanitized, after `Config.Validate` in the legacy shell. */ + readonly projectId: string; + readonly denoVersion: number | undefined; +} + +/** + * Legacy-shell-only Go-parity hook, injected so this file (used by both + * shells) never imports `legacy/`-specific validation/dotenv machinery + * directly — same isolation rationale as `download.ts`'s `styleEmphasis`/ + * `styleAqua`. `undefined` marks the `next` shell. + * + * A single method (not one hook per step) so the legacy implementation can + * delegate its dotenv/config-load work to `legacy-local-project-context.ts`'s + * `legacyLoadLocalProjectContext` end to end — the same pipeline `start`/ + * `stop`/`status` already share — rather than re-implementing it here. + */ +export interface FunctionsGoConfigCompat { + readonly load: (input: { + readonly projectRoot: string; + readonly projectRef: string | undefined; + }) => Effect.Effect< + { + readonly loaded: LoadedProjectConfig | null; + readonly projectEnvValues: Readonly>; + readonly projectId: string; + readonly denoVersion: number; + }, + Error, + FileSystem.FileSystem | Path.Path + >; +} + +/** + * Go: `flags.LoadConfig` (`internal/utils/flags/config_path.go:10-14` -> + * `pkg/config/config.go:579-611,878`) — loads dotenv, decodes config.toml + * (merging template defaults + env even when the file is absent), and ends in + * `Config.Validate`, unconditionally, before any Docker/API work. Only the + * legacy shell (`goConfigCompat` set) runs that Go-parity dotenv/validate + * pipeline; `next` keeps today's plain `loadProjectConfig` behavior exactly. + */ +export const loadFunctionsProjectConfig = Effect.fnUntraced(function* (input: { + readonly projectRoot: string; + readonly projectRef: string | undefined; + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; +}) { + if (input.goConfigCompat === undefined) { + const loaded = yield* loadProjectConfig(input.projectRoot, { + ...(input.projectRef === undefined ? {} : { projectRef: input.projectRef }), + goViperCompat: false, + }); + return { + loaded, + projectEnvValues: undefined, + // `input.projectRef` is a definite string for every current caller + // (`deploy`/`download` always resolve one first); the `basename` + // fallback only matters if this ever runs with `projectRef` + // `undefined` and no `project_id` in the file — matching Go's `Eject` + // basename default (`pkg/config/config.go:561-570`) and the legacy + // branch's own `legacyResolveLocalProjectId` fallback below. + // Sanitized like the legacy branch's (`legacySanitizeProjectId`, run + // inside its validate pipeline): this id feeds `dockerProjectLabels`' + // raw label values as well as `localDockerId`'s (self-sanitizing) + // resource names, and an unsanitized `project_id = "My Project"` would + // label the container `My Project` while its network/volume are named + // `..._My_Project` — breaking label-based cleanup filters. + projectId: normalizeProjectId( + loaded?.config.project_id ?? input.projectRef ?? basename(input.projectRoot), + ), + denoVersion: loaded?.config.edge_runtime.deno_version, + } satisfies FunctionsProjectConfigContext; + } + + const context = yield* input.goConfigCompat.load({ + projectRoot: input.projectRoot, + projectRef: input.projectRef, + }); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + projectId: context.projectId, + denoVersion: context.denoVersion, + } satisfies FunctionsProjectConfigContext; +}); diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts new file mode 100644 index 0000000000..d5872ea2c2 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -0,0 +1,358 @@ +// Docker orchestration primitives shared by `deploy.ts` and `download.ts` +// (the `functions` command family root, `src/shared/functions/`) — plus +// `serve.ts` (same family) and `legacy/shared/db-bootstrap/container-lifecycle.ts` +// (a different family, reaching in for the generic `isUserDefinedDockerNetwork` +// predicate), both of which already imported these primitives from `deploy.ts` +// before this file existed. +import { resolve } from "node:path"; +import { Effect, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; +import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; + +const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; +const MAX_PROJECT_ID_LENGTH = 40; +// Go's `deno1` image tag (`pkg/config/constants.go:15`, +// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim +// into `edgeRuntimeImage` (`functions.shared.ts`) with no `v` synthesis. +const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; + +export function toSlash(pathname: string) { + return pathname.replaceAll("\\", "/"); +} + +export function normalizeProjectId(source: string) { + const sanitized = source.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); + return sanitized.length > MAX_PROJECT_ID_LENGTH + ? sanitized.slice(0, MAX_PROJECT_ID_LENGTH) + : sanitized; +} + +export function localDockerId(name: string, projectId: string) { + return `supabase_${name}_${normalizeProjectId(projectId)}`; +} + +/** + * Go: `DockerStart`'s network selection (`internal/utils/docker.go:379-383`) + * combined with root's `viper.BindPFlags`/`AutomaticEnv` for the persistent + * `--network-id` flag (`cmd/root.go:316-334`). viper's `find()` resolves a + * `Changed` pflag *before* it ever consults a bound env var (`viper.go`'s + * flag-override branch precedes its env-override branch) — so an explicit + * `--network-id=` (empty, but still marks the flag `Changed`) makes + * `viper.GetString("network-id")` return `""` and stop there, WITHOUT + * falling through to `SUPABASE_NETWORK_ID`; only THEN does the consuming + * `len(networkId) > 0` check fall through, straight to the generated + * default. An explicit-but-empty *env* value, by contrast, genuinely means + * unset (viper never enables `AllowEmptyEnv`) and falls through to the + * default the normal way. Net effect: `explicit === undefined` (flag never + * touched) is the ONLY case that consults `envOverride` — `explicit === ""` + * (flag explicitly cleared) skips straight to the generated default, same + * as a non-empty `explicit` skips it by using the flag's own value. Callers + * MUST pass a flag reader that preserves this 3-way distinction — see + * `lastExplicitLongFlagValue` (`shared/cli/cobra-flag-groups.ts`). + * `envOverride` is `undefined` in `next` (no Go-viper env-binding claim + * there) — see `resolveDockerNetworkMode`'s callers. + */ +export function resolveDockerNetworkMode(input: { + readonly explicit: string | undefined; + readonly envOverride: string | undefined; + readonly projectId: string; +}): string { + if (input.explicit !== undefined) { + return input.explicit.length > 0 ? input.explicit : localDockerId("network", input.projectId); + } + if (input.envOverride !== undefined && input.envOverride.length > 0) { + return input.envOverride; + } + return localDockerId("network", input.projectId); +} + +const dockerCliProjectLabel = "com.supabase.cli.project"; +const dockerComposeProjectLabel = "com.docker.compose.project"; + +export function dockerProjectLabels(projectId: string) { + return { + [dockerCliProjectLabel]: projectId, + [dockerComposeProjectLabel]: projectId, + }; +} + +export function toDockerPath(hostPath: string) { + const normalized = toSlash(resolve(hostPath)); + return normalized.replace(/^[A-Za-z]:/, ""); +} + +export interface FunctionsDockerRunSpec { + /** Already registry/pull-resolved image reference. */ + readonly image: string; + /** Go's `Config.ProjectId` — the label value (`docker.go:374-376`). */ + readonly projectId: string; + readonly networkMode: string; + readonly binds: ReadonlyArray; + /** `KEY=VALUE` entries, each emitted as `-e KEY=VALUE`. */ + readonly env?: ReadonlyArray; + /** + * Emitted as `-w ` — Go's bundler sets `WorkingDir: + * utils.ToDockerPath(cwd)` (`bundle.go:79`); the unbundler sets none + * (`download.go:268-281`), so this is optional. + */ + readonly workingDir?: string; + /** argv after the image, e.g. `["bundle", "--entrypoint", …]`. */ + readonly containerArgs: ReadonlyArray; + readonly platform?: NodeJS.Platform; +} + +/** + * Assembles the one-shot `docker run` invocation shared by `deploy.ts`'s + * bundler and `download.ts`'s unbundler containers: binds, network, the + * linux `host.docker.internal` workaround, env, and Go's unconditional + * `com.supabase.cli.project`/`com.docker.compose.project` labels + * (`DockerStart`, `internal/utils/docker.go:349-386`) — previously applied + * only to the network/volume these containers depend on + * (`ensureDockerNetwork`/`ensureDockerNamedVolume` above), never to the + * one-shot containers themselves, so label-based cleanup/inspection couldn't + * associate an orphaned container with the project. + */ +export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array { + const command = ["run", "--rm", ...spec.binds.flatMap((bind) => ["-v", bind])]; + command.push("--network", spec.networkMode); + if ((spec.platform ?? process.platform) === "linux") { + command.push("--add-host", "host.docker.internal:host-gateway"); + } + for (const env of spec.env ?? []) { + command.push("-e", env); + } + if (spec.workingDir !== undefined) { + command.push("-w", spec.workingDir); + } + const labels = dockerProjectLabels(spec.projectId); + command.push( + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + ); + command.push(spec.image, ...spec.containerArgs); + return command; +} + +// Decodes a byte stream to text, both accumulating the full text (returned, +// for callers that need to post-process it, e.g. scanning stderr for +// "invalid eszip v2") AND tee-ing each decoded chunk to `onChunk` as it +// arrives — Go's `DockerStreamLogs`/`DockerRunOnceWithConfig` copy a +// container's log stream live while it runs, rather than buffering the whole +// thing until exit. +function collectByteStream( + stream: Stream.Stream, + onChunk?: (chunk: string) => Effect.Effect, +): Effect.Effect { + return Effect.suspend(() => { + const decoder = new TextDecoder(); + let text = ""; + const append = (chunk: string) => { + text += chunk; + return chunk.length > 0 && onChunk !== undefined ? onChunk(chunk) : Effect.void; + }; + return Stream.runForEach(stream, (bytes) => + append(decoder.decode(bytes, { stream: true })), + ).pipe( + Effect.flatMap(() => append(decoder.decode())), + Effect.map(() => text), + ); + }); +} + +// Runs a container CLI command and collects its output. Every caller runs +// `docker`, so the spawn goes through `spawnContainerCli` to fall back to +// `podman` on Docker-less hosts. `command` is retained for the extendEnv +// default and the `functions serve` dependency-injection seam. +// `Effect.scoped` closes the spawn's own acquireRelease scope as soon as the +// process has exited and both streams are drained — without it, every call +// parks a release finalizer in the CALLER's scope, and `functions serve`'s +// session-long restart loop (one `Effect.scoped` around an infinite loop) +// would accumulate one per docker invocation per file-change restart. +export const runChildProcess = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + opts: { + readonly stdout?: "pipe" | "ignore"; + readonly stderr?: "pipe" | "ignore"; + readonly env?: Readonly>; + readonly extendEnv?: boolean; + /** Tees each decoded stdout chunk as it arrives, live — see {@link collectByteStream}. */ + readonly onStdout?: (chunk: string) => Effect.Effect; + /** Tees each decoded stderr chunk as it arrives, live — see {@link collectByteStream}. */ + readonly onStderr?: (chunk: string) => Effect.Effect; + } = {}, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawnContainerCli(spawner, [...args], { + stdin: "ignore", + stdout: opts.stdout ?? "pipe", + stderr: opts.stderr ?? "pipe", + env: opts.env, + extendEnv: opts.extendEnv ?? command === "docker", + }); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + opts.stdout === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stdout, opts.onStdout), + opts.stderr === "ignore" + ? Effect.succeed("") + : collectByteStream(child.stderr, opts.onStderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }), + ); +}); + +// Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, +// via the unexported `containerID` helper, same file:493-499) — `--network container:` +// (Docker's syntax for attaching to another container's network stack) is recognized by a bare +// `"container:"` prefix before the first `:`, regardless of what (if anything) follows it. +function isContainerDockerNetworkMode(networkMode: string) { + const separatorIndex = networkMode.indexOf(":"); + return separatorIndex !== -1 && networkMode.slice(0, separatorIndex) === "container"; +} + +// Go: `container.NetworkMode.IsUserDefined()` (`docker/api/types/container/hostconfig_unix.go:23-25`) +// — `!IsDefault() && !IsBridge() && !IsHost() && !IsNone() && !IsContainer()`. Omitting the +// `IsContainer()` exclusion would make `DockerNetworkCreateIfNotExists` +// (`internal/utils/docker.go:63`) run `docker network inspect`/`create` against a +// `container:` mode, which isn't a network name at all — Go passes that mode straight +// through to the container's `NetworkMode` without ever touching the network subsystem. +export function isUserDefinedDockerNetwork(networkMode: string) { + return ( + networkMode.length > 0 && + networkMode !== "default" && + networkMode !== "bridge" && + networkMode !== "host" && + networkMode !== "none" && + !isContainerDockerNetworkMode(networkMode) + ); +} + +export const ensureDockerNetwork = Effect.fnUntraced(function* ( + networkMode: string, + projectId: string, +) { + if (!isUserDefinedDockerNetwork(networkMode)) { + return; + } + + const inspect = yield* runChildProcess("docker", ["network", "inspect", networkMode], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + if (inspect.exitCode === 0) { + return; + } + + const labels = dockerProjectLabels(projectId); + const create = yield* runChildProcess( + "docker", + [ + "network", + "create", + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + networkMode, + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); + if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { + return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); + } +}); + +export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( + volumeName: string, + projectId: string, +) { + if (process.env["BITBUCKET_CLONE_DIR"] !== undefined) { + return; + } + + const labels = dockerProjectLabels(projectId); + const create = yield* runChildProcess( + "docker", + [ + "volume", + "create", + "--label", + `${dockerCliProjectLabel}=${labels[dockerCliProjectLabel]}`, + "--label", + `${dockerComposeProjectLabel}=${labels[dockerComposeProjectLabel]}`, + volumeName, + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ); + if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { + return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); + } +}); + +export const isDockerRunning = Effect.fnUntraced(function* () { + const result = yield* runChildProcess("docker", ["info"], { + stdout: "ignore", + stderr: "ignore", + }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + return result.exitCode === 0; +}); + +/** + * Resolves the edge-runtime image TAG (fed verbatim into + * `edgeRuntimeImage`, `functions.shared.ts` — Go's `replaceImageTag` + * semantics, no `v` synthesis). `defaultVersion` is the + * `supabase/.temp/edge-runtime-version` pin when present, else the + * Dockerfile default tag (`resolveEdgeRuntimeVersionPin`); `deno_version = 1` + * overrides EITHER with Go's `deno1` image tag, matching `Config.Validate` + * running after the pin was applied (`pkg/config/config.go:847-849,1164-1169`). + */ +export function resolveEdgeRuntimeVersion( + denoVersion: number | undefined, + defaultVersion: string, +): Effect.Effect { + if (denoVersion === undefined || denoVersion === 2) { + return Effect.succeed(defaultVersion); + } + if (denoVersion === 1) { + return Effect.succeed(DENO1_EDGE_RUNTIME_VERSION); + } + return Effect.fail( + new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), + ); +} + +/** + * Go: `DockerStart` -> `DockerResolveImageIfNotCached`/`DockerImagePullWithRetry` + * (`internal/utils/docker.go:304-348,366-370`) — checks every registry + * candidate (ECR/GHCR/Docker Hub) for a local cache hit first, then pulls + * with 2 retries per candidate (4s/8s backoff), returning whichever + * candidate answered. Shared by both shells' `functions` Docker paths + * (`deploy`/`download`/`serve`) — `legacyGetRegistryImageUrl`'s single-URL + * mapping is already called unconditionally by both today, so the retry is + * strictly-better resilience, not a Go-only quirk. + */ +export const resolveFunctionsDockerImage = Effect.fnUntraced(function* ( + image: string, + projectEnvValues?: Readonly>, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* legacyMakeDockerImageResolver(spawner, projectEnvValues)(image); +}); diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts new file mode 100644 index 0000000000..60eaef8ea2 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildFunctionsDockerRunArgs, + localDockerId, + resolveDockerNetworkMode, + runChildProcess, +} from "./functions-docker.ts"; + +/** + * A `ChildProcessSpawner` layer whose handle emits exactly the given raw + * `Uint8Array` chunks on stdout/stderr — unlike the shared + * `mockChildProcessSpawner` (`packages/process-compose/tests/helpers/mocks.ts`), + * which encodes one full line per chunk, this lets a test place an arbitrary + * byte boundary mid-codepoint to exercise `collectByteStream`'s per-stream + * `TextDecoder` buffering. + */ +function mockStreamingChildProcessLayer( + opts: { + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; + } = {}, +) { + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(opts.stdout ?? []), + stderr: Stream.fromIterable(opts.stderr ?? []), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); +} + +describe("buildFunctionsDockerRunArgs", () => { + it("assembles run/--rm, binds, network, env, labels, image, and container args in order", () => { + const args = buildFunctionsDockerRunArgs({ + image: "supabase/edge-runtime:v1.2.3", + projectId: "my-project", + networkMode: "supabase_network_my-project", + binds: ["/host/a:/container/a", "/host/b:/container/b"], + env: ["FOO=bar", "BAZ=qux"], + containerArgs: ["bundle", "--entrypoint", "index.ts"], + platform: "darwin", + }); + + expect(args).toEqual([ + "run", + "--rm", + "-v", + "/host/a:/container/a", + "-v", + "/host/b:/container/b", + "--network", + "supabase_network_my-project", + "-e", + "FOO=bar", + "-e", + "BAZ=qux", + "--label", + "com.supabase.cli.project=my-project", + "--label", + "com.docker.compose.project=my-project", + "supabase/edge-runtime:v1.2.3", + "bundle", + "--entrypoint", + "index.ts", + ]); + }); + + it("omits --add-host on a non-linux platform", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("--add-host"); + }); + + it("inserts --add-host host.docker.internal:host-gateway between --network and the -e entries on linux", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + env: ["FOO=bar"], + containerArgs: [], + platform: "linux", + }); + + const networkIndex = args.indexOf("--network"); + expect(args.slice(networkIndex, networkIndex + 6)).toEqual([ + "--network", + "bridge", + "--add-host", + "host.docker.internal:host-gateway", + "-e", + "FOO=bar", + ]); + }); + + it("produces no -v flags for an empty binds array", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("-v"); + }); + + it("produces no -e flags when env is omitted", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).not.toContain("-e"); + }); + + it("preserves the input order of multiple binds and env entries", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "p", + networkMode: "bridge", + binds: ["/c:/c", "/a:/a", "/b:/b"], + env: ["C=3", "A=1", "B=2"], + containerArgs: [], + platform: "darwin", + }); + + expect(args.slice(2, 8)).toEqual(["-v", "/c:/c", "-v", "/a:/a", "-v", "/b:/b"]); + const networkIndex = args.indexOf("--network"); + expect(args.slice(networkIndex + 2, networkIndex + 8)).toEqual([ + "-e", + "C=3", + "-e", + "A=1", + "-e", + "B=2", + ]); + }); + + it("uses the exact projectId value in both labels, unsanitized", () => { + const args = buildFunctionsDockerRunArgs({ + image: "img", + projectId: "My Weird/Project!!", + networkMode: "bridge", + binds: [], + containerArgs: [], + platform: "darwin", + }); + + expect(args).toContain("--label"); + expect(args).toContain("com.supabase.cli.project=My Weird/Project!!"); + expect(args).toContain("com.docker.compose.project=My Weird/Project!!"); + }); +}); + +describe("resolveDockerNetworkMode", () => { + it("prefers the explicit flag over the env override when both are set", () => { + expect( + resolveDockerNetworkMode({ + explicit: "explicit-network", + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe("explicit-network"); + }); + + it("falls back to the env override when explicit is undefined", () => { + expect( + resolveDockerNetworkMode({ + explicit: undefined, + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe("env-network"); + }); + + it("treats an explicit empty flag (--network-id=) as skipping straight to the generated default, not the env override", () => { + // Go parity: viper's Changed pflag wins over AutomaticEnv outright — an + // explicit `--network-id=` never falls back to SUPABASE_NETWORK_ID, only + // an OMITTED flag does. + expect( + resolveDockerNetworkMode({ + explicit: "", + envOverride: "env-network", + projectId: "my-project", + }), + ).toBe(localDockerId("network", "my-project")); + }); + + it("treats an empty env override as unset and falls through to the generated default", () => { + expect( + resolveDockerNetworkMode({ + explicit: undefined, + envOverride: "", + projectId: "my-project", + }), + ).toBe(localDockerId("network", "my-project")); + }); + + it("generates supabase_network_ when both are unset", () => { + const result = resolveDockerNetworkMode({ + explicit: undefined, + envOverride: undefined, + projectId: "my-project", + }); + + expect(result).toBe(localDockerId("network", "my-project")); + expect(result).toBe("supabase_network_my-project"); + }); +}); + +describe("runChildProcess", () => { + it.effect( + "tees a multi-byte UTF-8 character split across a chunk boundary, decoding it correctly in both the live tee and the accumulated stdout, and never tees an empty string", + () => + Effect.gen(function* () { + // "café"'s bytes are [c, a, f, 0xC3, 0xA9] — "é" is the 2-byte sequence + // 0xC3 0xA9. Chunk 1 ends right after the leading byte (incomplete on + // its own); chunk 2 is a genuinely empty chunk (decodes to "", must + // never be teed); chunk 3 carries only the trailing byte, completing + // "é" once joined with the decoder's buffered leading byte. + const full = new TextEncoder().encode("café"); + const chunk1 = full.slice(0, 4); + const chunk2 = new Uint8Array(0); + const chunk3 = full.slice(4); + + const stdoutTee: Array = []; + const result = yield* runChildProcess("docker", ["logs"], { + onStdout: (chunk) => Effect.sync(() => stdoutTee.push(chunk)), + }).pipe( + Effect.provide(mockStreamingChildProcessLayer({ stdout: [chunk1, chunk2, chunk3] })), + ); + + expect(result.stdout).toBe("café"); + // The teed chunks, concatenated, must equal the returned stdout exactly. + expect(stdoutTee.join("")).toBe(result.stdout); + expect(stdoutTee).not.toContain(""); + expect(stdoutTee.every((chunk) => chunk.length > 0)).toBe(true); + }), + ); + + it.effect("tees stderr independently of stdout, both live and in the returned strings", () => + Effect.gen(function* () { + const encoder = new TextEncoder(); + const stdoutChunks = [encoder.encode("stdout-"), encoder.encode("chunk")]; + const stderrChunks = [encoder.encode("stderr-"), encoder.encode("chunk")]; + + const stdoutTee: Array = []; + const stderrTee: Array = []; + const result = yield* runChildProcess("docker", ["logs"], { + onStdout: (chunk) => Effect.sync(() => stdoutTee.push(chunk)), + onStderr: (chunk) => Effect.sync(() => stderrTee.push(chunk)), + }).pipe( + Effect.provide( + mockStreamingChildProcessLayer({ stdout: stdoutChunks, stderr: stderrChunks }), + ), + ); + + expect(result.stdout).toBe("stdout-chunk"); + expect(result.stderr).toBe("stderr-chunk"); + expect(stdoutTee.join("")).toBe(result.stdout); + expect(stderrTee.join("")).toBe(result.stderr); + // Neither stream's tee ever observes so much as a fragment of the other. + expect(stdoutTee.some((chunk) => chunk.includes("stderr"))).toBe(false); + expect(stderrTee.some((chunk) => chunk.includes("stdout"))).toBe(false); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 8c961e867d..63f740c849 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,3 +1,8 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; + const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; export const invalidFunctionSlugDetail = @@ -16,3 +21,47 @@ export const FUNCTIONS_PROJECT_REF_SAFE_FLAGS = ["project-ref"] as const; // `MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")` // (`cmd/functions.go:158,182`). export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-bundle"] as const; + +// Go: `Images.EdgeRuntime` is baked into the binary via the embedded +// Dockerfile (`pkg/config/constants.go:40-58`; `legacy-edge-runtime-image.ts` +// reads the same source) — sourced from there rather than `@supabase/stack`'s +// independently-maintained catalog, so a Dockerfile pin bump can never drift +// from what the `functions` Docker paths resolve. +const DEFAULT_EDGE_RUNTIME_IMAGE = dockerfileServiceImage("edgeruntime"); +const DEFAULT_EDGE_RUNTIME_TAG = DEFAULT_EDGE_RUNTIME_IMAGE.split(":")[1] ?? ""; + +/** + * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) + * — everything after the image's first `:` is replaced with `tag` VERBATIM, + * no `v` synthesis. A bare pin like `latest` or `9.9.9` therefore produces + * `supabase/edge-runtime:latest`/`:9.9.9`, exactly as Go does (an earlier + * revision `v`-prefixed bare pins here, which broke pins that work in Go and + * made this path disagree with `legacy-edge-runtime-image.ts`'s faithful + * `replaceImageTag` port reading the SAME pin file — review round on + * CLI-1963). Both non-pin sources are already full tags: the Dockerfile + * default above and `resolveEdgeRuntimeVersion`'s deno-1 constant. + * Single home for the repository too — only the tag half is parameterized, + * so a `supabase/edge-runtime` rename in the Dockerfile propagates whole. + */ +export function edgeRuntimeImage(tag: string): string { + const index = DEFAULT_EDGE_RUNTIME_IMAGE.indexOf(":"); + return DEFAULT_EDGE_RUNTIME_IMAGE.slice(0, index + 1) + tag.trim(); +} + +/** + * Go: `Config.EdgeRuntime.Image` reflects `supabase/.temp/edge-runtime-version` + * when present (`pkg/config/config.go:847-849`) — shared by every `functions` + * command that resolves a Docker edge-runtime image: `deploy`/`download` in + * both shells, plus `serve` (legacy-only — `next` has no native `serve`). + * Single home for the file-read rather than several copies of the same + * `readFile` -> `trim` -> fallback pipeline. + */ +export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabaseDir: string) { + return yield* Effect.tryPromise(() => + readFile(join(supabaseDir, ".temp", "edge-runtime-version"), "utf8"), + ).pipe( + Effect.map((version) => version.trim()), + Effect.catch(() => Effect.succeed("")), + Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), + ); +}); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index b19dd06d2e..2ef8eecfea 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -27,12 +27,12 @@ import { legacyDescribeContainerCliFailure, spawnContainerCli, } from "../../legacy/shared/legacy-container-cli.ts"; -import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL, legacyIsDockerDaemonUnreachable, } from "../../legacy/shared/legacy-docker-suggest.ts"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; import { resolveRemoteJwks, resolveThirdPartyIssuerUrl, @@ -46,25 +46,30 @@ import { type FileWatchEvent, } from "../runtime/file-watcher.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; -import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { buildDockerBinds, discoverFunctionSlugs, dockerBindContainerPath, dockerBindHostPath, - dockerProjectLabels, dockerWorkdirLabel, + rawFunctionConfigRecord, + resolveFunctionConfigs, + type ResolvedDeployFunctionConfig, +} from "./deploy.ts"; +import { + dockerProjectLabels, ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, normalizeProjectId, - rawFunctionConfigRecord, + resolveDockerNetworkMode, resolveEdgeRuntimeVersion, - resolveFunctionConfigs, + resolveFunctionsDockerImage, runChildProcess, toDockerPath, - type ResolvedDeployFunctionConfig, -} from "./deploy.ts"; +} from "./functions-docker.ts"; +import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; +import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); @@ -96,7 +101,6 @@ const ignoredDirNames = new Set([ ]); const dockerLogRetryDelay = Duration.millis(400); const dockerLogDiagnosticTailLength = 4_096; -const legacyDefaultEdgeRuntimeImage = dockerfileServiceImage("edgeruntime"); const defaultSupabaseEnv = "development"; const serveMainContainerPath = "/root/index.ts"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -140,6 +144,13 @@ export interface FunctionsServeDependencies { readonly networkId: Option.Option; readonly projectIdOverride: Option.Option; readonly goViperCompat: boolean; + /** + * `undefined` in `next`; the legacy shell injects + * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` + * directly — see {@link FunctionsGoConfigCompat}. Distinct from + * `goViperCompat` above, which only gates `env(...)` interpolation. + */ + readonly goConfigCompat: FunctionsGoConfigCompat | undefined; } interface PlainServeAuthConfig { @@ -169,6 +180,8 @@ interface ServeResolvedConfig { readonly configFunctions: Readonly>; readonly rawConfigFunctions: Readonly>>>; readonly configPath?: string; + /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` in `next`. */ + readonly projectEnvValues: Readonly> | undefined; } interface ServeFunctionContainerConfig { @@ -676,6 +689,7 @@ const resolveServeConfig = Effect.fnUntraced(function* ( projectRoot: string, projectIdOverride: Option.Option, goViperCompat: boolean, + goConfigCompat: FunctionsGoConfigCompat | undefined, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); const projectRef = Option.match(projectIdOverride, { @@ -689,10 +703,20 @@ const resolveServeConfig = Effect.fnUntraced(function* ( // environment. We resolve that environment ourselves (Go-accurate, layering // `.env.`/`.env.local`/`.env` over the ambient env) and pass it // in, so loading neither re-reads those files nor mutates `process.env`. + // + // `search: false`/`tomlOnly: true` when `goConfigCompat` is set (legacy + // shell): this MUST match `loadFunctionsProjectConfig`'s own options below + // exactly, or the two loads can resolve two different files (an ancestor's + // config.toml vs this dir's; a stray config.json vs config.toml) — one + // supplying `auth`/`edgeRuntime`/`apiPort` here, the other supplying + // `denoVersion`/`Config.Validate` below, silently mixing fields from two + // different projects. `next` (`goConfigCompat === undefined`) keeps the + // package defaults (ancestor search, JSON preferred), unchanged. const loadedConfig = yield* loadProjectConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { projectEnv }), goViperCompat, + ...(goConfigCompat === undefined ? {} : { search: false, tomlOnly: true }), }); const baseConfig = loadedConfig?.config ?? defaultProjectConfig; @@ -741,15 +765,59 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); + // Go: `flags.LoadConfig` -> `Config.Validate` (`pkg/config/config.go:878,989-1192`) + // — `restartEdgeRuntime` runs this FIRST, before `AssertSupabaseDbIsRunning` + // (see this function's own caller for that ordering) — so an invalid + // config must fail here too, before any Docker check. Legacy shell only; + // `next` keeps its own package-default config resolution above unchanged. + // A second, independent config/dotenv load (rather than reusing this + // function's own `loadedConfig`/`projectEnv` above) — that pipeline's + // `env(...)`-interpolation purpose is unrelated to Go's `SUPABASE_*` + // `AutomaticEnv` override system this one provides, and the two shouldn't + // be entangled for a shipped, long-running command's config path. + // `search`/`tomlOnly` are aligned with this file's own `loadedConfig` call + // above (see its comment) so the two loads can never disagree about which + // file is "the" project config. `projectEnvValues` (for registry/network-id + // env lookups, this file's own caller) and the env-overridden + // `deno_version` are consumed from it; `auth`/`apiPort`/functions above + // keep their existing derivation. `projectId` also keeps its existing + // derivation — a known gap, narrow to trigger but NOT cosmetic when hit: + // unlike `deploy`/`download` (which use `context.projectId` outright), + // `rawProjectId` below only ever sees `SUPABASE_PROJECT_ID` from the + // *ambient* shell (`projectIdOverride`, from `LegacyCliConfig`), not from + // project dotenv. A project that sets it only in `supabase/.env` therefore + // gets a different `supabase_edge_runtime_`/`supabase_network_` + // here than `deploy`/`download`/`start` resolve for the SAME project — so + // `serve` creates a second network and a container `reloadKong(projectId)`'s + // Kong (named off the other id) can't route to: a silently non-functional + // `serve`, where Go reads one `Config.ProjectId` for everything. Folding + // `goContext.projectEnvValues` in here would also require reconciling this + // function's `projectIdOverride`-wins-unconditionally precedence with + // `legacyResolveLocalProjectId`'s config-file-wins-over-`projectRef` + // precedence (they're not the same order) — left open rather than risking + // that regression under time pressure (review round on CLI-1963). + const goContext = + goConfigCompat === undefined + ? undefined + : yield* loadFunctionsProjectConfig({ + projectRoot, + projectRef, + goConfigCompat, + }); + return { projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), apiPort, auth, - edgeRuntime, + edgeRuntime: + goContext === undefined + ? edgeRuntime + : { ...edgeRuntime, deno_version: goContext.denoVersion }, configDeclaredFunctions, configFunctions, rawConfigFunctions: rawFunctionConfigRecord(loadedConfig?.document), configPath: loadedConfig?.path, + projectEnvValues: goContext?.projectEnvValues, } satisfies ServeResolvedConfig; }); @@ -1385,10 +1453,6 @@ async function writeServeMainTemplateFile(template: string, dir: string) { return { bind: `${pathname}:${serveMainContainerPath}:ro,Z` } as const; } -function edgeRuntimeImageTag(version: string) { - return version.startsWith("v") ? version : `v${version}`; -} - const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( projectRoot: string, supabaseDir: string, @@ -1742,31 +1806,35 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { input.dependencies.projectRoot, input.dependencies.projectIdOverride, input.dependencies.goViperCompat, + input.dependencies.goConfigCompat, ); const projectId = resolved.projectId; const containerId = localDockerId("edge_runtime", projectId); let ownsRuntime = false; let startedRuntime: StartedRuntime | undefined; return yield* Effect.gen(function* () { - const networkMode = Option.getOrElse(input.networkId, () => - localDockerId("network", projectId), - ); + // `SUPABASE_NETWORK_ID` (env or project dotenv) is legacy-shell-only — + // same Go-viper-parity gate as `resolved.projectEnvValues` itself + // (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: Option.getOrUndefined(input.networkId), + envOverride: + resolved.projectEnvValues === undefined + ? undefined + : legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + resolved.projectEnvValues, + ), + projectId, + }); const localAuthArtifacts = yield* resolveLocalAuthArtifacts(resolved.auth, resolved.configPath); - const edgeRuntimeVersionOverride = yield* Effect.tryPromise(() => - readFile(join(input.dependencies.supabaseDir, ".temp", "edge-runtime-version"), "utf8"), - ).pipe( - Effect.map((value) => value.trim()), - Effect.catch(() => Effect.succeed("")), + const edgeRuntimeVersionOverride = yield* resolveEdgeRuntimeVersionPin( + input.dependencies.supabaseDir, ); const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( resolved.edgeRuntime.deno_version, edgeRuntimeVersionOverride, ); - const image = legacyGetRegistryImageUrl( - edgeRuntimeVersion.length === 0 - ? legacyDefaultEdgeRuntimeImage - : `supabase/edge-runtime:${edgeRuntimeImageTag(edgeRuntimeVersion)}`, - ); yield* assertLocalDbRunning(projectId); yield* bestEffortRemoveContainer(containerId); @@ -1786,6 +1854,33 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // (`edge-runtime.service.ts`), which resolves its own JWKS. const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); + // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) + // — resolved here, not earlier: `hasLocalImage` fails fast on an + // unreachable daemon, which would otherwise hijack the down-daemon + // message `assertLocalDbRunning` above is responsible for producing. + // + // Known ordering divergence (not fixed here — see below): Go's own + // `ServeFunctions` (`serve.go:134-167`) parses `--env-file` and every + // per-function config BEFORE ever calling `DockerStart` + // (`serve.go:218`), so a broken env file or function config fails fast, + // before any pull. This port's `startEdgeRuntimeContainer` (below) does + // that same parsing internally, but AFTER receiving an already-resolved + // `image` — so on a cold image cache, a broken `--env-file` now surfaces + // after a potentially slow `docker pull` instead of immediately. Fixing + // this properly means splitting `startEdgeRuntimeContainer` into a + // "build container config" phase and a "run it" phase so this resolve + // can move between them — but that function is also `start`'s bring-up + // core (`edge-runtime.service.ts`), which already passes in a + // pre-resolved image via `legacyEnsureImagesCached`, so restructuring it + // risks that shipped, more critical path. Left as a documented + // UX-only regression (the command still fails with the right error, + // just later) rather than a hasty change to shared, `start`-critical + // code (review round on CLI-1963). + const image = yield* resolveFunctionsDockerImage( + edgeRuntimeImage(edgeRuntimeVersion), + resolved.projectEnvValues, + ); + startedRuntime = yield* startEdgeRuntimeContainer({ config: { projectId, diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts index 37454006ac..1e3d7b4195 100644 --- a/apps/cli/src/shared/init/project-init.errors.ts +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{ readonly detail: string; @@ -7,4 +12,8 @@ export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsE override get message() { return "Failed to parse existing IDE settings file."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 03f74e840e..c877b43951 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -338,3 +338,67 @@ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record` occurrence in argv resolves to a pflag `false` + * (`PFLAG_FALSE_VALUES`, matching `ParseBool`'s false set). pflag's `Value.Set` runs for every + * occurrence in argv order, so the last one wins: `--debug=false --debug=true` (or a trailing + * bare `--debug`) is `true` to Go/pflag, not `false` — the Effect parser itself resolves repeats + * first-wins instead (binary-verified precedent for this exact pflag-vs-Effect divergence: + * `apps/cli/src/legacy/commands/sso/sso.pflag-reconcile.ts:306-321`). `--debug` is bound to + * viper the same way as `--yes`/`--experimental` (`apps/cli-go/cmd/root.go:318-334`). + * {@link legacyYesFlagExplicitlyFalse}/{@link legacyExperimentalFlagExplicitlyFalse} above have + * the identical `Array.some` "any occurrence is false" gap (review: PRRT_kwDOErm0O86XKYiG) — + * left as-is here as a pre-existing, cross-cutting fix spanning those two flags too, not folded + * into this port (same scoping precedent as this file's own {@link legacyResolveDebugWithProjectEnv} + * doc comment for existing `LegacyDebugFlag` call sites). Like those siblings, this scans only the + * flag-parsing region (see {@link argsBeforeOperandTerminator}) and skips tokens pflag would + * consume as another flag's value (see {@link nonValueConsumedTokens}) — `db pull -- --debug=false` + * and `db pull --password --debug=false` leave `--debug` unchanged to pflag, so `SUPABASE_DEBUG` + * must still win. + */ +const legacyDebugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { + let lastExplicitlyFalse = false; + for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { + if (arg === "--debug") { + lastExplicitlyFalse = false; + } else if (arg.startsWith("--debug=")) { + lastExplicitlyFalse = PFLAG_FALSE_VALUES.has(arg.slice("--debug=".length)); + } + } + return lastExplicitlyFalse; +}; + +/** + * `--debug` resolved with Go's viper `AutomaticEnv` fallback (EVERY Go debug read goes through + * `viper.GetBool("DEBUG")` — never the bare pflag — across the whole Go CLI, `apps/cli-go/cmd/ + * root.go:122,289`, `internal/utils/{connect,docker,edgeruntime,logger}.go`, + * `internal/pgdelta/apply.go:332,342`, …) AND the project `.env` consulted too, for debug-gated + * behavior that runs downstream of a command that has already loaded the nested project env + * (e.g. `legacyApplyDeclarativePgDelta`, reached by `db diff`/`db pull` after + * `ParseDatabaseConfig`; `legacyBuildShadowCatalogInputs`, reached by `db diff --from/--to + * migrations` and `db schema declarative sync`). Go's `Config.Load` -> `loadNestedEnv` calls + * `godotenv.Load`, which `os.Setenv`s every project `.env` key not already present in the shell + * env (`godotenv@v1.5.1/godotenv.go:184-200`) — a REAL process-wide mutation that persists for + * the rest of that Go process, so a later `viper.GetBool("DEBUG")` (e.g. + * `pgdelta.ApplyDeclarative`, `apply.go:332,342`) sees a `SUPABASE_DEBUG` set only in + * `supabase/.env`. This port's own `legacyLoadProjectEnv` is deliberately pure (no + * `process.env` side effect, see its doc comment), so callers that need that same env-file + * value for a `viper.GetBool`-shaped read must pass the loaded map through explicitly instead + * — same shape as {@link legacyResolveYesWithProjectEnv}/ + * {@link legacyResolveExperimentalWithProjectEnv} above (review: PRRT_kwDOErm0O86XL_oz). + * Shell *presence* — any value, including `false`, empty, or garbage — suppresses the file + * value entirely; an explicit `--debug` — including `--debug=false` — wins over both, matching + * viper's bound-pflag precedence. `projectEnv` is the loaded map from `legacyLoadProjectEnv` + * (or `legacyReadDbToml`'s re-export of it). Existing bare {@link LegacyDebugFlag} call sites + * are unaffected — this is additive, for call sites that opt in. + */ +export const legacyResolveDebugWithProjectEnv = (projectEnv: Record) => + Effect.gen(function* () { + const flag = yield* LegacyDebugFlag; + const cliArgs = yield* CliArgs; + if (legacyDebugFlagExplicitlyFalse(cliArgs.args)) { + return false; + } + return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv); + }); diff --git a/apps/cli/src/shared/legacy/global-flags.unit.test.ts b/apps/cli/src/shared/legacy/global-flags.unit.test.ts index ec54f34982..561c365522 100644 --- a/apps/cli/src/shared/legacy/global-flags.unit.test.ts +++ b/apps/cli/src/shared/legacy/global-flags.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import { CliArgs } from "../cli/cli-args.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyAgentFlag, @@ -13,6 +14,7 @@ import { LegacyWorkdirFlag, LegacyYesFlag, legacyGlobalFlagValues, + legacyResolveDebugWithProjectEnv, } from "./global-flags.ts"; describe("legacyGlobalFlagValues", () => { @@ -81,3 +83,50 @@ describe("legacyGlobalFlagValues", () => { ); }); }); + +describe("legacyResolveDebugWithProjectEnv", () => { + it.live( + "ignores a --debug=false-style token after the -- operand terminator (not an explicit false)", + () => { + // `LegacyDebugFlag: true` stands in for a REAL `--debug` occurrence before the `--` + // terminator; the trailing `--debug=false` is a positional operand (e.g. a migration + // name that happens to look like a flag) — `legacyDebugFlagExplicitlyFalse`'s + // `argsBeforeOperandTerminator` guard must never see it, so the resolved value stays + // the flag's own `true` rather than being flipped to `false`. + const layer = Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["db", "pull", "--", "--debug=false"] }), + ); + return legacyResolveDebugWithProjectEnv({}).pipe( + Effect.provide(layer), + Effect.tap((resolved) => + Effect.sync(() => { + expect(resolved).toBe(true); + }), + ), + ); + }, + ); + + it.live( + "ignores a --debug=false token consumed as another flag's value (e.g. --password)", + () => { + // `--password` is a `VALUE_CONSUMING_LONG_FLAGS` entry, so real pflag semantics parse + // `--password --debug=false` as `--password`'s space-separated value being the literal + // string `"--debug=false"`, not a changed `--debug` — `nonValueConsumedTokens` must skip + // it, so the resolved value stays the flag's own `true`. + const layer = Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["db", "pull", "--password", "--debug=false"] }), + ); + return legacyResolveDebugWithProjectEnv({}).pipe( + Effect.provide(layer), + Effect.tap((resolved) => + Effect.sync(() => { + expect(resolved).toBe(true); + }), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index d359113450..d4c51342b9 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -202,10 +202,17 @@ export function makeGoProxyLayer(opts?: { // Scoped via `Effect.scoped` so listeners are always removed on // normal completion, failure, or fiber interruption. yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - // Per-call env (execOpts.env) overlays the construction-time env; - // `extendEnv: true` keeps both on top of the inherited process env. - const env = - opts?.env || execOpts?.env ? { ...opts?.env, ...execOpts?.env } : undefined; + // Only an instrumented caller that delegates the whole command + // suppresses child telemetry, because there the parent already + // emits `cli_command_executed`. Pure proxy commands have no + // parent event, so the child must stay free to report. + const env = { + ...opts?.env, + ...execOpts?.env, + ...(execOpts?.suppressChildTelemetry === true + ? { SUPABASE_TELEMETRY_DISABLED: "1" } + : {}), + }; const command = ChildProcess.make(binary, [...globalArgs, ...args], { cwd: execOpts?.cwd ?? opts?.cwd, env, @@ -242,8 +249,15 @@ export function makeGoProxyLayer(opts?: { } const binary = resolved.found; yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - const env = - opts?.env || execOpts?.env ? { ...opts?.env, ...execOpts?.env } : undefined; + // Same rule as `exec`: only an instrumented caller that owns the + // parent `cli_command_executed` event suppresses child telemetry. + const env = { + ...opts?.env, + ...execOpts?.env, + ...(execOpts?.suppressChildTelemetry === true + ? { SUPABASE_TELEMETRY_DISABLED: "1" } + : {}), + }; // Capture stdout (pipe) while keeping stderr inherited, so the child's // progress still reaches the user but its stdout is collected for // wrapping rather than written to our stdout. stdin defaults to diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index 7a305197a8..31a46f8e39 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -283,6 +283,51 @@ describe("makeGoProxyLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("leaves child telemetry enabled for pure proxy commands", () => { + // Pure proxy commands (`migration squash`, `db branch *`, `db remote *`, + // `gen keys`) have no TS instrumentation, so the Go child is the only + // emitter of `cli_command_executed`. Disabling it here would drop those + // commands from telemetry entirely. + const spawner = mockSpawner({ kind: "success", code: 0 }); + const pc = mockProcessControl(); + const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( + Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), + ); + return Effect.gen(function* () { + const proxy = yield* LegacyGoProxy; + yield* proxy.exec(["migration", "squash"]); + yield* proxy.execCapture(["gen", "keys"]); + + for (const captured of spawner.spawned) { + expect(captured.options.env ?? {}).not.toHaveProperty("SUPABASE_TELEMETRY_DISABLED"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("suppresses child telemetry when the caller owns the parent event", () => { + // Instrumented handlers that delegate the whole command (db pull/diff/reset, + // functions download) already emit `cli_command_executed` themselves, so the + // child's copy would double-count. + const spawner = mockSpawner({ kind: "success", code: 0 }); + const pc = mockProcessControl(); + const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( + Layer.provide(Layer.mergeAll(spawner.layer, pc.layer)), + ); + return Effect.gen(function* () { + const proxy = yield* LegacyGoProxy; + yield* proxy.exec(["db", "pull"], { suppressChildTelemetry: true }); + yield* proxy.execCapture(["db", "diff"], { + env: { CUSTOM: "kept" }, + suppressChildTelemetry: true, + }); + + for (const captured of spawner.spawned) { + expect(captured.options.env).toMatchObject({ SUPABASE_TELEMETRY_DISABLED: "1" }); + } + expect(spawner.spawned[1]?.options.env).toMatchObject({ CUSTOM: "kept" }); + }).pipe(Effect.provide(layer)); + }); + it.effect("propagates non-zero exit codes via LegacyGoChildExitError", () => { const spawner = mockSpawner({ kind: "success", code: 7 }); const pc = mockProcessControl(); diff --git a/apps/cli/src/shared/legacy/go-proxy.service.ts b/apps/cli/src/shared/legacy/go-proxy.service.ts index 6ea6a50eb4..6bc2595463 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -17,10 +17,22 @@ interface LegacyGoProxyShape { * use it to pass values the user supplied as environment variables back to the * proxy as environment variables, rather than cross-mapping them onto CLI * flags (CLI-1617). + * + * `opts.suppressChildTelemetry` disables telemetry in the child. Set it ONLY + * from a handler that is itself wrapped in command instrumentation and + * delegates the whole command to Go, where the parent already emits + * `cli_command_executed` and the child's copy would double-count. Pure proxy + * handlers (no TS instrumentation) must leave it unset: for those the Go + * child is the only emitter, and disabling it would drop the command from + * telemetry entirely. */ readonly exec: ( args: ReadonlyArray, - opts?: { readonly cwd?: string; readonly env?: Record }, + opts?: { + readonly cwd?: string; + readonly env?: Record; + readonly suppressChildTelemetry?: boolean; + }, ) => Effect.Effect; /** @@ -49,6 +61,7 @@ interface LegacyGoProxyShape { readonly cwd?: string; readonly env?: Record; readonly stdin?: "inherit" | "ignore"; + readonly suppressChildTelemetry?: boolean; }, ) => Effect.Effect; } diff --git a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts index c0cd93673e..09b8be2ba0 100644 --- a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts +++ b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts @@ -1,5 +1,11 @@ import { Data, Runtime } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + /** * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, or * (historically, before CLI-1955 removed it) the hidden `db __db-bootstrap` seam — @@ -52,4 +58,8 @@ export class LegacyGoChildExitError extends Data.TaggedError("LegacyGoChildExitE readonly message: string; }> { override readonly [Runtime.errorExitCode] = this.exitCode; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.unknown; + } } diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 39bc34be27..c78861075c 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; /** * Byte-for-byte render of Go's `context.Canceled` sentinel. @@ -33,4 +38,8 @@ export class NonInteractiveError extends Data.TaggedError("NonInteractiveError") override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index e0930473d2..da68d1e74b 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -200,12 +200,18 @@ export function normalizeCliError( const code = rawCode === "UnknownSubcomand" ? "UnknownSubcommand" : rawCode; const message = readString(error, "message") ?? readString(error, "detail") ?? code; const detail = readString(error, "detail"); - const suggestion = readString(error, "suggestion"); + // Raw read: some producers' suggestion text is meaningful leading/trailing + // whitespace, not incidental — e.g. `suggestLegacyBundle`'s Go-parity + // string (`shared/functions/download.ts`) starts with `\n` to reproduce + // Go's blank separator line before the hint (`cmd/root.go:301-302`, + // `Fprintln(os.Stderr, CmdSuggestion)`). `readString` would trim exactly + // that away. + const suggestion = readRawString(error, "suggestion"); return { code, message, ...(detail && detail !== message ? { detail } : {}), - ...(suggestion ? { suggestion } : {}), + ...(suggestion !== undefined && suggestion.length > 0 ? { suggestion } : {}), }; } diff --git a/apps/cli/src/shared/runtime/file-watcher.service.ts b/apps/cli/src/shared/runtime/file-watcher.service.ts index b7dfb6c05a..70f0c5f047 100644 --- a/apps/cli/src/shared/runtime/file-watcher.service.ts +++ b/apps/cli/src/shared/runtime/file-watcher.service.ts @@ -1,6 +1,12 @@ import type { Stream } from "effect"; import { Data, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + type FileWatchEventType = "create" | "update" | "delete"; export interface FileWatchEvent { @@ -15,7 +21,11 @@ export interface FileWatchOptions { export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ readonly path: string; readonly cause: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} interface FileWatcherShape { readonly watch: ( diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 94e9c59d84..56e5aa666d 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -4,6 +4,11 @@ import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { renderGlamourTable } from "../../legacy/output/legacy-glamour-table.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; import { dockerfileServiceImages, parseDockerfileServiceImages, @@ -197,9 +202,14 @@ export interface ServiceFetchConfig { readonly tenantBaseUrlOverride?: string; } -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +/** @public */ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function fieldValue(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null) { diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.ts b/apps/cli/src/shared/telemetry/command-instrumentation.ts index 1b77bc35a3..3cb3b4477e 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, Exit, Option, Stdio } from "effect"; +import { Cause, Clock, Effect, Exit, Option, Stdio } from "effect"; import { CommandRuntime, getCommandRuntimeCommand, @@ -7,6 +7,13 @@ import { import { Output } from "../output/output.service.ts"; import { withAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; +import { + EventCommandExecuted, + PropDurationMs, + PropExitCode, + PropOutputFormat, +} from "./event-catalog.ts"; +import { failureTelemetryPropertiesForCause } from "./failure-metadata.ts"; interface CommandInstrumentationOptions = never> { readonly analytics?: boolean; @@ -119,12 +126,21 @@ function withCommandAnalyticsImplementation + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.void, + ), + ); if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts index 13b6f6d999..10cb1dd66f 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts @@ -1,11 +1,48 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Cause, Data, Effect, Exit, Layer, Option, Stdio } from "effect"; import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; import { withCommandInstrumentation } from "./command-instrumentation.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "./error-actionability.ts"; +import { + PropErrorCategory, + PropErrorFingerprint, + PropErrorKind, + PropHasSuggestion, + PropSuggestedCommand, + PropSuggestionType, + PropWorkflow, +} from "./event-catalog.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +const FAILURE_PROPERTY_NAMES = [ + PropErrorKind, + PropErrorCategory, + PropErrorFingerprint, + PropHasSuggestion, + PropSuggestionType, + PropSuggestedCommand, + PropWorkflow, +] as const; + +class InstrumentationAuthError extends Data.TaggedError("InstrumentationAuthError")<{ + readonly message: string; + readonly path: string; + readonly sql: string; + readonly projectRef: string; + readonly hostname: string; + readonly token: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} + function mockContextualAnalytics() { const captured: Array<{ event: string; @@ -35,6 +72,30 @@ function mockContextualAnalytics() { return { layer, captured }; } +function failingAnalytics(defect: unknown) { + return Layer.succeed( + Analytics, + Analytics.of({ + capture: () => Effect.die(defect), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); +} + +function interruptingAnalytics() { + return Layer.succeed( + Analytics, + Analytics.of({ + capture: () => Effect.interrupt, + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); +} + describe("withCommandInstrumentation", () => { it.live("creates a command span and annotates it with command metadata", () => { const analytics = mockContextualAnalytics(); @@ -92,15 +153,27 @@ describe("withCommandInstrumentation", () => { expect(command?.properties.flags_used).toEqual(["detach", "exclude"]); expect(command?.properties.flag_values).toEqual({}); expect(command?.properties.exit_code).toBe(0); + for (const property of FAILURE_PROPERTY_NAMES) { + expect(command?.properties).not.toHaveProperty(property); + } }), ), ); }); - it.live("captures failed commands with a non-zero exit code", () => { + it.live("adds sanitized actionability metadata and preserves the original failure", () => { const analytics = mockContextualAnalytics(); + const secrets = { + message: "failed at /Users/alice/private/config.toml", + path: "/Users/alice/private/config.toml", + sql: "select * from customer_private_table", + projectRef: "abcdefghijklmnopqrst", + hostname: "db.customer.internal", + token: "customer-secret-token", + }; + const failure = new InstrumentationAuthError(secrets); - const program = withCommandInstrumentation()(Effect.fail(new Error("boom"))).pipe( + const program = withCommandInstrumentation()(Effect.fail(failure)).pipe( Effect.provide(analytics.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( @@ -110,11 +183,28 @@ describe("withCommandInstrumentation", () => { ), Effect.provide(commandRuntimeLayer(["login"])), Effect.exit, - Effect.tap(() => + Effect.tap((exit) => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); - expect(analytics.captured[0]?.event).toBe("cli_command_executed"); - expect(analytics.captured[0]?.properties.exit_code).toBe(1); + const event = analytics.captured[0]; + expect(event?.event).toBe("cli_command_executed"); + expect(event?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:InstrumentationAuthError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + expect(event?.properties).not.toHaveProperty(PropWorkflow); + const encoded = JSON.stringify(event); + for (const secret of Object.values(secrets)) expect(encoded).not.toContain(secret); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(failure); + } }), ), ); @@ -122,6 +212,87 @@ describe("withCommandInstrumentation", () => { return program.pipe(Effect.asVoid); }); + it.live("classifies defects as internal panics without capturing their message", () => { + const analytics = mockContextualAnalytics(); + const secret = "panic at /Users/alice/customer-project"; + + return Effect.die(new TypeError(secret)).pipe( + withCommandInstrumentation(), + Effect.provide(analytics.layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) })), + Effect.provide(commandRuntimeLayer(["branches", "list"])), + Effect.exit, + Effect.tap(() => + Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: "error:TypeError", + has_suggestion: true, + suggestion_type: "rerun_debug", + }); + expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); + }), + ), + Effect.asVoid, + ); + }); + + it.live("preserves the command failure when telemetry capture defects", () => { + const failure = new InstrumentationAuthError({ + message: "command failure", + path: "path", + sql: "sql", + projectRef: "project", + hostname: "host", + token: "token", + }); + + return Effect.fail(failure).pipe( + withCommandInstrumentation(), + Effect.provide(failingAnalytics(new Error("telemetry defect"))), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), + Effect.provide(commandRuntimeLayer(["login"])), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(failure); + expect(Cause.hasDies(exit.cause)).toBe(false); + } + }), + ), + Effect.asVoid, + ); + }); + + it.live("propagates fiber interruption from telemetry capture", () => { + // A capture failure or defect is swallowed (best-effort telemetry), but an + // interruption landing during the trailing capture must not be — the fiber + // is being cancelled and swallowing would fight the cancellation. + return Effect.void.pipe( + withCommandInstrumentation(), + Effect.provide(interruptingAnalytics()), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), + Effect.provide(commandRuntimeLayer(["login"])), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }), + ), + Effect.asVoid, + ); + }); + it.live("captures flag values only when explicitly allowlisted", () => { const analytics = mockContextualAnalytics(); diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts new file mode 100644 index 0000000000..f24f937133 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -0,0 +1,429 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { CliError } from "effect/unstable/cli"; + +// Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace +// tsconfig does not load `vite/client`, so declare the one member we use. +declare global { + interface ImportMeta { + readonly glob: (patterns: ReadonlyArray) => Record Promise>; + } +} +import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model"; +import { + CliErrorCategory, + CliErrorKind, + CliSuggestionType, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, + isClassifiedExternalErrorTag, + isClassifiedManagedErrorCode, +} from "./error-actionability.ts"; + +/** + * Drift guard for the error actionability taxonomy: every error class defined + * in `apps/cli/src` must declare its own classification under + * {@link ErrorActionabilityId}, and every error tag defined in the workspace + * packages that can surface through CLI commands must have an external + * adapter. A new error type failing here is the feature — `unknown` in + * production telemetry must mean "genuinely unforeseen failure", never "we + * forgot to classify". + */ + +// The scan below recognizes every way an error class is defined in this +// workspace: direct `Data.TaggedError("Tag")`, any local `*Error(...)` factory +// whose heritage call carries the tag literal (`CliError("Tag")`, +// `LoginError("Tag")`, ...), and plain `extends Error` classes (identified by +// class name). Error factories must therefore be named `Error` to +// stay guarded — which also keeps `Data.TaggedClass` event types out of the +// scan. It runs on a real TypeScript AST rather than on text, so a definition +// merely *mentioned* in a comment, a string, or a template literal is +// structurally invisible and needs no special casing. + +// The simple name of a call's callee: `TaggedError` for both `TaggedError(...)` +// and `Data.TaggedError(...)`. +function calleeName(expression: ts.Expression): string { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + return ""; +} + +// The value of a plain string literal, seeing through an `as const` assertion +// (`readonly code = "X" as const`). A computed or interpolated string cannot be +// resolved statically, and none exists in this workspace. +function stringLiteralText(expression: ts.Expression | undefined): string | undefined { + const inner = + expression !== undefined && ts.isAsExpression(expression) ? expression.expression : expression; + return inner !== undefined && ts.isStringLiteral(inner) ? inner.text : undefined; +} + +function extendsExpression(node: ts.ClassLikeDeclaration): ts.Expression | undefined { + const clause = node.heritageClauses?.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword); + return clause?.types[0]?.expression; +} + +function parse(fileName: string, source: string): ts.SourceFile { + return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); +} + +// Extracts the error identifiers a source file defines: the tag literal of +// every `class X extends Error("Tag")` heritage call and of every +// free-standing `TaggedError("Tag")` factory call, plus the class name of +// every plain `class X extends Error` (untagged classes are fingerprinted by +// name). A tagged class contributes its tag once — the heritage call is +// claimed by the class rule so the factory rule does not count it again. +function extractErrorTags(source: string, fileName = "scan.ts"): Array { + const tags: Array = []; + const claimed = new Set(); + + const visit = (node: ts.Node): void => { + if (ts.isClassLike(node)) { + const heritage = extendsExpression(node); + if (heritage !== undefined && ts.isCallExpression(heritage)) { + const tag = calleeName(heritage.expression).endsWith("Error") + ? stringLiteralText(heritage.arguments[0]) + : undefined; + if (tag !== undefined) { + tags.push(tag); + claimed.add(heritage); + } + } else if ( + heritage !== undefined && + ts.isIdentifier(heritage) && + heritage.text === "Error" && + node.name !== undefined + ) { + tags.push(node.name.text); + } + } + + if ( + ts.isCallExpression(node) && + !claimed.has(node) && + calleeName(node.expression).endsWith("TaggedError") + ) { + const tag = stringLiteralText(node.arguments[0]); + if (tag !== undefined) tags.push(tag); + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(parse(fileName, source), visit); + return tags; +} + +function scanErrorTags(root: string): Map> { + const tagsByFile = new Map>(); + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + walk(path); + continue; + } + if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; + const tags = extractErrorTags(readFileSync(path, "utf8"), path); + if (tags.length > 0) tagsByFile.set(path, tags); + } + }; + walk(root); + return tagsByFile; +} + +describe("extractErrorTags", () => { + it("finds tagged, factory-tagged and plain error class definitions", () => { + const source = [ + 'export class TaggedThingError extends Data.TaggedError("TaggedThingError") {}', + 'export class FactoryThingError extends CliError("FactoryTag") {}', + "export class PlainThingError extends Error {}", + 'const Base = Data.TaggedError("FreeStandingTag");', + ].join("\n"); + expect(extractErrorTags(source)).toEqual([ + "TaggedThingError", + "FactoryTag", + "PlainThingError", + "FreeStandingTag", + ]); + }); + + it("ignores definitions that only appear in comments", () => { + const source = [ + "// class Fake extends Error", + '/* e.g. Data.TaggedError("FakeTag") */', + "const x = 1;", + ].join("\n"); + expect(extractErrorTags(source)).toEqual([]); + }); + + it("ignores definitions that only appear inside string and template literals", () => { + const source = [ + 'const a = "class Fake extends Error";', + 'const b = `Data.TaggedError("FakeTag")`;', + "const c = 'class AlsoFake extends Error';", + ].join("\n"); + expect(extractErrorTags(source)).toEqual([]); + }); +}); + +const kindValues = new Set(Object.values(CliErrorKind)); +const categoryValues = new Set(Object.values(CliErrorCategory)); +const suggestionValues = new Set(Object.values(CliSuggestionType)); + +interface DeclaredErrorClass { + readonly constructor: object; + readonly exportName: string; + readonly isTagged: boolean; + readonly tag: string; + readonly prototype: object; +} + +function collectErrorClasses(module: Record): Array { + const classes: Array = []; + for (const [exportName, value] of Object.entries(module)) { + if (typeof value !== "function") continue; + const prototype: unknown = value.prototype; + if (typeof prototype !== "object" || prototype === null) continue; + if (!(prototype instanceof Error)) continue; + // effect V4 assigns `_tag` per instance, so probe with an empty props bag. + // Plain `extends Error` classes have no `_tag`; identify them by class name. + let tag: unknown; + try { + tag = Reflect.get(Reflect.construct(value, [{}]), "_tag"); + } catch { + tag = undefined; + } + classes.push({ + constructor: value, + exportName, + isTagged: typeof tag === "string", + tag: typeof tag === "string" ? tag : exportName, + prototype, + }); + } + return classes; +} + +const srcRoot = resolve(import.meta.dirname, "../.."); +const repoRoot = resolve(import.meta.dirname, "../../../../.."); + +const moduleLoaders = new Map( + Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => [ + resolve(import.meta.dirname, key), + loader, + ]), +); + +describe("apps/cli error classes declare their actionability", () => { + const tagsByFile = scanErrorTags(srcRoot); + + it("finds the error definition surface", () => { + expect(tagsByFile.size).toBeGreaterThan(50); + }); + + for (const [file, tags] of tagsByFile) { + const relativePath = file.slice(srcRoot.length + 1); + // Importing a command module can pull in a large transitive graph on first + // load; give these dynamic-import tests more headroom than the default 5s. + it(relativePath, { timeout: 30_000 }, async () => { + const loader = moduleLoaders.get(file); + expect(loader, `no module loader for ${relativePath}`).toBeDefined(); + const module = await loader?.(); + expect(typeof module).toBe("object"); + const classes = collectErrorClasses(Object(module)); + + const exportedTags = new Set(classes.map((cls) => cls.tag)); + for (const tag of tags) { + expect( + exportedTags.has(tag), + `error "${tag}" is defined in ${relativePath} but not exported — export it so its actionability declaration is verifiable`, + ).toBe(true); + } + + for (const { constructor, exportName, isTagged, tag, prototype } of classes) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); + expect( + typeof descriptor?.get, + `${exportName} ("${tag}") does not declare an own [ErrorActionabilityId] getter — add one returning its CliErrorActionabilityDeclaration`, + ).toBe("function"); + + // Evaluate the getter against a field-less probe: instance-dependent + // declarations must degrade to a valid declaration when fields are + // absent, and static ones are checked directly. + const probe: object = Object.create(prototype); + const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); + expect( + typeof declaration === "object" && declaration !== null, + `${exportName} ("${tag}") declaration is not an object`, + ).toBe(true); + const record: Record = Object(declaration); + expect(kindValues.has(String(record["error_kind"]))).toBe(true); + expect(categoryValues.has(String(record["error_category"]))).toBe(true); + expect(typeof record["has_suggestion"]).toBe("boolean"); + expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); + + if (!isTagged) { + const fingerprintDescriptor = Object.getOwnPropertyDescriptor( + constructor, + ErrorActionabilityFingerprintId, + ); + expect( + fingerprintDescriptor !== undefined && + "value" in fingerprintDescriptor && + fingerprintDescriptor.value === exportName, + `${exportName} is an untagged Error and must declare its stable source identifier as an own static [ErrorActionabilityFingerprintId] value`, + ).toBe(true); + } + } + }); + } +}); + +describe("workspace package error tags have external adapters", () => { + const packageRoots = [ + "packages/api/src", + "packages/stack/src", + "packages/config/src", + "packages/process-compose/src", + ]; + + for (const packageRoot of packageRoots) { + it(packageRoot, () => { + const tagsByFile = scanErrorTags(resolve(repoRoot, packageRoot)); + expect(tagsByFile.size).toBeGreaterThan(0); + for (const [file, tags] of tagsByFile) { + for (const tag of tags) { + expect( + isClassifiedExternalErrorTag(tag), + `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, + ).toBe(true); + } + } + }); + } +}); + +// Managed failures are tagged errors that also declare a stable `code`, and the +// CLI's dispatch table is generated from the package's tag/code map. The +// generic scan above already requires an adapter for each tag; this guard is +// what keeps the two halves of the contract joined — the (class, tag, code) +// triples in the model must agree with the exported map, the code list, and the +// code-keyed classification table. +interface ManagedErrorClass { + readonly className: string; + readonly tag: string; + readonly code: string; +} + +// Collects the (class, tag, code) triples of every `class X extends +// Data.TaggedError("Tag")` that also declares a string-literal `code` member. +function scanManagedErrorClasses(path: string): Array { + const classes: Array = []; + const visit = (node: ts.Node): void => { + if (ts.isClassDeclaration(node) && node.name !== undefined) { + const heritage = extendsExpression(node); + const tag = + heritage !== undefined && + ts.isCallExpression(heritage) && + calleeName(heritage.expression) === "TaggedError" + ? stringLiteralText(heritage.arguments[0]) + : undefined; + const code = stringLiteralText( + node.members + .filter(ts.isPropertyDeclaration) + .find((member) => ts.isIdentifier(member.name) && member.name.text === "code") + ?.initializer, + ); + if (tag !== undefined && code !== undefined) { + classes.push({ className: node.name.text, tag, code }); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(parse(path, readFileSync(path, "utf8")), visit); + return classes; +} + +describe("managed registry error codes are classified", () => { + it("packages/stack/src/managed/model.ts", () => { + const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); + const scanned = scanManagedErrorClasses(modelPath); + // One class per declared code: a class written in a shape this scan cannot + // see would otherwise pass vacuously instead of failing loudly. + expect(scanned.length).toBe(MANAGED_ERROR_CODES.length); + const declaredCodes = new Set(MANAGED_ERROR_CODES); + const scannedCodes = new Set(); + for (const { className, tag, code } of scanned) { + scannedCodes.add(code); + expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe( + className, + ); + expect( + declaredCodes.has(code), + `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, + ).toBe(true); + expect( + Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code), + `MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`, + ).toBe(tag); + expect( + isClassifiedManagedErrorCode(code), + `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, + ).toBe(true); + expect( + isClassifiedExternalErrorTag(tag), + `${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`, + ).toBe(true); + } + // Every declared code is backed by a class, not just the other way round. + expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort()); + }); +}); + +describe("Effect CLI parser errors have exhaustive handling", () => { + it("covers every exported parser error class", () => { + const tags = new Set(); + const probe = { + option: "--probe", + command: [], + suggestions: [], + parentCommand: "parent", + childCommand: "child", + argument: "argument", + arguments: [], + value: "value", + expected: "expected", + kind: "flag", + subcommand: "subcommand", + parent: [], + cause: new Error("probe"), + commandPath: [], + errors: [], + }; + + for (const value of Object.values(CliError)) { + if (typeof value !== "function") continue; + const prototype: unknown = value.prototype; + if (typeof prototype !== "object" || prototype === null) continue; + if (!(prototype instanceof Error)) continue; + + try { + const tag = Reflect.get(Reflect.construct(value, [probe]), "_tag"); + if (typeof tag === "string") tags.add(tag); + } catch { + // Non-error exports and constructors that require runtime setup are + // outside the parser error union checked at compile time by the map. + } + } + + expect(tags.size).toBeGreaterThan(5); + for (const tag of tags) { + expect( + tag === "ShowHelp" || tag === "UserError" || isClassifiedExternalErrorTag(tag), + `Effect CLI parser error "${tag}" has no actionability handling`, + ).toBe(true); + } + }); +}); diff --git a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts new file mode 100644 index 0000000000..4f743b3b06 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, test } from "vitest"; + +describe("release-minified error fingerprints", () => { + test("keeps a declared tagged error's source identifier", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "supabase-error-actionability-")); + const bundlePath = join(tempDir, "fixture.mjs"); + const errorModule = resolve(import.meta.dirname, "../functions/delete.errors.ts"); + const plainErrorModule = resolve( + import.meta.dirname, + "../../legacy/shared/legacy-config-validate.ts", + ); + const classifierModule = resolve(import.meta.dirname, "error-actionability.ts"); + + try { + const build = await Bun.build({ + entrypoints: ["actionability-fixture"], + target: "bun", + minify: true, + plugins: [ + { + name: "actionability-fixture", + setup(builder) { + builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ + path: "actionability-fixture", + namespace: "actionability-fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ + contents: ` + import { InvalidFunctionSlugError } from ${JSON.stringify(errorModule)}; + import { LegacyConfigValidateError } from ${JSON.stringify(plainErrorModule)}; + import { classifyCliErrorActionability } from ${JSON.stringify(classifierModule)}; + export const taggedConstructorName = InvalidFunctionSlugError.name; + export const taggedClassification = classifyCliErrorActionability( + new InvalidFunctionSlugError({ message: "private user input" }), + ); + export const plainConstructorName = LegacyConfigValidateError.name; + export const plainClassification = classifyCliErrorActionability( + new LegacyConfigValidateError("private user input"), + ); + `, + loader: "ts", + })); + }, + }, + ], + }); + + expect(build.success, build.logs.map(String).join("\n")).toBe(true); + expect(build.outputs).toHaveLength(1); + const output = build.outputs[0]; + expect(output).toBeDefined(); + if (output === undefined) return; + + await Bun.write(bundlePath, output); + const fixture = await import(`${pathToFileURL(bundlePath).href}?run=${crypto.randomUUID()}`); + expect(Reflect.get(fixture, "taggedConstructorName")).not.toBe("InvalidFunctionSlugError"); + expect(Reflect.get(fixture, "taggedClassification")).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:InvalidFunctionSlugError", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect(Reflect.get(fixture, "plainConstructorName")).not.toBe("LegacyConfigValidateError"); + expect(Reflect.get(fixture, "plainClassification")).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "error:LegacyConfigValidateError", + has_suggestion: true, + suggestion_type: "update_config", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts new file mode 100644 index 0000000000..a23106060b --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -0,0 +1,1254 @@ +import { + MANAGED_ERROR_CODES, + MANAGED_ERROR_TAG_BY_CODE, + type ManagedErrorCode, +} from "@supabase/stack/managed-model"; +import { Cause, Option } from "effect"; +import type { CliError as EffectCliError } from "effect/unstable/cli"; + +/** + * CLI error actionability taxonomy for KPI reporting (CLI-1560). + * + * Classification is declared where each error is defined: every error class in + * `apps/cli/src` exposes a {@link CliErrorActionabilityDeclaration} under the + * {@link ErrorActionabilityId} symbol (enforced by + * `error-actionability-coverage.unit.test.ts`). Errors originating outside the + * CLI workspace (`@supabase/stack`, `@supabase/config`, + * `@supabase/process-compose`, `effect` cli/http) are classified by the + * structural adapters at the bottom of this module, which are themselves + * exhaustiveness-checked against those packages' sources. + * + * Everything emitted from here is sanitized by construction: kinds, categories, + * suggestion types, and fingerprints use closed enums and source-owned + * identifiers — never raw error text or user-specific data. + */ + +export const CliErrorKind = { + UserActionable: "user_actionable", + InternalBug: "internal_bug", + ExternalService: "external_service", + UserCancelled: "user_cancelled", + Unknown: "unknown", +} as const; + +export type CliErrorKind = (typeof CliErrorKind)[keyof typeof CliErrorKind]; + +export const CliErrorCategory = { + Auth: "auth", + MissingProjectRef: "missing_project_ref", + ProjectNotLinked: "project_not_linked", + DockerNotRunning: "docker_not_running", + InvalidConfig: "invalid_config", + DbConnection: "db_connection", + MigrationDrift: "migration_drift", + Permission: "permission", + PlanLimit: "plan_limit", + ProjectPaused: "project_paused", + InvalidInput: "invalid_input", + Network: "network", + ApiStatus: "api_status", + Cancelled: "cancelled", + Panic: "panic", + ImpossibleState: "impossible_state", + Unknown: "unknown", +} as const; + +export type CliErrorCategory = (typeof CliErrorCategory)[keyof typeof CliErrorCategory]; + +export const CliSuggestionType = { + Login: "login", + LinkProject: "link_project", + StartDocker: "start_docker", + ProvideFlags: "provide_flags", + SetEnvVar: "set_env_var", + RepairMigration: "repair_migration", + UpdateConfig: "update_config", + RunCommand: "run_command", + UpgradePlan: "upgrade_plan", + RerunDebug: "rerun_debug", + OpenDashboard: "open_dashboard", + None: "none", +} as const; + +export type CliSuggestionType = (typeof CliSuggestionType)[keyof typeof CliSuggestionType]; + +const CLI_SUGGESTED_COMMANDS = [ + "supabase branches create", + "supabase link", + "supabase login", + "supabase start", + "supabase stop", +] as const; + +type CliSuggestedCommand = (typeof CLI_SUGGESTED_COMMANDS)[number]; + +const CLI_ERROR_FINGERPRINT_SUFFIXES = [ + "api_response", + "api_status", + "asset_checksum", + "asset_preparation", + "auth", + "bad_argument", + "conflict", + "cancelled", + "connect", + "container_configuration", + "daemon_start", + "daemon_protocol", + "daemon_status", + "daemon_transport", + "database", + "docker_not_running", + "filesystem", + "forbidden", + "gateway_auth", + "image_inspect", + "invalid_content", + "invalid_url", + "internal_build", + "invalid_config", + "managed_identity", + "managed_identity_conflict", + "managed_initialization", + "managed_operation_in_progress", + "managed_operation_ownership", + "managed_owner_pid", + "managed_pending_update", + "managed_port", + "managed_port_change", + "managed_port_duplicate_key", + "managed_publication_timeout", + "managed_recovery", + "managed_stack_name", + "managed_stack_not_stopped", + "network", + "not_found", + "plan_limit", + "platform_error", + "port_allocation", + "port_conflict", + "query", + "registry_pull", + "replication_slots_active", + "replication_slots_query", + "request_encoding", + "request_input", + "saml_disabled", +] as const; + +type CliErrorFingerprintSuffix = (typeof CLI_ERROR_FINGERPRINT_SUFFIXES)[number]; + +type UserActionableErrorCategory = + | typeof CliErrorCategory.Auth + | typeof CliErrorCategory.MissingProjectRef + | typeof CliErrorCategory.ProjectNotLinked + | typeof CliErrorCategory.DockerNotRunning + | typeof CliErrorCategory.InvalidConfig + | typeof CliErrorCategory.DbConnection + | typeof CliErrorCategory.MigrationDrift + | typeof CliErrorCategory.Permission + | typeof CliErrorCategory.PlanLimit + | typeof CliErrorCategory.ProjectPaused + | typeof CliErrorCategory.InvalidInput; + +type CliErrorKindCategory = + | { + readonly error_kind: typeof CliErrorKind.UserActionable; + readonly error_category: UserActionableErrorCategory; + } + | { + readonly error_kind: typeof CliErrorKind.InternalBug; + readonly error_category: + | typeof CliErrorCategory.Panic + | typeof CliErrorCategory.ImpossibleState; + } + | { + readonly error_kind: typeof CliErrorKind.ExternalService; + readonly error_category: typeof CliErrorCategory.Network | typeof CliErrorCategory.ApiStatus; + } + | { + readonly error_kind: typeof CliErrorKind.UserCancelled; + readonly error_category: typeof CliErrorCategory.Cancelled; + } + | { + readonly error_kind: typeof CliErrorKind.Unknown; + readonly error_category: typeof CliErrorCategory.Unknown; + }; + +type CliErrorSuggestion = + | { + readonly has_suggestion: false; + readonly suggestion_type: typeof CliSuggestionType.None; + readonly suggested_command?: never; + } + | { + readonly has_suggestion: true; + readonly suggestion_type: typeof CliSuggestionType.Login; + readonly suggested_command?: "supabase login"; + } + | { + readonly has_suggestion: true; + readonly suggestion_type: typeof CliSuggestionType.LinkProject; + readonly suggested_command?: "supabase link"; + } + | { + readonly has_suggestion: true; + readonly suggestion_type: typeof CliSuggestionType.RunCommand; + readonly suggested_command?: "supabase branches create" | "supabase start" | "supabase stop"; + } + | { + readonly has_suggestion: true; + readonly suggestion_type: Exclude< + CliSuggestionType, + | typeof CliSuggestionType.None + | typeof CliSuggestionType.Login + | typeof CliSuggestionType.LinkProject + | typeof CliSuggestionType.RunCommand + >; + readonly suggested_command?: never; + }; + +/** Q2 KPI metric definitions, kept next to the taxonomy they consume. */ +export const CliErrorActionabilityMetricDefinitions = { + strictRecovery: { + id: "same_command_success_same_session", + event: "cli_command_executed", + partition_by: ["device_id", "$session_id", "command"], + command_identity: + "Exact equality of the command property, which is the normalized command path emitted by CLI instrumentation without argument values.", + order_by: "event timestamp ascending", + eligible_failure: "exit_code != 0 AND error_kind = 'user_actionable'", + recovered_when: + "For eligible failure F, there exists event S with S.timestamp > F.timestamp, S.exit_code = 0, and S.device_id = F.device_id, S.$session_id = F.$session_id, and S.command = F.command. Intervening events do not change the result.", + reset_when: + "The observation window ends with the $session_id. A success with a different device_id, $session_id, or command never counts.", + description: + "A user-actionable failure is recovered only when the same normalized command later succeeds for the same device_id and $session_id.", + }, + repeatError: { + id: "same_command_same_error_same_session_before_success", + event: "cli_command_executed", + partition_by: ["device_id", "$session_id", "command"], + command_identity: + "Exact equality of the command property, which is the normalized command path emitted by CLI instrumentation without argument values.", + order_by: "event timestamp ascending", + eligible_failure: + "exit_code != 0 AND error_kind = 'user_actionable' AND error_fingerprint IS NOT NULL", + repeated_when: + "Failed event F is a repeat when an earlier failed event P in the same partition has P.error_fingerprint = F.error_fingerprint and no event S in that partition has P.timestamp < S.timestamp < F.timestamp and S.exit_code = 0.", + reset_when: + "Any exit_code = 0 event in the partition clears every prior fingerprint for that command. A different $session_id starts a new partition; successes for other commands do not reset it.", + description: + "The second and each later occurrence of the same user-actionable error_fingerprint is a repeat until that normalized command succeeds or the session changes.", + }, + internalUnknownBugRate: { + id: "failed_commands_internal_bug_or_unknown", + event: "cli_command_executed", + denominator: "count where exit_code != 0 AND error_kind IS NOT NULL", + numerator: "count where exit_code != 0 AND error_kind IN ('internal_bug', 'unknown')", + description: + "Internal/unknown bug failure rate is the share of classified failed commands reported as internal_bug or unknown. Failures without error_kind (pure Go-proxy commands, CLI versions predating the classification) are excluded from both sides; classificationCoverage reports their share.", + }, + classificationCoverage: { + id: "failed_commands_with_classification", + event: "cli_command_executed", + denominator: "count where exit_code != 0", + numerator: "count where exit_code != 0 AND error_kind IS NOT NULL", + description: + "Share of failed commands carrying the error classification. Pure Go-proxy commands report through the Go binary without these fields, and older CLI versions never send them, so the recovery, repeat, and bug-rate metrics cover exactly this fraction of the failure volume.", + }, +} as const; + +/** + * Symbol under which CLI error classes declare their own actionability. + * It is intentionally absent from the global symbol registry, so errors from + * dependencies cannot impersonate a CLI-owned declaration. + * + * Declare it as a getter so it lives on the prototype (visible to the coverage + * test without instantiating the class) and can branch on instance fields: + * + * ```ts + * class MyStatusError extends Data.TaggedError(tag)<{ status: number }> { + * get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + * return statusCodeActionability(this.status); + * } + * } + * ``` + */ +export const ErrorActionabilityId: unique symbol = Symbol( + "@supabase/cli/telemetry/ErrorActionability", +); + +/** Stable source identifier for CLI-owned plain `Error` subclasses. */ +export const ErrorActionabilityFingerprintId: unique symbol = Symbol( + "@supabase/cli/telemetry/ErrorActionabilityFingerprint", +); + +/** + * Closed metadata declared by each error class. `has_suggestion` describes a + * canonical remediation; raw instance suggestion text is never inspected. + */ +export type CliErrorActionabilityDeclaration = CliErrorKindCategory & + CliErrorSuggestion & { + /** + * Distinguishes branches of instance-dependent declarations in the repeat + * fingerprint, so e.g. a registry pull failure and a daemon-down failure of + * the same wrapper tag never count as repeats of one another. Must be a + * static safe identifier, never derived from error text. + */ + readonly fingerprint_suffix?: CliErrorFingerprintSuffix; + }; + +/** Sanitized classification emitted with failed `cli_command_executed` events. */ +export type CliErrorActionability = CliErrorKindCategory & + CliErrorSuggestion & { + readonly error_fingerprint: string; + }; + +/** Shared declarations for the recurring classification shapes. */ +export const actionability = { + authLogin: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + authToken: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.SetEnvVar, + }, + provideFlags: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.ProvideFlags, + }, + invalidInput: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + invalidConfig: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + /** + * A database operation failed because of the user's own SQL, schema, or + * data — actionable, but the CLI has no generic remediation to suggest. + */ + dbFinding: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + dbConnection: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DbConnection, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + migrationDrift: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MigrationDrift, + has_suggestion: true, + suggestion_type: CliSuggestionType.RepairMigration, + }, + permission: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + accountAccess: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + planLimit: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.PlanLimit, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpgradePlan, + }, + projectNotLinked: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectNotLinked, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + missingProjectRef: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MissingProjectRef, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + /** Local link state exists but is unusable — re-linking repairs it. */ + relinkProject: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + dockerNotRunning: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + startStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase start", + }, + stopStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase stop", + }, + externalNetwork: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.Network, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + apiStatus: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.ApiStatus, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + cancelled: { + error_kind: CliErrorKind.UserCancelled, + error_category: CliErrorCategory.Cancelled, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + internalPanic: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.Panic, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + impossibleState: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.ImpossibleState, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + unknown: { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, +} as const satisfies Record; + +/** + * The declaration for a failure confirmed plan-gated by the entitlement + * check (`legacySuggestUpgrade`). Shared so every gated surface groups under + * the same fingerprint family. + */ +export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { + ...actionability.planLimit, + fingerprint_suffix: "plan_limit", +}; + +/** + * Classification policy for errors that carry a Management API status code. + * `upgradeSuggested` is the typed result of the entitlement gate + * (`legacySuggestUpgrade`) threaded through the error constructor — never + * inferred from message text. + * + * A 404 is user-actionable only when the caller knows the endpoint names a + * user-selected resource. List and discovery endpoints can also return 404, + * so the default remains an API-status failure. The entitlement-gate branch + * stays ahead of that opt-in so a confirmed plan-limited 404 still classifies + * as `plan_limit`. + */ +export function statusCodeActionability( + status: number | undefined, + opts: { + readonly upgradeSuggested?: boolean; + readonly notFoundIsInvalidInput?: boolean; + } = {}, +): CliErrorActionabilityDeclaration { + if (status === 401) { + return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + } + if (opts.upgradeSuggested === true && status !== undefined && status >= 400 && status < 500) { + return planLimitGatedActionability; + } + if (status === 403) { + return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + } + if (status === 404 && opts.notFoundIsInvalidInput === true) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } + if (status === undefined) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; +} + +type ErrorRecord = Record; + +function isErrorRecord(value: unknown): value is ErrorRecord { + return typeof value === "object" && value !== null; +} + +function readString(value: ErrorRecord, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; +} + +function readNumber(value: ErrorRecord, key: string): number | undefined { + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +const suggestedCommandValues = new Set(CLI_SUGGESTED_COMMANDS); +const fingerprintSuffixValues = new Set(CLI_ERROR_FINGERPRINT_SUFFIXES); + +function isUserActionableCategory(value: unknown): value is UserActionableErrorCategory { + return ( + value === CliErrorCategory.Auth || + value === CliErrorCategory.MissingProjectRef || + value === CliErrorCategory.ProjectNotLinked || + value === CliErrorCategory.DockerNotRunning || + value === CliErrorCategory.InvalidConfig || + value === CliErrorCategory.DbConnection || + value === CliErrorCategory.MigrationDrift || + value === CliErrorCategory.Permission || + value === CliErrorCategory.PlanLimit || + value === CliErrorCategory.ProjectPaused || + value === CliErrorCategory.InvalidInput + ); +} + +function sanitizeKindCategory(kind: unknown, category: unknown): CliErrorKindCategory | undefined { + if (kind === CliErrorKind.UserActionable && isUserActionableCategory(category)) { + return { error_kind: kind, error_category: category }; + } + if ( + kind === CliErrorKind.InternalBug && + (category === CliErrorCategory.Panic || category === CliErrorCategory.ImpossibleState) + ) { + return { error_kind: kind, error_category: category }; + } + if ( + kind === CliErrorKind.ExternalService && + (category === CliErrorCategory.Network || category === CliErrorCategory.ApiStatus) + ) { + return { error_kind: kind, error_category: category }; + } + if (kind === CliErrorKind.UserCancelled && category === CliErrorCategory.Cancelled) { + return { error_kind: kind, error_category: category }; + } + if (kind === CliErrorKind.Unknown && category === CliErrorCategory.Unknown) { + return { error_kind: kind, error_category: category }; + } + return undefined; +} + +function isSuggestionWithRemediation( + value: unknown, +): value is Exclude { + return ( + value === CliSuggestionType.Login || + value === CliSuggestionType.LinkProject || + value === CliSuggestionType.StartDocker || + value === CliSuggestionType.ProvideFlags || + value === CliSuggestionType.SetEnvVar || + value === CliSuggestionType.RepairMigration || + value === CliSuggestionType.UpdateConfig || + value === CliSuggestionType.RunCommand || + value === CliSuggestionType.UpgradePlan || + value === CliSuggestionType.RerunDebug || + value === CliSuggestionType.OpenDashboard + ); +} + +function isSuggestedCommand(value: unknown): value is CliSuggestedCommand { + return typeof value === "string" && suggestedCommandValues.has(value); +} + +function sanitizeSuggestion( + hasSuggestion: unknown, + suggestionType: unknown, + suggestedCommand: unknown, +): CliErrorSuggestion | undefined { + if ( + hasSuggestion === false && + suggestionType === CliSuggestionType.None && + suggestedCommand === undefined + ) { + return { has_suggestion: false, suggestion_type: suggestionType }; + } + if (hasSuggestion !== true || !isSuggestionWithRemediation(suggestionType)) { + return undefined; + } + if (suggestedCommand === undefined) { + return { has_suggestion: true, suggestion_type: suggestionType }; + } + if (!isSuggestedCommand(suggestedCommand)) return undefined; + if (suggestionType === CliSuggestionType.Login && suggestedCommand === "supabase login") { + return { + has_suggestion: true, + suggestion_type: suggestionType, + suggested_command: suggestedCommand, + }; + } + if (suggestionType === CliSuggestionType.LinkProject && suggestedCommand === "supabase link") { + return { + has_suggestion: true, + suggestion_type: suggestionType, + suggested_command: suggestedCommand, + }; + } + if ( + suggestionType === CliSuggestionType.RunCommand && + (suggestedCommand === "supabase branches create" || + suggestedCommand === "supabase start" || + suggestedCommand === "supabase stop") + ) { + return { + has_suggestion: true, + suggestion_type: suggestionType, + suggested_command: suggestedCommand, + }; + } + return undefined; +} + +function isFingerprintSuffix(value: unknown): value is CliErrorFingerprintSuffix { + return typeof value === "string" && fingerprintSuffixValues.has(value); +} + +function sanitizeDeclaration(value: unknown): CliErrorActionabilityDeclaration | undefined { + if (!isErrorRecord(value)) return undefined; + const kindCategory = sanitizeKindCategory(value["error_kind"], value["error_category"]); + if (kindCategory === undefined) return undefined; + const suggestion = sanitizeSuggestion( + value["has_suggestion"], + value["suggestion_type"], + value["suggested_command"], + ); + if (suggestion === undefined) return undefined; + const fingerprintSuffix = value["fingerprint_suffix"]; + if (fingerprintSuffix === undefined) return { ...kindCategory, ...suggestion }; + if (!isFingerprintSuffix(fingerprintSuffix)) return undefined; + return { ...kindCategory, ...suggestion, fingerprint_suffix: fingerprintSuffix }; +} + +function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | undefined { + if (!(error instanceof Error)) return undefined; + try { + const prototype = Object.getPrototypeOf(error); + if (!isErrorRecord(prototype)) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); + if (descriptor?.get === undefined) return undefined; + return sanitizeDeclaration(Reflect.apply(descriptor.get, error, [])); + } catch { + return undefined; + } +} + +function safeIdentifier(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + // The length cap is defense-in-depth: every legitimate identifier is a + // class/tag name, so an oversized value is never a real CLI error source. + return /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ? value : undefined; +} + +function readErrorTag(error: unknown): string | undefined { + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "_tag")); +} + +function readErrorName(error: unknown): string | undefined { + if (error instanceof Error) return safeIdentifier(error.name); + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "name")); +} + +function readDeclaredErrorFingerprintId(error: unknown): string | undefined { + if (!(error instanceof Error)) return undefined; + const prototype = Object.getPrototypeOf(error); + if (!isErrorRecord(prototype)) return undefined; + const constructorDescriptor = Object.getOwnPropertyDescriptor(prototype, "constructor"); + if (constructorDescriptor === undefined || !("value" in constructorDescriptor)) return undefined; + const constructor = constructorDescriptor.value; + if (typeof constructor !== "function") return undefined; + const identifierDescriptor = Object.getOwnPropertyDescriptor( + constructor, + ErrorActionabilityFingerprintId, + ); + if (identifierDescriptor === undefined || !("value" in identifierDescriptor)) return undefined; + return typeof identifierDescriptor.value === "string" + ? safeIdentifier(identifierDescriptor.value) + : undefined; +} + +/** + * `Data.TaggedError` stores its literal tag as an own data property named + * `name` on a base prototype. Unlike `constructor.name`, that value survives + * identifier minification. Never invoke prototype getters here: only the + * static data property created by Effect is a safe fingerprint authority. + */ +function readStableTaggedPrototypeName(error: unknown): string | undefined { + if (!(error instanceof Error)) return undefined; + let prototype: unknown = Object.getPrototypeOf(error); + while (prototype !== Error.prototype) { + if (!isErrorRecord(prototype)) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "name"); + if (descriptor !== undefined && "value" in descriptor) { + const name = + typeof descriptor.value === "string" ? safeIdentifier(descriptor.value) : undefined; + if (name !== undefined && name !== "Error") return name; + } + prototype = Object.getPrototypeOf(prototype); + } + return undefined; +} + +function fingerprint( + prefix: "error" | "string" | "tag", + identifier: string | undefined, + suffix?: CliErrorFingerprintSuffix, +): string { + const base = identifier === undefined ? `${prefix}:unknown` : `${prefix}:${identifier}`; + return suffix === undefined ? base : `${base}:${suffix}`; +} + +function toActionability( + declaration: CliErrorActionabilityDeclaration, + fingerprintPrefix: "error" | "string" | "tag", + identifier: string | undefined, +): CliErrorActionability { + const { fingerprint_suffix, ...metadata } = declaration; + return { + ...metadata, + error_fingerprint: fingerprint(fingerprintPrefix, identifier, fingerprint_suffix), + }; +} + +/** + * Adapters for errors defined outside `apps/cli`, keyed by `_tag`. Kept + * exhaustive against those packages' sources by + * `error-actionability-coverage.unit.test.ts`. Everything defined inside + * `apps/cli` must declare {@link ErrorActionabilityId} instead of being + * added here. + */ +type ErrorActionabilityAdapter = (error: ErrorRecord) => CliErrorActionabilityDeclaration; + +type EffectCliAdapterTag = Exclude; + +// ShowHelp and UserError recurse in classifyCliErrorActionability. This map is +// typed against Effect's complete parser-error union so dependency upgrades +// cannot silently add an unclassified parser failure. +const effectCliActionabilityByTag = { + MissingOption: () => actionability.invalidInput, + MissingArgument: () => actionability.invalidInput, + DuplicateOption: () => actionability.invalidInput, + UnexpectedArgument: () => actionability.invalidInput, + InvalidValue: () => actionability.invalidInput, + // Effect 4.0.0-beta.103 has this typo in the runtime tag. Keep the adapter + // keyed to reality; the emitted fingerprint is normalized below. + UnknownSubcomand: () => actionability.invalidInput, + UnrecognizedOption: () => actionability.invalidInput, +} satisfies Record; + +/** + * `@supabase/stack` managed registry failures, keyed by the stable `code` + * literal each class declares. `code` is the package's wire-level contract: it + * survives the identifier minification of release builds, and Node/Bun callers + * outside an Effect runtime branch on it. Dispatch, however, goes through + * `_tag` like every other external error — {@link managedActionabilityByTag} + * projects this table onto the tags via the package's own tag/code map. + * + * Keyed by the package's exported {@link ManagedErrorCode} union, so the table + * is exhaustive by construction: a new managed failure cannot be added in + * `@supabase/stack` without being classified here. + */ +const managedActionabilityByCode: Record = { + INVALID_MANAGED_IDENTITY: { + ...actionability.invalidInput, + fingerprint_suffix: "managed_identity", + }, + DUPLICATE_MANAGED_IDENTITY: { + ...actionability.invalidConfig, + fingerprint_suffix: "managed_identity_conflict", + }, + MANAGED_INVALID_STACK_NAME: { + ...actionability.invalidInput, + fingerprint_suffix: "managed_stack_name", + }, + UNSUPPORTED_MANAGED_REGISTRY_VERSION: { + ...actionability.invalidConfig, + fingerprint_suffix: "invalid_config", + }, + MANAGED_STACK_NOT_FOUND: { ...actionability.invalidInput, fingerprint_suffix: "not_found" }, + // Another caller owns the stack right now; the remediation is to settle that + // operation before retrying. + MANAGED_OPERATION_IN_PROGRESS: { + ...actionability.stopStack, + fingerprint_suffix: "managed_operation_in_progress", + }, + MANAGED_OPERATION_OWNERSHIP_MISMATCH: { + ...actionability.stopStack, + fingerprint_suffix: "managed_operation_ownership", + }, + MANAGED_STACK_PUBLICATION_TIMEOUT: { + ...actionability.stopStack, + fingerprint_suffix: "managed_publication_timeout", + }, + MANAGED_OPERATION_REQUIRES_RECONCILIATION: { + ...actionability.stopStack, + fingerprint_suffix: "managed_recovery", + }, + MANAGED_STACK_NOT_STOPPED: { + ...actionability.stopStack, + fingerprint_suffix: "managed_stack_not_stopped", + }, + MANAGED_RUNNING_STACK_PORT_CHANGE: { + ...actionability.stopStack, + fingerprint_suffix: "managed_port_change", + }, + // The operation owner pid comes from the CLI process itself, never from user + // input, so a rejected pid is a broken internal invariant. + MANAGED_INVALID_OWNER_PID: { + ...actionability.impossibleState, + fingerprint_suffix: "managed_owner_pid", + }, + // Only internal misuse of the repository can call `updateStack` on a still + // unpublished (pending) row; nothing a user does reaches this. + MANAGED_PENDING_STACK_UPDATE: { + ...actionability.impossibleState, + fingerprint_suffix: "managed_pending_update", + }, + MANAGED_PORT_ALREADY_RESERVED: { + ...actionability.invalidConfig, + fingerprint_suffix: "port_conflict", + }, + // The port number itself is unusable (fractional or outside 1-65535), which + // is the user's own configured value rather than a conflict with a peer. + MANAGED_INVALID_PORT: { ...actionability.invalidConfig, fingerprint_suffix: "managed_port" }, + // Two of the user's own port assignments name the same key, which is the + // user's configured value rather than a conflict with another stack. + MANAGED_DUPLICATE_PORT_KEY: { + ...actionability.invalidConfig, + fingerprint_suffix: "managed_port_duplicate_key", + }, + // The registry derives every stack root itself, so a path that fails the + // containment check means the CLI passed a rejected argument. + UNSAFE_MANAGED_STACK_PATH: { + ...actionability.impossibleState, + fingerprint_suffix: "bad_argument", + }, + MANAGED_STACK_INITIALIZATION_FAILED: { + ...actionability.startStack, + fingerprint_suffix: "managed_initialization", + }, +}; + +/** + * The managed table above, re-keyed by the `_tag` of the class that declares + * each code. Generated from `@supabase/stack`'s own tag/code map so the + * eighteen managed tags are classified without restating a single verdict: + * {@link managedActionabilityByCode} stays the one place a managed failure is + * classified, and a tag/code pair the package renames cannot silently fall + * through to `unknown`. + */ +const managedActionabilityByTag: Record = Object.fromEntries( + MANAGED_ERROR_CODES.map((code) => { + const declaration = managedActionabilityByCode[code]; + return [MANAGED_ERROR_TAG_BY_CODE[code], () => declaration]; + }), +); + +/** + * Whether a `@supabase/stack` managed error code has a classification in + * {@link managedActionabilityByCode}. Used by the coverage test to keep the + * table exhaustive against the managed classes; the tags themselves are checked + * through {@link isClassifiedExternalErrorTag}, which the generated entries + * satisfy. + */ +export function isClassifiedManagedErrorCode(code: string): boolean { + return Object.hasOwn(managedActionabilityByCode, code); +} + +const externalActionabilityByTag: Record = { + ...effectCliActionabilityByTag, + ...managedActionabilityByTag, + + // effect PlatformError — OS/filesystem operations. `reason` is + // `BadArgument | SystemError`; BadArgument means the CLI itself passed a + // rejected argument (internal bug). Only the closed PermissionDenied and + // NotFound reasons have context-independent user-actionable meanings. + PlatformError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) + ? safeIdentifier(readString(reason, "_tag")) + : undefined; + if (reasonTag === "BadArgument") { + return { ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }; + } + if (reasonTag === "PermissionDenied") { + return { ...actionability.permission, fingerprint_suffix: "filesystem" }; + } + if (reasonTag === "NotFound") { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } + // The remaining closed SystemError reasons are context-dependent. Keep + // them unknown until a command boundary supplies a more specific wrapper + // instead of misreporting every local I/O failure as a permission issue. + return { ...actionability.unknown, fingerprint_suffix: "platform_error" }; + }, + BadArgument: () => ({ ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }), + + // @supabase/config + ProjectConfigParseError: () => actionability.invalidConfig, + ProjectEnvParseError: () => actionability.invalidConfig, + MissingProjectConfigValueError: () => actionability.invalidConfig, + DuplicateRemoteProjectIdError: () => actionability.invalidConfig, + InvalidRemoteProjectIdError: () => actionability.invalidConfig, + + // @supabase/api — client construction failed before any request (missing + // access token / bad configuration); remediation is the token env var. + SupabaseApiConfigError: () => actionability.authToken, + + // @supabase/api — the generated client's input schema rejected a request + // before it was sent. Treat it as an internal request-construction failure + // unless the command boundary explicitly marked the whole request as + // user-derived; never infer provenance from the schema error message. + SupabaseApiInputError: (error) => + readString(error, "source") === "user_input" + ? { ...actionability.invalidInput, fingerprint_suffix: "request_input" } + : { ...actionability.impossibleState, fingerprint_suffix: "request_encoding" }, + + // effect/unstable/http — generated Management API client transport/decoding + HttpClientError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) ? readString(reason, "_tag") : undefined; + const response = error["response"]; + const status = isErrorRecord(response) ? readNumber(response, "status") : undefined; + if (reasonTag === "DecodeError" || reasonTag === "EmptyBodyError") { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + if (status === 401) return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + if (status === 403) return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + if (reasonTag === "StatusCodeError" || isErrorRecord(response)) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + }, + // Request-body construction failed before the HTTP request was sent. The + // generated client owns this encoding boundary, so it is an impossible + // state rather than an API-response failure. + HttpBodyError: () => ({ + ...actionability.impossibleState, + fingerprint_suffix: "request_encoding", + }), + SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + + // @supabase/stack — StackError is a plain Error subclass matched by `name` + // in classifyCliErrorActionability, with a structured `code` field. + StackError: (error) => + readString(error, "code") === "PORT_ALLOCATION" + ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } + : actionability.unknown, + BinaryNotFoundError: () => actionability.invalidConfig, + DownloadError: () => actionability.externalNetwork, + ChecksumMismatchError: () => ({ + ...actionability.externalNetwork, + fingerprint_suffix: "asset_checksum", + }), + DockerPullError: (error) => + error["daemonDown"] === true + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, + StackBuildError: (error) => { + const reason = readString(error, "reason"); + if (reason === "invalid_config") { + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + } + if (reason === "docker_not_running") { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + if (reason === "asset_preparation") { + return { ...actionability.externalNetwork, fingerprint_suffix: "asset_preparation" }; + } + return { ...actionability.impossibleState, fingerprint_suffix: "internal_build" }; + }, + PortConflictError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_conflict", + }), + PortAllocationError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_allocation", + }), + StateNotFoundError: () => actionability.startStack, + StateClaimError: (error) => + readString(error, "reason") === "already-claimed" + ? { ...actionability.stopStack, fingerprint_suffix: "conflict" } + : { ...actionability.permission, fingerprint_suffix: "filesystem" }, + StackNotRunningError: () => actionability.startStack, + StackReadinessError: () => actionability.startStack, + StackMetadataNotFoundError: () => actionability.startStack, + InvalidStackStateError: () => actionability.invalidConfig, + InvalidStackMetadataError: () => actionability.invalidConfig, + UnsupportedStackMetadataVersionError: () => actionability.invalidConfig, + NoRunningStackError: () => actionability.startStack, + StackAlreadyRunningError: () => actionability.stopStack, + DaemonStartError: () => actionability.unknown, + DaemonStillRunningError: () => actionability.stopStack, + // Transport failures retain the existing stack-recovery policy. An HTTP + // status or protocol failure came from the CLI-owned daemon itself and is + // therefore an internal invariant failure, not user configuration. + UnixHttpClientError: (error) => { + const reason = readString(error, "reason"); + if (reason === "protocol") { + return { ...actionability.impossibleState, fingerprint_suffix: "daemon_protocol" }; + } + if (reason === "status") { + return { ...actionability.impossibleState, fingerprint_suffix: "daemon_status" }; + } + if (reason !== "transport") return actionability.unknown; + const path = readString(error, "path"); + if (path !== undefined && path.startsWith("/start")) { + return { ...actionability.startStack, fingerprint_suffix: "daemon_start" }; + } + return { ...actionability.stopStack, fingerprint_suffix: "daemon_transport" }; + }, + + // @supabase/process-compose — the CLI generates the process graph, so graph + // invariants are internal bugs; runtime service failures are stack-state + // problems the user resolves by restarting the stack. + CyclicDependencyError: () => actionability.impossibleState, + MissingDependencyError: () => actionability.impossibleState, + ServiceNotFoundError: () => actionability.impossibleState, + SpawnError: () => actionability.startStack, + ShutdownTimeoutError: () => actionability.stopStack, + ServiceReadyError: () => actionability.startStack, +}; + +/** + * Whether a tag defined outside `apps/cli` has an external adapter. Used by + * the coverage test to keep {@link externalActionabilityByTag} exhaustive + * against the workspace packages. + */ +export function isClassifiedExternalErrorTag(tag: string): boolean { + return Object.hasOwn(externalActionabilityByTag, tag); +} + +/** + * A wrapper's preserved `cause`, but only when classifying it cannot degrade + * the result: the cause must carry its own declaration or a known external + * adapter tag, otherwise the wrapper's own classification is more truthful. + */ +function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { + const cause = error["cause"]; + if (!isErrorRecord(cause)) return undefined; + if (readDeclaration(cause) !== undefined) return cause; + const causeTag = readErrorTag(cause); + if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; + return undefined; +} + +function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { + const errors = error["errors"]; + if (!Array.isArray(errors)) return undefined; + if (errors.length === 1) return classifyAtDepth(errors[0], depth + 1); + return toActionability(actionability.invalidInput, "tag", "ShowHelp"); +} + +function isNativeJsExceptionName(name: string | undefined): boolean { + return ( + name === "TypeError" || + name === "ReferenceError" || + name === "RangeError" || + name === "SyntaxError" || + name === "EvalError" || + name === "URIError" || + name === "AggregateError" + ); +} + +/** + * Hard cap on cause-chain recursion (ShowHelp, UserError, and stack wrapper + * causes). Real chains are 1-2 deep; the cap exists so a cyclic or + * adversarial cause chain can never stack-overflow the failure-telemetry + * path itself. + */ +const MAX_CAUSE_DEPTH = 8; + +export function classifyCliErrorActionability(error: unknown): CliErrorActionability { + try { + return classifyAtDepth(error, 0); + } catch { + return toActionability(actionability.unknown, "error", "ClassificationFailure"); + } +} + +function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { + if (depth >= MAX_CAUSE_DEPTH) { + return toActionability(actionability.unknown, "error", "CauseChainLimit"); + } + const declared = readDeclaration(error); + if (declared !== undefined) { + const stableTaggedName = readStableTaggedPrototypeName(error); + const tag = readErrorTag(error); + if (stableTaggedName !== undefined && tag === stableTaggedName) { + return toActionability(declared, "tag", tag); + } + // The declared static identifier outranks the prototype walk: a class + // extending a native Error subtype (e.g. `extends TypeError`) would + // otherwise pick up the native prototype's own `name` and collide on it. + return toActionability( + declared, + "error", + readDeclaredErrorFingerprintId(error) ?? stableTaggedName ?? "DeclaredError", + ); + } + + const tag = readErrorTag(error); + + if (tag === "ShowHelp" && isErrorRecord(error)) { + const classified = classifyShowHelp(error, depth); + if (classified !== undefined) return classified; + } + + // effect cli wraps handler failures in UserError({ cause }) — classify the + // actual failure instead of the wrapper. + if (tag === "UserError" && isErrorRecord(error) && error["cause"] !== undefined) { + return classifyAtDepth(error["cause"], depth + 1); + } + + // @supabase/stack wrapper errors preserve the underlying tagged failure in + // `cause`; classify it when it is more specific than the wrapper (e.g. a + // daemon-down DockerPullError inside an asset-preparation StackBuildError, + // or a user's ProjectConfigParseError inside a reason-less StackBuildError). + // Explicit `invalid_config` StackBuildErrors are deliberate user-facing + // config verdicts and are never overridden by their cause. + if ( + isErrorRecord(error) && + tag === "StackBuildError" && + readString(error, "reason") !== "invalid_config" + ) { + const cause = classifiableCause(error); + if (cause !== undefined) { + return classifyAtDepth(cause, depth + 1); + } + } + + // DownloadError recurses ONLY into local filesystem causes (PlatformError: + // unwritable cache, extraction failure). HTTP causes stay on the wrapper — + // the HttpClientError adapter's 401/403 → auth/permission policy is + // Management-API-specific and must not apply to GitHub/CDN asset downloads. + if (isErrorRecord(error) && tag === "DownloadError") { + const cause = error["cause"]; + if (isErrorRecord(cause) && readErrorTag(cause) === "PlatformError") { + return classifyAtDepth(cause, depth + 1); + } + } + + // ManagedStackInitializationError is only a wrapper: the real provisioning + // failure (a Docker pull, a config parse, ...) is preserved in `cause`, and + // the generic initialization verdict would hide the actionable one. + if (isErrorRecord(error) && tag === "ManagedStackInitializationError") { + const cause = classifiableCause(error); + if (cause !== undefined) return classifyAtDepth(cause, depth + 1); + } + + if (tag !== undefined && isErrorRecord(error)) { + // Own-property lookup: a sanitized tag like "constructor" must not pick + // up Object.prototype members as adapters. + if (Object.hasOwn(externalActionabilityByTag, tag)) { + const external = externalActionabilityByTag[tag]; + if (external !== undefined) { + const fingerprintTag = tag === "UnknownSubcomand" ? "UnknownSubcommand" : tag; + return toActionability(external(error), "tag", fingerprintTag); + } + } + return toActionability(actionability.unknown, "tag", undefined); + } + + if (isErrorRecord(error) && readErrorName(error) === "StackError") { + // The public Stack promise API wraps tagged failures via `toStackError`, + // preserving the original in `cause` — classify that instead of the + // wrapper whenever it is itself classifiable. + const cause = classifiableCause(error); + if (cause !== undefined) { + return classifyAtDepth(cause, depth + 1); + } + // toStackError wraps arbitrary thrown errors with code "UNKNOWN"; a + // native JS exception cause is a stack-internal crash and must land in + // the internal-bug bucket, matching the top-level native-exception rule. + if (isNativeJsExceptionName(readErrorName(error["cause"]))) { + return classifyAtDepth(error["cause"], depth + 1); + } + const classify = externalActionabilityByTag["StackError"]; + if (classify !== undefined) { + return toActionability(classify(error), "error", "StackError"); + } + } + + if (typeof error === "string") { + return toActionability(actionability.unknown, "string", undefined); + } + + const name = readErrorName(error); + if (isNativeJsExceptionName(name)) { + return toActionability(actionability.internalPanic, "error", name); + } + + return toActionability(actionability.unknown, "error", undefined); +} + +export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { + let firstKnownDefect: CliErrorActionability | undefined; + let hasUnknownDefect = false; + for (const reason of cause.reasons) { + if (!Cause.isDieReason(reason)) continue; + const classified = classifyCliErrorActionability(reason.defect); + if (classified.error_kind === CliErrorKind.InternalBug) return classified; + if (classified.error_kind === CliErrorKind.Unknown) hasUnknownDefect = true; + else firstKnownDefect ??= classified; + } + if (hasUnknownDefect) return toActionability(actionability.internalPanic, "error", "Defect"); + if (firstKnownDefect !== undefined) return firstKnownDefect; + if (Cause.hasInterruptsOnly(cause)) { + return toActionability(actionability.cancelled, "error", "Interrupt"); + } + const error = Option.getOrElse(Cause.findErrorOption(cause), () => Cause.squash(cause)); + return classifyCliErrorActionability(error); +} + +/** + * Fallback for a command that deliberately signalled failure through + * ProcessControl without failing its Effect, when no typed error is available + * to derive a classification from (see `withLegacyCommandInstrumentation`, + * which classifies the command's own fail-on error class where one exists). + */ +export const unknownProcessControlledFailureActionability: CliErrorActionability = toActionability( + actionability.unknown, + "error", + "ProcessControlledFailure", +); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts new file mode 100644 index 0000000000..5993ddebcc --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -0,0 +1,1135 @@ +import { Cause, Data } from "effect"; +import { CliError } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { markSupabaseApiInputErrorAsUserInput, SupabaseApiInputError } from "@supabase/api/effect"; +import { LegacyBootstrapHealthError } from "../../legacy/commands/bootstrap/bootstrap.errors.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + classifyCliCauseActionability, + classifyCliErrorActionability, + CliErrorActionabilityMetricDefinitions, + ErrorActionabilityFingerprintId, + ErrorActionabilityId, + statusCodeActionability, +} from "./error-actionability.ts"; + +class DeclaredError extends Data.TaggedError("DeclaredError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} + +class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ + readonly status: number; + readonly upgradeSuggested?: boolean; + readonly notFoundIsInvalidInput?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { + upgradeSuggested: this.upgradeSuggested, + notFoundIsInvalidInput: this.notFoundIsInvalidInput, + }); + } +} + +class UndeclaredError extends Data.TaggedError("UndeclaredError")<{ + readonly message: string; +}> {} + +class DeclaredNoSuggestionError extends Data.TaggedError("DeclaredNoSuggestionError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +class PlainDeclaredError extends Error { + static readonly [ErrorActionabilityFingerprintId] = "PlainDeclaredError"; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +class DeclarationCarrierError extends Error { + readonly _tag: string; + readonly declaration: Record; + + constructor(tag: string, declaration: Record) { + super(tag); + this._tag = tag; + this.declaration = declaration; + } + + get [ErrorActionabilityId](): Record { + return this.declaration; + } +} + +function declarationCarrier(tag: string, declaration: Record) { + return new DeclarationCarrierError(tag, declaration); +} + +describe("classifyCliErrorActionability", () => { + it("uses the declaration co-located on the error class", () => { + expect( + classifyCliErrorActionability(new DeclaredError({ message: "raw secret text" })), + ).toEqual({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:DeclaredError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + it("uses a declared Error subclass constructor for an untagged fingerprint", () => { + const result = classifyCliErrorActionability(new PlainDeclaredError("private path")); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:PlainDeclaredError"); + }); + + it("prefers the declared static identifier over a native ancestor's prototype name", () => { + // TypeError.prototype carries an own `name` data property ("TypeError"); + // without the static-identifier precedence, every declared class extending + // a native Error subtype would collide on the native name. + class NativeSubtypeDeclaredError extends TypeError { + static readonly [ErrorActionabilityFingerprintId] = "NativeSubtypeDeclaredError"; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } + } + const result = classifyCliErrorActionability(new NativeSubtypeDeclaredError("boom")); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:NativeSubtypeDeclaredError"); + }); + + it("classifies Effect's runtime unknown-subcommand tag without fingerprinting user input", () => { + const secret = "customerProjectRef123"; + const result = classifyCliErrorActionability( + new CliError.UnknownSubcommand({ + subcommand: secret, + suggestions: [], + }), + ); + + expect(result).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:UnknownSubcommand", + has_suggestion: false, + suggestion_type: "none", + }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("classifies Effect's unexpected positional arguments without fingerprinting their values", () => { + const secret = "/Users/alice/private.sql"; + const result = classifyCliErrorActionability( + new CliError.UnexpectedArgument({ arguments: [secret] }), + ); + + expect(result).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:UnexpectedArgument", + has_suggestion: false, + suggestion_type: "none", + }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("does not claim a generic remediation for local permission failures", () => { + expect(actionability.permission).toEqual({ + error_kind: "user_actionable", + error_category: "permission", + has_suggestion: false, + suggestion_type: "none", + }); + }); + + it("lets instance-dependent declarations branch on typed fields", () => { + const auth = classifyCliErrorActionability(new DeclaredStatusError({ status: 401 })); + expect(auth.error_category).toBe("auth"); + expect(auth.error_fingerprint).toBe("tag:DeclaredStatusError:auth"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 404, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + expect(gated.suggestion_type).toBe("upgrade_plan"); + expect(gated.error_fingerprint).toBe("tag:DeclaredStatusError:plan_limit"); + + const notFound = classifyCliErrorActionability(new DeclaredStatusError({ status: 404 })); + expect(notFound.error_kind).toBe("external_service"); + expect(notFound.error_category).toBe("api_status"); + expect(notFound.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); + + const namedResourceNotFound = classifyCliErrorActionability( + new DeclaredStatusError({ status: 404, notFoundIsInvalidInput: true }), + ); + expect(namedResourceNotFound.error_kind).toBe("user_actionable"); + expect(namedResourceNotFound.error_category).toBe("invalid_input"); + expect(namedResourceNotFound.error_fingerprint).toBe("tag:DeclaredStatusError:not_found"); + + const status = classifyCliErrorActionability(new DeclaredStatusError({ status: 500 })); + expect(status.error_kind).toBe("external_service"); + expect(status.error_category).toBe("api_status"); + expect(status.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); + }); + + it("classifies undeclared tagged errors as unknown with a sanitized fingerprint", () => { + const result = classifyCliErrorActionability(new UndeclaredError({ message: "boom" })); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:unknown"); + }); + + it.each([ + ["user_actionable", "auth"], + ["user_actionable", "missing_project_ref"], + ["user_actionable", "project_not_linked"], + ["user_actionable", "docker_not_running"], + ["user_actionable", "invalid_config"], + ["user_actionable", "db_connection"], + ["user_actionable", "migration_drift"], + ["user_actionable", "permission"], + ["user_actionable", "plan_limit"], + ["user_actionable", "project_paused"], + ["user_actionable", "invalid_input"], + ["internal_bug", "panic"], + ["internal_bug", "impossible_state"], + ["external_service", "network"], + ["external_service", "api_status"], + ["user_cancelled", "cancelled"], + ["unknown", "unknown"], + ])("accepts the valid %s and %s taxonomy pair", (errorKind, errorCategory) => { + const result = classifyCliErrorActionability( + declarationCarrier("MatrixError", { + error_kind: errorKind, + error_category: errorCategory, + has_suggestion: false, + suggestion_type: "none", + }), + ); + expect(result.error_kind).toBe(errorKind); + expect(result.error_category).toBe(errorCategory); + }); + + it.each([ + ["user_actionable", "panic"], + ["internal_bug", "invalid_input"], + ["external_service", "auth"], + ["user_cancelled", "unknown"], + ["unknown", "cancelled"], + ])("rejects the invalid %s and %s taxonomy pair", (errorKind, errorCategory) => { + const result = classifyCliErrorActionability( + declarationCarrier("InvalidMatrixError", { + error_kind: errorKind, + error_category: errorCategory, + has_suggestion: false, + suggestion_type: "none", + }), + ); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:unknown"); + }); + + it("accepts a closed command remediation", () => { + const result = classifyCliErrorActionability( + declarationCarrier("RunCommandError", { + error_kind: "user_actionable", + error_category: "invalid_config", + has_suggestion: true, + suggestion_type: "run_command", + suggested_command: "supabase start", + }), + ); + expect(result.suggestion_type).toBe("run_command"); + expect(result.suggested_command).toBe("supabase start"); + }); + + it.each([ + [false, "login", undefined], + [true, "none", undefined], + [false, "none", "supabase login"], + ])( + "rejects inconsistent suggestion metadata (%s, %s, %s)", + (hasSuggestion, suggestionType, suggestedCommand) => { + const result = classifyCliErrorActionability( + declarationCarrier("InvalidSuggestionError", { + error_kind: "user_actionable", + error_category: "auth", + has_suggestion: hasSuggestion, + suggestion_type: suggestionType, + suggested_command: suggestedCommand, + }), + ); + expect(result.error_kind).toBe("unknown"); + expect(result).not.toHaveProperty("suggested_command"); + }, + ); + + it("ignores a declaration whose symbol getter throws", () => { + const secret = "token-from-throwing-getter"; + class ThrowingDeclarationError extends Error { + readonly _tag = "ThrowingDeclarationError"; + + get [ErrorActionabilityId]() { + throw new Error(secret); + } + } + const error = new ThrowingDeclarationError(); + const result = classifyCliErrorActionability(error); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:unknown"); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("falls back safely when even structural property access throws", () => { + const secret = "token-from-hostile-proxy"; + const hostile = new Proxy( + {}, + { + get() { + throw new Error(secret); + }, + }, + ); + const result = classifyCliErrorActionability(hostile); + expect(result).toEqual({ + error_kind: "unknown", + error_category: "unknown", + has_suggestion: false, + suggestion_type: "none", + error_fingerprint: "error:ClassificationFailure", + }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("rejects arbitrary declaration commands and fingerprint suffixes", () => { + const commandSecret = "supabase login --token user-secret-token"; + const unsafeCommand = classifyCliErrorActionability( + declarationCarrier("UnsafeCommandError", { + error_kind: "user_actionable", + error_category: "auth", + has_suggestion: true, + suggestion_type: "login", + suggested_command: commandSecret, + }), + ); + expect(unsafeCommand.error_kind).toBe("unknown"); + expect(JSON.stringify(unsafeCommand)).not.toContain(commandSecret); + + const suffixSecret = "customer_project_reference"; + const unsafeSuffix = classifyCliErrorActionability( + declarationCarrier("UnsafeSuffixError", { + error_kind: "user_actionable", + error_category: "invalid_input", + has_suggestion: false, + suggestion_type: "none", + fingerprint_suffix: suffixSecret, + }), + ); + expect(unsafeSuffix.error_fingerprint).toBe("tag:unknown"); + expect(JSON.stringify(unsafeSuffix)).not.toContain(suffixSecret); + }); + + it("rejects a closed command paired with the wrong suggestion type", () => { + const result = classifyCliErrorActionability( + declarationCarrier("MismatchedSuggestionError", { + error_kind: "user_actionable", + error_category: "auth", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase stop", + }), + ); + expect(result.error_kind).toBe("unknown"); + expect(result).not.toHaveProperty("suggested_command"); + }); + + it("snapshots validated declaration fields before emitting them", () => { + const secret = "supabase login --token later-secret"; + let reads = 0; + const declaration = { + error_kind: "user_actionable", + error_category: "auth", + has_suggestion: true, + suggestion_type: "login", + get suggested_command() { + reads += 1; + return reads === 1 ? "supabase login" : secret; + }, + }; + const result = classifyCliErrorActionability( + declarationCarrier("ChangingDeclarationError", declaration), + ); + expect(result.suggested_command).toBe("supabase login"); + expect(reads).toBe(1); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("does not include sensitive error fields in classification output", () => { + const secrets = [ + "/Users/alice/private/project/config.toml", + "select * from private_table", + "abcdefghijklmnopqrst", + "db.customer.internal", + "user-specific-project-ref", + ]; + const result = classifyCliErrorActionability({ + _tag: "UndeclaredSensitiveError", + message: secrets[0], + sql: secrets[1], + token: secrets[2], + hostname: secrets[3], + projectRef: secrets[4], + }); + const encoded = JSON.stringify(result); + for (const secret of secrets) expect(encoded).not.toContain(secret); + }); + + it("does not fingerprint an unknown tag that looks like an identifier", () => { + const secret = "CustomerSecret123"; + const result = classifyCliErrorActionability({ _tag: secret }); + expect(result.error_fingerprint).toBe("tag:unknown"); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("does not trust an arbitrary declaration carrier as a fingerprint authority", () => { + const secret = "CustomerProjectRef123"; + const plainResult = classifyCliErrorActionability({ + _tag: secret, + [ErrorActionabilityId]: actionability.invalidInput, + }); + expect(plainResult.error_fingerprint).toBe("tag:unknown"); + + const errorResult = classifyCliErrorActionability( + declarationCarrier(secret, actionability.invalidInput), + ); + expect(errorResult.error_fingerprint).toBe("error:DeclaredError"); + expect(JSON.stringify([plainResult, errorResult])).not.toContain(secret); + }); + + it("does not trust mutable instance tags or names over the tagged prototype", () => { + const secret = "CustomerProjectRef123"; + const error = new DeclaredError({ message: "failed" }); + Reflect.set(error, "_tag", secret); + Reflect.set(error, "name", secret); + + const result = classifyCliErrorActionability(error); + expect(result.error_fingerprint).toBe("error:DeclaredError"); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("does not accept declarations through the global symbol registry", () => { + const secret = "CustomerProjectRef123"; + const globalDeclarationKey = Symbol.for("@supabase/cli/telemetry/ErrorActionability"); + class HostileDeclaredError extends Error { + readonly _tag = secret; + + get [globalDeclarationKey](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } + } + Object.defineProperty(HostileDeclaredError, "name", { value: secret }); + + const result = classifyCliErrorActionability(new HostileDeclaredError(secret)); + expect(result.error_fingerprint).toBe("tag:unknown"); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("classifies external stack build errors by structured reason", () => { + const invalidConfig = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", + }); + expect(invalidConfig.error_category).toBe("invalid_config"); + expect(invalidConfig.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + + const assetPreparation = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + }); + expect(assetPreparation.error_kind).toBe("external_service"); + expect(assetPreparation.error_fingerprint).toBe("tag:StackBuildError:asset_preparation"); + + const dockerNotRunning = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "docker_not_running", + }); + expect(dockerNotRunning.error_category).toBe("docker_not_running"); + expect(dockerNotRunning.error_fingerprint).toBe("tag:StackBuildError:docker_not_running"); + + const internal = classifyCliErrorActionability({ _tag: "StackBuildError", detail: "bug" }); + expect(internal.error_kind).toBe("internal_bug"); + expect(internal.error_category).toBe("impossible_state"); + expect(internal.error_fingerprint).toBe("tag:StackBuildError:internal_build"); + }); + + it("classifies stack readiness and state-claim failures without reading details", () => { + const readiness = classifyCliErrorActionability({ + _tag: "StackReadinessError", + target: "auth", + timeoutMs: 10, + detail: "private runtime detail", + }); + expect(readiness.error_category).toBe("invalid_config"); + expect(readiness.suggestion_type).toBe("run_command"); + expect(readiness.suggested_command).toBe("supabase start"); + + const claimed = classifyCliErrorActionability({ + _tag: "StateClaimError", + reason: "already-claimed", + path: "/private/state.json", + }); + expect(claimed.suggested_command).toBe("supabase stop"); + expect(claimed.suggestion_type).toBe("run_command"); + expect(claimed.error_fingerprint).toBe("tag:StateClaimError:conflict"); + + const filesystem = classifyCliErrorActionability({ + _tag: "StateClaimError", + reason: "io-error", + path: "/private/state.json", + }); + expect(filesystem.error_category).toBe("permission"); + expect(filesystem.has_suggestion).toBe(false); + expect(filesystem.error_fingerprint).toBe("tag:StateClaimError:filesystem"); + }); + + it("splits docker pull failures from a stopped docker daemon", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "DockerPullError", + image: "postgres", + daemonDown: true, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.suggestion_type).toBe("start_docker"); + + const pull = classifyCliErrorActionability({ _tag: "DockerPullError", image: "postgres" }); + expect(pull.error_kind).toBe("external_service"); + expect(pull.error_fingerprint).toBe("tag:DockerPullError:registry_pull"); + }); + + it("classifies http client errors by response presence and status", () => { + const auth = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 401 }, + }); + expect(auth.error_category).toBe("auth"); + + const notFound = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 404 }, + }); + expect(notFound.error_kind).toBe("external_service"); + expect(notFound.error_category).toBe("api_status"); + expect(notFound.error_fingerprint).toBe("tag:HttpClientError:api_status"); + + const status = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 503 }, + }); + expect(status.error_category).toBe("api_status"); + + const transport = classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "TransportError" }, + }); + expect(transport.error_category).toBe("network"); + + for (const reason of ["DecodeError", "EmptyBodyError"]) { + const decode = classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: reason }, + response: { status: 200 }, + }); + expect(decode.error_category).toBe("api_status"); + expect(decode.error_fingerprint).toBe("tag:HttpClientError:api_response"); + } + }); + + it("classifies a stopped external stack through its static adapter", () => { + const result = classifyCliErrorActionability({ + _tag: "StackNotRunningError", + statePath: "/private/project/.temp/stack.json", + }); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.suggested_command).toBe("supabase start"); + expect(JSON.stringify(result)).not.toContain("/private/project"); + }); + + it("recurses into single-error ShowHelp wrappers", () => { + const single = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [new DeclaredError({ message: "inner" })], + }); + expect(single.error_fingerprint).toBe("tag:DeclaredError"); + + const multiple = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [{ _tag: "MissingOption" }, { _tag: "MissingOption" }], + }); + expect(multiple.error_category).toBe("invalid_input"); + expect(multiple.error_fingerprint).toBe("tag:ShowHelp"); + }); + + it("classifies StackError port allocation failures", () => { + const error = new Error("no free port"); + error.name = "StackError"; + Object.defineProperty(error, "code", { value: "PORT_ALLOCATION" }); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:StackError:port_allocation"); + + const other = new Error("other"); + other.name = "StackError"; + expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); + }); + + // Managed registry errors are tagged errors that also declare a stable + // `code`: the tag routes them to an adapter generated from the package's + // tag/code map, and the code keys the verdict that adapter resolves. + // `managed-model.unit.test.ts` in `@supabase/stack` pins the real classes to + // the (tag, code) pairs reproduced here. + it.each([ + [ + "InvalidManagedIdentityError", + "INVALID_MANAGED_IDENTITY", + "managed_identity", + "invalid_input", + ], + ["InvalidManagedPortError", "MANAGED_INVALID_PORT", "managed_port", "invalid_config"], + [ + "UnsafeManagedStackPathError", + "UNSAFE_MANAGED_STACK_PATH", + "bad_argument", + "impossible_state", + ], + // The operation pid and the pending-update guard are both internal + // invariants: the CLI supplies the pid, and only repository misuse can + // update an unpublished row. + [ + "InvalidManagedOwnerPidError", + "MANAGED_INVALID_OWNER_PID", + "managed_owner_pid", + "impossible_state", + ], + [ + "ManagedPendingStackUpdateError", + "MANAGED_PENDING_STACK_UPDATE", + "managed_pending_update", + "impossible_state", + ], + // Each of these five used to share a suffix with an unrelated failure, so + // distinct defects grouped together as repeats (CLI-2106). + [ + "ManagedOperationInProgressError", + "MANAGED_OPERATION_IN_PROGRESS", + "managed_operation_in_progress", + "invalid_config", + ], + [ + "ManagedOperationOwnershipError", + "MANAGED_OPERATION_OWNERSHIP_MISMATCH", + "managed_operation_ownership", + "invalid_config", + ], + [ + "ManagedStackPublicationTimeoutError", + "MANAGED_STACK_PUBLICATION_TIMEOUT", + "managed_publication_timeout", + "invalid_config", + ], + [ + "ManagedStackNotStoppedError", + "MANAGED_STACK_NOT_STOPPED", + "managed_stack_not_stopped", + "invalid_config", + ], + [ + "ManagedRunningStackPortChangeError", + "MANAGED_RUNNING_STACK_PORT_CHANGE", + "managed_port_change", + "invalid_config", + ], + ])("classifies %s through its generated tag adapter", (tag, code, suffix, category) => { + const error = new Error("managed registry failure"); + error.name = tag; + Object.defineProperty(error, "_tag", { value: tag }); + Object.defineProperty(error, "code", { value: code }); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(`tag:${tag}:${suffix}`); + }); + + it("leaves an unregistered managed-shaped failure unclassified", () => { + const unrecognized = new Error("managed failure"); + unrecognized.name = "ManagedFutureError"; + Object.defineProperty(unrecognized, "_tag", { value: "ManagedFutureError" }); + Object.defineProperty(unrecognized, "code", { value: "MANAGED_FUTURE_FAILURE" }); + expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown"); + }); + + // ManagedStackInitializationError wraps the real provisioning failure in + // `cause`; reporting the generic initialization verdict would lose it. + it("classifies the provisioning cause of a managed initialization failure", () => { + const wrapped = new Error("managed stack initialization failed"); + wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" }); + Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); + Object.defineProperty(wrapped, "cause", { + value: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, + }); + expect(classifyCliErrorActionability(wrapped)).toEqual( + classifyCliErrorActionability({ + _tag: "DockerPullError", + image: "postgres", + daemonDown: true, + }), + ); + }); + + it("falls back to the managed initialization verdict for an opaque cause", () => { + const wrapped = new Error("managed stack initialization failed"); + wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" }); + Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); + Object.defineProperty(wrapped, "cause", { value: { detail: "opaque" } }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_kind).toBe("user_actionable"); + expect(result.suggested_command).toBe("supabase start"); + expect(result.error_fingerprint).toBe( + "tag:ManagedStackInitializationError:managed_initialization", + ); + }); + + it("classifies a managed cause nested inside a stack wrapper", () => { + const managed = new Error("port already reserved"); + managed.name = "ManagedPortReservationError"; + Object.defineProperty(managed, "_tag", { value: "ManagedPortReservationError" }); + Object.defineProperty(managed, "code", { value: "MANAGED_PORT_ALREADY_RESERVED" }); + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "x", + cause: managed, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:ManagedPortReservationError:port_conflict"); + }); + + it("classifies the preserved tagged cause of a StackError wrapper", () => { + const wrapped = new Error("stack failure"); + wrapped.name = "StackError"; + Object.defineProperty(wrapped, "code", { value: "BUILD_ERROR" }); + Object.defineProperty(wrapped, "cause", { + value: { _tag: "StackBuildError", detail: "x", reason: "invalid_config" }, + }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + }); + + it("classifies a native exception wrapped by StackError as an internal bug", () => { + const wrapped = new Error("boom"); + wrapped.name = "StackError"; + Object.defineProperty(wrapped, "code", { value: "UNKNOWN" }); + Object.defineProperty(wrapped, "cause", { value: new TypeError("x is not a function") }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:TypeError"); + }); + + it("treats forbidden API statuses as account permission failures", () => { + const forbidden = classifyCliErrorActionability(new DeclaredStatusError({ status: 403 })); + expect(forbidden.error_kind).toBe("user_actionable"); + expect(forbidden.error_category).toBe("permission"); + expect(forbidden.error_fingerprint).toBe("tag:DeclaredStatusError:forbidden"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 403, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + + const http = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 403 }, + }); + expect(http.error_category).toBe("permission"); + }); + + it("classifies the preserved cause of stack wrapper errors", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + cause: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.error_fingerprint).toBe("tag:DockerPullError:docker_not_running"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_kind).toBe("user_actionable"); + expect(localFs.error_category).toBe("permission"); + + // An unclassifiable cause keeps the wrapper's own bucket. + const opaque = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://example.com", + cause: new Error("boom"), + }); + expect(opaque.error_kind).toBe("external_service"); + expect(opaque.error_category).toBe("network"); + }); + + it.each([ + ["PermissionDenied", "user_actionable", "permission", "tag:PlatformError:filesystem"], + ["NotFound", "user_actionable", "invalid_input", "tag:PlatformError:not_found"], + ["TimedOut", "unknown", "unknown", "tag:PlatformError:platform_error"], + ["Unknown", "unknown", "unknown", "tag:PlatformError:platform_error"], + ])( + "classifies PlatformError reason %s without conflating it with permissions", + (reason, errorKind, errorCategory, errorFingerprint) => { + const result = classifyCliErrorActionability({ + _tag: "PlatformError", + reason: { _tag: reason }, + }); + expect(result.error_kind).toBe(errorKind); + expect(result.error_category).toBe(errorCategory); + expect(result.error_fingerprint).toBe(errorFingerprint); + }, + ); + + it("splits daemon start failures from other daemon RPC failures", () => { + const start = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/start", + reason: "transport", + }); + expect(start.error_category).toBe("invalid_config"); + expect(start.suggested_command).toBe("supabase start"); + expect(start.error_fingerprint).toBe("tag:UnixHttpClientError:daemon_start"); + + const status = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/status", + reason: "transport", + }); + expect(status.error_category).toBe("invalid_config"); + expect(status.suggested_command).toBe("supabase stop"); + expect(status.error_fingerprint).toBe("tag:UnixHttpClientError:daemon_transport"); + }); + + it("keeps daemon status and protocol failures in the internal-bug bucket", () => { + for (const [reason, suffix] of [ + ["status", "daemon_status"], + ["protocol", "daemon_protocol"], + ] as const) { + const result = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + path: "/status", + reason, + }); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("impossible_state"); + expect(result.error_fingerprint).toBe(`tag:UnixHttpClientError:${suffix}`); + } + }); + + it("does not claim daemon startup failures are recoverable stack config", () => { + const result = classifyCliErrorActionability({ _tag: "DaemonStartError" }); + expect(result.error_kind).toBe("unknown"); + expect(result.error_category).toBe("unknown"); + }); + + it("classifies API client configuration failures as token problems", () => { + const result = classifyCliErrorActionability({ + _tag: "SupabaseApiConfigError", + message: "Missing access token.", + }); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + }); + + it("classifies API input-schema rejections from typed provenance", () => { + const generated = new SupabaseApiInputError("private generated schema details"); + const generatedResult = classifyCliErrorActionability(generated); + expect(generatedResult.error_kind).toBe("internal_bug"); + expect(generatedResult.error_category).toBe("impossible_state"); + expect(generatedResult.error_fingerprint).toBe("tag:SupabaseApiInputError:request_encoding"); + expect(JSON.stringify(generatedResult)).not.toContain("private generated schema details"); + + const userInput = new SupabaseApiInputError("private user schema details"); + expect(markSupabaseApiInputErrorAsUserInput(userInput)).toBe(userInput); + const userResult = classifyCliErrorActionability(userInput); + expect(userResult.error_kind).toBe("user_actionable"); + expect(userResult.error_category).toBe("invalid_input"); + expect(userResult.error_fingerprint).toBe("tag:SupabaseApiInputError:request_input"); + expect(JSON.stringify(userResult)).not.toContain("private user schema details"); + }); + + it("caps cause-chain recursion instead of overflowing on cycles", () => { + const a: Record = { + _tag: "StackBuildError", + detail: "x", + reason: "asset_preparation", + }; + const b: Record = { + _tag: "StackBuildError", + detail: "y", + reason: "asset_preparation", + cause: a, + }; + a["cause"] = b; + const result = classifyCliErrorActionability(a); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("error:CauseChainLimit"); + + const self: Record = { _tag: "UserError" }; + self["cause"] = self; + expect(classifyCliErrorActionability(self).error_fingerprint).toBe("error:CauseChainLimit"); + }); + + it("classifies the user config cause of a reason-less StackBuildError", () => { + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to configure Edge Functions", + cause: { _tag: "ProjectConfigParseError", path: "supabase/config.toml" }, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:ProjectConfigParseError"); + }); + + it("keeps a local stack schema failure in the invalid-config bucket", () => { + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Invalid Edge Functions bundle", + reason: "invalid_config", + cause: { _tag: "SchemaError", issue: "private local config details" }, + }); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + }); + + it("keeps HTTP download causes in the download bucket", () => { + // GitHub/CDN 401/403 during asset download must NOT hit the + // Management-API auth/permission policy of the HttpClientError adapter. + const forbidden = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://github.com/releases/x", + cause: { _tag: "HttpClientError", response: { status: 403 } }, + }); + expect(forbidden.error_kind).toBe("external_service"); + expect(forbidden.error_category).toBe("network"); + expect(forbidden.error_fingerprint).toBe("tag:DownloadError"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_category).toBe("permission"); + }); + + it("does not treat Object.prototype members as external adapters", () => { + const result = classifyCliErrorActionability({ _tag: "constructor" }); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:unknown"); + }); + + it("buckets native JS exceptions as internal panics", () => { + const result = classifyCliErrorActionability(new TypeError("x is not a function")); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:TypeError"); + }); + + it("does not inspect raw instance-level suggestion text", () => { + const secret = "run --token user-secret-token"; + const withSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row", suggestion: secret }), + ); + expect(withSuggestion.has_suggestion).toBe(false); + expect(withSuggestion.suggestion_type).toBe("none"); + expect(JSON.stringify(withSuggestion)).not.toContain(secret); + + const withoutSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row" }), + ); + expect(withoutSuggestion.has_suggestion).toBe(false); + }); + + it("never leaks raw text into fingerprints for unknown failures", () => { + expect(classifyCliErrorActionability("raw failure text").error_fingerprint).toBe( + "string:unknown", + ); + const named = new Error("boom"); + named.name = "CustomerSecret123"; + expect(classifyCliErrorActionability(named).error_fingerprint).toBe("error:unknown"); + }); +}); + +describe("metric definitions", () => { + it("keeps the Q2 baseline definitions stable for reporting queries", () => { + // These ids are referenced by the PostHog reporting built in CLI-1562; + // changing them invalidates the Q2 baseline and must be deliberate. + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.id).toBe( + "same_command_success_same_session", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.id).toBe( + "same_command_same_error_same_session_before_success", + ); + expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.id).toBe( + "failed_commands_internal_bug_or_unknown", + ); + expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.denominator).toBe( + "count where exit_code != 0 AND error_kind IS NOT NULL", + ); + expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.id).toBe( + "failed_commands_with_classification", + ); + expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.numerator).toBe( + "count where exit_code != 0 AND error_kind IS NOT NULL", + ); + expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.denominator).toBe( + "count where exit_code != 0", + ); + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.partition_by).toEqual([ + "device_id", + "$session_id", + "command", + ]); + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.eligible_failure).toBe( + "exit_code != 0 AND error_kind = 'user_actionable'", + ); + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.recovered_when).toContain( + "S.device_id = F.device_id, S.$session_id = F.$session_id, and S.command = F.command", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.partition_by).toEqual([ + "device_id", + "$session_id", + "command", + ]); + expect(CliErrorActionabilityMetricDefinitions.repeatError.eligible_failure).toBe( + "exit_code != 0 AND error_kind = 'user_actionable' AND error_fingerprint IS NOT NULL", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.repeated_when).toContain( + "P.error_fingerprint = F.error_fingerprint", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.reset_when).toContain( + "clears every prior fingerprint", + ); + }); +}); + +describe("classifyCliCauseActionability", () => { + it("classifies the typed failure inside a cause", () => { + const cause = Cause.fail(new DeclaredError({ message: "inner" })); + expect(classifyCliCauseActionability(cause).error_category).toBe("auth"); + }); + + it("classifies defects", () => { + const cause = Cause.die(new TypeError("boom")); + expect(classifyCliCauseActionability(cause).error_category).toBe("panic"); + }); + + it("gives defects precedence over typed failures in a combined cause", () => { + const cause = Cause.combine( + Cause.fail(new DeclaredError({ message: "recoverable" })), + Cause.die(new TypeError("boom")), + ); + expect(classifyCliCauseActionability(cause)).toMatchObject({ + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: "error:TypeError", + }); + }); + + it("preserves a known tagged defect's structured classification", () => { + const cause = Cause.die({ + _tag: "UnixHttpClientError", + path: "/status", + reason: "protocol", + }); + expect(classifyCliCauseActionability(cause)).toMatchObject({ + error_kind: "internal_bug", + error_category: "impossible_state", + error_fingerprint: "tag:UnixHttpClientError:daemon_protocol", + }); + }); + + it("does not let an earlier known defect hide a later panic", () => { + const cause = Cause.combine( + Cause.die({ _tag: "UnixHttpClientError", path: "/start", reason: "transport" }), + Cause.die(new TypeError("boom")), + ); + expect(classifyCliCauseActionability(cause)).toMatchObject({ + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: "error:TypeError", + }); + }); + + it("classifies interrupt-only causes as user cancellation", () => { + expect(classifyCliCauseActionability(Cause.interrupt(42))).toEqual({ + error_kind: "user_cancelled", + error_category: "cancelled", + has_suggestion: false, + suggestion_type: "none", + error_fingerprint: "error:Interrupt", + }); + }); +}); + +describe("LegacyBootstrapHealthError actionability", () => { + it("classifies a non-200 health poll on the status-code policy", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 500: boom", status: 500 }), + ); + expect(result.error_category).toBe("api_status"); + }); + + it("classifies a 200 the generated client could not decode as an api-response problem", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 0: boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:api_response"); + }); + + it("classifies a responseless health poll failure as network", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Error status 0: boom", transport: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:network"); + }); + + it("falls back to the api-status policy for an unhealthy service report", () => { + const result = classifyCliErrorActionability( + new LegacyBootstrapHealthError({ message: "Service not healthy: db (unhealthy)" }), + ); + expect(result.error_category).toBe("api_status"); + }); +}); diff --git a/apps/cli/src/shared/telemetry/event-catalog.ts b/apps/cli/src/shared/telemetry/event-catalog.ts index f35296517e..9dce324431 100644 --- a/apps/cli/src/shared/telemetry/event-catalog.ts +++ b/apps/cli/src/shared/telemetry/event-catalog.ts @@ -1,6 +1,9 @@ // CLI telemetry catalog. Mirrors apps/cli-go/internal/telemetry/events.go // 1:1 so legacy/ ports send byte-identical PostHog payloads. When the Go -// catalog changes, update this file in the same PR. +// catalog changes, update this file in the same PR. The failure-classification +// properties below (error_kind … workflow) are TS-only: the native shells +// classify failures (CLI-1561) and the Go binary never emits these fields, so +// they are deliberately absent from the Go catalog. export const EventCommandExecuted = "cli_command_executed"; export const EventProjectLinked = "cli_project_linked"; @@ -29,6 +32,15 @@ export const PropFlags = "flags"; export const PropExitCode = "exit_code"; export const PropDurationMs = "duration_ms"; export const PropOutputFormat = "output_format"; +export const PropErrorKind = "error_kind"; +export const PropErrorCategory = "error_category"; +export const PropErrorFingerprint = "error_fingerprint"; +export const PropHasSuggestion = "has_suggestion"; +export const PropSuggestionType = "suggestion_type"; +export const PropSuggestedCommand = "suggested_command"; +// Reserved for a closed workflow label; nothing emits it until a closed +// vocabulary is agreed (tests assert its absence on failure events). +export const PropWorkflow = "workflow"; export const GroupOrganization = "organization"; export const GroupProject = "project"; diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts new file mode 100644 index 0000000000..1975bfa6cc --- /dev/null +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -0,0 +1,111 @@ +import { createServer, type Server } from "node:http"; +import { gunzipSync } from "node:zlib"; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { runSupabase } from "../../../tests/helpers/cli.ts"; + +type CapturedEvent = { + readonly event: unknown; + readonly properties: unknown; +}; + +describe("failed command telemetry", () => { + let server: Server; + let host: string; + const capturedEvents: CapturedEvent[] = []; + + beforeAll(async () => { + server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const body = Buffer.concat(chunks); + const decoded = request.headers["content-encoding"] === "gzip" ? gunzipSync(body) : body; + const payload: unknown = JSON.parse(decoded.toString()); + if (typeof payload === "object" && payload !== null) { + const batch = Reflect.get(payload, "batch"); + if (Array.isArray(batch)) capturedEvents.push(...batch); + } + response.writeHead(200, { "content-type": "application/json" }); + response.end("{}"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Failed to allocate a telemetry receiver port"); + } + host = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + }); + + beforeEach(() => { + capturedEvents.length = 0; + }); + + // Legacy auth-gate failures abort during runtime-layer construction, before + // the instrumented handler runs, and deliberately keep their existing + // no-event behavior — so the legacy case exercises an in-handler failure. + test.each([ + { + entrypoint: "next" as const, + args: ["branches", "list"], + command: "branches list", + expected: { + error_kind: "user_actionable", + error_category: "project_not_linked", + error_fingerprint: "tag:ProjectNotLinkedError", + has_suggestion: true, + suggestion_type: "link_project", + suggested_command: "supabase link", + }, + rawErrors: ["No project is linked in this directory."], + }, + { + entrypoint: "legacy" as const, + args: [ + "db", + "query", + "--db-url", + "postgres://postgres:postgres@127.0.0.1:1/postgres", + "select 1", + ], + command: "db query", + expected: { + error_kind: "user_actionable", + error_category: "db_connection", + error_fingerprint: "tag:LegacyDbConnectError", + has_suggestion: true, + suggestion_type: "update_config", + }, + rawErrors: ["failed to connect", "127.0.0.1", "select 1"], + }, + ])("emits sanitized metadata from the compiled $entrypoint shell", async (testCase) => { + const result = await runSupabase(testCase.args, { + entrypoint: testCase.entrypoint, + env: { + SUPABASE_ACCESS_TOKEN: "", + SUPABASE_TELEMETRY_DISABLED: "0", + DO_NOT_TRACK: "0", + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", + SUPABASE_TELEMETRY_POSTHOG_HOST: host, + }, + }); + + expect(result.exitCode).toBe(1); + const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + command: testCase.command, + exit_code: 1, + ...testCase.expected, + }); + expect(event?.properties).not.toHaveProperty("workflow"); + const encoded = JSON.stringify(event); + for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); + }); +}); diff --git a/apps/cli/src/shared/telemetry/failure-metadata.ts b/apps/cli/src/shared/telemetry/failure-metadata.ts new file mode 100644 index 0000000000..e35911b734 --- /dev/null +++ b/apps/cli/src/shared/telemetry/failure-metadata.ts @@ -0,0 +1,36 @@ +import type { Cause } from "effect"; +import { + classifyCliCauseActionability, + type CliErrorActionability, +} from "./error-actionability.ts"; +import { + PropErrorCategory, + PropErrorFingerprint, + PropErrorKind, + PropHasSuggestion, + PropSuggestedCommand, + PropSuggestionType, +} from "./event-catalog.ts"; + +/** Projects the closed taxonomy contract onto the public telemetry schema. */ +export function toFailureTelemetryProperties( + classification: CliErrorActionability, +): Record { + const properties = { + [PropErrorKind]: classification.error_kind, + [PropErrorCategory]: classification.error_category, + [PropErrorFingerprint]: classification.error_fingerprint, + [PropHasSuggestion]: classification.has_suggestion, + [PropSuggestionType]: classification.suggestion_type, + }; + + return classification.suggested_command === undefined + ? properties + : { ...properties, [PropSuggestedCommand]: classification.suggested_command }; +} + +export function failureTelemetryPropertiesForCause( + cause: Cause.Cause, +): Record { + return toFailureTelemetryProperties(classifyCliCauseActionability(cause)); +} diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 311ec8f621..71e72f3959 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -4,8 +4,9 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Redacted, Sink, Stream } from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -45,7 +46,14 @@ import type { ProcessControl } from "../../src/shared/runtime/process-control.se import type { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; import type { Tty } from "../../src/shared/runtime/tty.service.ts"; import { Analytics } from "../../src/shared/telemetry/analytics.service.ts"; -import { mockAnalytics, mockProcessControl, mockRuntimeInfo, mockStdin, mockTty } from "./mocks.ts"; +import { + mockAnalytics, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, + processEnvLayer, +} from "./mocks.ts"; // --------------------------------------------------------------------------- // Constants — Go-parity test fixtures used across every native-port integration @@ -641,8 +649,17 @@ export function mockLegacyPlatformApiService( }, }); + // The legacy shell is a Go-parity port and only calls v1 operations, so v2 + // has no stub support — any v2 call from legacy code is a wiring bug. + const v2Proxy = new Proxy({} as ApiClient["v2"], { + get(_target, prop: string) { + return () => Effect.die(`Unmocked LegacyPlatformApi.v2.${prop}`); + }, + }); + const layer = Layer.succeed(LegacyPlatformApi, { v1: v1Proxy, + v2: v2Proxy, // Direct-service consumers don't exercise the raw-execute escape hatch. executeRaw: () => Effect.die("Unmocked LegacyPlatformApi.executeRaw"), } as ApiClient); @@ -681,6 +698,26 @@ export function useLegacyTempWorkdir(prefix = "supabase-legacy-test-"): { }; } +/** + * Ambient isolation for tests that construct the REAL `legacyCliConfigLayer` / + * `legacyCredentialsLayer` (directly or inside a command runtime layer) against + * a real filesystem. Those layers read `/.supabase/profile` and + * `/.supabase/access-token`, resolving `SUPABASE_HOME` / + * `SUPABASE_PROFILE` from the raw process env — so both the home directory and + * the env must be pinned or stale files and ambient variables on the host + * machine leak into the test. + * + * Point `homeDir` at a per-test temp dir (see {@link useLegacyTempWorkdir}); + * `env` replaces the entire ambient env for the layer's lifetime, so list every + * variable the test needs (e.g. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_NO_KEYRING`). + */ +export function legacyIsolatedHomeLayer( + homeDir: string, + env: Readonly> = {}, +): Layer.Layer { + return Layer.mergeAll(mockRuntimeInfo({ homeDir }), processEnvLayer(env)); +} + // --------------------------------------------------------------------------- // Failing filesystem — wraps the real Bun `FileSystem` and fails the Nth // `writeFileString` call with a `PlatformError`, so cleanup-on-failure paths @@ -722,6 +759,169 @@ export function legacyFailWriteStringOnNthCallFsLayer( ).pipe(Layer.provide(BunServices.layer)); } +// --------------------------------------------------------------------------- +// Shadow-database container-CLI spawner — shared by `db diff`/`db pull`'s native +// shadow-provisioning integration tests (CLI-1956). Hoisted here (it was a verbatim +// ~55-line duplicate in both `diff.integration.test.ts` and `pull.integration.test.ts`) +// per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. +// --------------------------------------------------------------------------- + +/** The shadow container's fake id — used both as `docker create`'s stdout and the `dbHost` `.slice(0, 12)` derives from. */ +export const LEGACY_FAKE_SHADOW_CONTAINER_ID = "abc123456789shadow0".padEnd(64, "0").slice(0, 64); + +/** Go's `container.HealthConfig`-shaped inspect JSON for a healthy container. */ +const LEGACY_SHADOW_HEALTHY_STATE = + '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; + +/** + * A real (Docker-valid) "still starting" state — NOT `Effect.never` — so + * {@link legacyWaitForHealthyServices}'s retry loop genuinely retries on its real 1-second + * `Schedule.spaced` backoff instead of hanging on a single probe forever. Mirrors + * `start.integration.test.ts`'s own "never healthy" containers (same rationale: a fiber + * interrupted mid-retry must be observed actually suspended inside the retry loop, not merely + * past the initial `create` call). + */ +const LEGACY_SHADOW_STARTING_STATE = + '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; + +/** + * Fakes every `docker`/`podman` subprocess call the native shadow-provisioning path issues + * (`legacyBuildLocalDbContainerInputs`'s image-cache check, `legacyCreateShadowDatabase`'s + * network-create + container create/start, `legacyWaitForHealthyServices`'s container + * inspect, and `legacyRemoveShadowDatabase`'s cleanup) — scoped-down port of + * `start.integration.test.ts`'s own `mockContainerCliSpawner`, since both callers only ever + * create one (shadow) container, never named. + * + * `neverHealthy` (default `false`) makes every `container inspect` report `"starting"` instead + * of `"healthy"` — for the interrupt-during-health-wait regression coverage (review: + * PRRT_kwDOErm0O86XMrID): with the default healthy-immediately response, a forked fiber can run + * the ENTIRE shadow-provisioning sequence to completion synchronously before a test's own + * polling loop is even scheduled, making `Fiber.interrupt` a no-op on an already-finished fiber. + * + * `failCreate`/`failRemove` (both default `false`) make `docker create`/`docker rm` exit + * non-zero instead — hoisted from `migration squash`'s own scoped-down copy of this mock + * (CLI-1969 review), which needed these two extra failure knobs `db diff`/`db pull`'s own + * scenarios never exercised. Defaulting both to `false` keeps every existing caller + * (`pull.integration.test.ts`, `declarative.orchestrate.integration.test.ts`, + * `diff.integration.test.ts`) byte-identical. + * + * `dbNotRunning`/`dbInspectFailsWith` (CLI-1968) fake the SEPARATE `docker container inspect + * supabase_db_` probe `legacyIsLocalDbRunning` issues before `--use-pgadmin` + * provisions anything — distinguished from the shadow's own `container inspect <64-hex-id>` + * health probe by the target id's `supabase_db_` prefix, so both options leave the shadow's + * own health check on its normal (healthy/never-healthy) path. `dbNotRunning` reports the + * Go/Docker "container doesn't exist" shape (`legacyIsContainerNotFoundMessage`); mutually + * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure + * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test + * that sets both throws immediately, rather than one option silently winning). + */ +export function mockLegacyShadowContainerCliSpawner( + opts: { + readonly neverHealthy?: boolean; + readonly failCreate?: boolean; + readonly failRemove?: boolean; + readonly dbNotRunning?: boolean; + readonly dbInspectFailsWith?: string; + } = {}, +): { + readonly layer: Layer.Layer; + readonly spawned: ReadonlyArray<{ readonly args: ReadonlyArray }>; +} { + if (opts.dbNotRunning === true && opts.dbInspectFailsWith !== undefined) { + throw new Error( + "mockLegacyShadowContainerCliSpawner: dbNotRunning and dbInspectFailsWith are mutually exclusive", + ); + } + const neverHealthy = opts.neverHealthy ?? false; + const failCreate = opts.failCreate ?? false; + const failRemove = opts.failRemove ?? false; + const spawned: Array<{ readonly args: ReadonlyArray }> = []; + const encoder = new TextEncoder(); + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ args }); + if (command._tag !== "StandardCommand") { + return yield* Effect.fail( + new PlatformError( + new SystemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ), + ); + } + const isLocalDbInspect = + args[0] === "container" && + args[1] === "inspect" && + (args[2] ?? "").startsWith("supabase_db_"); + if ( + isLocalDbInspect && + (opts.dbNotRunning === true || opts.dbInspectFailsWith !== undefined) + ) { + const stderrText = + opts.dbInspectFailsWith ?? `Error response from daemon: No such container: ${args[2]}`; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.fromIterable([encoder.encode(stderrText)]), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + let stdoutLines: ReadonlyArray = []; + let stderrLines: ReadonlyArray = []; + let exitCode = 0; + if (args[0] === "create") { + if (failCreate) { + exitCode = 1; + stderrLines = ["network error"]; + } else { + stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID]; + } + } else if (args[0] === "container" && args[1] === "inspect") { + stdoutLines = [neverHealthy ? LEGACY_SHADOW_STARTING_STATE : LEGACY_SHADOW_HEALTHY_STATE]; + } else if (args[0] === "rm") { + if (failRemove) { + exitCode = 1; + stderrLines = ["boom removing container"]; + } + } + // "image inspect", "network create", "start" (and "rm -f -v" when not `failRemove`) + // all succeed with no output. + const stdoutBytes = stdoutLines.map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = stderrLines.map((line) => encoder.encode(`${line}\n`)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { layer, spawned }; +} + // --------------------------------------------------------------------------- // Runtime composition — bundles the entire Layer.mergeAll(...) graph that // every native-port integration test re-builds, including the easy-to-mis-wire diff --git a/apps/cli/tests/helpers/legacy-storage.ts b/apps/cli/tests/helpers/legacy-storage.ts index f1d4020452..5e8ceb34db 100644 --- a/apps/cli/tests/helpers/legacy-storage.ts +++ b/apps/cli/tests/helpers/legacy-storage.ts @@ -176,8 +176,15 @@ export function setupLegacyStorage(workdir: string, opts: SetupLegacyStorageOpti resolveForLink: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), - loadProjectRef: () => - opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), + // Gives an explicit `--project-ref` flag top precedence, same as Go's + // `flags.LoadProjectRef` — short-circuits BEFORE `linkedFails`, so a test + // can prove the flag resolves a ref even for an "unlinked" workdir. + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail(notLinked()) + : Effect.succeed(projectRefRef), promptProjectRef: () => Effect.succeed(projectRefRef), }); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 792c27fbb8..cf9d6869ff 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -1,3 +1,5 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Layer, Option, PubSub, Redacted, Stream } from "effect"; @@ -70,6 +72,18 @@ type OutputEvent = { [key: string]: unknown; }; +// Default home for mocks that need *some* path value. Unique per process (never +// created on disk here) so a test that accidentally combines this default with a +// real FileSystem layer can never pick up stale files written by earlier test +// runs or manual CLI invocations — the failure mode the previous fixed literal +// `/tmp/supabase-cli-test-home` allowed. Tests that really read or write files +// under homeDir must pass their own per-test temp dir instead (see +// `useLegacyTempWorkdir` in `legacy-mocks.ts`). +const defaultTestHomeDir = join( + tmpdir(), + `supabase-cli-test-home-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, +); + // --------------------------------------------------------------------------- // Stateless mocks // --------------------------------------------------------------------------- @@ -158,7 +172,7 @@ export function mockRuntimeInfo( cwd: opts.cwd ?? "/test/project", platform: opts.platform ?? "linux", arch: opts.arch ?? "x64", - homeDir: opts.homeDir ?? "/tmp/supabase-cli-test-home", + homeDir: opts.homeDir ?? defaultTestHomeDir, execPath: opts.execPath ?? "/test/bin/bun", pid: opts.pid ?? 1234, }); @@ -585,8 +599,8 @@ export function mockTelemetryRuntime( return Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ - configDir: opts.configDir ?? "/tmp/supabase-cli-test-home/.supabase", - tracesDir: opts.tracesDir ?? "/tmp/supabase-cli-test-home/.supabase/traces", + configDir: opts.configDir ?? join(defaultTestHomeDir, ".supabase"), + tracesDir: opts.tracesDir ?? join(defaultTestHomeDir, ".supabase", "traces"), consent: opts.consent ?? "granted", showDebug: opts.showDebug ?? false, deviceId: opts.deviceId ?? "test-device-id", diff --git a/docs/adr/0015-managed-stack-contract-fixtures.md b/docs/adr/0015-managed-stack-contract-fixtures.md new file mode 100644 index 0000000000..4141a3ccd9 --- /dev/null +++ b/docs/adr/0015-managed-stack-contract-fixtures.md @@ -0,0 +1,175 @@ +# 0015. Managed Stack Contract Fixtures + +**Status**: proposed +**Date**: 2026-08-10 + +## Problem Statement + +The managed local-stack design combines project, checkout, branch, and named-stack identity with +mutable state, host-wide port allocation, runtime selection, legacy bootstrap, credentials, and +reclamation. These decisions affect both the reusable `@supabase/stack` package and the CLI. If each +layer encodes its own behavior matrix, they will drift and tests will eventually validate +implementation details instead of the behavior developers observe. + +The persistence technology is intentionally not part of the product contract. A later adapter may +use SQLite or another store, but changing storage must not change identity or lifecycle semantics. + +## Decision + +The typed fixtures exported from `@supabase/stack/testing` are the normative executable description +of the M1 managed-stack behavior. Each scenario records: + +- explicit input state; +- a public CLI, Git, direct-stack API, or managed-stack API action; +- the resolved opaque identities and outcome; +- the complete set of permitted managed-state writes and runtime side effects; and +- human, JSON, or programmatic output, including structured warnings and deterministic recovery + guidance. + +Opaque symbolic IDs make the same scenario reusable across an in-memory repository, a persistent +adapter, the managed package, and future CLI integration tests. Linear records the decision history +and links to implementation work; it is not a second source of executable truth. +Each scenario starts from its own isolated `given` state, so a symbolic ID or port has no shared +state across scenarios unless a fixture explicitly references another scenario. Conformance drivers +must reset their repository between scenarios. + +Structured error and warning codes follow ADR 0001's `SCREAMING_SNAKE_CASE` convention. A `report` +is always read-only, and an `error` has no state mutation or runtime effect except the explicit +failed-bootstrap rollback, whose only permitted effects remove partial managed state. + +`@supabase/stack` has two distinct public responsibilities: + +1. Direct `createStack(config)` creates one caller-controlled stack. Omitted stack and runtime roots + are resolved independently as disposable temporary directories and are removed on disposal. + Supplying project, cache, or one state-root path does not make another omitted state root + persistent. Direct usage does not inspect Git, create identity markers, or mutate a global + managed registry. +2. The explicit managed surface owns system-aware discovery, identity, stack selection, ports, + runtime persistence, bootstrap, and reclamation. It accepts an isolated state root or injected + repository so applications and tests can use it without the CLI. + +The CLI is a consumer and presentation layer. It translates arguments into managed operations and +projects managed results into human and JSON output. It must not implement a second identity, +selection, port, runtime, or lifecycle decision path. + +Git workspaces store project and branch-context identities in common local Git configuration, which +is shared by linked worktrees. Checkout identity is stored separately under each checkout's Git +directory. Context writes declare their owning branch so storage adapters cannot persist an unbound +context. A tracked working-tree identity marker is inert: discovery never trusts or rewrites it. +Ordinary non-Git folders persist a project-local, untracked identity marker on first start and +recover that same project, checkout, and context identity on later starts. + +Read-only status remains a successful `report` when it can identify a running stack but finds +unapplied port, credential, or runtime configuration. The report includes a structured warning and +recovery guidance. Conditions that prevent safe identity selection, such as ambiguous ownership, +remain errors. + +Persistence sits behind the managed package's repository boundary. Contract fixtures must run +against a storage-independent test repository and then against each selected persistent adapter. +The choice of SQLite, files, or another implementation does not move product policy into the CLI or +change the package boundary. + +## Testing Strategy + +Tests should be as close as possible to how a developer uses the product: + +- Package integration tests invoke public direct or managed APIs and compare their observable + result with the shared fixture. +- CLI integration tests invoke command handlers and assert argument translation plus human/JSON + projection from that same managed result. +- Repository conformance tests execute the same fixtures against the isolated repository and the + selected persistent adapter. +- Unit tests are reserved for genuinely pure algorithms and public export/type checks; they do not + duplicate the behavior matrix through private helpers. +- E2E tests cover a small number of real subprocess/runtime golden paths. Add a targeted E2E test + when a boundary cannot be represented faithfully in an in-process integration test, rather than + mocking away the behavior under test. + +CLI-2102 checks in the fixture data and public direct-stack boundary before the managed engine and +persistent adapter exist. The implementation issues it unblocks must attach real drivers to these +fixtures. CLI integration coverage begins when a real command boundary exists; a fixture-presence +test is not evidence that an unimplemented command already satisfies the behavior. + +The fixture validator is deliberately fixture lint, not a second implementation of the managed +stack policy. It checks a small set of generic rule families: + +- catalog shape and unique scenario identity; +- referential integrity for selected, written, and effected identities; +- state-write and runtime-effect pairing; +- structured diagnostic and read-only outcome shape; and +- consistency between the managed result and its human, JSON, and API projections. + +The lint implementation lives separately in `managed-stack-contract-validation.ts` so the contract +module remains centered on types and normative scenario data. + +The native qualification matrix derives service names and versions from the package service catalog +so it cannot drift from the shipped manifest. Identity resolution, lifecycle preconditions, port and +runtime selection, bootstrap policy, credential policy, and reclamation semantics belong to the real +managed resolver and engine delivered by the implementation issues below. Further requests to +"validate" those semantics should be covered by running these scenarios against that implementation, +not by expanding this lint into a parallel rule engine. A new lint rule is appropriate only when it +protects a generic fixture-format invariant across behavior areas. + +Native-qualification facts describe the complete M5 launch-scope target, not the package's current +Docker-backed implementation. CLI-2121 through CLI-2141 attach the real native service graph to that +target contract. + +## Implementation Handoff + +The downstream implementation issues own the executable drivers, while this ADR and fixture data +own the expected behavior: + +- CLI-2106, CLI-2107, and CLI-2108 attach the repository and identity resolver to the identity + fixtures, including ordinary folders, worktrees, branches, and orphan handling. +- CLI-2106 and CLI-2108 must store checkout identity beneath the checkout-specific Git directory + without implicitly enabling `extensions.worktreeConfig`; branch contexts remain in common local + Git configuration. +- CLI-2109 attaches automatic legacy bootstrap and rollback-safe publication. +- CLI-2110 attaches exact and automatic port intent, allocation, stickiness, drift, and collisions. +- CLI-2124 attaches runtime selection, persistence, and strict conflict handling. +- CLI-2114 attaches the experimental CLI handlers and verifies that their human and JSON output is + projected from managed results. +- The selected persistent adapter must run the same repository contract as the isolated test + repository before its implementation issue is complete. + +## Rationale + +A single typed matrix makes disagreements visible in review and allows every layer to consume the +same expected result. Public-interface integration tests survive refactors because they assert +commands, API calls, outputs, and state transitions rather than internal call graphs. Injected +repositories keep system-aware behavior programmatically reusable while preventing a persistence +choice from leaking into product semantics. + +Keeping direct and managed stack creation separate also preserves a simple embedding API for tests: +`createStack()` remains isolated, while callers that want branch/worktree-aware state opt into the +managed surface explicitly. + +## Consequences + +### Positive + +- Package, CLI, and persistence adapters share one reviewed behavioral authority. +- Tests describe developer-visible journeys and remain useful through implementation refactors. +- Programmatic consumers can use managed state without importing CLI code. +- Direct test stacks stay isolated from Git and system-wide state. +- Storage technology can change without changing package ownership or managed semantics. + +### Negative / Trade-offs + +- The fixture catalog is intentionally large because it records edge cases that otherwise become + implicit behavior. +- New managed behavior requires updating the shared matrix before layer-specific tests. +- Until downstream implementations attach real drivers, fixture catalog tests validate contract + completeness and projection seams, not the future engine itself. + +## Alternatives Considered + +1. **Duplicate package and CLI test tables**: rejected because identity and lifecycle rules would + drift and reviewers could not identify the authoritative result. +2. **Make CLI tests authoritative**: rejected because managed behavior must be reusable from Node + and Bun without the CLI. +3. **Define behavior through a SQLite schema**: rejected because schemas describe persistence, not + product semantics, and would make a technology choice distort package boundaries. +4. **Put the whole matrix in E2E tests**: rejected because the suite would be slow and failure + diagnosis poor. E2E remains the fallback for boundaries that integration tests cannot exercise + faithfully. diff --git a/docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md b/docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md new file mode 100644 index 0000000000..c3aedeae36 --- /dev/null +++ b/docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md @@ -0,0 +1,99 @@ +# 0016. Legacy Port Completion and Go CLI Authority Scope + +**Status**: proposed +**Date**: 2026-08-11 + +## Problem Statement + +`src/legacy/` started as a from-scratch, strict 1:1 port of the Go CLI (`apps/cli-go/`): every +command began as a Phase 0 proxy to the Go binary, then moved to a native TypeScript implementation +(Phase 1+). While that was true, treating `apps/cli-go/` as the unconditional authority for anything +touching `src/legacy/` was correct — nearly every change was either wrapping a new command or +replacing its proxy, and the Go source was the only available spec for what the command should do. + +That phase is essentially over. Per [`apps/cli/docs/go-cli-porting-status.md`](../../apps/cli/docs/go-cli-porting-status.md#legacy-shell-command-status), +95 of 103 legacy leaf commands (~92%) are natively ported; only 8 remain Phase 0 proxies. Most +changes landing in `src/legacy/` today are ordinary engineering on already-ported commands — bug +fixes, internal refactors, hoisting shared helpers, adding documented TS-only flags, telemetry and +observability work, tests — not porting. + +`apps/cli/AGENTS.md` still reads, top to bottom, as porting-era guidance: it opens with the Phase +0/1 wrapping workflow and states unconditionally that `apps/cli-go/` is "the authoritative source" +for the legacy shell. Agents (and humans) doing net-new work keep following that instruction +literally — auditing Go source, or judging review feedback against Go parity, for changes that have +nothing to do with Go parity. This slows down unrelated work and anchors reviews to the wrong +standard. + +## Decision + +`apps/cli-go/` remains authoritative, and must be consulted, for exactly two situations: + +1. **Working on one of the remaining wrapped commands** — either maintaining its command/flag + definition and proxy handler (these gate which invocations reach the Go binary and must still + match it exactly) or replacing the wrapper with a native implementation (Phase 0 → Phase 1). +2. **A change that touches an already-ported command's established parity surface** — command or + flag names, stdout/stderr text, exit codes, all documented side effects (filesystem, database, + Docker/subprocess, API requests — see each command's `SIDE_EFFECTS.md`), or telemetry semantics + (which events fire, when, and their payload shape). Here, Go source is a regression check ("does + this still match what we already shipped"), not a design source. + +For everything else in `src/legacy/` — internal refactors, bug fixes that don't change the surface +above, hoisting shared helpers, adding a documented TS-only flag/feature on an already-ported +command (the pattern already established by `--skip-vault`, `--reveal`, `--high-availability`), +tests, tooling — `apps/cli-go/` is not required reading and Go behavior is not the deciding +standard. Normal engineering judgment (correctness, tests, maintainability, DX) applies, the same as +it does in `src/next/` or `src/shared/`. + +`apps/cli/AGENTS.md` is updated to lead with this scope instead of assuming every reader is mid-port. + +## Rationale + +The cost of the blanket framing was asymmetric: it made net-new work slower and reviews harder to +reason about, without making the remaining real parity work (8 commands, plus regression risk on the +95 already ported) any safer — that work is already called out explicitly in the porting-status +tracker and doesn't need a blanket rule to be found. Scoping the authority claim to the two cases +where it actually matters keeps the real guarantee (the legacy shell does not silently regress +against Go, and the last wrapped commands still get ported) while freeing the other ~92% of the +surface from a parity check it never needed. + +## Consequences + +### Positive + +- Net-new changes in `src/legacy/` — bug fixes, refactors, TS-only additions — no longer require + auditing `apps/cli-go/` or justifying themselves against Go behavior that isn't in scope. +- Review feedback on such changes is judged on normal engineering merit instead of being forced + through a parity lens that doesn't apply. +- The two cases where Go really is authoritative (the 8 still-wrapped commands, including finishing + their ports; not regressing the 95 already-ported commands) are named explicitly instead of being + implied by a blanket rule. + +### Negative + +- Contributors now have to briefly classify a change (does it touch the parity surface?) rather than + defaulting to "always check Go." Misclassification risk is partly, not fully, mitigated: + [`apps/cli/docs/go-cli-porting-status.md`](../../apps/cli/docs/go-cli-porting-status.md) stays the + source of truth for which commands are still `wrapped`, and CI's `testParity` / + `*.e2e.test.ts` suites catch output/behavior drift on the already-ported commands and code paths + they cover — but that coverage is deliberately partial (e.g. `db pull --local` and `db lint + --local` skip `testParity` today, see `apps/cli-e2e/src/tests/database-core.e2e.test.ts`), so a + misclassified change on an uncovered path can still land without a human or agent ever consulting + Go. + +## Alternatives Considered + +1. **Keep the blanket authority framing and rely on agents/reviewers to infer scope.** Rejected — + this is what's failing today; the framing is read literally. +2. **Drop Go-CLI parity as a concern entirely now that the port is mostly done.** Rejected — the + remaining 8 wrapped commands still need porting, and the whole point of `src/legacy/` is to be a + stable-channel drop-in replacement for the Go CLI, so already-ported commands must not regress. + +## Related Decisions + +- None yet — this is the first ADR to describe the `legacy`/`next` split and the scope of Go CLI + authority explicitly; prior guidance lived only in `apps/cli/AGENTS.md`. + +## See Also + +- [`apps/cli/AGENTS.md`](../../apps/cli/AGENTS.md) +- [`apps/cli/docs/go-cli-porting-status.md`](../../apps/cli/docs/go-cli-porting-status.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 90f4694b45..887b3e57b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,20 +41,22 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi ## ADR index -| ID | Title | Status | -| ---- | ---------------------------------------------------------------------------------------- | -------- | -| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | -| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | -| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | -| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | -| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | +| ID | Title | Status | +| ---- | ------------------------------------------------------------------------------------------ | -------- | +| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted | +| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted | +| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted | +| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted | +| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted | | 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed | -| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | -| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | -| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | -| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | -| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed | +| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | +| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | +| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed | +| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | proposed | +| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | ## Template diff --git a/packages/api/README.md b/packages/api/README.md index d82750704a..cfb0e9e27e 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -17,8 +17,14 @@ import { createApiClient } from "@supabase/api"; const client = await createApiClient({ accessToken: "" }); const projects = await client.v1.listAllProjects(); +const projectConfig = await client.v2.getProjectConfig({ ref: "" }); ``` +Operations are namespaced by version, derived from the leading path segment (`/v1/...` or +`/v2/...`). Same-named operations can coexist under separate namespaces: `client.v1.listOrganizationMembers` +and `client.v2.listOrganizationMembers` are distinct operations hitting `/v1/...` and `/v2/...` +respectively. + `baseUrl` defaults to `https://api.supabase.com` and `accessToken` can also come from `SUPABASE_ACCESS_TOKEN`. @@ -31,7 +37,10 @@ import { makeApiClient } from "@supabase/api/effect"; const program = Effect.gen(function* () { const client = yield* makeApiClient({ accessToken: "" }); - return yield* client.v1.listAllProjects(); + const projects = yield* client.v1.listAllProjects(); + const projectConfig = yield* client.v2.getProjectConfig({ ref: "" }); + + return { projects, projectConfig }; }); ``` @@ -51,6 +60,7 @@ The only callable client surface is the versioned namespace: ```ts const projects = await client.v1.listAllProjects(); +const projectConfig = await client.v2.getProjectConfig({ ref: "" }); ``` For tools that need the raw generated spec: @@ -74,14 +84,75 @@ The public binary input contract is: ## Development ```sh -pnpm check:all # Run all quality checks in parallel -pnpm fix:all # Auto-fix lint, format, and unused exports in parallel -pnpm test # Run tests -pnpm generate # Refresh the OpenAPI spec and regenerate the SDK +pnpm check:all # Run all quality checks in parallel +pnpm fix:all # Auto-fix lint, format, and unused exports in parallel +pnpm test # Run tests +pnpm generate # Refresh the OpenAPI spec and regenerate the SDK +pnpm generate:check # Regenerate in place and fail on any resulting diff ``` +## Spec pipeline + +The spec is built from two upstream OpenAPI documents, `{baseUrl}/api/v1-json` and +`{baseUrl}/api/v2-json`. They are fetched and merged into a single document (paths and +`components.schemas` are unioned, and `info.title` is normalized to `Supabase API`), then +overrides from `scripts/openapi-overrides.json` are applied to the merged document. The result is +validated — operation ids must be unique, and version-prefixed operation ids must match the +path's leading segment — before being written to `src/generated/openapi.json`. The merged +document keeps only the keys the generator consumes (`openapi`, `info`, `paths`, +`components.schemas`); upstream extras such as `servers`, `tags`, and `components.securitySchemes` +are dropped so the snapshot never contains keys a regeneration would remove. + +The committed snapshot and the generated modules are also checked against each other offline in +ordinary test runs: `scripts/generated-output-sync.unit.test.ts` re-renders every generated file +from the committed snapshot and requires byte equality, and `src/generated-contract-sync.unit.test.ts` +asserts the operation-level bijection. Hand edits to `src/generated` fail both. + +The base URL is resolved in this order: + +1. `SUPABASE_API_URL` environment variable +2. `scripts/openapi-source.json`, a committed sidecar file (`{ "baseUrl": ... }`) that is + rewritten after every successful `pnpm generate` run +3. `https://api.supabase.com` + To refresh from staging instead of production: ```sh SUPABASE_API_URL=https://api.supabase.green pnpm generate ``` + +`pnpm generate` is the single command to regenerate the spec and SDK. `pnpm generate:check` +regenerates in place, formats, and fails if that produces any diff in `src/generated` or +`scripts/openapi-source.json` — useful for verifying the committed snapshot is still current. If a +failed check leaves an unwanted diff, discard it with: + +```sh +git restore -- src/generated scripts/openapi-source.json +``` + +The hourly [`api-package-sync.yml`](../../.github/workflows/api-package-sync.yml) workflow runs +`generate` against production and opens a PR against `develop` whenever it detects drift, acting +as the automated drift detector for the committed snapshot. + +### Overrides + +`scripts/openapi-overrides.json` is a JSON-Patch-_like_ array applied to the merged document. It +supports: + +- `test` — assert a value at `path` before proceeding (as in RFC 6902) +- `add` — add a value at `path`; throws if the key already exists +- `replace` — replace the value at `path` +- `remove` — remove the value at `path` **if present** + +`remove` is deliberately remove-if-present rather than RFC 6902's strict "must exist" semantics, +because the upstream documents differ between environments — staging's `v2-json` is currently +served by two backend variants that disagree about some paths. Entries may carry a `$comment` +field to document why an override exists. + +### Known limitation: `deepObject` query parameters + +Three v2 operations declare object-valued query parameters with `style: deepObject`: +`v2-list-organization-members`, `v2-list-organization-projects`, and +`v2-list-organization-github-connections`. The client currently serializes these as JSON strings +rather than the expected `page[size]=...` form. Do not rely on those parameters until this is +fixed. diff --git a/packages/api/package.json b/packages/api/package.json index 871ca55188..19d31fe6b4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,6 +14,7 @@ "scripts": { "generate:spec": "bun run scripts/download-openapi.ts", "generate": "bun run generate:spec && bun run scripts/generate.ts", + "generate:check": "pnpm generate && pnpm exec nx run @supabase/api:fmt:fix && git diff --exit-code -- src/generated scripts/openapi-source.json", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", @@ -45,7 +46,8 @@ "scripts/download-openapi.ts", "scripts/download-openapi.unit.test.ts", "scripts/generate.ts", - "scripts/generate.unit.test.ts" + "scripts/generate.unit.test.ts", + "scripts/generated-output-sync.unit.test.ts" ], "ignoreDependencies": [ "undici", diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index a6e189b2b2..521dc83929 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -7,18 +7,36 @@ const DEFAULT_SUPABASE_API_URL = "https://api.supabase.com"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const OPENAPI_SPEC_PATH = path.join(scriptDir, "../src/generated/openapi.json"); const OPENAPI_OVERRIDES_PATH = path.join(scriptDir, "openapi-overrides.json"); +const OPENAPI_SOURCE_PATH = path.join(scriptDir, "openapi-source.json"); + +const OPENAPI_DOCUMENT_VERSIONS = ["v1", "v2"] as const; +type OpenApiDocumentVersion = (typeof OPENAPI_DOCUMENT_VERSIONS)[number]; + +const HTTP_METHOD_KEYS = ["get", "post", "put", "patch", "delete", "head"] as const; type OpenApiDocument = { readonly [key: string]: unknown; readonly paths: Record; + readonly components?: { + readonly schemas?: Record; + }; }; -type JsonPatchOperation = { - readonly op: "add" | "test" | "replace"; - readonly path: string; - readonly value: unknown; +type OpenApiSource = { + readonly baseUrl: string; }; +type JsonPatchOperation = + | { + readonly op: "add" | "test" | "replace"; + readonly path: string; + readonly value: unknown; + } + | { + readonly op: "remove"; + readonly path: string; + }; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -109,16 +127,67 @@ function addJsonPointerValue(document: unknown, pointer: string, value: unknown) parent[key] = value; } +function removeJsonPointerValue(document: unknown, pointer: string): boolean { + const segments = jsonPointerSegments(pointer); + if (segments.length === 0) { + return false; + } + + let parent: unknown = document; + for (const segment of segments.slice(0, -1)) { + if (Array.isArray(parent)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= parent.length) { + return false; + } + parent = parent[index]; + continue; + } + if (isRecord(parent) && segment in parent) { + parent = parent[segment]; + continue; + } + return false; + } + + const key = segments[segments.length - 1]!; + if (Array.isArray(parent)) { + const index = Number(key); + if (!Number.isInteger(index) || index < 0 || index >= parent.length) { + return false; + } + parent.splice(index, 1); + return true; + } + + if (!isRecord(parent) || !(key in parent)) { + return false; + } + delete parent[key]; + return true; +} + function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOperation { if (!isRecord(value)) { throw new Error("OpenAPI override entry must be an object."); } - if (value.op !== "add" && value.op !== "test" && value.op !== "replace") { - throw new Error("OpenAPI overrides only support add, test and replace operations."); + if ( + value.op !== "add" && + value.op !== "test" && + value.op !== "replace" && + value.op !== "remove" + ) { + throw new Error("OpenAPI overrides only support add, test, replace and remove operations."); } if (typeof value.path !== "string") { throw new Error("OpenAPI override path must be a string."); } + if (value.op === "remove") { + if ("value" in value) { + throw new Error("OpenAPI remove overrides must not include a value."); + } + return; + } if (!("value" in value)) { throw new Error("OpenAPI override value is required."); } @@ -147,6 +216,17 @@ export function applyOpenApiOverrides( addJsonPointerValue(document, override.path, override.value); continue; } + if (override.op === "remove") { + // Deliberate deviation from RFC 6902 (mirroring the existing "add" + // deviation below, which throws when the target key already exists): + // silently ignore removal of a pointer that doesn't exist. This file + // applies to documents that differ between environments — staging's + // /api/v2-json is currently served by two backend variants that + // disagree about whether the webhook paths exist — so a strict + // remove would fail on most staging runs. + removeJsonPointerValue(document, override.path); + continue; + } replaceJsonPointerValue(document, override.path, override.value); } return document; @@ -160,9 +240,60 @@ async function loadOpenApiOverrides(): Promise> { return parsed; } -export function resolveOpenApiSpecUrl(baseUrl = process.env.SUPABASE_API_URL): string { - const normalizedBaseUrl = (baseUrl ?? DEFAULT_SUPABASE_API_URL).replace(/\/+$/, ""); - return `${normalizedBaseUrl}/api/v1-json`; +function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { + if (!isRecord(value) || typeof value.baseUrl !== "string") { + throw new Error('OpenAPI source file must be an object with a string "baseUrl" property.'); + } +} + +async function loadPinnedBaseUrl(): Promise { + let raw: string; + try { + raw = await readFile(OPENAPI_SOURCE_PATH, "utf8"); + } catch (error) { + // A missing pin falls through to the default base URL; only a present + // but malformed pin is an error worth stopping for. + if (isRecord(error) && error.code === "ENOENT") { + return undefined; + } + throw error; + } + const parsed = JSON.parse(raw); + assertOpenApiSource(parsed); + return parsed.baseUrl; +} + +async function writeOpenApiSource(baseUrl: string): Promise { + const source: OpenApiSource = { baseUrl }; + await writeFile(OPENAPI_SOURCE_PATH, `${JSON.stringify(source, null, 2)}\n`); +} + +export function resolveOpenApiBaseUrl({ + envBaseUrl, + pinnedBaseUrl, +}: { + readonly envBaseUrl?: string; + readonly pinnedBaseUrl?: string; +}): string { + const baseUrl = envBaseUrl ?? pinnedBaseUrl ?? DEFAULT_SUPABASE_API_URL; + return baseUrl.replace(/\/+$/, ""); +} + +export function resolveOpenApiSpecUrl( + baseUrl = process.env.SUPABASE_API_URL, + version: OpenApiDocumentVersion = "v1", +): string { + const normalizedBaseUrl = resolveOpenApiBaseUrl({ envBaseUrl: baseUrl }); + return `${normalizedBaseUrl}/api/${version}-json`; +} + +export function resolveOpenApiSpecUrls( + baseUrl?: string, +): ReadonlyArray<{ readonly version: OpenApiDocumentVersion; readonly url: string }> { + return OPENAPI_DOCUMENT_VERSIONS.map((version) => ({ + version, + url: resolveOpenApiSpecUrl(baseUrl, version), + })); } export function assertOpenApiDocument(document: unknown): asserts document is OpenApiDocument { @@ -171,19 +302,199 @@ export function assertOpenApiDocument(document: unknown): asserts document is Op } } -export async function downloadOpenApiSpec(specUrl = resolveOpenApiSpecUrl()): Promise { - const response = await fetch(specUrl); +function getOpenApiVersion(document: OpenApiDocument): string { + if (typeof document.openapi !== "string") { + throw new Error('OpenAPI document is missing an "openapi" version string.'); + } + return document.openapi; +} - if (!response.ok) { - throw new Error(`Failed to download OpenAPI spec from ${specUrl}: ${response.status}`); +function getInfoVersion(document: OpenApiDocument): string { + if (!isRecord(document.info) || typeof document.info.version !== "string") { + throw new Error('OpenAPI document is missing an "info.version" string.'); } + return document.info.version; +} - const document = await response.json(); - assertOpenApiDocument(document); +export function mergeOpenApiDocuments( + documents: ReadonlyArray<{ + readonly version: OpenApiDocumentVersion; + readonly document: OpenApiDocument; + }>, +): OpenApiDocument { + const [firstEntry, ...restEntries] = documents; + if (firstEntry === undefined) { + throw new Error("mergeOpenApiDocuments requires at least one document."); + } + + const openapiVersion = getOpenApiVersion(firstEntry.document); + for (const entry of restEntries) { + const entryOpenapiVersion = getOpenApiVersion(entry.document); + if (entryOpenapiVersion !== openapiVersion) { + throw new Error( + `OpenAPI "openapi" version mismatch between ${firstEntry.version} (${openapiVersion}) and ${entry.version} (${entryOpenapiVersion}).`, + ); + } + } + + const infoVersion = getInfoVersion(firstEntry.document); + for (const entry of restEntries) { + const entryInfoVersion = getInfoVersion(entry.document); + if (entryInfoVersion !== infoVersion) { + throw new Error( + `OpenAPI "info.version" mismatch between ${firstEntry.version} (${infoVersion}) and ${entry.version} (${entryInfoVersion}).`, + ); + } + } + + for (const { version, document } of documents) { + for (const pathKey of Object.keys(document.paths)) { + if (!pathKey.startsWith(`/${version}/`)) { + throw new Error( + `OpenAPI path ${JSON.stringify(pathKey)} in the ${version} document does not start with "/${version}/".`, + ); + } + } + } + + const paths: Record = {}; + const pathVersions = new Map(); + for (const { version, document } of documents) { + for (const [pathKey, pathValue] of Object.entries(document.paths)) { + const existingVersion = pathVersions.get(pathKey); + if (existingVersion !== undefined) { + throw new Error( + `Duplicate OpenAPI path ${JSON.stringify(pathKey)} found in both the ${existingVersion} and ${version} documents.`, + ); + } + pathVersions.set(pathKey, version); + paths[pathKey] = pathValue; + } + } + + const schemas: Record = {}; + const schemaVersions = new Map(); + for (const { version, document } of documents) { + for (const [name, schema] of Object.entries(document.components?.schemas ?? {})) { + const existingVersion = schemaVersions.get(name); + if (existingVersion === undefined) { + schemaVersions.set(name, version); + schemas[name] = schema; + continue; + } + if (!valuesEqual(schemas[name], schema)) { + throw new Error( + `Conflicting OpenAPI schema ${JSON.stringify(name)} found in both the ${existingVersion} and ${version} documents.`, + ); + } + } + } + + // The merged document carries only the keys the generator consumes. + // Upstream extras (`servers`, `tags`, `components.securitySchemes`, …) + // must not reach the snapshot even transiently: generate.ts rewrites the + // file without them, so if they were written here a crash between the two + // steps would leave a plausible-looking openapi.json that disagrees with + // every healthy regeneration. + return { + openapi: openapiVersion, + info: { title: "Supabase API", version: infoVersion }, + paths, + components: { schemas }, + }; +} + +// Runs AFTER overrides are applied — this ordering is load-bearing. Prod's +// v2 document currently has 20 webhook operations sharing just 2 duplicated +// operationIds, and the overrides remove those paths. Validating before +// overrides were applied would abort every production regeneration. +export function assertMergedOpenApiDocument(document: OpenApiDocument): void { + const operationClaims = new Map>(); + + for (const [pathKey, pathValue] of Object.entries(document.paths)) { + if (!isRecord(pathValue)) { + continue; + } + for (const method of HTTP_METHOD_KEYS) { + const operation = pathValue[method]; + if (!isRecord(operation)) { + continue; + } + + const label = `${method.toUpperCase()} ${pathKey}`; + const operationId = operation.operationId; + if (typeof operationId !== "string" || operationId.length === 0) { + // generate.ts silently skips operations without an operationId; the + // documented escape hatch is adding a "remove" override for the path. + console.warn(`OpenAPI operation ${label} has no operationId; generate.ts will skip it.`); + continue; + } + + const claims = operationClaims.get(operationId); + if (claims === undefined) { + operationClaims.set(operationId, [label]); + } else { + claims.push(label); + } + + const versionPrefixMatch = /^(v\d+)-/i.exec(operationId); + if (versionPrefixMatch) { + const prefix = versionPrefixMatch[1]!.toLowerCase(); + const leadingSegment = pathKey.split("/")[1]?.toLowerCase(); + if (leadingSegment !== prefix) { + throw new Error( + `OpenAPI operationId ${JSON.stringify(operationId)} for ${label} has version prefix ${JSON.stringify(prefix)} that does not match the path's leading segment ${JSON.stringify(leadingSegment ?? "")}.`, + ); + } + } + } + } + + for (const [operationId, claims] of operationClaims) { + if (claims.length > 1) { + throw new Error( + `Duplicate OpenAPI operationId ${JSON.stringify(operationId)} claimed by: ${claims.join(", ")}.`, + ); + } + } +} + +export async function downloadOpenApiSpec(): Promise { + const envBaseUrl = process.env.SUPABASE_API_URL; + // The sidecar is consulted only when the environment does not override it, + // so an explicit SUPABASE_API_URL works even when the pin is absent or + // malformed. + const pinnedBaseUrl = envBaseUrl === undefined ? await loadPinnedBaseUrl() : undefined; + const baseUrl = resolveOpenApiBaseUrl({ envBaseUrl, pinnedBaseUrl }); + console.log(`Resolved OpenAPI base URL: ${baseUrl}`); + + const documents: Array<{ + readonly version: OpenApiDocumentVersion; + readonly document: OpenApiDocument; + }> = []; + for (const { version, url } of resolveOpenApiSpecUrls(baseUrl)) { + console.log(`Fetching ${version} OpenAPI document from ${url}`); + const response = await fetch(url); + + // Hard-fail on a missing document instead of tolerating it: a 404 on + // /api/v2-json would silently delete the whole v2 namespace from the + // generated client, and the hourly regeneration sync would auto-merge + // that deletion without anyone noticing. + if (!response.ok) { + throw new Error(`Failed to download OpenAPI spec from ${url}: ${response.status}`); + } + + const document = await response.json(); + assertOpenApiDocument(document); + documents.push({ version, document }); + } - applyOpenApiOverrides(document, await loadOpenApiOverrides()); + const mergedDocument = mergeOpenApiDocuments(documents); + applyOpenApiOverrides(mergedDocument, await loadOpenApiOverrides()); + assertMergedOpenApiDocument(mergedDocument); - await writeFile(OPENAPI_SPEC_PATH, `${JSON.stringify(document, null, 2)}\n`); + await writeFile(OPENAPI_SPEC_PATH, `${JSON.stringify(mergedDocument, null, 2)}\n`); + await writeOpenApiSource(baseUrl); } if (import.meta.main) { diff --git a/packages/api/scripts/download-openapi.unit.test.ts b/packages/api/scripts/download-openapi.unit.test.ts index 4111f8d305..a7521eb899 100644 --- a/packages/api/scripts/download-openapi.unit.test.ts +++ b/packages/api/scripts/download-openapi.unit.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { applyOpenApiOverrides, + assertMergedOpenApiDocument, assertOpenApiDocument, + mergeOpenApiDocuments, + resolveOpenApiBaseUrl, resolveOpenApiSpecUrl, + resolveOpenApiSpecUrls, } from "./download-openapi.ts"; describe("download-openapi", () => { @@ -107,4 +111,269 @@ describe("download-openapi", () => { ), ).toThrow("cannot be added"); }); + + test("derives the v2 spec URL and still normalizes a trailing slash", () => { + expect(resolveOpenApiSpecUrl("https://api.supabase.com", "v2")).toBe( + "https://api.supabase.com/api/v2-json", + ); + expect(resolveOpenApiSpecUrl("https://api.supabase.com/", "v2")).toBe( + "https://api.supabase.com/api/v2-json", + ); + }); + + test("resolves both the v1 and v2 spec URLs for a single base URL", () => { + expect(resolveOpenApiSpecUrls("https://api.supabase.com")).toEqual([ + { version: "v1", url: "https://api.supabase.com/api/v1-json" }, + { version: "v2", url: "https://api.supabase.com/api/v2-json" }, + ]); + }); + + test("resolves the base URL with env > pinned > default precedence", () => { + expect( + resolveOpenApiBaseUrl({ + envBaseUrl: "https://env.supabase.com", + pinnedBaseUrl: "https://pinned.supabase.com", + }), + ).toBe("https://env.supabase.com"); + expect(resolveOpenApiBaseUrl({ pinnedBaseUrl: "https://pinned.supabase.com" })).toBe( + "https://pinned.supabase.com", + ); + expect(resolveOpenApiBaseUrl({})).toBe("https://api.supabase.com"); + }); + + test("merging a single document is an identity for its paths and schemas", () => { + const document = { + openapi: "3.0.0", + info: { title: "Some Title", version: "1.0.0" }, + paths: { "/v1/a": { get: {} } }, + components: { schemas: { Foo: { type: "string" } } }, + }; + + const merged = mergeOpenApiDocuments([{ version: "v1", document }]); + + expect(merged.paths).toEqual(document.paths); + expect(merged.components?.schemas).toEqual(document.components.schemas); + }); + + test("merges v1 and v2 documents, ordering v1 paths before v2 and unioning their schemas", () => { + const v1Document = { + openapi: "3.0.0", + info: { title: "V1 Title", version: "1.0.0" }, + paths: { "/v1/a": { get: {} }, "/v1/b": { get: {} } }, + components: { schemas: { Foo: { type: "string" } } }, + }; + const v2Document = { + openapi: "3.0.0", + info: { title: "V2 Title", version: "1.0.0" }, + paths: { "/v2/c": { get: {} } }, + components: { schemas: { Bar: { type: "number" } } }, + }; + + const merged = mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]); + + expect(Object.keys(merged.paths)).toEqual(["/v1/a", "/v1/b", "/v2/c"]); + expect(merged.components?.schemas).toEqual({ + Foo: { type: "string" }, + Bar: { type: "number" }, + }); + expect(merged.info).toEqual({ title: "Supabase API", version: "1.0.0" }); + }); + + test('throws when the documents\' "openapi" versions disagree', () => { + const v1Document = { openapi: "3.0.0", info: { version: "1.0.0" }, paths: { "/v1/a": {} } }; + const v2Document = { openapi: "3.1.0", info: { version: "1.0.0" }, paths: { "/v2/a": {} } }; + + expect(() => + mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]), + ).toThrow('OpenAPI "openapi" version mismatch between v1 (3.0.0) and v2 (3.1.0).'); + }); + + test('throws when the documents\' "info.version" disagree', () => { + const v1Document = { openapi: "3.0.0", info: { version: "1.0.0" }, paths: { "/v1/a": {} } }; + const v2Document = { openapi: "3.0.0", info: { version: "2.0.0" }, paths: { "/v2/a": {} } }; + + expect(() => + mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]), + ).toThrow('OpenAPI "info.version" mismatch between v1 (1.0.0) and v2 (2.0.0).'); + }); + + test("throws when a v2 document contains a path outside the /v2/ namespace", () => { + const v1Document = { openapi: "3.0.0", info: { version: "1.0.0" }, paths: { "/v1/a": {} } }; + const v2Document = { openapi: "3.0.0", info: { version: "1.0.0" }, paths: { "/v1/foo": {} } }; + + expect(() => + mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]), + ).toThrow('OpenAPI path "/v1/foo" in the v2 document does not start with "/v2/".'); + }); + + test("throws when the same path key appears twice across documents", () => { + // Can only happen when the same declared version is fetched/merged twice, + // since a document's own version-prefix check would otherwise reject a + // literal path belonging to a different version before this check runs. + const firstDocument = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v1/a": { get: {} } }, + }; + const secondDocument = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v1/a": { post: {} } }, + }; + + expect(() => + mergeOpenApiDocuments([ + { version: "v1", document: firstDocument }, + { version: "v1", document: secondDocument }, + ]), + ).toThrow('Duplicate OpenAPI path "/v1/a" found in both the v1 and v1 documents.'); + }); + + test("dedupes an identical duplicate schema found in both documents", () => { + const v1Document = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v1/a": {} }, + components: { schemas: { Shared: { type: "string" } } }, + }; + const v2Document = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v2/a": {} }, + components: { schemas: { Shared: { type: "string" } } }, + }; + + const merged = mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]); + + expect(merged.components?.schemas).toEqual({ Shared: { type: "string" } }); + }); + + test("throws when two documents disagree on the same schema name", () => { + const v1Document = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v1/a": {} }, + components: { schemas: { Shared: { type: "string" } } }, + }; + const v2Document = { + openapi: "3.0.0", + info: { version: "1.0.0" }, + paths: { "/v2/a": {} }, + components: { schemas: { Shared: { type: "number" } } }, + }; + + expect(() => + mergeOpenApiDocuments([ + { version: "v1", document: v1Document }, + { version: "v2", document: v2Document }, + ]), + ).toThrow('Conflicting OpenAPI schema "Shared" found in both the v1 and v2 documents.'); + }); + + test("throws on duplicate operationId across the v2 webhook paths (CLI-2157 platform bug)", () => { + const document = { + paths: { + "/v2/projects/{ref}/webhooks/endpoints": { + get: { operationId: "allV2ProjectsByRefWebhooks" }, + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}": { + get: { operationId: "allV2ProjectsByRefWebhooks" }, + }, + }, + }; + + expect(() => assertMergedOpenApiDocument(document)).toThrow( + 'Duplicate OpenAPI operationId "allV2ProjectsByRefWebhooks" claimed by: GET /v2/projects/{ref}/webhooks/endpoints, GET /v2/projects/{ref}/webhooks/endpoints/{id}.', + ); + }); + + test("throws when an operationId's version prefix disagrees with its path", () => { + const document = { paths: { "/v2/x": { get: { operationId: "v1-x" } } } }; + + expect(() => assertMergedOpenApiDocument(document)).toThrow( + 'OpenAPI operationId "v1-x" for GET /v2/x has version prefix "v1" that does not match the path\'s leading segment "v2".', + ); + }); + + test("warns instead of throwing when an operation has no operationId", () => { + const document = { paths: { "/v1/a": { get: {} } } }; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(() => assertMergedOpenApiDocument(document)).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + "OpenAPI operation GET /v1/a has no operationId; generate.ts will skip it.", + ); + + warnSpy.mockRestore(); + }); + + test("applyOpenApiOverrides tolerantly removes JSON pointers that no longer exist", () => { + const withExistingPath = { paths: { "/v1/foo": { get: {} } } }; + applyOpenApiOverrides(withExistingPath, [{ op: "remove", path: "/paths/~1v1~1foo" }]); + expect(withExistingPath.paths).toEqual({}); + + const withMissingPath = { paths: {} }; + applyOpenApiOverrides(withMissingPath, [{ op: "remove", path: "/paths/~1v1~1missing" }]); + expect(withMissingPath.paths).toEqual({}); + + const withMissingIntermediateSegment = { paths: {} }; + applyOpenApiOverrides(withMissingIntermediateSegment, [ + { op: "remove", path: "/paths/~1nope/get" }, + ]); + expect(withMissingIntermediateSegment.paths).toEqual({}); + + const withArray = { paths: {}, components: { schemas: { Foo: { enum: ["a", "b", "c"] } } } }; + applyOpenApiOverrides(withArray, [{ op: "remove", path: "/components/schemas/Foo/enum/1" }]); + expect(withArray.components.schemas.Foo.enum).toEqual(["a", "c"]); + }); + + test("rejects a remove override that carries a value", () => { + expect(() => + applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths", value: {} }]), + ).toThrow("OpenAPI remove overrides must not include a value."); + }); + + test("assertMergedOpenApiDocument passes after the webhook-collision remove overrides are applied but fails without them", () => { + const buildDocument = () => ({ + paths: { + "/v2/projects/{ref}/webhooks/endpoints": { + get: { operationId: "allV2ProjectsByRefWebhooks" }, + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}": { + get: { operationId: "allV2ProjectsByRefWebhooks" }, + }, + "/v2/projects/{ref}": { + get: { operationId: "v2-get-a-project" }, + }, + }, + }); + + const overrides = [ + { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints" }, + { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}" }, + ]; + + expect(() => assertMergedOpenApiDocument(buildDocument())).toThrow( + 'Duplicate OpenAPI operationId "allV2ProjectsByRefWebhooks"', + ); + + const patchedDocument = applyOpenApiOverrides(buildDocument(), overrides); + expect(Object.keys(patchedDocument.paths)).toEqual(["/v2/projects/{ref}"]); + expect(() => assertMergedOpenApiDocument(patchedDocument)).not.toThrow(); + }); }); diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index 8d1e52f11d..4cea516e4e 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -10,7 +10,7 @@ import * as SchemaRepresentation from "effect/SchemaRepresentation"; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; type OpenApiHttpMethod = Lowercase; -type OpenApiDocument = { +export type OpenApiDocument = { readonly openapi: string; readonly info?: { readonly title?: string; @@ -22,7 +22,7 @@ type OpenApiDocument = { }; }; -type OpenApiOperation = { +export type OpenApiOperation = { readonly operationId?: string; readonly summary?: string; readonly description?: string; @@ -111,6 +111,8 @@ type OperationDefinition = { readonly schemaBase: string; readonly method: HttpMethod; readonly path: string; + readonly version: string; + readonly methodName: string; readonly description: string; readonly pathParams: ReadonlyArray; readonly queryParams: ReadonlyArray; @@ -142,7 +144,7 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function loadSpec(): OpenApiDocument { +export function loadSpec(): OpenApiDocument { const parsed = JSON.parse(readFileSync(sourceSpecPath, "utf8")); if (!isRecord(parsed) || !isRecord(parsed.paths)) { throw new Error(`Invalid OpenAPI document at ${sourceSpecPath}`); @@ -588,7 +590,94 @@ function buildCombinedInputSchema( }; } -function extractOperations(document: OpenApiDocument): ReadonlyArray { +// The version namespace is derived from the path (not the operationId) so +// that `/v2/...` routes land in `api.v2` regardless of how their operationId +// happens to be spelled in the upstream spec. +export function operationVersionFromPath(path: string): string { + const version = path.split("/")[1]; + if (version === undefined || !/^v\d+$/u.test(version)) { + throw new Error(`Expected a version-prefixed path, got ${path}`); + } + return version; +} + +// Strips a leading version prefix (e.g. `v1`/`V2`) from a camelized operation +// name, lowercasing the character that follows it, so `v2GetProjectConfig` +// becomes `getProjectConfig`. Operation names without a version prefix are +// returned unchanged — the path, not the operationId, is the authority on +// version. +export function operationMethodName(operationName: string): string { + const match = /^([vV]\d+)(.*)$/u.exec(operationName); + if (!match) { + return operationName; + } + + const methodBase = match[2]; + if (methodBase === undefined || methodBase.length === 0) { + return operationName; + } + + const first = methodBase.slice(0, 1).toLowerCase(); + return `${first}${methodBase.slice(1)}`; +} + +// A version prefix on the operationId is optional, but when present it must +// agree with the path-derived version — otherwise the generated namespace +// (from the path) and the SDK method name (from the operationId) would imply +// different API versions for the same operation. +function assertOperationVersionAgreement(operation: { + readonly operationId: string; + readonly operationName: string; + readonly path: string; + readonly version: string; +}): void { + const match = /^[vV]\d+/u.exec(operation.operationName); + if (!match) { + return; + } + + const operationNameVersion = match[0].toLowerCase(); + if (operationNameVersion !== operation.version) { + throw new Error( + `Operation "${operation.operationId}" at path "${operation.path}" has operationId version "${operationNameVersion}" that disagrees with the path-derived version "${operation.version}"`, + ); + } +} + +function assertUniqueOperations(operations: ReadonlyArray): void { + const byNamespaceMethod = new Map(); + const byOperationName = new Map(); + const bySchemaBase = new Map(); + + for (const operation of operations) { + const namespaceMethod = `${operation.version}.${operation.methodName}`; + const existingNamespaceMethod = byNamespaceMethod.get(namespaceMethod); + if (existingNamespaceMethod) { + throw new Error( + `Duplicate namespace method "${namespaceMethod}": "${existingNamespaceMethod.operationId}" (${existingNamespaceMethod.method} ${existingNamespaceMethod.path}) collides with "${operation.operationId}" (${operation.method} ${operation.path})`, + ); + } + byNamespaceMethod.set(namespaceMethod, operation); + + const existingOperationName = byOperationName.get(operation.operationName); + if (existingOperationName) { + throw new Error( + `Duplicate operation name "${operation.operationName}": "${existingOperationName.operationId}" (${existingOperationName.method} ${existingOperationName.path}) collides with "${operation.operationId}" (${operation.method} ${operation.path})`, + ); + } + byOperationName.set(operation.operationName, operation); + + const existingSchemaBase = bySchemaBase.get(operation.schemaBase); + if (existingSchemaBase) { + throw new Error( + `Duplicate schema base "${operation.schemaBase}": "${existingSchemaBase.operationId}" (${existingSchemaBase.method} ${existingSchemaBase.path}) collides with "${operation.operationId}" (${operation.method} ${operation.path})`, + ); + } + bySchemaBase.set(operation.schemaBase, operation); + } +} + +export function extractOperations(document: OpenApiDocument): ReadonlyArray { const operations: Array = []; for (const [pathName, pathItem] of Object.entries(document.paths)) { @@ -602,13 +691,25 @@ function extractOperations(document: OpenApiDocument): ReadonlyArray parameter.in === "path") @@ -629,7 +730,11 @@ function extractOperations(document: OpenApiDocument): ReadonlyArray left.operationId.localeCompare(right.operationId)); + const sortedOperations = operations.sort((left, right) => + left.operationId.localeCompare(right.operationId), + ); + assertUniqueOperations(sortedOperations); + return sortedOperations; } function renderSchemaSource( @@ -758,7 +863,7 @@ function renderResponse(definition: ResponseDefinition): string { return `{ kind: ${JSON.stringify(definition.kind)} }`; } -function renderContracts( +export function renderContracts( document: OpenApiDocument, operations: ReadonlyArray, ): string { @@ -818,34 +923,12 @@ export type VoidOperationDefinition = Extr `; } -function splitOperationVersion(operationName: string): { - readonly version: string; - readonly methodName: string; -} { - const match = /^((?:v|V)\d+)(.+)$/u.exec(operationName); - if (!match) { - throw new Error(`Expected a version-prefixed operation id, got ${operationName}`); - } - - const [, version, methodBase] = match; - if (version === undefined || methodBase === undefined || methodBase.length === 0) { - throw new Error(`Expected an operation method segment after the version in ${operationName}`); - } - const first = methodBase.slice(0, 1).toLowerCase(); - - return { - version, - methodName: `${first}${methodBase.slice(1)}`, - }; -} - -function renderEffectClient(operations: ReadonlyArray): string { +export function renderEffectClient(operations: ReadonlyArray): string { const versionedOperations = new Map>(); for (const operation of operations) { - const { version } = splitOperationVersion(operation.operationName); - const group = versionedOperations.get(version); + const group = versionedOperations.get(operation.version); if (group === undefined) { - versionedOperations.set(version, [operation]); + versionedOperations.set(operation.version, [operation]); } else { group.push(operation); } @@ -856,7 +939,7 @@ function renderEffectClient(operations: ReadonlyArray): str .map(([version, groupedOperations]) => { const methods = groupedOperations .map((operation) => { - const { methodName } = splitOperationVersion(operation.operationName); + const { methodName } = operation; const isEmptyInput = operation.inputSchema.type === "object" && Object.keys(operation.inputSchema.properties ?? {}).length === 0; @@ -886,7 +969,7 @@ ${methods} const executorCases = operations .map((operation) => { - const { version, methodName } = splitOperationVersion(operation.operationName); + const { version, methodName } = operation; const isEmptyInput = operation.inputSchema.type === "object" && Object.keys(operation.inputSchema.properties ?? {}).length === 0; diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index c0e2dab526..116f9bf946 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -3,12 +3,73 @@ import * as JsonSchema from "effect/JsonSchema"; import * as SchemaRepresentation from "effect/SchemaRepresentation"; import { describe, expect, test } from "vitest"; +import type { OpenApiDocument, OpenApiOperation } from "./generate.ts"; import { + extractOperations, normalizeNullableJsonSchema, normalizeQueryParameterSchema, + operationMethodName, + operationVersionFromPath, + renderContracts, + renderEffectClient, sanitizeOpenApiSchema, } from "./generate.ts"; +function jsonResponseOperation( + operationId: string, + pathParamName: string, + responseSchema: Record, +): OpenApiOperation { + return { + operationId, + parameters: [{ name: pathParamName, in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { + content: { + "application/json": { + schema: responseSchema, + }, + }, + }, + }, + }; +} + +function twoVersionFixture(): OpenApiDocument { + return { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0.0" }, + paths: { + "/v1/organizations/{slug}/members": { + get: jsonResponseOperation("v1-list-organization-members", "slug", { + type: "array", + items: { type: "object", properties: {}, required: [] }, + }), + }, + "/v1/projects/{ref}": { + get: jsonResponseOperation("v1-get-a-project", "ref", { + type: "object", + properties: {}, + required: [], + }), + }, + "/v2/organizations/{slug}/members": { + get: jsonResponseOperation("v2-list-organization-members", "slug", { + type: "array", + items: { type: "object", properties: {}, required: [] }, + }), + }, + "/v2/projects/{ref}/config": { + get: jsonResponseOperation("v2-get-a-project-config", "ref", { + type: "object", + properties: {}, + required: [], + }), + }, + }, + }; +} + function renderOpenApiSchema(schema: Parameters[0]) { const normalized = normalizeNullableJsonSchema( JsonSchema.fromSchemaOpenApi3_0(sanitizeOpenApiSchema(schema)).schema, @@ -119,4 +180,102 @@ describe("generate", () => { }), ).toContain('"default": Schema.optionalKey(Schema.Json'); }); + + test("extractOperations derives version from the path and methodName from the operationId", () => { + const operations = extractOperations(twoVersionFixture()); + + expect(operations.map((operation) => operation.operationId)).toEqual([ + "v1-get-a-project", + "v1-list-organization-members", + "v2-get-a-project-config", + "v2-list-organization-members", + ]); + expect( + operations.map((operation) => ({ + version: operation.version, + methodName: operation.methodName, + })), + ).toEqual([ + { version: "v1", methodName: "getAProject" }, + { version: "v1", methodName: "listOrganizationMembers" }, + { version: "v2", methodName: "getAProjectConfig" }, + { version: "v2", methodName: "listOrganizationMembers" }, + ]); + + const membersOperations = operations.filter( + (operation) => operation.methodName === "listOrganizationMembers", + ); + expect(membersOperations).toHaveLength(2); + expect(membersOperations.map((operation) => operation.version)).toEqual(["v1", "v2"]); + }); + + test("operationMethodName strips a real v1 version prefix and passes unprefixed names through unchanged", () => { + expect(operationMethodName("v1GetABranchConfig")).toBe("getABranchConfig"); + expect(operationMethodName("v1ListAllProjects")).toBe("listAllProjects"); + expect(operationMethodName("healthCheck")).toBe("healthCheck"); + }); + + test("operationVersionFromPath reads the version segment from a versioned path", () => { + expect(operationVersionFromPath("/v2/projects/{ref}/config")).toBe("v2"); + }); + + test("operationVersionFromPath throws for a path with no version prefix", () => { + expect(() => operationVersionFromPath("/health")).toThrow( + "Expected a version-prefixed path, got /health", + ); + }); + + test("extractOperations throws when a path's version disagrees with the operationId's version prefix", () => { + const document = { + openapi: "3.0.0", + paths: { + "/v2/x": { get: jsonResponseOperation("v1-x", "x", { type: "object" }) }, + }, + }; + + expect(() => extractOperations(document)).toThrow( + 'Operation "v1-x" at path "/v2/x" has operationId version "v1" that disagrees with the path-derived version "v2"', + ); + }); + + test("extractOperations throws when two operationIds camelize to the same (version, methodName) pair", () => { + const document = { + openapi: "3.0.0", + paths: { + "/v2/a": { get: jsonResponseOperation("v2-get-config", "x", { type: "object" }) }, + "/v2/b": { get: jsonResponseOperation("v2-get--config", "x", { type: "object" }) }, + }, + }; + + expect(() => extractOperations(document)).toThrow( + 'Duplicate namespace method "v2.getConfig": "v2-get--config" (GET /v2/b) collides with "v2-get-config" (GET /v2/a)', + ); + }); + + test("renderEffectClient emits both version namespaces with the shared method name and a versioned executor case", () => { + const document = twoVersionFixture(); + const operations = extractOperations(document); + const source = renderEffectClient(operations); + + expect(source).toContain(" v1: {"); + expect(source).toContain(" v2: {"); + + const v1Block = source.slice(source.indexOf(" v1: {"), source.indexOf(" v2: {")); + const v2Block = source.slice(source.indexOf(" v2: {")); + expect(v1Block).toContain("listOrganizationMembers: ("); + expect(v2Block).toContain("listOrganizationMembers: ("); + + expect(source).toContain("api.v2.listOrganizationMembers(decoded)"); + }); + + test("renderContracts includes both versioned operation names and the raw kebab operationIds", () => { + const document = twoVersionFixture(); + const operations = extractOperations(document); + const source = renderContracts(document, operations); + + expect(source).toContain('"v1ListOrganizationMembers": {'); + expect(source).toContain('"v2ListOrganizationMembers": {'); + expect(source).toContain(' "v1-list-organization-members": "v1ListOrganizationMembers",'); + expect(source).toContain(' "v2-list-organization-members": "v2ListOrganizationMembers",'); + }); }); diff --git a/packages/api/scripts/generated-output-sync.unit.test.ts b/packages/api/scripts/generated-output-sync.unit.test.ts new file mode 100644 index 0000000000..e1c3dda7f1 --- /dev/null +++ b/packages/api/scripts/generated-output-sync.unit.test.ts @@ -0,0 +1,105 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +import { extractOperations, loadSpec, renderContracts, renderEffectClient } from "./generate.ts"; + +// Full-fidelity drift guard: re-renders every generated file from the +// committed openapi.json snapshot, formats the result through the same oxfmt +// the pipeline uses, and requires byte equality with the committed files. +// Unlike the operation-level bijection test in src/generated-contract-sync, +// this catches hand edits to schema definitions, parameter lists, request +// bodies, response types, and the executor switch — anything short of +// editing the snapshot and the generated output consistently, which the +// hourly upstream sync then catches. + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.join(scriptDir, ".."); +const generatedDir = path.join(packageDir, "src", "generated"); +const oxfmtBin = path.join(packageDir, "node_modules", ".bin", "oxfmt"); + +function formatWithOxfmt(source: string, fileName: string): string { + // oxfmt runs in file mode (also what the pipeline's fmt:fix runs) rather + // than through stdin/stdout: Bun on Linux truncates a child's piped stdout + // at ~219 KB, and these renders are 600+ KB. The temp directory lives + // inside the package so oxfmt resolves the same configuration, but not + // under node_modules, which oxfmt skips by default. + const tempDir = mkdtempSync(path.join(packageDir, ".generated-output-sync-")); + try { + const tempFile = path.join(tempDir, fileName); + writeFileSync(tempFile, source); + execFileSync(oxfmtBin, [tempFile], { cwd: packageDir }); + return readFileSync(tempFile, "utf8"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function committedFile(fileName: string): string { + return readFileSync(path.join(generatedDir, fileName), "utf8"); +} + +function expectSameSource(rendered: string, fileName: string): void { + const committed = committedFile(fileName); + if (rendered === committed) { + return; + } + const renderedLines = rendered.split("\n"); + const committedLines = committed.split("\n"); + const limit = Math.min(renderedLines.length, committedLines.length); + let line = 0; + while (line < limit && renderedLines[line] === committedLines[line]) { + line += 1; + } + expect.fail( + `src/generated/${fileName} is not what the generator renders from the committed snapshot ` + + `(first difference at line ${line + 1}):\n` + + ` committed: ${JSON.stringify(committedLines[line] ?? "")}\n` + + ` rendered: ${JSON.stringify(renderedLines[line] ?? "")}\n` + + `Hand edits to src/generated are not allowed — run \`pnpm generate\` instead.`, + ); +} + +// Rendering contracts.ts runs the real schema codegen for every operation, +// which takes well over vitest's default 5s budget. +const RENDER_TIMEOUT_MS = 120_000; + +describe("generated output sync", () => { + const document = loadSpec(); + const operations = extractOperations(document); + + test( + "contracts.ts is byte-identical to the generator's render of the committed snapshot", + { timeout: RENDER_TIMEOUT_MS }, + () => { + expectSameSource( + formatWithOxfmt(renderContracts(document, operations), "contracts.ts"), + "contracts.ts", + ); + }, + ); + + test( + "effect-client.ts is byte-identical to the generator's render of the committed snapshot", + { timeout: RENDER_TIMEOUT_MS }, + () => { + expectSameSource( + formatWithOxfmt(renderEffectClient(operations), "effect-client.ts"), + "effect-client.ts", + ); + }, + ); + + test( + "openapi.json is byte-identical to the generator's normalized rewrite of itself", + { timeout: RENDER_TIMEOUT_MS }, + () => { + expectSameSource( + formatWithOxfmt(`${JSON.stringify(document, null, 2)}\n`, "openapi.json"), + "openapi.json", + ); + }, + ); +}); diff --git a/packages/api/scripts/openapi-overrides.json b/packages/api/scripts/openapi-overrides.json index ee82bed371..5e60739640 100644 --- a/packages/api/scripts/openapi-overrides.json +++ b/packages/api/scripts/openapi-overrides.json @@ -622,5 +622,70 @@ "op": "replace", "path": "/components/schemas/UpdateAuthConfigBody/properties/sms_test_otp/pattern", "value": "^(?:[0-9]{1,15}=(?:[0-9]+,[0-9]{1,15}=|[0-9]{2,}=)*[0-9]+,?)?$" + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints", + "$comment": "CLI-2157: the platform's v2 spec gives all 10 project-webhook operations the shared operationId \"allV2ProjectsByRefWebhooks\" (and all 10 org-webhook operations share \"allV2OrganizationsBySlugWebhooks\") — duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}", + "$comment": "CLI-2157: part of the project-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1deliveries", + "$comment": "CLI-2157: part of the project-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1test", + "$comment": "CLI-2157: part of the project-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}", + "$comment": "CLI-2157: part of the project-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}~1retry", + "$comment": "CLI-2157: part of the project-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints", + "$comment": "CLI-2157: the platform's v2 spec gives all 10 org-webhook operations the shared operationId \"allV2OrganizationsBySlugWebhooks\" (and all 10 project-webhook operations share \"allV2ProjectsByRefWebhooks\") — duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}", + "$comment": "CLI-2157: part of the org-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1deliveries", + "$comment": "CLI-2157: part of the org-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1test", + "$comment": "CLI-2157: part of the org-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}", + "$comment": "CLI-2157: part of the org-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}~1retry", + "$comment": "CLI-2157: part of the org-webhook operationId collision; see the endpoints path comment above." + }, + { + "op": "remove", + "path": "/components/schemas/APIErrorObject", + "$comment": "CLI-2157: referenced only by the removed webhook paths (verified in prod and staging); removing it also makes the staging document deterministic." } ] diff --git a/packages/api/scripts/openapi-source.json b/packages/api/scripts/openapi-source.json new file mode 100644 index 0000000000..95dd824ea3 --- /dev/null +++ b/packages/api/scripts/openapi-source.json @@ -0,0 +1,3 @@ +{ + "baseUrl": "https://api.supabase.com" +} diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index 0cb0a4d4fe..e86a7d4ed4 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -11,8 +11,16 @@ import { versionedEffectOperations, } from "./generated/effect-client.ts"; -export type { SupabaseApiError, SupabaseApiRetryOptions } from "./internal/client.ts"; -export { SupabaseApiConfigError } from "./internal/client.ts"; +export type { + SupabaseApiError, + SupabaseApiInputErrorSource, + SupabaseApiRetryOptions, +} from "./internal/client.ts"; +export { + markSupabaseApiInputErrorAsUserInput, + SupabaseApiConfigError, + SupabaseApiInputError, +} from "./internal/client.ts"; export type { SupabaseApiClientOptions, SupabaseApiConfig } from "./internal/client.ts"; export { apiConfigLayer, DEFAULT_SUPABASE_API_URL } from "./config/api-config.layer.ts"; export { ApiConfig } from "./config/api-config.service.ts"; diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index 75f7380ee2..ce0a15a73e 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -442,6 +442,75 @@ describe("makeApiClient", () => { ]); }); + test("addresses same-named v1 and v2 operations independently by namespace", async () => { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = await Effect.runPromise( + makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if (request.url === "https://api.supabase.com/v1/organizations/my-org/members") { + return Effect.succeed( + jsonResponse(request, 200, [ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]), + ); + } + + return Effect.succeed( + jsonResponse(request, 200, { + data: [], + links: { prev: null, next: null }, + }), + ); + }), + ), + ), + ); + + expect(typeof client.v1.listOrganizationMembers).toBe("function"); + expect(typeof client.v2.listOrganizationMembers).toBe("function"); + + const v1Members = await Effect.runPromise( + client.v1.listOrganizationMembers({ slug: "my-org" }), + ); + const v2Members = await Effect.runPromise( + client.v2.listOrganizationMembers({ slug: "my-org" }), + ); + + expect(v1Members).toEqual([ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]); + expect(v2Members.data).toEqual([]); + expect(seenRequests).toEqual([ + { + method: "GET", + url: "https://api.supabase.com/v1/organizations/my-org/members", + }, + { + method: "GET", + url: "https://api.supabase.com/v2/organizations/my-org/members", + }, + ]); + }); + test("serializes generated binary methods through the effect facade", async () => { let seenRequest: HttpClientRequest.HttpClientRequest | undefined; diff --git a/packages/api/src/generated-contract-sync.unit.test.ts b/packages/api/src/generated-contract-sync.unit.test.ts new file mode 100644 index 0000000000..1dc1641a04 --- /dev/null +++ b/packages/api/src/generated-contract-sync.unit.test.ts @@ -0,0 +1,184 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +import { openApiOperationIdMap, operationDefinitions } from "./generated/contracts.ts"; +import { versionedEffectOperations } from "./generated/effect-client.ts"; + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const; + +interface OpenApiOperationObject { + readonly operationId?: string; +} + +type OpenApiPathItem = Readonly>; + +interface OpenApiDocumentShape { + readonly paths: Readonly>; +} + +interface SnapshotOperation { + readonly path: string; + readonly method: string; + readonly operationId: string; +} + +const openApiJsonPath = join(dirname(fileURLToPath(import.meta.url)), "generated/openapi.json"); +const rawOpenApiJson = readFileSync(openApiJsonPath, "utf8"); +const openApiDocument = JSON.parse(rawOpenApiJson) as OpenApiDocumentShape; + +function extractSnapshotOperations( + document: OpenApiDocumentShape, +): ReadonlyArray { + const operations: Array = []; + for (const [path, pathItem] of Object.entries(document.paths)) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!operation?.operationId) { + continue; + } + operations.push({ path, method: method.toUpperCase(), operationId: operation.operationId }); + } + } + return operations; +} + +function leadingPathSegment(path: string): string { + const segment = path.split("/")[1]; + if (segment === undefined) { + throw new Error(`Expected a version-prefixed path, got "${path}"`); + } + return segment; +} + +// Mirrors scripts/generate.ts's operationMethodName: strips the leading +// version prefix from the SDK operation id and lowercases the character that +// follows it, e.g. "v2GetProjectConfig" -> "getProjectConfig". +function methodNameFromSdkOperationId(sdkOperationId: string): string { + const match = /^v\d+(.+)$/.exec(sdkOperationId); + const rest = match?.[1]; + if (!rest) { + return sdkOperationId; + } + return `${rest[0]!.toLowerCase()}${rest.slice(1)}`; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null; +} + +function stringProperty(value: unknown, key: string): string { + if (!isRecord(value)) { + throw new Error(`Expected an object while reading property "${key}"`); + } + const propertyValue = value[key]; + if (typeof propertyValue !== "string") { + throw new Error(`Expected a string property "${key}", got ${typeof propertyValue}`); + } + return propertyValue; +} + +const operationIdMap = new Map(Object.entries(openApiOperationIdMap)); +const definitionsByOperationName = new Map(Object.entries(operationDefinitions)); +const versionedOperationsByVersion = new Map( + Object.entries(versionedEffectOperations), +); + +function versionedOperationFunction(version: string, methodName: string): unknown { + const operations = versionedOperationsByVersion.get(version); + if (!isRecord(operations)) { + return undefined; + } + return operations[methodName]; +} + +const snapshotOperations = extractSnapshotOperations(openApiDocument); + +describe("generated client drift against the committed openapi.json snapshot", () => { + test("maps every snapshot operation to a matching generated contract and versioned client method", () => { + for (const { path, method, operationId } of snapshotOperations) { + const sdkOperationId = operationIdMap.get(operationId); + expect(sdkOperationId, `no openApiOperationIdMap entry for "${operationId}"`).toBeDefined(); + if (sdkOperationId === undefined) { + continue; + } + + const definition = definitionsByOperationName.get(sdkOperationId); + expect( + definition, + `no operationDefinitions entry for "${sdkOperationId}" (${method} ${path})`, + ).toBeDefined(); + + expect(stringProperty(definition, "method")).toBe(method); + expect(stringProperty(definition, "path")).toBe(path); + + const namespace = leadingPathSegment(path); + const methodName = methodNameFromSdkOperationId(sdkOperationId); + expect( + typeof versionedOperationFunction(namespace, methodName), + `versionedEffectOperations.${namespace}.${methodName} is not a function for "${sdkOperationId}" (${method} ${path})`, + ).toBe("function"); + } + }); + + test("does not carry a hand-added or stale operation in the generated contracts", () => { + const sdkOperationIdsFromSnapshot = snapshotOperations.map(({ operationId }) => + operationIdMap.get(operationId), + ); + + expect(new Set(sdkOperationIdsFromSnapshot).size).toBe(sdkOperationIdsFromSnapshot.length); + expect(sdkOperationIdsFromSnapshot.length).toBe(Object.keys(operationDefinitions).length); + expect(new Set(sdkOperationIdsFromSnapshot)).toEqual( + new Set(Object.keys(operationDefinitions)), + ); + }); + + test("does not carry a hand-added or stale method on the versioned effect client", () => { + const totalVersionedOperationFunctions = Array.from( + versionedOperationsByVersion.values(), + ).reduce( + (total, operations) => + isRecord(operations) ? total + Object.keys(operations).length : total, + 0, + ); + + const versionMethodPairsFromSnapshot = new Set( + snapshotOperations.map(({ path, operationId }) => { + const sdkOperationId = operationIdMap.get(operationId); + return `${leadingPathSegment(path)}.${ + sdkOperationId ? methodNameFromSdkOperationId(sdkOperationId) : operationId + }`; + }), + ); + + expect(versionMethodPairsFromSnapshot.size).toBe(snapshotOperations.length); + expect(totalVersionedOperationFunctions).toBe(snapshotOperations.length); + }); + + test("exposes exactly the API versions present in the snapshot as top-level namespaces", () => { + const versionsFromSnapshot = new Set( + snapshotOperations.map(({ path }) => leadingPathSegment(path)), + ); + + expect(Object.keys(versionedEffectOperations).sort()).toEqual( + Array.from(versionsFromSnapshot).sort(), + ); + expect(versionsFromSnapshot).toContain("v1"); + expect(versionsFromSnapshot).toContain("v2"); + }); + + // A byte-for-byte `JSON.stringify(parsed, null, 2) + "\n"` reproduction of + // the committed file does not hold: oxfmt collapses short arrays (e.g. + // `"tags": ["Environments"]`) onto a single line after generation, so a + // naive re-stringify diverges purely on formatting, not content. This + // instead checks that the committed bytes parse deterministically and keep + // the single trailing newline `scripts/generate.ts` writes. + test("parses the committed snapshot deterministically and keeps a single trailing newline", () => { + const reparsed = JSON.parse(readFileSync(openApiJsonPath, "utf8")) as OpenApiDocumentShape; + expect(reparsed).toEqual(openApiDocument); + expect(rawOpenApiJson.endsWith("\n")).toBe(true); + expect(rawOpenApiJson.endsWith("\n\n")).toBe(false); + }); +}); diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 1cbcb9dcc7..a60b90399b 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -9989,6 +9989,2005 @@ export const V1VerifyDnsConfigOutput = Schema.Struct({ }), }), }); +export const V2AssignOrganizationMemberRoleInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + user_id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("organization_member_role").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + description: + "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", + }), + projects: Schema.optionalKey( + Schema.Array(Schema.Struct({ ref: Schema.String.annotate({ description: "Project ref" }) })) + .annotate({ + description: + "The projects to assign a project-scoped role for. If omitted, assigns an org-wide role.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + }), + }), +}); +export const V2AssignOrganizationMemberRoleOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("organization_member_role").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + name: Schema.String.annotate({ + description: "Role name. For project-scoped assignments this is the base role name.", + }), + scope: Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + projects: Schema.Array(Schema.Struct({ ref: Schema.String, name: Schema.String })).annotate({ + description: "Project refs this role is scoped to. Empty array for org-level roles.", + }), + }), + }), +}); +export const V2CreateLogDrainInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("log_drain").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + config: Schema.Union([ + Schema.Struct({ + url: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + schema: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + port: Schema.optionalKey( + Schema.Union([ + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Null, + ]), + ), + hostname: Schema.optionalKey(Schema.String), + }).annotate({ title: "postgres" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + gzip: Schema.optionalKey(Schema.Boolean), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "webhook" }), + Schema.Struct({ + project_id: Schema.optionalKey(Schema.String), + dataset_id: Schema.optionalKey(Schema.String), + }).annotate({ title: "bigquery" }), + Schema.Struct({ + api_key: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), + }).annotate({ title: "datadog" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "loki" }), + Schema.Struct({ dsn: Schema.optionalKey(Schema.String) }).annotate({ title: "sentry" }), + Schema.Struct({ + domain: Schema.optionalKey(Schema.String), + api_token: Schema.optionalKey(Schema.String), + dataset_name: Schema.optionalKey(Schema.String), + }).annotate({ title: "axiom" }), + Schema.Struct({ + host: Schema.optionalKey(Schema.String), + port: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(65535).annotate({ + expected: "a value less than or equal to 65535", + }), + ), + ), + tls: Schema.optionalKey(Schema.Boolean), + structured_data: Schema.optionalKey(Schema.String), + cipher_key: Schema.optionalKey(Schema.String), + ca_cert: Schema.optionalKey(Schema.String), + client_cert: Schema.optionalKey(Schema.String), + client_key: Schema.optionalKey(Schema.String), + }).annotate({ title: "syslog" }), + ]), + backend_type: Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + }), + }), +}); +export const V2CreateLogDrainOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("log_drain").annotate({ description: "Resource type." }), + id: Schema.String, + attributes: Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + config: Schema.Union([ + Schema.Struct({ + url: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + schema: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + port: Schema.optionalKey( + Schema.Union([ + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Null, + ]), + ), + hostname: Schema.optionalKey(Schema.String), + }).annotate({ title: "postgres" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + gzip: Schema.optionalKey(Schema.Boolean), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "webhook" }), + Schema.Struct({ + project_id: Schema.optionalKey(Schema.String), + dataset_id: Schema.optionalKey(Schema.String), + }).annotate({ title: "bigquery" }), + Schema.Struct({ + api_key: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), + }).annotate({ title: "datadog" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "loki" }), + Schema.Struct({ dsn: Schema.optionalKey(Schema.String) }).annotate({ title: "sentry" }), + Schema.Struct({ + domain: Schema.optionalKey(Schema.String), + api_token: Schema.optionalKey(Schema.String), + dataset_name: Schema.optionalKey(Schema.String), + }).annotate({ title: "axiom" }), + Schema.Struct({ + host: Schema.optionalKey(Schema.String), + port: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(65535).annotate({ + expected: "a value less than or equal to 65535", + }), + ), + ), + tls: Schema.optionalKey(Schema.Boolean), + structured_data: Schema.optionalKey(Schema.String), + cipher_key: Schema.optionalKey(Schema.String), + ca_cert: Schema.optionalKey(Schema.String), + client_cert: Schema.optionalKey(Schema.String), + client_key: Schema.optionalKey(Schema.String), + }).annotate({ title: "syslog" }), + ]), + backend_type: Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + }), + }), +}); +export const V2CreateOrganizationInvitationsInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_invitation").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + email: Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + description: + "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + }), + projects: Schema.optionalKey( + Schema.Array( + Schema.Struct({ ref: Schema.String.annotate({ description: "Project ref" }) }), + ) + .annotate({ + description: + "The projects to limit a user to. If omitted, user will have org-wide access with the provided role.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + require_sso: Schema.optionalKey(Schema.Boolean), + }), + }), + ) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })) + .check(Schema.isMaxLength(50).annotate({ expected: "a value with a length of at most 50" })), +}); +export const V2CreateOrganizationInvitationsOutput = Schema.Struct({ + error: Schema.optionalKey( + Schema.Struct({ + id: Schema.optionalKey(Schema.String), + code: Schema.String, + message: Schema.String, + description: Schema.optionalKey(Schema.String), + links: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ + href: Schema.String, + rel: Schema.optionalKey(Schema.String), + title: Schema.optionalKey(Schema.String), + type: Schema.optionalKey(Schema.String), + describedby: Schema.optionalKey(Schema.String), + meta: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })), + ), + }), + ), + ), + meta: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })), + ), + issues: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + id: Schema.optionalKey(Schema.String), + code: Schema.String, + message: Schema.String, + description: Schema.optionalKey(Schema.String), + links: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ + href: Schema.String, + rel: Schema.optionalKey(Schema.String), + title: Schema.optionalKey(Schema.String), + type: Schema.optionalKey(Schema.String), + describedby: Schema.optionalKey(Schema.String), + meta: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })), + ), + }), + ), + ), + meta: Schema.Struct({ + email: Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + }), + }), + ), + ), + }), + ), + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_invitation").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + email: Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + }), + }), + ), +}); +export const V2CreatePrivateLinkAssociationInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("private_link_association").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + aws_account_id: Schema.String.annotate({ + description: "The AWS account ID to add to the project PrivateLink share.", + }) + .check( + Schema.isMinLength(12).annotate({ expected: "a value with a length of at least 12" }), + ) + .check(Schema.isMaxLength(12).annotate({ expected: "a value with a length of at most 12" })) + .check( + Schema.isPattern(new RegExp("^\\d{12}$")).annotate({ + expected: "a string matching the RegExp ^\\d{12}$", + }), + ), + account_name: Schema.optionalKey( + Schema.String.annotate({ + description: "Optional human-readable name for the AWS account.", + }).check( + Schema.isMaxLength(128).annotate({ expected: "a value with a length of at most 128" }), + ), + ), + database_identifier: Schema.optionalKey( + Schema.String.annotate({ + description: + "Identifier of the read replica this PrivateLink share should target. Omit to target the primary database.", + }), + ), + }), + }), +}); +export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("private_link_association").annotate({ description: "Resource type." }), + id: Schema.String, + attributes: Schema.Struct({ + aws_account_id: Schema.String.annotate({ + description: "The AWS account ID this PrivateLink share is associated with.", + }) + .check( + Schema.isMinLength(12).annotate({ expected: "a value with a length of at least 12" }), + ) + .check(Schema.isMaxLength(12).annotate({ expected: "a value with a length of at most 12" })) + .check( + Schema.isPattern(new RegExp("^\\d{12}$")).annotate({ + expected: "a string matching the RegExp ^\\d{12}$", + }), + ), + account_name: Schema.optionalKey( + Schema.String.annotate({ description: "Human-readable name for the AWS account." }), + ), + status: Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + shared_at: Schema.Union([ + Schema.String.annotate({ + description: + "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + format: "date-time", + }), + Schema.Null, + ]), + database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + database_identifier: Schema.String.annotate({ + description: + "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", + }), + }), + }), +}); +export const V2DeleteLogDrainInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2DeleteOrganizationInvitationsInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_invitation").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + email: Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + }), + }), + ) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })) + .check(Schema.isMaxLength(100).annotate({ expected: "a value with a length of at most 100" })), +}); +export const V2DeleteOrganizationInvitationsOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_invitation").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + email: Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + }), + }), + ), +}); +export const V2DeletePrivateLinkAssociationInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + aws_account_id: Schema.String, +}); +export const V2DeletePrivateLinkAssociationForDatabaseInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + aws_account_id: Schema.String, + database_identifier: Schema.String, +}); +export const V2GetProjectConfigInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), +}); +export const V2GetProjectConfigOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("project_config").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ description: "Project ref." }), + attributes: Schema.Struct({ + database: Schema.Struct({ + ssl_enforced: Schema.Boolean.annotate({ + description: "Whether the database rejects plaintext connections", + }), + network_restrictions: Schema.Struct({ + entitlement: Schema.Literals(["disallowed", "allowed"]), + status: Schema.Literals(["stored", "applied"]).annotate({ + description: "Whether the allowlist below is applied to the project or only stored.", + }), + allowed_cidrs: Schema.Array( + Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + ), + updated_at: Schema.optionalKey(Schema.String), + applied_at: Schema.optionalKey(Schema.String), + }), + postgres_settings: Schema.Struct({ + effective_cache_size: Schema.optionalKey(Schema.String), + logical_decoding_work_mem: Schema.optionalKey(Schema.String), + log_autovacuum_min_duration: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( + { + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }, + ), + ), + ), + log_checkpoints: Schema.optionalKey(Schema.Boolean), + log_connections: Schema.optionalKey(Schema.Boolean), + log_disconnections: Schema.optionalKey(Schema.Boolean), + log_duration: Schema.optionalKey(Schema.Boolean), + log_lock_waits: Schema.optionalKey(Schema.Boolean), + log_recovery_conflict_waits: Schema.optionalKey(Schema.Boolean), + log_replication_commands: Schema.optionalKey(Schema.Boolean), + log_startup_progress_interval: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( + { + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }, + ), + ), + ), + log_temp_files: Schema.optionalKey(Schema.String), + maintenance_work_mem: Schema.optionalKey(Schema.String), + track_activity_query_size: Schema.optionalKey(Schema.String), + max_connections: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(1).annotate({ + expected: "a value greater than or equal to 1", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_locks_per_transaction: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(10).annotate({ + expected: "a value greater than or equal to 10", + }), + ) + .check( + Schema.isLessThanOrEqualTo(2147483640).annotate({ + expected: "a value less than or equal to 2147483640", + }), + ), + ), + max_logical_replication_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_parallel_maintenance_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_parallel_workers: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_parallel_workers_per_gather: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(1024).annotate({ + expected: "a value less than or equal to 1024", + }), + ), + ), + max_replication_slots: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_slot_wal_keep_size: Schema.optionalKey(Schema.String), + max_standby_archive_delay: Schema.optionalKey(Schema.String), + max_standby_streaming_delay: Schema.optionalKey(Schema.String), + max_sync_workers_per_subscription: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + max_wal_size: Schema.optionalKey(Schema.String), + max_wal_senders: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + ), + max_worker_processes: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(262143).annotate({ + expected: "a value less than or equal to 262143", + }), + ), + ), + session_replication_role: Schema.optionalKey( + Schema.Literals(["origin", "replica", "local"]), + ), + shared_buffers: Schema.optionalKey(Schema.String), + statement_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( + { + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }, + ), + ), + ), + track_commit_timestamp: Schema.optionalKey(Schema.Boolean), + wal_keep_size: Schema.optionalKey(Schema.String), + wal_sender_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: ms" }).check( + Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( + { + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }, + ), + ), + ), + work_mem: Schema.optionalKey(Schema.String), + checkpoint_timeout: Schema.optionalKey( + Schema.String.annotate({ description: "Default unit: s" }).check( + Schema.isPattern(new RegExp("^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$")).annotate( + { + expected: + "a string matching the RegExp ^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + }, + ), + ), + ), + hot_standby_feedback: Schema.optionalKey(Schema.Boolean), + cron_log_statement: Schema.optionalKey(Schema.Boolean), + }).annotate({ + description: + "Postgres parameter overrides. Empty when the project runs entirely on defaults.", + }), + }), + pooler: Schema.Struct({ + pool_mode: Schema.Literals(["transaction", "session", "statement"]), + ignore_startup_parameters: Schema.String, + server_idle_timeout: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + server_lifetime: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + query_wait_timeout: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + reserve_pool_size: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + default_pool_size: Schema.Number.annotate({ + description: + "Defaults to the pooler's size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_client_conn: Schema.Number.annotate({ + description: + "Defaults to the pooler's size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + }), + auth: Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate( + { + description: + "Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext.", + }, + ), + api: Schema.Struct({ + db_schema: Schema.String.annotate({ description: "Schemas exposed through the Data API" }), + db_extra_search_path: Schema.String, + max_rows: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + db_pool_acquisition_timeout: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + db_pool: Schema.Union([ + Schema.Number.annotate({ + description: + "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + Schema.Null, + ]), + }), + realtime: Schema.Struct({ + private_only: Schema.Boolean, + max_concurrent_users: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_events_per_second: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_bytes_per_second: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_channels_per_client: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_joins_per_second: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_presence_events_per_second: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_payload_size_in_kb: Schema.Number.check( + Schema.isInt().annotate({ expected: "an integer" }), + ) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + presence_enabled: Schema.Boolean, + suspend: Schema.Boolean, + connection_pool: Schema.Number.annotate({ + description: + "Defaults to Realtime's pool size for the project's compute when not overridden.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + postgres_changes_pool: Schema.Union([ + Schema.Number.annotate({ + description: "If `null`, no override is stored and Realtime applies its own default.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + Schema.Null, + ]), + }), + storage: Schema.Struct({ + file_size_limit: Schema.Number.annotate({ format: "int64" }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + features: Schema.Struct({ + image_transformation: Schema.Struct({ enabled: Schema.Boolean }), + s3_protocol: Schema.Struct({ enabled: Schema.Boolean }), + purge_cache: Schema.Struct({ enabled: Schema.Boolean }), + iceberg_catalog: Schema.Struct({ + enabled: Schema.Boolean, + max_namespaces: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_tables: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_catalogs: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + }), + vector_buckets: Schema.Struct({ + enabled: Schema.Boolean, + max_buckets: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + max_indexes: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + }), + }), + capabilities: Schema.Struct({ list_v2: Schema.Boolean, iceberg_catalog: Schema.Boolean }), + upstream_target: Schema.Literals(["main", "canary"]), + migration_version: Schema.String, + database_pool_mode: Schema.String, + }).annotate({ + description: + "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config.", + }), + }), + }), +}); +export const V2ListLogDrainsInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), +}); +export const V2ListLogDrainsOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("log_drain").annotate({ description: "Resource type." }), + id: Schema.String, + attributes: Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + config: Schema.Union([ + Schema.Struct({ + url: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + schema: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + port: Schema.optionalKey( + Schema.Union([ + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Null, + ]), + ), + hostname: Schema.optionalKey(Schema.String), + }).annotate({ title: "postgres" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + gzip: Schema.optionalKey(Schema.Boolean), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "webhook" }), + Schema.Struct({ + project_id: Schema.optionalKey(Schema.String), + dataset_id: Schema.optionalKey(Schema.String), + }).annotate({ title: "bigquery" }), + Schema.Struct({ + api_key: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), + }).annotate({ title: "datadog" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "loki" }), + Schema.Struct({ dsn: Schema.optionalKey(Schema.String) }).annotate({ title: "sentry" }), + Schema.Struct({ + domain: Schema.optionalKey(Schema.String), + api_token: Schema.optionalKey(Schema.String), + dataset_name: Schema.optionalKey(Schema.String), + }).annotate({ title: "axiom" }), + Schema.Struct({ + host: Schema.optionalKey(Schema.String), + port: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(65535).annotate({ + expected: "a value less than or equal to 65535", + }), + ), + ), + tls: Schema.optionalKey(Schema.Boolean), + structured_data: Schema.optionalKey(Schema.String), + cipher_key: Schema.optionalKey(Schema.String), + ca_cert: Schema.optionalKey(Schema.String), + client_cert: Schema.optionalKey(Schema.String), + client_key: Schema.optionalKey(Schema.String), + }).annotate({ title: "syslog" }), + ]), + backend_type: Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + }), + }), + ), +}); +export const V2ListOrganizationGithubConnectionsInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + page: Schema.optionalKey( + Schema.Struct({ + size: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(1).annotate({ + expected: "a value greater than or equal to 1", + }), + ) + .check( + Schema.isLessThanOrEqualTo(100).annotate({ + expected: "a value less than or equal to 100", + }), + ), + ), + after: Schema.optionalKey( + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check( + Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + ), + before: Schema.optionalKey( + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check( + Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + ), + }), + ), + filter: Schema.optionalKey( + Schema.Struct({ + project_ref: Schema.optionalKey( + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check( + Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + ), + }), + ), +}); +export const V2ListOrganizationGithubConnectionsOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("github_connection").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ description: "Connection id." }), + attributes: Schema.Struct({ + inserted_at: Schema.String.annotate({ description: "When the connection was created" }), + updated_at: Schema.String.annotate({ description: "When the connection was last updated" }), + installation_id: Schema.Number.annotate({ + description: "GitHub App installation id", + }).check(Schema.isFinite().annotate({ expected: "a finite number" })), + workdir: Schema.String.annotate({ + description: "Directory within the repository the project lives in", + }), + supabase_changes_only: Schema.Boolean.annotate({ + description: "Whether branches are only created for changes under `supabase/`", + }), + branch_limit: Schema.Number.annotate({ + description: "Maximum number of preview branches", + }).check(Schema.isFinite().annotate({ expected: "a finite number" })), + new_branch_per_pr: Schema.Boolean.annotate({ + description: "Whether a preview branch is created for every pull request", + }), + project: Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + ref: Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check( + Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + name: Schema.String, + }).annotate({ description: "The connected Supabase project" }), + repository: Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + name: Schema.String, + }).annotate({ description: "The connected GitHub repository" }), + user: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + primary_email: Schema.Union([Schema.String, Schema.Null]), + }).annotate({ description: "The user who created the connection, if still known" }), + Schema.Null, + ]), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the first page if available." }), + Schema.Null, + ]), + ), + prev: Schema.Union([ + Schema.String.annotate({ description: "URL path to the previous page." }), + Schema.Null, + ]), + next: Schema.Union([ + Schema.String.annotate({ description: "URL path to the next page." }), + Schema.Null, + ]), + last: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the last page if available." }), + Schema.Null, + ]), + ), + }), +}); +export const V2ListOrganizationMembersInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + page: Schema.optionalKey( + Schema.Struct({ + size: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(1).annotate({ + expected: "a value greater than or equal to 1", + }), + ) + .check( + Schema.isLessThanOrEqualTo(100).annotate({ + expected: "a value less than or equal to 100", + }), + ), + ), + after: Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + before: Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + }), + ), + filter: Schema.optionalKey( + Schema.Struct({ + username: Schema.optionalKey(Schema.String), + primary_email: Schema.optionalKey( + Schema.String.annotate({ format: "email" }).check( + Schema.isPattern( + new RegExp( + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + }), + ), + ), + }), + ), +}); +export const V2ListOrganizationMembersOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_member").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", + }), + ), + attributes: Schema.Struct({ + username: Schema.Union([ + Schema.String.annotate({ description: "Member's username" }), + Schema.Null, + ]), + primary_email: Schema.Union([ + Schema.String.annotate({ description: "Member's primary email" }), + Schema.Null, + ]), + mfa_enabled: Schema.Boolean.annotate({ + description: "Whether Multi-Factor Authentication is enabled for this member", + }), + is_sso_user: Schema.Boolean.annotate({ + description: "Whether this member is a Single Sign-On user", + }), + avatar_url: Schema.Union([ + Schema.String.annotate({ description: "Member's avatar URL" }), + Schema.Null, + ]), + roles: Schema.Array( + Schema.Struct({ + name: Schema.String.annotate({ + description: "Role name. For project-scoped roles this is the base role name.", + }), + scope: Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + projects: Schema.Array( + Schema.Struct({ ref: Schema.String, name: Schema.String }), + ).annotate({ + description: "Project refs this role is scoped to. Empty array for org-level roles.", + }), + }), + ).annotate({ + description: + "Roles assigned to this member. Includes both org-level and project-scoped roles.", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the first page if available." }), + Schema.Null, + ]), + ), + prev: Schema.Union([ + Schema.String.annotate({ description: "URL path to the previous page." }), + Schema.Null, + ]), + next: Schema.Union([ + Schema.String.annotate({ description: "URL path to the next page." }), + Schema.Null, + ]), + last: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the last page if available." }), + Schema.Null, + ]), + ), + }), +}); +export const V2ListOrganizationProjectsInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + page: Schema.optionalKey( + Schema.Struct({ + size: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(1).annotate({ + expected: "a value greater than or equal to 1", + }), + ) + .check( + Schema.isLessThanOrEqualTo(100).annotate({ + expected: "a value less than or equal to 100", + }), + ), + ), + after: Schema.optionalKey( + Schema.String.check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + before: Schema.optionalKey( + Schema.String.check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + }), + ), + sort: Schema.optionalKey(Schema.Literals(["inserted_at", "-inserted_at"])), + search: Schema.optionalKey( + Schema.String.check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), +}); +export const V2ListOrganizationProjectsOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("project").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + attributes: Schema.Struct({ + name: Schema.String.annotate({ description: "Project name" }), + status: Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]).annotate({ description: "Project status" }), + cloud_provider: Schema.String.annotate({ + description: "Cloud provider hosting the project", + }), + region: Schema.String.annotate({ description: "Region the project is hosted in" }), + inserted_at: Schema.String.annotate({ description: "When the project was created" }), + databases: Schema.Array( + Schema.Struct({ + cloud_provider: Schema.String, + identifier: Schema.String, + region: Schema.Union([Schema.String, Schema.Null]), + status: Schema.Literals([ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING", + ]), + type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + infra_compute_size: Schema.optionalKey( + Schema.Literals([ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory", + ]), + ), + disk_volume_size_gb: Schema.optionalKey( + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + ), + disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), + disk_throughput_mbps: Schema.optionalKey( + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + ), + disk_last_modified_at: Schema.optionalKey(Schema.String), + }), + ).annotate({ + description: "The project's databases including compute and disk attributes.", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the first page if available." }), + Schema.Null, + ]), + ), + prev: Schema.Union([ + Schema.String.annotate({ description: "URL path to the previous page." }), + Schema.Null, + ]), + next: Schema.Union([ + Schema.String.annotate({ description: "URL path to the next page." }), + Schema.Null, + ]), + last: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ description: "URL path to the last page if available." }), + Schema.Null, + ]), + ), + }), +}); +export const V2ListOrganizationRolesInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), +}); +export const V2ListOrganizationRolesOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("organization_role").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ name: Schema.String.annotate({ description: "Role name." }) }), + }), + ), +}); +export const V2ListPrivateLinkAssociationsInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), +}); +export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("private_link_association").annotate({ description: "Resource type." }), + id: Schema.String, + attributes: Schema.Struct({ + aws_account_id: Schema.String.annotate({ + description: "The AWS account ID this PrivateLink share is associated with.", + }) + .check( + Schema.isMinLength(12).annotate({ expected: "a value with a length of at least 12" }), + ) + .check( + Schema.isMaxLength(12).annotate({ expected: "a value with a length of at most 12" }), + ) + .check( + Schema.isPattern(new RegExp("^\\d{12}$")).annotate({ + expected: "a string matching the RegExp ^\\d{12}$", + }), + ), + account_name: Schema.optionalKey( + Schema.String.annotate({ description: "Human-readable name for the AWS account." }), + ), + status: Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + shared_at: Schema.Union([ + Schema.String.annotate({ + description: + "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + format: "date-time", + }), + Schema.Null, + ]), + database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + database_identifier: Schema.String.annotate({ + description: + "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", + }), + }), + }), + ), +}); +export const V2PreviewAProjectTransferInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ target_organization_slug: Schema.String }), + }), +}); +export const V2PreviewAProjectTransferOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("project_transfer_result").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + valid: Schema.Boolean, + warnings: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + errors: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + info: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + }), + }), +}); +export const V2TransferAProjectInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ target_organization_slug: Schema.String }), + }), +}); +export const V2UpdateLogDrainInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), + ) + .check(Schema.isMaxLength(20).annotate({ expected: "a value with a length of at most 20" })) + .check( + Schema.isPattern(new RegExp("^[a-z]+$")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("log_drain").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + name: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + config: Schema.optionalKey( + Schema.Union([ + Schema.Struct({ + url: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + schema: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + port: Schema.optionalKey( + Schema.Union([ + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Null, + ]), + ), + hostname: Schema.optionalKey(Schema.String), + }).annotate({ title: "postgres" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + gzip: Schema.optionalKey(Schema.Boolean), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "webhook" }), + Schema.Struct({ + project_id: Schema.optionalKey(Schema.String), + dataset_id: Schema.optionalKey(Schema.String), + }).annotate({ title: "bigquery" }), + Schema.Struct({ + api_key: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), + }).annotate({ title: "datadog" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "loki" }), + Schema.Struct({ dsn: Schema.optionalKey(Schema.String) }).annotate({ title: "sentry" }), + Schema.Struct({ + domain: Schema.optionalKey(Schema.String), + api_token: Schema.optionalKey(Schema.String), + dataset_name: Schema.optionalKey(Schema.String), + }).annotate({ title: "axiom" }), + Schema.Struct({ + host: Schema.optionalKey(Schema.String), + port: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(65535).annotate({ + expected: "a value less than or equal to 65535", + }), + ), + ), + tls: Schema.optionalKey(Schema.Boolean), + structured_data: Schema.optionalKey(Schema.String), + cipher_key: Schema.optionalKey(Schema.String), + ca_cert: Schema.optionalKey(Schema.String), + client_cert: Schema.optionalKey(Schema.String), + client_key: Schema.optionalKey(Schema.String), + }).annotate({ title: "syslog" }), + ]), + ), + backend_type: Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + }), + }), +}); +export const V2UpdateLogDrainOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("log_drain").annotate({ description: "Resource type." }), + id: Schema.String, + attributes: Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + config: Schema.Union([ + Schema.Struct({ + url: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + schema: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + port: Schema.optionalKey( + Schema.Union([ + Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Null, + ]), + ), + hostname: Schema.optionalKey(Schema.String), + }).annotate({ title: "postgres" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + gzip: Schema.optionalKey(Schema.Boolean), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "webhook" }), + Schema.Struct({ + project_id: Schema.optionalKey(Schema.String), + dataset_id: Schema.optionalKey(Schema.String), + }).annotate({ title: "bigquery" }), + Schema.Struct({ + api_key: Schema.optionalKey(Schema.String), + region: Schema.optionalKey(Schema.String), + }).annotate({ title: "datadog" }), + Schema.Struct({ + url: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }).annotate({ title: "loki" }), + Schema.Struct({ dsn: Schema.optionalKey(Schema.String) }).annotate({ title: "sentry" }), + Schema.Struct({ + domain: Schema.optionalKey(Schema.String), + api_token: Schema.optionalKey(Schema.String), + dataset_name: Schema.optionalKey(Schema.String), + }).annotate({ title: "axiom" }), + Schema.Struct({ + host: Schema.optionalKey(Schema.String), + port: Schema.optionalKey( + Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ) + .check( + Schema.isLessThanOrEqualTo(65535).annotate({ + expected: "a value less than or equal to 65535", + }), + ), + ), + tls: Schema.optionalKey(Schema.Boolean), + structured_data: Schema.optionalKey(Schema.String), + cipher_key: Schema.optionalKey(Schema.String), + ca_cert: Schema.optionalKey(Schema.String), + client_cert: Schema.optionalKey(Schema.String), + client_key: Schema.optionalKey(Schema.String), + }).annotate({ title: "syslog" }), + ]), + backend_type: Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + }), + }), +}); export const V1ApplyAMigrationOutput = Schema.Void; export const V1ApplyProjectAddonOutput = Schema.Void; export const V1AuthorizeUserOutput = Schema.Void; @@ -10027,6 +12026,10 @@ export const V1UndoOutput = Schema.Void; export const V1UpdateRealtimeConfigOutput = Schema.Void; export const V1UpdateStorageConfigOutput = Schema.Void; export const V1UpsertAMigrationOutput = Schema.Void; +export const V2DeleteLogDrainOutput = Schema.Void; +export const V2DeletePrivateLinkAssociationOutput = Schema.Void; +export const V2DeletePrivateLinkAssociationForDatabaseOutput = Schema.Void; +export const V2TransferAProjectOutput = Schema.Void; export const openApiOperationIdMap = { "v1-accept-invite-external-jit-access": "v1AcceptInviteExternalJitAccess", @@ -10199,6 +12202,24 @@ export const openApiOperationIdMap = { "v1-upgrade-postgres-version": "v1UpgradePostgresVersion", "v1-upsert-a-migration": "v1UpsertAMigration", "v1-verify-dns-config": "v1VerifyDnsConfig", + "v2-assign-organization-member-role": "v2AssignOrganizationMemberRole", + "v2-create-log-drain": "v2CreateLogDrain", + "v2-create-organization-invitations": "v2CreateOrganizationInvitations", + "v2-create-private-link-association": "v2CreatePrivateLinkAssociation", + "v2-delete-log-drain": "v2DeleteLogDrain", + "v2-delete-organization-invitations": "v2DeleteOrganizationInvitations", + "v2-delete-private-link-association": "v2DeletePrivateLinkAssociation", + "v2-delete-private-link-association-for-database": "v2DeletePrivateLinkAssociationForDatabase", + "v2-get-project-config": "v2GetProjectConfig", + "v2-list-log-drains": "v2ListLogDrains", + "v2-list-organization-github-connections": "v2ListOrganizationGithubConnections", + "v2-list-organization-members": "v2ListOrganizationMembers", + "v2-list-organization-projects": "v2ListOrganizationProjects", + "v2-list-organization-roles": "v2ListOrganizationRoles", + "v2-list-private-link-associations": "v2ListPrivateLinkAssociations", + "v2-preview-a-project-transfer": "v2PreviewAProjectTransfer", + "v2-transfer-a-project": "v2TransferAProject", + "v2-update-log-drain": "v2UpdateLogDrain", } as const; export const operationDefinitions = { @@ -12910,6 +14931,250 @@ export const operationDefinitions = { inputSchema: V1VerifyDnsConfigInput, outputSchema: V1VerifyDnsConfigOutput, }, + v2AssignOrganizationMemberRole: { + id: "v2AssignOrganizationMemberRole", + description: + "Assigns an org-wide role when projects is omitted, or creates a project-scoped assignment when projects is provided. Uses an org-level role template id from GET /v2/organizations/{slug}/roles. Stale role assignments are automatically cleaned up: if a role no longer has any projects, it is deleted; overlapping project assignments in other roles are automatically removed to avoid duplication.", + method: "PATCH", + path: "/v2/organizations/{slug}/members/{user_id}/roles", + pathParams: ["slug", "user_id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2AssignOrganizationMemberRoleInput, + outputSchema: V2AssignOrganizationMemberRoleOutput, + }, + v2CreateLogDrain: { + id: "v2CreateLogDrain", + description: "Create a log drain for a project", + method: "POST", + path: "/v2/projects/{ref}/analytics/log-drains", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2CreateLogDrainInput, + outputSchema: V2CreateLogDrainOutput, + }, + v2CreateOrganizationInvitations: { + id: "v2CreateOrganizationInvitations", + description: + "Creates member invitations for an organization. Each invitation can have different role and project scope settings.", + method: "POST", + path: "/v2/organizations/{slug}/members/invitations", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2CreateOrganizationInvitationsInput, + outputSchema: V2CreateOrganizationInvitationsOutput, + }, + v2CreatePrivateLinkAssociation: { + id: "v2CreatePrivateLinkAssociation", + description: + "Adds an AWS account to the project's PrivateLink configuration and schedules the AWS resources to be created.", + method: "POST", + path: "/v2/projects/{ref}/private-link/associations", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2CreatePrivateLinkAssociationInput, + outputSchema: V2CreatePrivateLinkAssociationOutput, + }, + v2DeleteLogDrain: { + id: "v2DeleteLogDrain", + description: "Delete a project log drain", + method: "DELETE", + path: "/v2/projects/{ref}/analytics/log-drains/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "void" }, + inputSchema: V2DeleteLogDrainInput, + outputSchema: V2DeleteLogDrainOutput, + }, + v2DeleteOrganizationInvitations: { + id: "v2DeleteOrganizationInvitations", + description: "Bulk delete member invitations for an organization by email address.", + method: "DELETE", + path: "/v2/organizations/{slug}/members/invitations", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2DeleteOrganizationInvitationsInput, + outputSchema: V2DeleteOrganizationInvitationsOutput, + }, + v2DeletePrivateLinkAssociation: { + id: "v2DeletePrivateLinkAssociation", + description: + "Removes an AWS account from the project's PrivateLink configuration (targeting the primary database). Cleans up the associated AWS resources.", + method: "DELETE", + path: "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}", + pathParams: ["ref", "aws_account_id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "void" }, + inputSchema: V2DeletePrivateLinkAssociationInput, + outputSchema: V2DeletePrivateLinkAssociationOutput, + }, + v2DeletePrivateLinkAssociationForDatabase: { + id: "v2DeletePrivateLinkAssociationForDatabase", + description: + "Removes an AWS account from the project's PrivateLink configuration for the given read replica. Cleans up the associated AWS resources.", + method: "DELETE", + path: "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}/database/{database_identifier}", + pathParams: ["ref", "aws_account_id", "database_identifier"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "void" }, + inputSchema: V2DeletePrivateLinkAssociationForDatabaseInput, + outputSchema: V2DeletePrivateLinkAssociationForDatabaseOutput, + }, + v2GetProjectConfig: { + id: "v2GetProjectConfig", + description: + "Returns the project's database, pooler, Auth, Data API, Realtime and Storage configuration — the same configuration a branch inherits from its base project. Each is the effective config, so a setting the project has never overridden is reported at its platform default rather than as null. Auth secrets are returned as an HMAC of their value. `storage` is read live from the storage service; the rest come from this platform's own records.", + method: "GET", + path: "/v2/projects/{ref}/config", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2GetProjectConfigInput, + outputSchema: V2GetProjectConfigOutput, + }, + v2ListLogDrains: { + id: "v2ListLogDrains", + description: "List project log drains", + method: "GET", + path: "/v2/projects/{ref}/analytics/log-drains", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListLogDrainsInput, + outputSchema: V2ListLogDrainsOutput, + }, + v2ListOrganizationGithubConnections: { + id: "v2ListOrganizationGithubConnections", + description: + "Returns a cursor-paginated list of the GitHub connections of the organization's projects.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the page size.\nPaging walks the organization projects, so a page holds at most `page[size]` connections and can hold fewer (or none) when some of its projects are not connected.\nFollow `links.next` until it is `null` rather than stopping on a short page.\n\nUse `filter[project_ref]` to narrow the list down to a single project.", + method: "GET", + path: "/v2/organizations/{slug}/integrations/github/connections", + pathParams: ["slug"], + queryParams: ["page", "filter"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListOrganizationGithubConnectionsInput, + outputSchema: V2ListOrganizationGithubConnectionsOutput, + }, + v2ListOrganizationMembers: { + id: "v2ListOrganizationMembers", + description: + "Returns a cursor-paginated list of organization members including their roles and project-scoped permissions.", + method: "GET", + path: "/v2/organizations/{slug}/members", + pathParams: ["slug"], + queryParams: ["page", "filter"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListOrganizationMembersInput, + outputSchema: V2ListOrganizationMembersOutput, + }, + v2ListOrganizationProjects: { + id: "v2ListOrganizationProjects", + description: + "Returns a cursor-paginated list of projects for the specified organization, including their databases.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the number of projects returned per page.", + method: "GET", + path: "/v2/organizations/{slug}/projects", + pathParams: ["slug"], + queryParams: ["page", "sort", "search"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListOrganizationProjectsInput, + outputSchema: V2ListOrganizationProjectsOutput, + }, + v2ListOrganizationRoles: { + id: "v2ListOrganizationRoles", + description: "Returns a list of org-level roles for the organization.", + method: "GET", + path: "/v2/organizations/{slug}/roles", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListOrganizationRolesInput, + outputSchema: V2ListOrganizationRolesOutput, + }, + v2ListPrivateLinkAssociations: { + id: "v2ListPrivateLinkAssociations", + description: "List AWS accounts attached to the project PrivateLink share", + method: "GET", + path: "/v2/projects/{ref}/private-link/associations", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ListPrivateLinkAssociationsInput, + outputSchema: V2ListPrivateLinkAssociationsOutput, + }, + v2PreviewAProjectTransfer: { + id: "v2PreviewAProjectTransfer", + description: + "Previews transferring a project to a different organizations, shows eligibility and impact", + method: "POST", + path: "/v2/projects/{ref}/transfers/previews", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2PreviewAProjectTransferInput, + outputSchema: V2PreviewAProjectTransferOutput, + }, + v2TransferAProject: { + id: "v2TransferAProject", + description: "Transfers a project to a different organization", + method: "POST", + path: "/v2/projects/{ref}/transfers", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "void" }, + inputSchema: V2TransferAProjectInput, + outputSchema: V2TransferAProjectOutput, + }, + v2UpdateLogDrain: { + id: "v2UpdateLogDrain", + description: "Update a project log drain", + method: "PUT", + path: "/v2/projects/{ref}/analytics/log-drains/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2UpdateLogDrainInput, + outputSchema: V2UpdateLogDrainOutput, + }, } as const; export type OpenApiOperationId = keyof typeof openApiOperationIdMap; diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index f8651d8acf..1d7dbd2e14 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -2340,6 +2340,260 @@ export const versionedEffectOperations = { ); }), }, + v2: { + assignOrganizationMemberRole: ( + input: typeof operationDefinitions.v2AssignOrganizationMemberRole.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2AssignOrganizationMemberRole.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2AssignOrganizationMemberRole">( + operationDefinitions.v2AssignOrganizationMemberRole, + input, + ); + }), + createLogDrain: ( + input: typeof operationDefinitions.v2CreateLogDrain.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2CreateLogDrain.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2CreateLogDrain">( + operationDefinitions.v2CreateLogDrain, + input, + ); + }), + createOrganizationInvitations: ( + input: typeof operationDefinitions.v2CreateOrganizationInvitations.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2CreateOrganizationInvitations.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2CreateOrganizationInvitations">( + operationDefinitions.v2CreateOrganizationInvitations, + input, + ); + }), + createPrivateLinkAssociation: ( + input: typeof operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2CreatePrivateLinkAssociation.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2CreatePrivateLinkAssociation">( + operationDefinitions.v2CreatePrivateLinkAssociation, + input, + ); + }), + deleteLogDrain: ( + input: typeof operationDefinitions.v2DeleteLogDrain.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2DeleteLogDrain.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2DeleteLogDrain">( + operationDefinitions.v2DeleteLogDrain, + input, + ); + }), + deleteOrganizationInvitations: ( + input: typeof operationDefinitions.v2DeleteOrganizationInvitations.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2DeleteOrganizationInvitations.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2DeleteOrganizationInvitations">( + operationDefinitions.v2DeleteOrganizationInvitations, + input, + ); + }), + deletePrivateLinkAssociation: ( + input: typeof operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2DeletePrivateLinkAssociation.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2DeletePrivateLinkAssociation">( + operationDefinitions.v2DeletePrivateLinkAssociation, + input, + ); + }), + deletePrivateLinkAssociationForDatabase: ( + input: typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2DeletePrivateLinkAssociationForDatabase">( + operationDefinitions.v2DeletePrivateLinkAssociationForDatabase, + input, + ); + }), + getProjectConfig: ( + input: typeof operationDefinitions.v2GetProjectConfig.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2GetProjectConfig.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2GetProjectConfig">( + operationDefinitions.v2GetProjectConfig, + input, + ); + }), + listLogDrains: ( + input: typeof operationDefinitions.v2ListLogDrains.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListLogDrains.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListLogDrains">( + operationDefinitions.v2ListLogDrains, + input, + ); + }), + listOrganizationGithubConnections: ( + input: typeof operationDefinitions.v2ListOrganizationGithubConnections.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListOrganizationGithubConnections.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListOrganizationGithubConnections">( + operationDefinitions.v2ListOrganizationGithubConnections, + input, + ); + }), + listOrganizationMembers: ( + input: typeof operationDefinitions.v2ListOrganizationMembers.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListOrganizationMembers.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListOrganizationMembers">( + operationDefinitions.v2ListOrganizationMembers, + input, + ); + }), + listOrganizationProjects: ( + input: typeof operationDefinitions.v2ListOrganizationProjects.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListOrganizationProjects.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListOrganizationProjects">( + operationDefinitions.v2ListOrganizationProjects, + input, + ); + }), + listOrganizationRoles: ( + input: typeof operationDefinitions.v2ListOrganizationRoles.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListOrganizationRoles.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListOrganizationRoles">( + operationDefinitions.v2ListOrganizationRoles, + input, + ); + }), + listPrivateLinkAssociations: ( + input: typeof operationDefinitions.v2ListPrivateLinkAssociations.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2ListPrivateLinkAssociations.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ListPrivateLinkAssociations">( + operationDefinitions.v2ListPrivateLinkAssociations, + input, + ); + }), + previewAProjectTransfer: ( + input: typeof operationDefinitions.v2PreviewAProjectTransfer.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2PreviewAProjectTransfer.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2PreviewAProjectTransfer">( + operationDefinitions.v2PreviewAProjectTransfer, + input, + ); + }), + transferAProject: ( + input: typeof operationDefinitions.v2TransferAProject.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2TransferAProject.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2TransferAProject">( + operationDefinitions.v2TransferAProject, + input, + ); + }), + updateLogDrain: ( + input: typeof operationDefinitions.v2UpdateLogDrain.inputSchema.Type, + ): Effect.Effect< + typeof operationDefinitions.v2UpdateLogDrain.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2UpdateLogDrain">( + operationDefinitions.v2UpdateLogDrain, + input, + ); + }), + }, } as const; export type GeneratedEffectOperations = typeof versionedEffectOperations; @@ -3031,5 +3285,79 @@ export function executeApiClientOperation( return Schema.decodeUnknownEffect(operationDefinitions.v1VerifyDnsConfig.inputSchema)( input, ).pipe(Effect.flatMap((decoded) => api.v1.verifyDnsConfig(decoded))); + case "v2AssignOrganizationMemberRole": + return Schema.decodeUnknownEffect( + operationDefinitions.v2AssignOrganizationMemberRole.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.assignOrganizationMemberRole(decoded))); + case "v2CreateLogDrain": + return Schema.decodeUnknownEffect(operationDefinitions.v2CreateLogDrain.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.createLogDrain(decoded))); + case "v2CreateOrganizationInvitations": + return Schema.decodeUnknownEffect( + operationDefinitions.v2CreateOrganizationInvitations.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.createOrganizationInvitations(decoded))); + case "v2CreatePrivateLinkAssociation": + return Schema.decodeUnknownEffect( + operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.createPrivateLinkAssociation(decoded))); + case "v2DeleteLogDrain": + return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteLogDrain.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.deleteLogDrain(decoded))); + case "v2DeleteOrganizationInvitations": + return Schema.decodeUnknownEffect( + operationDefinitions.v2DeleteOrganizationInvitations.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.deleteOrganizationInvitations(decoded))); + case "v2DeletePrivateLinkAssociation": + return Schema.decodeUnknownEffect( + operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociation(decoded))); + case "v2DeletePrivateLinkAssociationForDatabase": + return Schema.decodeUnknownEffect( + operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema, + )(input).pipe( + Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociationForDatabase(decoded)), + ); + case "v2GetProjectConfig": + return Schema.decodeUnknownEffect(operationDefinitions.v2GetProjectConfig.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.getProjectConfig(decoded))); + case "v2ListLogDrains": + return Schema.decodeUnknownEffect(operationDefinitions.v2ListLogDrains.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.listLogDrains(decoded))); + case "v2ListOrganizationGithubConnections": + return Schema.decodeUnknownEffect( + operationDefinitions.v2ListOrganizationGithubConnections.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationGithubConnections(decoded))); + case "v2ListOrganizationMembers": + return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationMembers.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationMembers(decoded))); + case "v2ListOrganizationProjects": + return Schema.decodeUnknownEffect( + operationDefinitions.v2ListOrganizationProjects.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationProjects(decoded))); + case "v2ListOrganizationRoles": + return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationRoles.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationRoles(decoded))); + case "v2ListPrivateLinkAssociations": + return Schema.decodeUnknownEffect( + operationDefinitions.v2ListPrivateLinkAssociations.inputSchema, + )(input).pipe(Effect.flatMap((decoded) => api.v2.listPrivateLinkAssociations(decoded))); + case "v2PreviewAProjectTransfer": + return Schema.decodeUnknownEffect(operationDefinitions.v2PreviewAProjectTransfer.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.previewAProjectTransfer(decoded))); + case "v2TransferAProject": + return Schema.decodeUnknownEffect(operationDefinitions.v2TransferAProject.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.transferAProject(decoded))); + case "v2UpdateLogDrain": + return Schema.decodeUnknownEffect(operationDefinitions.v2UpdateLogDrain.inputSchema)( + input, + ).pipe(Effect.flatMap((decoded) => api.v2.updateLogDrain(decoded))); } } diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 07f8909630..97c4816183 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "info": { - "title": "Supabase API (v1)", + "title": "Supabase API", "version": "1.0.0" }, "paths": { @@ -11241,3738 +11241,5066 @@ "x-fga-permissions": [["organization_projects_read"]], "x-oauth-scope": "projects:read" } - } - }, - "components": { - "schemas": { - "BranchDetailResponse": { - "type": "object", - "properties": { - "ref": { - "type": "string" + }, + "/v2/projects/{ref}/analytics/log-drains": { + "get": { + "operationId": "v2-list-log-drains", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListLogDrainsResponse" + } + } + } }, - "postgres_version": { - "type": "string" + "401": { + "description": "Unauthorized" }, - "postgres_engine": { - "type": "string" + "403": { + "description": "Forbidden action" }, - "release_channel": { - "type": "string" + "429": { + "description": "Rate limit exceeded" }, - "status": { - "type": "string", - "enum": [ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING" - ] + "500": { + "description": "Failed to fetch log drains" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List project log drains", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:read", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_read"]], + "x-oauth-scope": "analytics_config:read" + }, + "post": { + "operationId": "v2-create-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLogDrainRequestOpenApi" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogDrainResponse" + } + } + } }, - "db_host": { - "type": "string" + "401": { + "description": "Unauthorized" }, - "db_port": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 + "402": { + "description": "This feature requires the Pro, Team, or Enterprise organization plan.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanGateErrorBodyV2" + } + } + } }, - "db_user": { - "type": "string" + "403": { + "description": "Forbidden action" }, - "db_pass": { - "type": "string" + "429": { + "description": "Rate limit exceeded" }, - "jwt_secret": { - "type": "string" + "500": { + "description": "Failed to create a log drain" } }, - "required": [ - "ref", - "postgres_version", - "postgres_engine", - "release_channel", - "status", - "db_host", - "db_port" - ] - }, - "UpdateBranchBody": { - "type": "object", - "properties": { - "branch_name": { - "type": "string" + "security": [ + { + "bearer": [] + } + ], + "summary": "Create a log drain for a project", + "tags": ["Analytics"], + "x-allowed-plans": ["Pro", "Team", "Enterprise"], + "x-badges": [ + { + "name": "Only available on Pro, Team, Enterprise", + "position": "before" }, - "git_branch": { - "type": "string" + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/analytics/log-drains/{id}": { + "put": { + "operationId": "v2-update-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } }, - "reset_on_push": { - "description": "This field is deprecated and will be ignored. Use v1-reset-a-branch endpoint directly instead.", - "deprecated": true, - "type": "boolean" + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLogDrainRequestOpenApi" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogDrainResponse" + } + } + } }, - "persistent": { - "type": "boolean" + "401": { + "description": "Unauthorized" }, - "status": { - "type": "string", - "enum": [ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED" - ] + "403": { + "description": "Forbidden action" }, - "request_review": { - "type": "boolean" + "429": { + "description": "Rate limit exceeded" }, - "notify_url": { - "type": "string", - "format": "uri", - "description": "HTTP endpoint to receive branch status updates." + "500": { + "description": "Failed to update log drain" } }, - "example": { - "branch_name": "preview-login-page", - "git_branch": "feature/login-page", - "persistent": true, - "request_review": true, - "notify_url": "https://example.com/webhooks/branches" - } + "security": [ + { + "bearer": [] + } + ], + "summary": "Update a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" }, - "BranchResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "name": { - "type": "string" - }, - "project_ref": { - "type": "string" - }, - "parent_project_ref": { - "type": "string" - }, - "is_default": { - "type": "boolean" - }, - "git_branch": { - "type": "string" - }, - "pr_number": { - "type": "integer", - "format": "int32", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "latest_check_run_id": { - "description": "This field is deprecated and will not be populated.", - "deprecated": true, - "type": "number" - }, - "persistent": { - "type": "boolean" + "delete": { + "operationId": "v2-delete-log-drain", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } }, - "status": { - "type": "string", - "enum": [ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED" - ], - "description": "This field is deprecated. List action runs to get branch status instead.", - "deprecated": true + { + "name": "id", + "required": true, + "in": "path", + "description": "Log drains identifier", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" }, - "created_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "401": { + "description": "Unauthorized" }, - "updated_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "403": { + "description": "Forbidden action" }, - "review_requested_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "429": { + "description": "Rate limit exceeded" }, - "with_data": { - "type": "boolean" + "500": { + "description": "Failed to delete a log drain" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Delete a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/config": { + "get": { + "description": "Returns the project's database, pooler, Auth, Data API, Realtime and Storage configuration — the same configuration a branch inherits from its base project. Each is the effective config, so a setting the project has never overridden is reported at its platform default rather than as null. Auth secrets are returned as an HMAC of their value. `storage` is read live from the storage service; the rest come from this platform's own records.", + "operationId": "v2-get-project-config", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ProjectConfigResponse" + } + } + } }, - "notify_url": { - "type": "string", - "format": "uri" + "401": { + "description": "Unauthorized" }, - "deletion_scheduled_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "403": { + "description": "Forbidden action" }, - "preview_project_status": { - "type": "string", - "enum": [ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING" - ] + "429": { + "description": "Rate limit exceeded" } }, - "required": [ - "id", - "name", - "project_ref", - "parent_project_ref", - "is_default", - "persistent", - "status", - "created_at", - "updated_at", - "with_data" + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Get a project's service configuration", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api", "infra"], + "x-fga-permissions": [ + [ + "database_config_read", + "database_read", + "database_ssl_config_read", + "database_network_restrictions_read", + "auth_config_read", + "data_api_config_read", + "realtime_config_read", + "storage_config_read" + ] ] - }, - "BranchDeleteResponse": { - "type": "object", - "properties": { - "message": { - "type": "string", - "enum": ["ok"] + } + }, + "/v2/projects/{ref}/transfers/previews": { + "post": { + "operationId": "v2-preview-a-project-transfer", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } } - }, - "required": ["message"] - }, - "BranchActionBody": { - "type": "object", - "properties": { - "migration_version": { - "type": "string" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2TransferProjectBody" + } + } } }, - "example": { - "migration_version": "20250312000000" - } - }, - "BranchUpdateResponse": { - "type": "object", - "properties": { - "workflow_run_id": { - "type": "string" - }, - "message": { - "type": "string", - "enum": ["ok"] - } - }, - "required": ["workflow_run_id", "message"] - }, - "BranchRestoreResponse": { - "type": "object", - "properties": { - "message": { - "type": "string", - "enum": ["Branch restoration initiated"] + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2PreviewProjectTransferResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, - "required": ["message"] - }, - "V1ProjectWithDatabaseResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "deprecated": true, - "description": "Deprecated: Use `ref` instead." - }, - "ref": { - "type": "string", - "minLength": 20, - "maxLength": 20, - "pattern": "^[a-z]+$", + "security": [ + { + "bearer": [] + } + ], + "summary": "Previews transferring a project to a different organizations, shows eligibility and impact", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["project_admin_read"]] + } + }, + "/v2/projects/{ref}/transfers": { + "post": { + "operationId": "v2-transfer-a-project", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", "description": "Project ref", - "example": "abcdefghijklmnopqrst" + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2TransferProjectBody" + } + } + } + }, + "responses": { + "200": { + "description": "" }, - "organization_id": { - "type": "string", - "description": "Deprecated: Use `organization_slug` instead.", - "deprecated": true + "401": { + "description": "Unauthorized" }, - "organization_slug": { - "type": "string", - "pattern": "^[\\w-]+$", - "description": "Organization slug", - "example": "tsrqponmlkjihgfedcba" + "403": { + "description": "Forbidden action" }, - "name": { - "type": "string", - "description": "Name of your project" + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Transfers a project to a different organization", + "tags": ["Projects"], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["organization_admin_write"]] + } + }, + "/v2/projects/{ref}/private-link/associations": { + "get": { + "operationId": "v2-list-private-link-associations", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListPrivateLinkAssociationsResponse" + } + } + } }, - "region": { - "type": "string", - "description": "Region of your project" + "401": { + "description": "Unauthorized" }, - "created_at": { - "type": "string", - "description": "Creation timestamp" + "403": { + "description": "Forbidden action" }, - "status": { - "type": "string", - "enum": [ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING" - ] + "429": { + "description": "Rate limit exceeded" }, - "database": { - "type": "object", - "properties": { - "host": { - "type": "string", - "description": "Database host" - }, - "version": { - "type": "string", - "description": "Database version" - }, - "postgres_engine": { - "type": "string", - "description": "Database engine" - }, - "release_channel": { - "type": "string", - "description": "Release channel" - } - }, - "required": ["host", "version", "postgres_engine", "release_channel"] + "500": { + "description": "Failed to retrieve AWS accounts for project" } }, - "required": [ - "id", - "ref", - "organization_id", - "organization_slug", - "name", - "region", - "created_at", - "status", - "database" - ] + "security": [ + { + "bearer": [] + } + ], + "summary": "List AWS accounts attached to the project PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["platform-networking", "management-api"], + "x-fga-permissions": [["project_admin_read"]] }, - "V1CreateProjectBody": { - "type": "object", - "properties": { - "db_pass": { - "type": "string", - "description": "Database password" - }, - "name": { - "type": "string", - "maxLength": 256, - "description": "Name of your project" + "post": { + "description": "Adds an AWS account to the project's PrivateLink configuration and schedules the AWS resources to be created.", + "operationId": "v2-create-private-link-association", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreatePrivateLinkAssociationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2PrivateLinkAssociationResponse" + } + } + } }, - "organization_id": { - "deprecated": true, - "description": "Deprecated: Use `organization_slug` instead.", - "type": "string" + "401": { + "description": "Unauthorized" }, - "organization_slug": { - "type": "string", - "pattern": "^[\\w-]+$", - "description": "Organization slug", - "example": "tsrqponmlkjihgfedcba" - }, - "plan": { - "deprecated": true, - "description": "Subscription Plan is now set on organization level and is ignored in this request", - "type": "string", - "enum": ["free", "pro"] - }, - "region": { - "description": "Region you want your server to reside in. Use region_selection instead.", - "deprecated": true, - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-east-1", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1" - ], - "type": "string" - }, - "region_selection": { - "description": "Region selection. Only one of region or region_selection can be specified.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["specific"] - }, - "code": { - "type": "string", - "description": "Specific region code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint.", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-east-1", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1" - ] - } - }, - "required": ["type", "code"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["smartGroup"] - }, - "code": { - "type": "string", - "enum": ["americas", "emea", "apac"], - "description": "The Smart Region Group's code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint." - } - }, - "required": ["type", "code"] + "402": { + "description": "This feature requires the Team, or Enterprise organization plan.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanGateErrorBodyV2" + } } - ] - }, - "kps_enabled": { - "deprecated": true, - "description": "This field is deprecated and is ignored in this request", - "type": "boolean" - }, - "desired_instance_size": { - "description": "Desired instance size. Omit this field to always default to the smallest possible size.", - "type": "string", - "enum": [ - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory" - ] - }, - "template_url": { - "description": "Template URL used to create the project from the CLI.", - "type": "string", - "format": "uri" + } }, - "release_channel": { - "deprecated": true, - "type": "null" + "403": { + "description": "Forbidden action" }, - "postgres_engine": { - "deprecated": true, - "type": "null" + "429": { + "description": "Rate limit exceeded" }, - "high_availability": { - "description": "[Experimental] Whether to enable high availability for the project.", - "type": "boolean" + "500": { + "description": "Failed to add AWS account to PrivateLink share" } }, - "required": ["db_pass", "name", "organization_slug"], - "example": { - "db_pass": "correct-horse-battery-staple", - "name": "acme-prod", - "organization_slug": "tsrqponmlkjihgfedcba", - "region": "us-east-1" - }, - "additionalProperties": false - }, - "V1ProjectResponse": { - "type": "object", - "properties": { - "id": { - "type": "string", - "deprecated": true, - "description": "Deprecated: Use `ref` instead." - }, - "ref": { - "type": "string", - "minLength": 20, - "maxLength": 20, - "pattern": "^[a-z]+$", + "security": [ + { + "bearer": [] + } + ], + "summary": "Add an AWS account to the project PrivateLink share", + "tags": ["Projects"], + "x-allowed-plans": ["Team", "Enterprise"], + "x-badges": [ + { + "name": "Only available on Team, Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["platform-networking", "management-api"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}": { + "delete": { + "description": "Removes an AWS account from the project's PrivateLink configuration (targeting the primary database). Cleans up the associated AWS resources.", + "operationId": "v2-delete-private-link-association", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", "description": "Project ref", - "example": "abcdefghijklmnopqrst" - }, - "organization_id": { - "type": "string", - "description": "Deprecated: Use `organization_slug` instead.", - "deprecated": true + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } }, - "organization_slug": { - "type": "string", - "pattern": "^[\\w-]+$", - "description": "Organization slug", - "example": "tsrqponmlkjihgfedcba" + { + "name": "aws_account_id", + "required": true, + "in": "path", + "description": "AWS account ID used in PrivateLink association", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" }, - "name": { - "type": "string", - "description": "Name of your project" + "401": { + "description": "Unauthorized" }, - "region": { - "type": "string", - "description": "Region of your project" + "403": { + "description": "Forbidden action" }, - "created_at": { - "type": "string", - "description": "Creation timestamp" + "429": { + "description": "Rate limit exceeded" }, - "status": { - "type": "string", - "enum": [ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING" - ] + "500": { + "description": "Failed to remove AWS account from PrivateLink share" } }, - "required": [ - "id", - "ref", - "organization_id", - "organization_slug", - "name", - "region", - "created_at", - "status" - ] - }, - "RegionsInfo": { - "type": "object", - "properties": { - "recommendations": { - "type": "object", - "properties": { - "smartGroup": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "code": { - "type": "string", - "enum": ["americas", "emea", "apac"] - }, - "type": { - "type": "string", - "enum": ["smartGroup"] - } + "security": [ + { + "bearer": [] + } + ], + "summary": "Remove an AWS account from the project PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["platform-networking", "management-api"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}/database/{database_identifier}": { + "delete": { + "description": "Removes an AWS account from the project's PrivateLink configuration for the given read replica. Cleans up the associated AWS resources.", + "operationId": "v2-delete-private-link-association-for-database", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "aws_account_id", + "required": true, + "in": "path", + "description": "AWS account ID used in PrivateLink association", + "schema": { + "type": "string" + } + }, + { + "name": "database_identifier", + "required": true, + "in": "path", + "description": "Identifier of the read replica this PrivateLink association targets", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + }, + "500": { + "description": "Failed to remove AWS account from PrivateLink share" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Remove an AWS account from a specific database PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["platform-networking", "management-api"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/organizations/{slug}/members": { + "get": { + "description": "Returns a cursor-paginated list of organization members including their roles and project-scoped permissions.", + "operationId": "v2-list-organization-members", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "properties": { + "size": { + "type": "integer", + "minimum": 1, + "maximum": 100 }, - "required": ["name", "code", "type"] - }, - "specific": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "code": { - "type": "string", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1" - ] - }, - "type": { - "type": "string", - "enum": ["specific"] - }, - "provider": { - "type": "string", - "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS"] - }, - "status": { - "type": "string", - "enum": ["capacity", "other"] - } - }, - "required": ["name", "code", "type", "provider"] + "after": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "before": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" } - } + }, + "type": "object" }, - "required": ["smartGroup", "specific"] + "style": "deepObject" }, - "all": { - "type": "object", - "properties": { - "smartGroup": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "code": { - "type": "string", - "enum": ["americas", "emea", "apac"] - }, - "type": { - "type": "string", - "enum": ["smartGroup"] - } - }, - "required": ["name", "code", "type"] + { + "name": "filter", + "required": false, + "in": "query", + "schema": { + "properties": { + "username": { + "type": "string" + }, + "primary_email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" } }, - "specific": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "code": { - "type": "string", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1" - ] - }, - "type": { - "type": "string", - "enum": ["specific"] - }, - "provider": { - "type": "string", - "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS"] - }, - "status": { - "type": "string", - "enum": ["capacity", "other"] - } - }, - "required": ["name", "code", "type", "provider"] + "type": "object" + }, + "style": "deepObject" + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListMembersResponse" } } - }, - "required": ["smartGroup", "specific"] + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, - "required": ["recommendations", "all"] - }, - "OrganizationResponseV1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Deprecated: Use `slug` instead.", - "deprecated": true - }, - "slug": { - "type": "string", - "pattern": "^[\\w-]+$", + "security": [ + { + "bearer": [] + } + ], + "summary": "List members of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["members_read"]], + "x-oauth-scope": "organizations:read" + } + }, + "/v2/organizations/{slug}/members/{user_id}/roles": { + "patch": { + "description": "Assigns an org-wide role when projects is omitted, or creates a project-scoped assignment when projects is provided. Uses an org-level role template id from GET /v2/organizations/{slug}/roles. Stale role assignments are automatically cleaned up: if a role no longer has any projects, it is deleted; overlapping project assignments in other roles are automatically removed to avoid duplication.", + "operationId": "v2-assign-organization-member-role", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", "description": "Organization slug", - "example": "tsrqponmlkjihgfedcba" + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } }, - "name": { - "type": "string" + { + "name": "user_id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } } - }, - "required": ["id", "slug", "name"] - }, - "CreateOrganizationV1": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 256 + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AssignOrganizationMemberRoleRequest" + } + } } }, - "required": ["name"], - "example": { - "name": "Acme" - }, - "additionalProperties": false - }, - "OAuthTokenBody": { - "type": "object", - "properties": { - "grant_type": { - "type": "string", - "enum": [ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:jwt-bearer" - ] - }, - "client_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "client_secret": { - "type": "string" - }, - "code": { - "type": "string" - }, - "code_verifier": { - "type": "string" + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMemberRoleResponse" + } + } + } }, - "redirect_uri": { - "type": "string" + "401": { + "description": "Unauthorized" }, - "refresh_token": { - "type": "string" + "402": { + "description": "This feature requires the Enterprise organization plan.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanGateErrorBodyV2" + } + } + } }, - "assertion": { - "description": "IDJAG assertion JWT for grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. Beta - available on Team and Enterprise plans only.", - "type": "string" + "403": { + "description": "Forbidden action" }, - "resource": { - "description": "Resource indicator for MCP (Model Context Protocol) clients", - "type": "string", - "format": "uri" + "429": { + "description": "Rate limit exceeded" }, - "scope": { - "type": "string" + "500": { + "description": "Failed to assign organization member role" } }, - "example": { - "grant_type": "authorization_code", - "client_id": "66666666-6666-4666-8666-666666666666", - "client_secret": "sb_secret_live_example_9f4d3a206b2e4a7e8c91", - "code": "oauth_code_9f4d3a206b2e4a7e8c91", - "code_verifier": "qW0Z6d9pQnW0mL1dK1q9wFq6Yz2nV5rA8jT3mP7sH4c", - "redirect_uri": "https://app.acme.com/auth/callback", - "scope": "projects:read projects:write" - }, - "additionalProperties": false - }, - "OAuthTokenResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" + "security": [ + { + "bearer": [] + } + ], + "summary": "Assign or change an organization member role", + "tags": ["Organizations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "Only available on Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["organization_admin_write"]] + } + }, + "/v2/organizations/{slug}/roles": { + "get": { + "description": "Returns a list of org-level roles for the organization.", + "operationId": "v2-list-organization-roles", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListRolesResponse" + } + } + } }, - "refresh_token": { - "description": "The `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type issues access tokens only, no refresh token is returned and the token cannot be revoked via `/v1/oauth/revoke`.", - "type": "string" + "401": { + "description": "Unauthorized" }, - "expires_in": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "403": { + "description": "Forbidden action" }, - "token_type": { - "type": "string", - "enum": ["Bearer"] + "429": { + "description": "Rate limit exceeded" } }, - "required": ["access_token", "expires_in", "token_type"], - "additionalProperties": false - }, - "OAuthRevokeTokenBody": { - "type": "object", - "properties": { - "client_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "client_secret": { - "type": "string" - }, - "refresh_token": { - "type": "string" + "security": [ + { + "bearer": [] } - }, - "required": ["client_id", "client_secret", "refresh_token"], - "example": { - "client_id": "66666666-6666-4666-8666-666666666666", - "client_secret": "sb_secret_live_example_9f4d3a206b2e4a7e8c91", - "refresh_token": "oauth_refresh_9f4d3a206b2e4a7e8c91" - }, - "additionalProperties": false - }, - "SnippetList": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "inserted_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["sql"] - }, - "visibility": { - "type": "string", - "enum": ["user", "project", "org", "public"] - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" - } - }, - "required": ["id", "name"] - }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "username": { - "type": "string" - } - }, - "required": ["id", "username"] - }, - "updated_by": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "username": { - "type": "string" - } - }, - "required": ["id", "username"] - }, - "favorite": { - "type": "boolean" - } - }, - "required": [ - "id", - "inserted_at", - "updated_at", - "type", - "visibility", - "name", - "description", - "project", - "owner", - "updated_by", - "favorite" - ] + ], + "summary": "List roles of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["members_read"]], + "x-oauth-scope": "organizations:read" + } + }, + "/v2/organizations/{slug}/members/invitations": { + "post": { + "description": "Creates member invitations for an organization. Each invitation can have different role and project scope settings.", + "operationId": "v2-create-organization-invitations", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreateInvitationsRequest" + } } - }, - "cursor": { - "type": "string" } }, - "required": ["data"] - }, - "SnippetResponse": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "inserted_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["sql"] - }, - "visibility": { - "type": "string", - "enum": ["user", "project", "org", "public"] - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreateInvitationsResponse" + } } - }, - "required": ["id", "name"] + } }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "username": { - "type": "string" - } - }, - "required": ["id", "username"] + "401": { + "description": "Unauthorized" }, - "updated_by": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "username": { - "type": "string" + "402": { + "description": "This feature requires the Enterprise organization plan.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanGateErrorBodyV2" + } } - }, - "required": ["id", "username"] + } }, - "favorite": { - "type": "boolean" + "403": { + "description": "Forbidden action" }, - "content": { - "type": "object", - "properties": { - "favorite": { - "deprecated": true, - "description": "Deprecated: Rely on root-level favorite property instead.", - "type": "boolean" - }, - "schema_version": { - "type": "string" - }, - "sql": { - "type": "string" - } - }, - "required": ["schema_version", "sql"] + "429": { + "description": "Rate limit exceeded" } }, - "required": [ - "id", - "inserted_at", - "updated_at", - "type", - "visibility", - "name", - "description", - "project", - "owner", - "updated_by", - "favorite", - "content" - ] - }, - "V1ProfileResponse": { - "type": "object", - "properties": { - "gotrue_id": { - "type": "string" - }, - "primary_email": { - "type": "string" + "security": [ + { + "bearer": [] + } + ], + "summary": "Creates organization invitations", + "tags": ["Organizations Members Invitations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "OAuth scope: organizations:write", + "position": "after" }, - "username": { - "type": "string" + { + "name": "Only available on Enterprise", + "position": "before" } - }, - "required": ["gotrue_id", "primary_email", "username"] + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["members_write"]], + "x-oauth-scope": "organizations:write" }, - "ListActionRunResponse": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "branch_id": { - "type": "string" - }, - "run_steps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["clone", "pull", "health", "configure", "migrate", "seed", "deploy"] - }, - "status": { - "type": "string", - "enum": [ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING" - ] - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": ["name", "status", "created_at", "updated_at"] - } - }, - "git_config": { - "nullable": true - }, - "workdir": { - "type": "string", - "nullable": true - }, - "check_run_id": { - "type": "number", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "updated_at": { + "delete": { + "description": "Bulk delete member invitations for an organization by email address.", + "operationId": "v2-delete-organization-invitations", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteInvitationsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteInvitationsResponse" + } + } + } }, - "required": [ - "id", - "branch_id", - "run_steps", - "workdir", - "check_run_id", - "created_at", - "updated_at" - ] - } - }, - "ActionRunResponse": { - "type": "object", - "properties": { - "id": { - "type": "string" + "401": { + "description": "Unauthorized" }, - "branch_id": { - "type": "string" + "402": { + "description": "This feature requires the Enterprise organization plan.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanGateErrorBodyV2" + } + } + } }, - "run_steps": { - "type": "array", - "items": { - "type": "object", + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Deletes organization invitations by email", + "tags": ["Organizations Members Invitations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "OAuth scope: organizations:write", + "position": "after" + }, + { + "name": "Only available on Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["members_write"]], + "x-oauth-scope": "organizations:write" + } + }, + "/v2/organizations/{slug}/projects": { + "get": { + "description": "Returns a cursor-paginated list of projects for the specified organization, including their databases.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the number of projects returned per page.", + "operationId": "v2-list-organization-projects", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { "properties": { - "name": { - "type": "string", - "enum": ["clone", "pull", "health", "configure", "migrate", "seed", "deploy"] + "size": { + "type": "integer", + "minimum": 1, + "maximum": 100 }, - "status": { + "after": { "type": "string", - "enum": [ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING" - ] - }, - "created_at": { - "type": "string" + "minLength": 1 }, - "updated_at": { - "type": "string" + "before": { + "type": "string", + "minLength": 1 } }, - "required": ["name", "status", "created_at", "updated_at"] - } - }, - "git_config": { - "nullable": true - }, - "workdir": { - "type": "string", - "nullable": true - }, - "check_run_id": { - "type": "number", - "nullable": true + "type": "object" + }, + "style": "deepObject" }, - "created_at": { - "type": "string" + { + "name": "sort", + "required": false, + "in": "query", + "description": "Sort order by creation time: `inserted_at` (oldest first) or `-inserted_at` (newest first). Defaults to `inserted_at`.", + "schema": { + "example": "-inserted_at", + "type": "string", + "enum": ["inserted_at", "-inserted_at"] + } }, - "updated_at": { - "type": "string" + { + "name": "search", + "required": false, + "in": "query", + "description": "Case-insensitive substring match on the project name.", + "schema": { + "minLength": 1, + "type": "string" + } } - }, - "required": [ - "id", - "branch_id", - "run_steps", - "workdir", - "check_run_id", - "created_at", - "updated_at" - ] - }, - "UpdateRunStatusBody": { - "type": "object", - "properties": { - "clone": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] - }, - "pull": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] - }, - "health": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] - }, - "configure": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListProjectsResponse" + } + } + } }, - "migrate": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + "401": { + "description": "Unauthorized" }, - "seed": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + "403": { + "description": "Forbidden action" }, - "deploy": { - "type": "string", - "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + "429": { + "description": "Rate limit exceeded" } }, - "example": { - "clone": "RUNNING", - "configure": "RUNNING", - "migrate": "RUNNING", - "deploy": "CREATED" - } - }, - "UpdateRunStatusResponse": { - "type": "object", - "properties": { - "message": { - "type": "string", - "enum": ["ok"] + "security": [ + { + "bearer": [] + } + ], + "summary": "List projects of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api"], + "x-fga-permissions": [["organization_projects_read"]], + "x-oauth-scope": "projects:read" + } + }, + "/v2/organizations/{slug}/integrations/github/connections": { + "get": { + "description": "Returns a cursor-paginated list of the GitHub connections of the organization's projects.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the page size.\nPaging walks the organization projects, so a page holds at most `page[size]` connections and can hold fewer (or none) when some of its projects are not connected.\nFollow `links.next` until it is `null` rather than stopping on a short page.\n\nUse `filter[project_ref]` to narrow the list down to a single project.", + "operationId": "v2-list-organization-github-connections", + "parameters": [ + { + "name": "slug", + "required": true, + "in": "path", + "description": "Organization slug", + "schema": { + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba", + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "properties": { + "size": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "after": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + "before": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "name": "filter", + "required": false, + "in": "query", + "schema": { + "properties": { + "project_ref": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + } + }, + "type": "object" + }, + "style": "deepObject" + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListGitHubConnectionsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" } }, - "required": ["message"] - }, - "ApiKeyResponse": { + "security": [ + { + "bearer": [] + } + ], + "summary": "List GitHub connections of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["management-api", "dev-workflows"], + "x-fga-permissions": [["organization_projects_read"]], + "x-oauth-scope": "projects:read" + } + } + }, + "components": { + "schemas": { + "BranchDetailResponse": { "type": "object", "properties": { - "api_key": { - "type": "string", - "nullable": true - }, - "id": { - "type": "string", - "nullable": true + "ref": { + "type": "string" }, - "type": { - "type": "string", - "enum": ["legacy", "publishable", "secret", null], - "nullable": true + "postgres_version": { + "type": "string" }, - "prefix": { - "type": "string", - "nullable": true + "postgres_engine": { + "type": "string" }, - "name": { + "release_channel": { "type": "string" }, - "description": { + "status": { "type": "string", - "nullable": true + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ] }, - "hash": { - "type": "string", - "nullable": true + "db_host": { + "type": "string" }, - "secret_jwt_template": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "nullable": true + "db_port": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 }, - "inserted_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "nullable": true + "db_user": { + "type": "string" }, - "updated_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "nullable": true + "db_pass": { + "type": "string" + }, + "jwt_secret": { + "type": "string" } }, - "required": ["name"] + "required": [ + "ref", + "postgres_version", + "postgres_engine", + "release_channel", + "status", + "db_host", + "db_port" + ] }, - "LegacyApiKeysResponse": { + "UpdateBranchBody": { "type": "object", "properties": { - "enabled": { + "branch_name": { + "type": "string" + }, + "git_branch": { + "type": "string" + }, + "reset_on_push": { + "description": "This field is deprecated and will be ignored. Use v1-reset-a-branch endpoint directly instead.", + "deprecated": true, + "type": "boolean" + }, + "persistent": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED" + ] + }, + "request_review": { "type": "boolean" + }, + "notify_url": { + "type": "string", + "format": "uri", + "description": "HTTP endpoint to receive branch status updates." } }, - "required": ["enabled"] + "example": { + "branch_name": "preview-login-page", + "git_branch": "feature/login-page", + "persistent": true, + "request_review": true, + "notify_url": "https://example.com/webhooks/branches" + } }, - "CreateApiKeyBody": { + "BranchResponse": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "enum": ["publishable", "secret"] - }, - "name": { - "type": "string", - "minLength": 4, - "maxLength": 64, - "pattern": "^[a-z_][a-z0-9_]+$" - }, - "description": { - "type": "string", - "nullable": true + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "secret_jwt_template": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "nullable": true - } - }, - "required": ["type", "name"], - "example": { - "type": "secret", - "name": "ci_secret_key", - "description": "CI deploy key" - } - }, - "UpdateApiKeyBody": { - "type": "object", - "properties": { "name": { - "type": "string", - "minLength": 4, - "maxLength": 64, - "pattern": "^[a-z_][a-z0-9_]+$" - }, - "description": { - "type": "string", - "nullable": true + "type": "string" }, - "secret_jwt_template": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "nullable": true - } - }, - "example": { - "name": "ci_secret_key_rotated", - "description": "Rotated after March release" - } - }, - "CreateBranchBody": { - "type": "object", - "properties": { - "branch_name": { - "type": "string", - "minLength": 1 + "project_ref": { + "type": "string" }, - "git_branch": { + "parent_project_ref": { "type": "string" }, "is_default": { "type": "boolean" }, + "git_branch": { + "type": "string" + }, + "pr_number": { + "type": "integer", + "format": "int32", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "latest_check_run_id": { + "description": "This field is deprecated and will not be populated.", + "deprecated": true, + "type": "number" + }, "persistent": { "type": "boolean" }, - "region": { - "type": "string" - }, - "desired_instance_size": { + "status": { "type": "string", "enum": [ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory" - ] + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED" + ], + "description": "This field is deprecated. List action runs to get branch status instead.", + "deprecated": true }, - "release_channel": { + "created_at": { "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"], - "description": "Release channel. If not provided, GA will be used." + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "postgres_engine": { + "updated_at": { "type": "string", - "enum": ["15", "17", "17-oriole"], - "description": "Postgres engine version. If not provided, the latest version will be used." + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "secrets": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "review_requested_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "with_data": { "type": "boolean" }, "notify_url": { "type": "string", - "format": "uri", - "description": "HTTP endpoint to receive branch status updates." + "format": "uri" + }, + "deletion_scheduled_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "preview_project_status": { + "type": "string", + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ] + } + }, + "required": [ + "id", + "name", + "project_ref", + "parent_project_ref", + "is_default", + "persistent", + "status", + "created_at", + "updated_at", + "with_data" + ] + }, + "BranchDeleteResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "enum": ["ok"] + } + }, + "required": ["message"] + }, + "BranchActionBody": { + "type": "object", + "properties": { + "migration_version": { + "type": "string" } }, - "required": ["branch_name"], "example": { - "branch_name": "preview-login-page", - "git_branch": "feature/login-page", - "persistent": true, - "with_data": false, - "notify_url": "https://example.com/webhooks/branches" + "migration_version": "20250312000000" } }, - "UpdateCustomHostnameResponseJsonValue": { - "description": "Any JSON-serializable value", - "anyOf": [ - { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "nullable": true - }, - { - "type": "array", - "items": { - "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" - } + "BranchUpdateResponse": { + "type": "object", + "properties": { + "workflow_run_id": { + "type": "string" }, - { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" - } + "message": { + "type": "string", + "enum": ["ok"] } - ] + }, + "required": ["workflow_run_id", "message"] }, - "UpdateCustomHostnameResponse": { + "BranchRestoreResponse": { "type": "object", "properties": { - "status": { + "message": { "type": "string", - "enum": [ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured" - ] - }, - "custom_hostname": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" - } - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" - } - }, - "result": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "ssl": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "validation_records": { - "type": "array", - "items": { - "type": "object", - "properties": { - "txt_name": { - "type": "string" - }, - "txt_value": { - "type": "string" - } - }, - "required": ["txt_name", "txt_value"] - } - }, - "validation_errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - } - }, - "required": ["status"] - }, - "ownership_verification": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": ["type", "name", "value"] - }, - "custom_origin_server": { - "type": "string" - }, - "verification_errors": { - "type": "array", - "items": { - "type": "string" - } - }, - "status": { - "type": "string" - } - }, - "required": ["id", "hostname", "ssl", "custom_origin_server", "status"] - } - }, - "required": ["success", "errors", "messages", "result"] - } - }, - "required": ["data"] - }, - "UpdateCustomHostnameBody": { - "type": "object", - "properties": { - "custom_hostname": { - "type": "string", - "minLength": 1, - "maxLength": 253 + "enum": ["Branch restoration initiated"] } }, - "required": ["custom_hostname"], - "example": { - "custom_hostname": "docs.example.com" - } + "required": ["message"] }, - "JitAccessRequestRequest": { + "V1ProjectWithDatabaseResponse": { "type": "object", "properties": { - "state": { + "id": { "type": "string", - "enum": ["enabled", "disabled"] - } - }, - "required": ["state"], - "example": { - "state": "enabled" - } - }, - "NetworkBanResponse": { - "type": "object", - "properties": { - "banned_ipv4_addresses": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["banned_ipv4_addresses"] - }, - "NetworkBanResponseEnriched": { - "type": "object", - "properties": { - "banned_ipv4_addresses": { - "type": "array", - "items": { - "type": "object", - "properties": { - "banned_address": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": ["banned_address", "identifier", "type"] - } - } - }, - "required": ["banned_ipv4_addresses"] - }, - "RemoveNetworkBanRequest": { - "type": "object", - "properties": { - "ipv4_addresses": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of IP addresses to unban." + "deprecated": true, + "description": "Deprecated: Use `ref` instead." }, - "requester_ip": { - "default": false, - "description": "Include requester's public IP in the list of addresses to unban.", - "type": "boolean" + "ref": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" }, - "identifier": { - "type": "string" - } - }, - "required": ["ipv4_addresses"], - "example": { - "ipv4_addresses": ["203.0.113.10"], - "requester_ip": false - } - }, - "NetworkRestrictionsResponse": { - "type": "object", - "properties": { - "entitlement": { + "organization_id": { "type": "string", - "enum": ["disallowed", "allowed"] + "description": "Deprecated: Use `organization_slug` instead.", + "deprecated": true }, - "config": { - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "string" - } - }, - "dbAllowedCidrsV6": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "example": { - "dbAllowedCidrs": ["203.0.113.0/24"], - "dbAllowedCidrsV6": ["2001:db8::/32"] - }, - "description": "At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`." + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" }, - "old_config": { - "description": "Populated when a new config has been received, but not registered as successfully applied to a project.", - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "string" - } - }, - "dbAllowedCidrsV6": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "example": { - "dbAllowedCidrs": ["203.0.113.0/24"], - "dbAllowedCidrsV6": ["2001:db8::/32"] - } + "name": { + "type": "string", + "description": "Name of your project" }, - "status": { + "region": { "type": "string", - "enum": ["stored", "applied"] + "description": "Region of your project" }, - "updated_at": { + "created_at": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "description": "Creation timestamp" }, - "applied_at": { + "status": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - } - }, - "required": ["entitlement", "config", "status"] - }, - "NetworkRestrictionsRequest": { - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "string" - } + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ] }, - "dbAllowedCidrsV6": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "example": { - "dbAllowedCidrs": ["203.0.113.0/24"], - "dbAllowedCidrsV6": ["2001:db8::/32"] - } - }, - "NetworkRestrictionsPatchRequest": { - "type": "object", - "properties": { - "add": { + "database": { "type": "object", "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "string" - } + "host": { + "type": "string", + "description": "Database host" }, - "dbAllowedCidrsV6": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "remove": { - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "string" - } + "version": { + "type": "string", + "description": "Database version" }, - "dbAllowedCidrsV6": { - "type": "array", - "items": { - "type": "string" - } + "postgres_engine": { + "type": "string", + "description": "Database engine" + }, + "release_channel": { + "type": "string", + "description": "Release channel" } - } + }, + "required": ["host", "version", "postgres_engine", "release_channel"] } }, - "example": { - "add": { - "dbAllowedCidrs": ["203.0.113.0/24"] - }, - "remove": { - "dbAllowedCidrs": ["198.51.100.0/24"] - } - } + "required": [ + "id", + "ref", + "organization_id", + "organization_slug", + "name", + "region", + "created_at", + "status", + "database" + ] }, - "NetworkRestrictionsV2Response": { + "V1CreateProjectBody": { "type": "object", "properties": { - "entitlement": { + "db_pass": { "type": "string", - "enum": ["disallowed", "allowed"] - }, - "config": { - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["v4", "v6"] - } - }, - "required": ["address", "type"] - } - } - }, - "description": "At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`." - }, - "old_config": { - "description": "Populated when a new config has been received, but not registered as successfully applied to a project.", - "type": "object", - "properties": { - "dbAllowedCidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["v4", "v6"] - } - }, - "required": ["address", "type"] - } - } - } + "description": "Database password" }, - "updated_at": { + "name": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "maxLength": 256, + "description": "Name of your project" }, - "applied_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "organization_id": { + "deprecated": true, + "description": "Deprecated: Use `organization_slug` instead.", + "type": "string" }, - "status": { - "type": "string", - "enum": ["stored", "applied"] - } - }, - "required": ["entitlement", "config", "status"] - }, - "PgsodiumConfigResponse": { - "type": "object", - "properties": { - "root_key": { - "type": "string", - "description": "The pgsodium root key: 32 bytes, hex-encoded (64 characters)." - } - }, - "required": ["root_key"], - "example": { - "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } - }, - "UpdatePgsodiumConfigBody": { - "type": "object", - "properties": { - "root_key": { + "organization_slug": { "type": "string", - "description": "The pgsodium root key: 32 bytes, hex-encoded (64 characters)." - } - }, - "required": ["root_key"], - "example": { - "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } - }, - "PostgrestConfigWithJWTSecretResponse": { - "type": "object", - "properties": { - "db_schema": { - "type": "string" + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" }, - "max_rows": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "plan": { + "deprecated": true, + "description": "Subscription Plan is now set on organization level and is ignored in this request", + "type": "string", + "enum": ["free", "pro"] }, - "db_extra_search_path": { + "region": { + "description": "Region you want your server to reside in. Use region_selection instead.", + "deprecated": true, + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1" + ], "type": "string" }, - "db_pool": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "description": "If `null`, the value is automatically configured based on compute size.", - "nullable": true - }, - "db_pool_acquisition_timeout": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "description": "If `null`, the value is automatically configured to 10.", - "nullable": true + "region_selection": { + "description": "Region selection. Only one of region or region_selection can be specified.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["specific"] + }, + "code": { + "type": "string", + "description": "Specific region code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint.", + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1" + ] + } + }, + "required": ["type", "code"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["smartGroup"] + }, + "code": { + "type": "string", + "enum": ["americas", "emea", "apac"], + "description": "The Smart Region Group's code. The codes supported are not a stable API, and should be retrieved from the /available-regions endpoint." + } + }, + "required": ["type", "code"] + } + ] }, - "jwt_secret": { - "type": "string" - } - }, - "required": [ - "db_schema", - "max_rows", - "db_extra_search_path", - "db_pool", - "db_pool_acquisition_timeout" - ] - }, - "V1UpdatePostgrestConfigBody": { - "type": "object", - "properties": { - "db_extra_search_path": { - "type": "string" + "kps_enabled": { + "deprecated": true, + "description": "This field is deprecated and is ignored in this request", + "type": "boolean" }, - "db_schema": { - "type": "string" + "desired_instance_size": { + "description": "Desired instance size. Omit this field to always default to the smallest possible size.", + "type": "string", + "enum": [ + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory" + ] }, - "max_rows": { - "type": "integer", - "minimum": 0, - "maximum": 1000000 + "template_url": { + "description": "Template URL used to create the project from the CLI.", + "type": "string", + "format": "uri" }, - "db_pool": { - "type": "integer", - "minimum": 0, - "maximum": 1000 + "release_channel": { + "deprecated": true, + "type": "null" }, - "db_pool_acquisition_timeout": { - "type": "integer", - "minimum": 0, - "maximum": 60 + "postgres_engine": { + "deprecated": true, + "type": "null" + }, + "high_availability": { + "description": "[Experimental] Whether to enable high availability for the project.", + "type": "boolean" } }, + "required": ["db_pass", "name", "organization_slug"], "example": { - "db_schema": "public,storage", - "db_pool": 20, - "max_rows": 1000 - } - }, - "V1PostgrestConfigResponse": { - "type": "object", - "properties": { - "db_schema": { - "type": "string" - }, - "max_rows": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "db_extra_search_path": { - "type": "string" - }, - "db_pool": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "description": "If `null`, the value is automatically configured based on compute size.", - "nullable": true - }, - "db_pool_acquisition_timeout": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "description": "If `null`, the value is automatically configured to 10.", - "nullable": true - } + "db_pass": "correct-horse-battery-staple", + "name": "acme-prod", + "organization_slug": "tsrqponmlkjihgfedcba", + "region": "us-east-1" }, - "required": [ - "db_schema", - "max_rows", - "db_extra_search_path", - "db_pool", - "db_pool_acquisition_timeout" - ] + "additionalProperties": false }, - "V1ProjectRefResponse": { + "V1ProjectResponse": { "type": "object", "properties": { "id": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "type": "string", + "deprecated": true, + "description": "Deprecated: Use `ref` instead." }, "ref": { - "type": "string" + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" }, - "name": { - "type": "string" - } - }, - "required": ["id", "ref", "name"] - }, - "V1UpdateProjectBody": { - "type": "object", - "properties": { - "name": { + "organization_id": { "type": "string", - "minLength": 1, - "maxLength": 256 - } - }, - "required": ["name"], - "example": { - "name": "Acme Platform" - } - }, - "SecretResponse": { - "type": "object", - "properties": { + "description": "Deprecated: Use `organization_slug` instead.", + "deprecated": true + }, + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, "name": { - "type": "string" + "type": "string", + "description": "Name of your project" }, - "value": { - "type": "string" + "region": { + "type": "string", + "description": "Region of your project" }, - "updated_at": { - "type": "string" + "created_at": { + "type": "string", + "description": "Creation timestamp" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ] } }, - "required": ["name", "value"] - }, - "CreateSecretBody": { - "maxItems": 100, - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 256, - "pattern": "^(?!SUPABASE_).*", - "description": "Secret name must not start with the SUPABASE_ prefix." - }, - "value": { - "type": "string", - "maxLength": 24576 - } - }, - "required": ["name", "value"] - }, - "example": [ - { - "name": "OPENAI_API_KEY", - "value": "sk-example-secret" - }, - { - "name": "STRIPE_WEBHOOK_SECRET", - "value": "whsec_example" - } + "required": [ + "id", + "ref", + "organization_id", + "organization_slug", + "name", + "region", + "created_at", + "status" ] }, - "DeleteSecretsBody": { - "type": "array", - "items": { - "type": "string" - }, - "example": ["OPENAI_API_KEY"] - }, - "SslEnforcementResponse": { + "RegionsInfo": { "type": "object", "properties": { - "currentConfig": { + "recommendations": { "type": "object", "properties": { - "database": { - "type": "boolean" + "smartGroup": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string", + "enum": ["americas", "emea", "apac"] + }, + "type": { + "type": "string", + "enum": ["smartGroup"] + } + }, + "required": ["name", "code", "type"] + }, + "specific": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1" + ] + }, + "type": { + "type": "string", + "enum": ["specific"] + }, + "provider": { + "type": "string", + "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS"] + }, + "status": { + "type": "string", + "enum": ["capacity", "other"] + } + }, + "required": ["name", "code", "type", "provider"] + } } }, - "required": ["database"] + "required": ["smartGroup", "specific"] }, - "appliedSuccessfully": { - "type": "boolean" - } - }, - "required": ["currentConfig", "appliedSuccessfully"] - }, - "SslEnforcementRequest": { - "type": "object", - "properties": { - "requestedConfig": { + "all": { "type": "object", "properties": { - "database": { - "type": "boolean" + "smartGroup": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string", + "enum": ["americas", "emea", "apac"] + }, + "type": { + "type": "string", + "enum": ["smartGroup"] + } + }, + "required": ["name", "code", "type"] + } + }, + "specific": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1" + ] + }, + "type": { + "type": "string", + "enum": ["specific"] + }, + "provider": { + "type": "string", + "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS"] + }, + "status": { + "type": "string", + "enum": ["capacity", "other"] + } + }, + "required": ["name", "code", "type", "provider"] + } } }, - "required": ["database"] + "required": ["smartGroup", "specific"] } }, - "required": ["requestedConfig"], - "example": { - "requestedConfig": { - "database": true - } - } + "required": ["recommendations", "all"] }, - "TypescriptResponse": { + "OrganizationResponseV1": { "type": "object", "properties": { - "types": { + "id": { + "type": "string", + "description": "Deprecated: Use `slug` instead.", + "deprecated": true + }, + "slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "name": { "type": "string" } }, - "required": ["types"] + "required": ["id", "slug", "name"] }, - "VanitySubdomainConfigResponse": { + "CreateOrganizationV1": { "type": "object", "properties": { - "status": { - "type": "string", - "enum": ["not-used", "custom-domain-used", "active"] - }, - "custom_domain": { + "name": { "type": "string", - "minLength": 1 + "maxLength": 256 } }, - "required": ["status"] + "required": ["name"], + "example": { + "name": "Acme" + }, + "additionalProperties": false }, - "PlanGateErrorBody": { + "OAuthTokenBody": { "type": "object", "properties": { - "message": { + "grant_type": { "type": "string", - "description": "Human-readable explanation of the plan gate" + "enum": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] }, - "error": { - "description": "Present on entitlement denials. Other errors with this status code (validation, billing state) carry only message.", - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Machine-readable marker for plan-gated denials", - "enum": ["entitlement_required"] - }, - "feature": { - "type": "string", - "description": "Entitlement feature key that failed the check" - }, - "upgrade_url": { - "description": "Billing page URL for the organization, present when the org is resolvable", - "type": "string" - } - }, - "required": ["code", "feature"] - } - }, - "required": ["message"] - }, - "VanitySubdomainBody": { - "type": "object", - "properties": { - "vanity_subdomain": { + "client_id": { "type": "string", - "maxLength": 63 + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "client_secret": { + "type": "string" + }, + "code": { + "type": "string" + }, + "code_verifier": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "assertion": { + "description": "IDJAG assertion JWT for grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. Beta - available on Team and Enterprise plans only.", + "type": "string" + }, + "resource": { + "description": "Resource indicator for MCP (Model Context Protocol) clients", + "type": "string", + "format": "uri" + }, + "scope": { + "type": "string" } }, - "required": ["vanity_subdomain"], "example": { - "vanity_subdomain": "acme-prod" - } - }, - "SubdomainAvailabilityResponse": { - "type": "object", - "properties": { - "available": { - "type": "boolean" - } + "grant_type": "authorization_code", + "client_id": "66666666-6666-4666-8666-666666666666", + "client_secret": "sb_secret_live_example_9f4d3a206b2e4a7e8c91", + "code": "oauth_code_9f4d3a206b2e4a7e8c91", + "code_verifier": "qW0Z6d9pQnW0mL1dK1q9wFq6Yz2nV5rA8jT3mP7sH4c", + "redirect_uri": "https://app.acme.com/auth/callback", + "scope": "projects:read projects:write" }, - "required": ["available"] + "additionalProperties": false }, - "ActivateVanitySubdomainResponse": { + "OAuthTokenResponse": { "type": "object", "properties": { - "custom_domain": { + "access_token": { "type": "string" - } - }, - "required": ["custom_domain"] - }, - "UpgradeDatabaseBody": { - "type": "object", - "properties": { - "target_version": { + }, + "refresh_token": { + "description": "The `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type issues access tokens only, no refresh token is returned and the token cannot be revoked via `/v1/oauth/revoke`.", "type": "string" }, - "release_channel": { + "expires_in": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "token_type": { "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + "enum": ["Bearer"] } }, - "required": ["target_version"], - "example": { - "target_version": "17", - "release_channel": "ga" - } + "required": ["access_token", "expires_in", "token_type"], + "additionalProperties": false }, - "ProjectUpgradeInitiateResponse": { + "OAuthRevokeTokenBody": { "type": "object", "properties": { - "tracking_id": { + "client_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "client_secret": { + "type": "string" + }, + "refresh_token": { "type": "string" } }, - "required": ["tracking_id"] + "required": ["client_id", "client_secret", "refresh_token"], + "example": { + "client_id": "66666666-6666-4666-8666-666666666666", + "client_secret": "sb_secret_live_example_9f4d3a206b2e4a7e8c91", + "refresh_token": "oauth_refresh_9f4d3a206b2e4a7e8c91" + }, + "additionalProperties": false }, - "ProjectUpgradeEligibilityResponse": { + "SnippetList": { "type": "object", "properties": { - "eligible": { - "type": "boolean" - }, - "current_app_version": { - "type": "string" - }, - "current_app_version_release_channel": { - "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] - }, - "latest_app_version": { - "type": "string" - }, - "target_upgrade_versions": { + "data": { "type": "array", "items": { "type": "object", "properties": { - "postgres_version": { + "id": { + "type": "string" + }, + "inserted_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "type": { "type": "string", - "enum": ["13", "14", "15", "17", "17-oriole"] + "enum": ["sql"] }, - "release_channel": { + "visibility": { "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + "enum": ["user", "project", "org", "public"] }, - "app_version": { + "name": { "type": "string" - } - }, - "required": ["postgres_version", "release_channel", "app_version"] - } - }, - "duration_estimate_hours": { - "type": "number" - }, - "legacy_auth_custom_roles": { - "type": "array", - "items": { - "type": "string" - } - }, - "objects_to_be_dropped": { - "type": "array", - "items": { - "type": "string" - }, - "deprecated": true, - "description": "Use validation_errors instead." - }, - "unsupported_extensions": { - "type": "array", - "items": { - "type": "string" - }, - "deprecated": true, - "description": "Use validation_errors instead." - }, - "user_defined_objects_in_internal_schemas": { - "type": "array", - "items": { - "type": "string" - }, - "deprecated": true, - "description": "Use validation_errors instead." - }, - "validation_errors": { - "type": "array", - "items": { - "anyOf": [ - { + }, + "description": { + "type": "string", + "nullable": true + }, + "project": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["objects_depending_on_pg_cron"] + "id": { + "type": "number" }, - "dependents": { - "type": "array", - "items": { - "type": "string" - } + "name": { + "type": "string" } }, - "required": ["type", "dependents"] + "required": ["id", "name"] }, - { + "owner": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["indexes_referencing_ll_to_earth"] - }, - "schema_name": { - "type": "string" - }, - "table_name": { - "type": "string" + "id": { + "type": "number" }, - "index_name": { + "username": { "type": "string" } }, - "required": ["type", "schema_name", "table_name", "index_name"] + "required": ["id", "username"] }, - { + "updated_by": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["function_using_obsolete_lang"] - }, - "schema_name": { - "type": "string" - }, - "function_name": { - "type": "string" + "id": { + "type": "number" }, - "lang_name": { + "username": { "type": "string" } }, - "required": ["type", "schema_name", "function_name", "lang_name"] + "required": ["id", "username"] }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unsupported_extension"] - }, - "extension_name": { - "type": "string" - } - }, - "required": ["type", "extension_name"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unsupported_fdw_handler"] - }, - "fdw_name": { - "type": "string" - }, - "fdw_handler_name": { - "type": "string" - } - }, - "required": ["type", "fdw_name", "fdw_handler_name"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["unlogged_table_with_persistent_sequence"] - }, - "schema_name": { - "type": "string" - }, - "table_name": { - "type": "string" - }, - "sequence_name": { - "type": "string" - } - }, - "required": ["type", "schema_name", "table_name", "sequence_name"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["user_defined_objects_in_internal_schemas"] - }, - "obj_type": { - "anyOf": [ - { - "type": "string", - "enum": ["table"] - }, - { - "type": "string", - "enum": ["function"] - } - ] - }, - "schema_name": { - "type": "string" - }, - "obj_name": { - "type": "string" - } - }, - "required": ["type", "obj_type", "schema_name", "obj_name"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["active_replication_slot"] - }, - "slot_name": { - "type": "string" - } - }, - "required": ["type", "slot_name"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["x86_architecture"] - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["project_hibernating"] - } - }, - "required": ["type"] + "favorite": { + "type": "boolean" } + }, + "required": [ + "id", + "inserted_at", + "updated_at", + "type", + "visibility", + "name", + "description", + "project", + "owner", + "updated_by", + "favorite" ] } }, - "warnings": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["pg_graphql_introspection_change"] - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["ltree_reindex_required"] - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["operator_estimator_gate"] - } - }, - "required": ["type"] - } - ] - } + "cursor": { + "type": "string" } }, - "required": [ - "eligible", - "current_app_version", - "current_app_version_release_channel", - "latest_app_version", - "target_upgrade_versions", - "duration_estimate_hours", - "legacy_auth_custom_roles", - "objects_to_be_dropped", - "unsupported_extensions", - "user_defined_objects_in_internal_schemas", - "validation_errors", - "warnings" - ] + "required": ["data"] }, - "DatabaseUpgradeStatusResponse": { + "SnippetResponse": { "type": "object", "properties": { - "databaseUpgradeStatus": { + "id": { + "type": "string" + }, + "inserted_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["sql"] + }, + "visibility": { + "type": "string", + "enum": ["user", "project", "org", "public"] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "project": { "type": "object", "properties": { - "initiated_at": { - "type": "string" + "id": { + "type": "number" }, - "latest_status_at": { + "name": { "type": "string" - }, - "target_version": { + } + }, + "required": ["id", "name"] + }, + "owner": { + "type": "object", + "properties": { + "id": { "type": "number" }, - "error": { - "type": "string", - "enum": [ - "1_upgraded_instance_launch_failed", - "2_volume_detachchment_from_upgraded_instance_failed", - "3_volume_attachment_to_original_instance_failed", - "4_data_upgrade_initiation_failed", - "5_data_upgrade_completion_failed", - "6_volume_detachchment_from_original_instance_failed", - "7_volume_attachment_to_upgraded_instance_failed", - "8_upgrade_completion_failed", - "9_post_physical_backup_failed" - ] - }, - "progress": { - "type": "string", - "enum": [ - "0_requested", - "1_started", - "2_launched_upgraded_instance", - "3_detached_volume_from_upgraded_instance", - "4_attached_volume_to_original_instance", - "5_initiated_data_upgrade", - "6_completed_data_upgrade", - "7_detached_volume_from_original_instance", - "8_attached_volume_to_upgraded_instance", - "9_completed_upgrade", - "10_completed_post_physical_backup" - ] - }, - "status": { - "type": "number" + "username": { + "type": "string" } }, - "required": ["initiated_at", "latest_status_at", "target_version", "status"], - "nullable": true - } - }, - "required": ["databaseUpgradeStatus"] - }, - "ReadOnlyStatusResponse": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" + "required": ["id", "username"] }, - "override_enabled": { + "updated_by": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "username": { + "type": "string" + } + }, + "required": ["id", "username"] + }, + "favorite": { "type": "boolean" }, - "override_active_until": { - "type": "string" - } - }, - "required": ["enabled", "override_enabled", "override_active_until"] - }, - "SetUpReadReplicaBody": { - "type": "object", - "properties": { - "read_replica_region": { - "type": "string", - "enum": [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-east-1", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1" - ], - "description": "Region you want your read replica to reside in" + "content": { + "type": "object", + "properties": { + "favorite": { + "deprecated": true, + "description": "Deprecated: Rely on root-level favorite property instead.", + "type": "boolean" + }, + "schema_version": { + "type": "string" + }, + "sql": { + "type": "string" + } + }, + "required": ["schema_version", "sql"] } }, - "required": ["read_replica_region"], - "example": { - "read_replica_region": "us-west-1" - } + "required": [ + "id", + "inserted_at", + "updated_at", + "type", + "visibility", + "name", + "description", + "project", + "owner", + "updated_by", + "favorite", + "content" + ] }, - "RemoveReadReplicaBody": { + "V1ProfileResponse": { "type": "object", "properties": { - "database_identifier": { + "gotrue_id": { + "type": "string" + }, + "primary_email": { + "type": "string" + }, + "username": { "type": "string" } }, - "required": ["database_identifier"], - "example": { - "database_identifier": "abcdefghijklmnopqrst-rr-us-west-1-abcde" - } + "required": ["gotrue_id", "primary_email", "username"] }, - "V1ServiceHealthResponse": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "auth", - "db", - "db_postgres_user", - "pooler", - "realtime", - "rest", - "storage", - "pg_bouncer" - ] - }, - "healthy": { - "type": "boolean", - "deprecated": true, - "description": "Deprecated. Use `status` instead." - }, - "status": { - "type": "string", - "enum": ["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"] - }, - "info": { - "anyOf": [ - { + "ListActionRunResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "branch_id": { + "type": "string" + }, + "run_steps": { + "type": "array", + "items": { "type": "object", "properties": { "name": { "type": "string", - "enum": ["GoTrue"] + "enum": ["clone", "pull", "health", "configure", "migrate", "seed", "deploy"] }, - "version": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING" + ] }, - "description": { + "created_at": { "type": "string" - } - }, - "required": ["name", "version", "description"] - }, - { - "type": "object", - "properties": { - "healthy": { - "type": "boolean", - "deprecated": true, - "description": "Deprecated. Use `status` instead." - }, - "db_connected": { - "type": "boolean" - }, - "replication_connected": { - "type": "boolean" }, - "connected_cluster": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - }, - "required": [ - "healthy", - "db_connected", - "replication_connected", - "connected_cluster" - ] - }, - { - "type": "object", - "properties": { - "db_schema": { + "updated_at": { "type": "string" } }, - "required": ["db_schema"] + "required": ["name", "status", "created_at", "updated_at"] } - ] + }, + "git_config": { + "nullable": true + }, + "workdir": { + "type": "string", + "nullable": true + }, + "check_run_id": { + "type": "number", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } }, - "error": { - "type": "string" - } - }, - "required": ["name", "healthy", "status"] + "required": [ + "id", + "branch_id", + "run_steps", + "workdir", + "check_run_id", + "created_at", + "updated_at" + ] + } }, - "SigningKeyResponse": { + "ActionRunResponse": { "type": "object", "properties": { "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "algorithm": { - "type": "string", - "enum": ["EdDSA", "ES256", "RS256", "HS256"] - }, - "status": { - "type": "string", - "enum": ["in_use", "previously_used", "revoked", "standby"] - }, - "public_jwk": { - "nullable": true + "type": "string" }, - "created_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "branch_id": { + "type": "string" }, - "updated_at": { + "run_steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["clone", "pull", "health", "configure", "migrate", "seed", "deploy"] + }, + "status": { + "type": "string", + "enum": [ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING" + ] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": ["name", "status", "created_at", "updated_at"] + } + }, + "git_config": { + "nullable": true + }, + "workdir": { + "type": "string", + "nullable": true + }, + "check_run_id": { + "type": "number", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "branch_id", + "run_steps", + "workdir", + "check_run_id", + "created_at", + "updated_at" + ] + }, + "UpdateRunStatusBody": { + "type": "object", + "properties": { + "clone": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "pull": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "health": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "configure": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "migrate": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "seed": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + }, + "deploy": { + "type": "string", + "enum": ["CREATED", "DEAD", "EXITED", "PAUSED", "REMOVING", "RESTARTING", "RUNNING"] + } + }, + "example": { + "clone": "RUNNING", + "configure": "RUNNING", + "migrate": "RUNNING", + "deploy": "CREATED" + } + }, + "UpdateRunStatusResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "enum": ["ok"] + } + }, + "required": ["message"] + }, + "ApiKeyResponse": { + "type": "object", + "properties": { + "api_key": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "enum": ["legacy", "publishable", "secret", null], + "nullable": true + }, + "prefix": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "hash": { + "type": "string", + "nullable": true + }, + "secret_jwt_template": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "nullable": true + }, + "inserted_at": { "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "nullable": true } }, - "required": ["id", "algorithm", "status", "public_jwk", "created_at", "updated_at"], - "additionalProperties": false + "required": ["name"] }, - "CreateSigningKeyBody": { + "LegacyApiKeysResponse": { "type": "object", "properties": { - "algorithm": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + }, + "CreateApiKeyBody": { + "type": "object", + "properties": { + "type": { "type": "string", - "enum": ["EdDSA", "ES256", "RS256", "HS256"] + "enum": ["publishable", "secret"] }, - "status": { + "name": { "type": "string", - "enum": ["in_use", "standby"] + "minLength": 4, + "maxLength": 64, + "pattern": "^[a-z_][a-z0-9_]+$" }, - "private_jwk": { - "oneOf": [ - { - "type": "object", - "properties": { - "kid": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "use": { - "type": "string", - "enum": ["sig"] - }, - "key_ops": { - "minItems": 2, - "maxItems": 2, - "type": "array", - "items": { - "type": "string", - "enum": ["sign", "verify"] - } - }, - "ext": { - "type": "boolean", - "enum": [true] - }, - "kty": { - "type": "string", - "enum": ["RSA"] - }, - "alg": { - "type": "string", - "enum": ["RS256"] - }, - "n": { - "type": "string" - }, - "e": { - "type": "string", - "enum": ["AQAB"] - }, - "d": { - "type": "string" - }, - "p": { - "type": "string" - }, - "q": { - "type": "string" - }, - "dp": { - "type": "string" - }, - "dq": { - "type": "string" - }, - "qi": { - "type": "string" - } - }, - "required": ["kty", "n", "e", "d", "p", "q", "dp", "dq", "qi"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kid": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "use": { - "type": "string", - "enum": ["sig"] - }, - "key_ops": { - "minItems": 2, - "maxItems": 2, - "type": "array", - "items": { - "type": "string", - "enum": ["sign", "verify"] - } - }, - "ext": { - "type": "boolean", - "enum": [true] - }, - "kty": { - "type": "string", - "enum": ["EC"] - }, - "alg": { - "type": "string", - "enum": ["ES256"] - }, - "crv": { - "type": "string", - "enum": ["P-256"] - }, - "x": { - "type": "string" - }, - "y": { - "type": "string" - }, - "d": { - "type": "string" - } - }, - "required": ["kty", "crv", "x", "y", "d"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kid": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "use": { - "type": "string", - "enum": ["sig"] - }, - "key_ops": { - "minItems": 2, - "maxItems": 2, - "type": "array", - "items": { - "type": "string", - "enum": ["sign", "verify"] - } - }, - "ext": { - "type": "boolean", - "enum": [true] - }, - "kty": { - "type": "string", - "enum": ["OKP"] - }, - "alg": { - "type": "string", - "enum": ["EdDSA"] - }, - "crv": { - "type": "string", - "enum": ["Ed25519"] - }, - "x": { - "type": "string" - }, - "d": { - "type": "string" - } - }, - "required": ["kty", "crv", "x", "d"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "kid": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "use": { - "type": "string", - "enum": ["sig"] - }, - "key_ops": { - "minItems": 2, - "maxItems": 2, - "type": "array", - "items": { - "type": "string", - "enum": ["sign", "verify"] - } - }, - "ext": { - "type": "boolean", - "enum": [true] - }, - "kty": { - "type": "string", - "enum": ["oct"] - }, - "alg": { - "type": "string", - "enum": ["HS256"] - }, - "k": { - "type": "string", - "minLength": 16 - } - }, - "required": ["kty", "k"], - "additionalProperties": false - } - ] + "description": { + "type": "string", + "nullable": true + }, + "secret_jwt_template": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "nullable": true } }, - "required": ["algorithm"], + "required": ["type", "name"], "example": { - "algorithm": "RS256", - "status": "standby" - }, - "additionalProperties": false - }, - "SigningKeysResponse": { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "algorithm": { - "type": "string", - "enum": ["EdDSA", "ES256", "RS256", "HS256"] - }, - "status": { - "type": "string", - "enum": ["in_use", "previously_used", "revoked", "standby"] - }, - "public_jwk": { - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - } - }, - "required": ["id", "algorithm", "status", "public_jwk", "created_at", "updated_at"], - "additionalProperties": false - } - } - }, - "required": ["keys"], - "additionalProperties": false + "type": "secret", + "name": "ci_secret_key", + "description": "CI deploy key" + } }, - "UpdateSigningKeyBody": { + "UpdateApiKeyBody": { "type": "object", "properties": { - "status": { + "name": { "type": "string", - "enum": ["in_use", "previously_used", "revoked", "standby"] + "minLength": 4, + "maxLength": 64, + "pattern": "^[a-z_][a-z0-9_]+$" + }, + "description": { + "type": "string", + "nullable": true + }, + "secret_jwt_template": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "nullable": true } }, - "required": ["status"], "example": { - "status": "standby" - }, - "additionalProperties": false + "name": "ci_secret_key_rotated", + "description": "Rotated after March release" + } }, - "AuthConfigResponse": { + "CreateBranchBody": { "type": "object", "properties": { - "api_max_request_duration": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true + "branch_name": { + "type": "string", + "minLength": 1 }, - "db_max_pool_size": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true + "git_branch": { + "type": "string" }, - "db_max_pool_size_unit": { - "type": "string", - "enum": ["connections", "percent", null], - "nullable": true + "is_default": { + "type": "boolean" }, - "disable_signup": { - "type": "boolean", - "nullable": true + "persistent": { + "type": "boolean" }, - "external_anonymous_users_enabled": { - "type": "boolean", - "nullable": true + "region": { + "type": "string" }, - "external_apple_additional_client_ids": { + "desired_instance_size": { "type": "string", - "nullable": true - }, - "external_apple_client_id": { - "type": "string", - "nullable": true - }, - "external_apple_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_apple_enabled": { - "type": "boolean", - "nullable": true + "enum": [ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory" + ] }, - "external_apple_secret": { + "release_channel": { "type": "string", - "nullable": true + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"], + "description": "Release channel. If not provided, GA will be used." }, - "external_azure_client_id": { + "postgres_engine": { "type": "string", - "nullable": true + "enum": ["15", "17", "17-oriole"], + "description": "Postgres engine version. If not provided, the latest version will be used." }, - "external_azure_email_optional": { - "type": "boolean", - "nullable": true + "secrets": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "external_azure_enabled": { - "type": "boolean", - "nullable": true + "with_data": { + "type": "boolean" }, - "external_azure_secret": { + "notify_url": { "type": "string", + "format": "uri", + "description": "HTTP endpoint to receive branch status updates." + } + }, + "required": ["branch_name"], + "example": { + "branch_name": "preview-login-page", + "git_branch": "feature/login-page", + "persistent": true, + "with_data": false, + "notify_url": "https://example.com/webhooks/branches" + } + }, + "UpdateCustomHostnameResponseJsonValue": { + "description": "Any JSON-serializable value", + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], "nullable": true }, - "external_azure_url": { - "type": "string", - "nullable": true + { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" + } }, - "external_bitbucket_client_id": { + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" + } + } + ] + }, + "UpdateCustomHostnameResponse": { + "type": "object", + "properties": { + "status": { "type": "string", - "nullable": true - }, - "external_bitbucket_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_bitbucket_enabled": { - "type": "boolean", - "nullable": true + "enum": [ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured" + ] }, - "external_bitbucket_secret": { - "type": "string", - "nullable": true + "custom_hostname": { + "type": "string" }, - "external_discord_client_id": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" + } + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpdateCustomHostnameResponseJsonValue" + } + }, + "result": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "ssl": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "validation_records": { + "type": "array", + "items": { + "type": "object", + "properties": { + "txt_name": { + "type": "string" + }, + "txt_value": { + "type": "string" + } + }, + "required": ["txt_name", "txt_value"] + } + }, + "validation_errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + } + }, + "required": ["status"] + }, + "ownership_verification": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["type", "name", "value"] + }, + "custom_origin_server": { + "type": "string" + }, + "verification_errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + }, + "required": ["id", "hostname", "ssl", "custom_origin_server", "status"] + } + }, + "required": ["success", "errors", "messages", "result"] + } + }, + "required": ["data"] + }, + "UpdateCustomHostnameBody": { + "type": "object", + "properties": { + "custom_hostname": { "type": "string", - "nullable": true - }, - "external_discord_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_discord_enabled": { - "type": "boolean", - "nullable": true - }, - "external_discord_secret": { + "minLength": 1, + "maxLength": 253 + } + }, + "required": ["custom_hostname"], + "example": { + "custom_hostname": "docs.example.com" + } + }, + "JitAccessRequestRequest": { + "type": "object", + "properties": { + "state": { "type": "string", - "nullable": true - }, - "external_email_enabled": { - "type": "boolean", - "nullable": true - }, - "external_facebook_client_id": { - "type": "string", - "nullable": true - }, - "external_facebook_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_facebook_enabled": { - "type": "boolean", - "nullable": true + "enum": ["enabled", "disabled"] + } + }, + "required": ["state"], + "example": { + "state": "enabled" + } + }, + "NetworkBanResponse": { + "type": "object", + "properties": { + "banned_ipv4_addresses": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["banned_ipv4_addresses"] + }, + "NetworkBanResponseEnriched": { + "type": "object", + "properties": { + "banned_ipv4_addresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "banned_address": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": ["banned_address", "identifier", "type"] + } + } + }, + "required": ["banned_ipv4_addresses"] + }, + "RemoveNetworkBanRequest": { + "type": "object", + "properties": { + "ipv4_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of IP addresses to unban." }, - "external_facebook_secret": { - "type": "string", - "nullable": true + "requester_ip": { + "default": false, + "description": "Include requester's public IP in the list of addresses to unban.", + "type": "boolean" }, - "external_figma_client_id": { + "identifier": { + "type": "string" + } + }, + "required": ["ipv4_addresses"], + "example": { + "ipv4_addresses": ["203.0.113.10"], + "requester_ip": false + } + }, + "NetworkRestrictionsResponse": { + "type": "object", + "properties": { + "entitlement": { "type": "string", - "nullable": true - }, - "external_figma_email_optional": { - "type": "boolean", - "nullable": true + "enum": ["disallowed", "allowed"] }, - "external_figma_enabled": { - "type": "boolean", - "nullable": true + "config": { + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dbAllowedCidrsV6": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "example": { + "dbAllowedCidrs": ["203.0.113.0/24"], + "dbAllowedCidrsV6": ["2001:db8::/32"] + }, + "description": "At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`." }, - "external_figma_secret": { - "type": "string", - "nullable": true + "old_config": { + "description": "Populated when a new config has been received, but not registered as successfully applied to a project.", + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dbAllowedCidrsV6": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "example": { + "dbAllowedCidrs": ["203.0.113.0/24"], + "dbAllowedCidrsV6": ["2001:db8::/32"] + } }, - "external_github_client_id": { + "status": { "type": "string", - "nullable": true - }, - "external_github_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_github_enabled": { - "type": "boolean", - "nullable": true + "enum": ["stored", "applied"] }, - "external_github_secret": { + "updated_at": { "type": "string", - "nullable": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "external_gitlab_client_id": { + "applied_at": { "type": "string", - "nullable": true - }, - "external_gitlab_email_optional": { - "type": "boolean", - "nullable": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": ["entitlement", "config", "status"] + }, + "NetworkRestrictionsRequest": { + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "string" + } }, - "external_gitlab_enabled": { - "type": "boolean", - "nullable": true + "dbAllowedCidrsV6": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "example": { + "dbAllowedCidrs": ["203.0.113.0/24"], + "dbAllowedCidrsV6": ["2001:db8::/32"] + } + }, + "NetworkRestrictionsPatchRequest": { + "type": "object", + "properties": { + "add": { + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dbAllowedCidrsV6": { + "type": "array", + "items": { + "type": "string" + } + } + } }, - "external_gitlab_secret": { - "type": "string", - "nullable": true + "remove": { + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "dbAllowedCidrsV6": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "example": { + "add": { + "dbAllowedCidrs": ["203.0.113.0/24"] }, - "external_gitlab_url": { + "remove": { + "dbAllowedCidrs": ["198.51.100.0/24"] + } + } + }, + "NetworkRestrictionsV2Response": { + "type": "object", + "properties": { + "entitlement": { "type": "string", - "nullable": true + "enum": ["disallowed", "allowed"] }, - "external_google_additional_client_ids": { - "type": "string", - "nullable": true - }, - "external_google_client_id": { - "type": "string", - "nullable": true - }, - "external_google_email_optional": { - "type": "boolean", - "nullable": true + "config": { + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["v4", "v6"] + } + }, + "required": ["address", "type"] + } + } + }, + "description": "At any given point in time, this is the config that the user has requested be applied to their project. The `status` field indicates if it has been applied to the project, or is pending. When an updated config is received, the applied config is moved to `old_config`." }, - "external_google_enabled": { - "type": "boolean", - "nullable": true + "old_config": { + "description": "Populated when a new config has been received, but not registered as successfully applied to a project.", + "type": "object", + "properties": { + "dbAllowedCidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["v4", "v6"] + } + }, + "required": ["address", "type"] + } + } + } }, - "external_google_secret": { + "updated_at": { "type": "string", - "nullable": true - }, - "external_google_skip_nonce_check": { - "type": "boolean", - "nullable": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "external_kakao_client_id": { + "applied_at": { "type": "string", - "nullable": true - }, - "external_kakao_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_kakao_enabled": { - "type": "boolean", - "nullable": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "external_kakao_secret": { + "status": { "type": "string", - "nullable": true - }, - "external_keycloak_client_id": { + "enum": ["stored", "applied"] + } + }, + "required": ["entitlement", "config", "status"] + }, + "PgsodiumConfigResponse": { + "type": "object", + "properties": { + "root_key": { "type": "string", - "nullable": true - }, - "external_keycloak_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_keycloak_enabled": { - "type": "boolean", - "nullable": true - }, - "external_keycloak_secret": { + "description": "The pgsodium root key: 32 bytes, hex-encoded (64 characters)." + } + }, + "required": ["root_key"], + "example": { + "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + "UpdatePgsodiumConfigBody": { + "type": "object", + "properties": { + "root_key": { "type": "string", - "nullable": true + "description": "The pgsodium root key: 32 bytes, hex-encoded (64 characters)." + } + }, + "required": ["root_key"], + "example": { + "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + "PostgrestConfigWithJWTSecretResponse": { + "type": "object", + "properties": { + "db_schema": { + "type": "string" }, - "external_keycloak_url": { - "type": "string", - "nullable": true + "max_rows": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "external_linkedin_oidc_client_id": { - "type": "string", - "nullable": true + "db_extra_search_path": { + "type": "string" }, - "external_linkedin_oidc_email_optional": { - "type": "boolean", + "db_pool": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, the value is automatically configured based on compute size.", "nullable": true }, - "external_linkedin_oidc_enabled": { - "type": "boolean", + "db_pool_acquisition_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, the value is automatically configured to 10.", "nullable": true }, - "external_linkedin_oidc_secret": { - "type": "string", - "nullable": true + "jwt_secret": { + "type": "string" + } + }, + "required": [ + "db_schema", + "max_rows", + "db_extra_search_path", + "db_pool", + "db_pool_acquisition_timeout" + ] + }, + "V1UpdatePostgrestConfigBody": { + "type": "object", + "properties": { + "db_extra_search_path": { + "type": "string" }, - "external_slack_oidc_client_id": { - "type": "string", - "nullable": true + "db_schema": { + "type": "string" }, - "external_slack_oidc_email_optional": { - "type": "boolean", - "nullable": true + "max_rows": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 }, - "external_slack_oidc_enabled": { - "type": "boolean", - "nullable": true + "db_pool": { + "type": "integer", + "minimum": 0, + "maximum": 1000 }, - "external_slack_oidc_secret": { - "type": "string", - "nullable": true + "db_pool_acquisition_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 60 + } + }, + "example": { + "db_schema": "public,storage", + "db_pool": 20, + "max_rows": 1000 + } + }, + "V1PostgrestConfigResponse": { + "type": "object", + "properties": { + "db_schema": { + "type": "string" }, - "external_notion_client_id": { - "type": "string", - "nullable": true + "max_rows": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "external_notion_email_optional": { - "type": "boolean", - "nullable": true + "db_extra_search_path": { + "type": "string" }, - "external_notion_enabled": { - "type": "boolean", + "db_pool": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, the value is automatically configured based on compute size.", "nullable": true }, - "external_notion_secret": { - "type": "string", + "db_pool_acquisition_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, the value is automatically configured to 10.", "nullable": true + } + }, + "required": [ + "db_schema", + "max_rows", + "db_extra_search_path", + "db_pool", + "db_pool_acquisition_timeout" + ] + }, + "V1ProjectRefResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "external_phone_enabled": { - "type": "boolean", - "nullable": true + "ref": { + "type": "string" }, - "external_slack_client_id": { + "name": { + "type": "string" + } + }, + "required": ["id", "ref", "name"] + }, + "V1UpdateProjectBody": { + "type": "object", + "properties": { + "name": { "type": "string", - "nullable": true - }, - "external_slack_email_optional": { - "type": "boolean", - "nullable": true + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["name"], + "example": { + "name": "Acme Platform" + } + }, + "SecretResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "external_slack_enabled": { - "type": "boolean", - "nullable": true + "value": { + "type": "string" }, - "external_slack_secret": { - "type": "string", - "nullable": true + "updated_at": { + "type": "string" + } + }, + "required": ["name", "value"] + }, + "CreateSecretBody": { + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 256, + "pattern": "^(?!SUPABASE_).*", + "description": "Secret name must not start with the SUPABASE_ prefix." + }, + "value": { + "type": "string", + "maxLength": 24576 + } }, - "external_spotify_client_id": { - "type": "string", - "nullable": true + "required": ["name", "value"] + }, + "example": [ + { + "name": "OPENAI_API_KEY", + "value": "sk-example-secret" }, - "external_spotify_email_optional": { - "type": "boolean", - "nullable": true + { + "name": "STRIPE_WEBHOOK_SECRET", + "value": "whsec_example" + } + ] + }, + "DeleteSecretsBody": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["OPENAI_API_KEY"] + }, + "SslEnforcementResponse": { + "type": "object", + "properties": { + "currentConfig": { + "type": "object", + "properties": { + "database": { + "type": "boolean" + } + }, + "required": ["database"] }, - "external_spotify_enabled": { - "type": "boolean", - "nullable": true + "appliedSuccessfully": { + "type": "boolean" + } + }, + "required": ["currentConfig", "appliedSuccessfully"] + }, + "SslEnforcementRequest": { + "type": "object", + "properties": { + "requestedConfig": { + "type": "object", + "properties": { + "database": { + "type": "boolean" + } + }, + "required": ["database"] + } + }, + "required": ["requestedConfig"], + "example": { + "requestedConfig": { + "database": true + } + } + }, + "TypescriptResponse": { + "type": "object", + "properties": { + "types": { + "type": "string" + } + }, + "required": ["types"] + }, + "VanitySubdomainConfigResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["not-used", "custom-domain-used", "active"] }, - "external_spotify_secret": { + "custom_domain": { "type": "string", - "nullable": true + "minLength": 1 + } + }, + "required": ["status"] + }, + "PlanGateErrorBody": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Human-readable explanation of the plan gate" }, - "external_twitch_client_id": { + "error": { + "description": "Present on entitlement denials. Other errors with this status code (validation, billing state) carry only message.", + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Machine-readable marker for plan-gated denials", + "enum": ["entitlement_required"] + }, + "feature": { + "type": "string", + "description": "Entitlement feature key that failed the check" + }, + "upgrade_url": { + "description": "Billing page URL for the organization, present when the org is resolvable", + "type": "string" + } + }, + "required": ["code", "feature"] + } + }, + "required": ["message"] + }, + "VanitySubdomainBody": { + "type": "object", + "properties": { + "vanity_subdomain": { "type": "string", - "nullable": true + "maxLength": 63 + } + }, + "required": ["vanity_subdomain"], + "example": { + "vanity_subdomain": "acme-prod" + } + }, + "SubdomainAvailabilityResponse": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + } + }, + "required": ["available"] + }, + "ActivateVanitySubdomainResponse": { + "type": "object", + "properties": { + "custom_domain": { + "type": "string" + } + }, + "required": ["custom_domain"] + }, + "UpgradeDatabaseBody": { + "type": "object", + "properties": { + "target_version": { + "type": "string" }, - "external_twitch_email_optional": { - "type": "boolean", - "nullable": true + "release_channel": { + "type": "string", + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + } + }, + "required": ["target_version"], + "example": { + "target_version": "17", + "release_channel": "ga" + } + }, + "ProjectUpgradeInitiateResponse": { + "type": "object", + "properties": { + "tracking_id": { + "type": "string" + } + }, + "required": ["tracking_id"] + }, + "ProjectUpgradeEligibilityResponse": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" }, - "external_twitch_enabled": { - "type": "boolean", - "nullable": true + "current_app_version": { + "type": "string" }, - "external_twitch_secret": { + "current_app_version_release_channel": { "type": "string", - "nullable": true + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] }, - "external_twitter_client_id": { - "type": "string", - "nullable": true - }, - "external_twitter_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_twitter_enabled": { - "type": "boolean", - "nullable": true - }, - "external_twitter_secret": { - "type": "string", - "nullable": true + "latest_app_version": { + "type": "string" }, - "external_x_client_id": { - "type": "string", - "nullable": true + "target_upgrade_versions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "postgres_version": { + "type": "string", + "enum": ["13", "14", "15", "17", "17-oriole"] + }, + "release_channel": { + "type": "string", + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + }, + "app_version": { + "type": "string" + } + }, + "required": ["postgres_version", "release_channel", "app_version"] + } }, - "external_x_email_optional": { - "type": "boolean", - "nullable": true + "duration_estimate_hours": { + "type": "number" }, - "external_x_enabled": { - "type": "boolean", - "nullable": true + "legacy_auth_custom_roles": { + "type": "array", + "items": { + "type": "string" + } }, - "external_x_secret": { - "type": "string", - "nullable": true + "objects_to_be_dropped": { + "type": "array", + "items": { + "type": "string" + }, + "deprecated": true, + "description": "Use validation_errors instead." }, - "external_workos_client_id": { - "type": "string", - "nullable": true + "unsupported_extensions": { + "type": "array", + "items": { + "type": "string" + }, + "deprecated": true, + "description": "Use validation_errors instead." }, - "external_workos_enabled": { - "type": "boolean", - "nullable": true + "user_defined_objects_in_internal_schemas": { + "type": "array", + "items": { + "type": "string" + }, + "deprecated": true, + "description": "Use validation_errors instead." }, - "external_workos_secret": { - "type": "string", - "nullable": true + "validation_errors": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["objects_depending_on_pg_cron"] + }, + "dependents": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "dependents"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["indexes_referencing_ll_to_earth"] + }, + "schema_name": { + "type": "string" + }, + "table_name": { + "type": "string" + }, + "index_name": { + "type": "string" + } + }, + "required": ["type", "schema_name", "table_name", "index_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["function_using_obsolete_lang"] + }, + "schema_name": { + "type": "string" + }, + "function_name": { + "type": "string" + }, + "lang_name": { + "type": "string" + } + }, + "required": ["type", "schema_name", "function_name", "lang_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unsupported_extension"] + }, + "extension_name": { + "type": "string" + } + }, + "required": ["type", "extension_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unsupported_fdw_handler"] + }, + "fdw_name": { + "type": "string" + }, + "fdw_handler_name": { + "type": "string" + } + }, + "required": ["type", "fdw_name", "fdw_handler_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unlogged_table_with_persistent_sequence"] + }, + "schema_name": { + "type": "string" + }, + "table_name": { + "type": "string" + }, + "sequence_name": { + "type": "string" + } + }, + "required": ["type", "schema_name", "table_name", "sequence_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["user_defined_objects_in_internal_schemas"] + }, + "obj_type": { + "anyOf": [ + { + "type": "string", + "enum": ["table"] + }, + { + "type": "string", + "enum": ["function"] + } + ] + }, + "schema_name": { + "type": "string" + }, + "obj_name": { + "type": "string" + } + }, + "required": ["type", "obj_type", "schema_name", "obj_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["active_replication_slot"] + }, + "slot_name": { + "type": "string" + } + }, + "required": ["type", "slot_name"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["x86_architecture"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["project_hibernating"] + } + }, + "required": ["type"] + } + ] + } }, - "external_workos_url": { - "type": "string", - "nullable": true - }, - "external_web3_solana_enabled": { - "type": "boolean", - "nullable": true - }, - "external_web3_ethereum_enabled": { - "type": "boolean", - "nullable": true - }, - "external_zoom_client_id": { - "type": "string", - "nullable": true - }, - "external_zoom_email_optional": { - "type": "boolean", - "nullable": true - }, - "external_zoom_enabled": { - "type": "boolean", - "nullable": true - }, - "external_zoom_secret": { - "type": "string", - "nullable": true - }, - "hook_custom_access_token_enabled": { - "type": "boolean", - "nullable": true - }, - "hook_custom_access_token_uri": { - "type": "string", - "nullable": true - }, - "hook_custom_access_token_secrets": { - "type": "string", - "nullable": true - }, - "hook_mfa_verification_attempt_enabled": { - "type": "boolean", - "nullable": true - }, - "hook_mfa_verification_attempt_uri": { - "type": "string", - "nullable": true - }, - "hook_mfa_verification_attempt_secrets": { - "type": "string", - "nullable": true - }, - "hook_password_verification_attempt_enabled": { - "type": "boolean", - "nullable": true - }, - "hook_password_verification_attempt_uri": { - "type": "string", - "nullable": true - }, - "hook_password_verification_attempt_secrets": { - "type": "string", + "warnings": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["pg_graphql_introspection_change"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["ltree_reindex_required"] + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["operator_estimator_gate"] + } + }, + "required": ["type"] + } + ] + } + } + }, + "required": [ + "eligible", + "current_app_version", + "current_app_version_release_channel", + "latest_app_version", + "target_upgrade_versions", + "duration_estimate_hours", + "legacy_auth_custom_roles", + "objects_to_be_dropped", + "unsupported_extensions", + "user_defined_objects_in_internal_schemas", + "validation_errors", + "warnings" + ] + }, + "DatabaseUpgradeStatusResponse": { + "type": "object", + "properties": { + "databaseUpgradeStatus": { + "type": "object", + "properties": { + "initiated_at": { + "type": "string" + }, + "latest_status_at": { + "type": "string" + }, + "target_version": { + "type": "number" + }, + "error": { + "type": "string", + "enum": [ + "1_upgraded_instance_launch_failed", + "2_volume_detachchment_from_upgraded_instance_failed", + "3_volume_attachment_to_original_instance_failed", + "4_data_upgrade_initiation_failed", + "5_data_upgrade_completion_failed", + "6_volume_detachchment_from_original_instance_failed", + "7_volume_attachment_to_upgraded_instance_failed", + "8_upgrade_completion_failed", + "9_post_physical_backup_failed" + ] + }, + "progress": { + "type": "string", + "enum": [ + "0_requested", + "1_started", + "2_launched_upgraded_instance", + "3_detached_volume_from_upgraded_instance", + "4_attached_volume_to_original_instance", + "5_initiated_data_upgrade", + "6_completed_data_upgrade", + "7_detached_volume_from_original_instance", + "8_attached_volume_to_upgraded_instance", + "9_completed_upgrade", + "10_completed_post_physical_backup" + ] + }, + "status": { + "type": "number" + } + }, + "required": ["initiated_at", "latest_status_at", "target_version", "status"], "nullable": true + } + }, + "required": ["databaseUpgradeStatus"] + }, + "ReadOnlyStatusResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "hook_send_sms_enabled": { - "type": "boolean", - "nullable": true + "override_enabled": { + "type": "boolean" }, - "hook_send_sms_uri": { + "override_active_until": { + "type": "string" + } + }, + "required": ["enabled", "override_enabled", "override_active_until"] + }, + "SetUpReadReplicaBody": { + "type": "object", + "properties": { + "read_replica_region": { "type": "string", - "nullable": true - }, - "hook_send_sms_secrets": { + "enum": [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1" + ], + "description": "Region you want your read replica to reside in" + } + }, + "required": ["read_replica_region"], + "example": { + "read_replica_region": "us-west-1" + } + }, + "RemoveReadReplicaBody": { + "type": "object", + "properties": { + "database_identifier": { + "type": "string" + } + }, + "required": ["database_identifier"], + "example": { + "database_identifier": "abcdefghijklmnopqrst-rr-us-west-1-abcde" + } + }, + "V1ServiceHealthResponse": { + "type": "object", + "properties": { + "name": { "type": "string", - "nullable": true + "enum": [ + "auth", + "db", + "db_postgres_user", + "pooler", + "realtime", + "rest", + "storage", + "pg_bouncer" + ] }, - "hook_send_email_enabled": { + "healthy": { "type": "boolean", - "nullable": true + "deprecated": true, + "description": "Deprecated. Use `status` instead." }, - "hook_send_email_uri": { + "status": { "type": "string", - "nullable": true + "enum": ["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"] }, - "hook_send_email_secrets": { - "type": "string", - "nullable": true - }, - "hook_before_user_created_enabled": { - "type": "boolean", - "nullable": true - }, - "hook_before_user_created_uri": { - "type": "string", - "nullable": true - }, - "hook_before_user_created_secrets": { - "type": "string", - "nullable": true - }, - "hook_after_user_created_enabled": { - "type": "boolean", - "nullable": true - }, - "hook_after_user_created_uri": { - "type": "string", - "nullable": true - }, - "hook_after_user_created_secrets": { - "type": "string", - "nullable": true - }, - "jwt_exp": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true - }, - "mailer_allow_unverified_email_sign_ins": { - "type": "boolean", - "nullable": true - }, - "mailer_autoconfirm": { - "type": "boolean", - "nullable": true - }, - "mailer_otp_exp": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "mailer_otp_length": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true - }, - "mailer_secure_email_change_enabled": { - "type": "boolean", - "nullable": true - }, - "mailer_subjects_confirmation": { - "type": "string", - "nullable": true - }, - "mailer_subjects_email_change": { - "type": "string", - "nullable": true - }, - "mailer_subjects_invite": { - "type": "string", - "nullable": true - }, - "mailer_subjects_magic_link": { - "type": "string", - "nullable": true - }, - "mailer_subjects_reauthentication": { - "type": "string", - "nullable": true - }, - "mailer_subjects_recovery": { - "type": "string", - "nullable": true - }, - "mailer_subjects_password_changed_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_email_changed_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_phone_changed_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_mfa_factor_enrolled_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_mfa_factor_unenrolled_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_identity_linked_notification": { - "type": "string", - "nullable": true - }, - "mailer_subjects_identity_unlinked_notification": { - "type": "string", - "nullable": true - }, - "mailer_templates_confirmation_content": { - "type": "string", - "nullable": true - }, - "mailer_templates_email_change_content": { - "type": "string", - "nullable": true - }, - "mailer_templates_invite_content": { - "type": "string", - "nullable": true - }, - "mailer_templates_magic_link_content": { - "type": "string", - "nullable": true - }, - "mailer_templates_reauthentication_content": { - "type": "string", - "nullable": true + "info": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["GoTrue"] + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name", "version", "description"] + }, + { + "type": "object", + "properties": { + "healthy": { + "type": "boolean", + "deprecated": true, + "description": "Deprecated. Use `status` instead." + }, + "db_connected": { + "type": "boolean" + }, + "replication_connected": { + "type": "boolean" + }, + "connected_cluster": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "healthy", + "db_connected", + "replication_connected", + "connected_cluster" + ] + }, + { + "type": "object", + "properties": { + "db_schema": { + "type": "string" + } + }, + "required": ["db_schema"] + } + ] }, - "mailer_templates_recovery_content": { + "error": { + "type": "string" + } + }, + "required": ["name", "healthy", "status"] + }, + "SigningKeyResponse": { + "type": "object", + "properties": { + "id": { "type": "string", - "nullable": true + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "mailer_templates_password_changed_notification_content": { + "algorithm": { "type": "string", - "nullable": true + "enum": ["EdDSA", "ES256", "RS256", "HS256"] }, - "mailer_templates_email_changed_notification_content": { + "status": { "type": "string", - "nullable": true + "enum": ["in_use", "previously_used", "revoked", "standby"] }, - "mailer_templates_phone_changed_notification_content": { - "type": "string", + "public_jwk": { "nullable": true }, - "mailer_templates_mfa_factor_enrolled_notification_content": { + "created_at": { "type": "string", - "nullable": true + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "mailer_templates_mfa_factor_unenrolled_notification_content": { + "updated_at": { "type": "string", - "nullable": true - }, - "mailer_templates_identity_linked_notification_content": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": ["id", "algorithm", "status", "public_jwk", "created_at", "updated_at"], + "additionalProperties": false + }, + "CreateSigningKeyBody": { + "type": "object", + "properties": { + "algorithm": { "type": "string", - "nullable": true + "enum": ["EdDSA", "ES256", "RS256", "HS256"] }, - "mailer_templates_identity_unlinked_notification_content": { + "status": { "type": "string", - "nullable": true - }, - "mailer_notifications_password_changed_enabled": { - "type": "boolean", - "nullable": true - }, - "mailer_notifications_email_changed_enabled": { - "type": "boolean", - "nullable": true - }, - "mailer_notifications_phone_changed_enabled": { - "type": "boolean", - "nullable": true - }, - "mailer_notifications_mfa_factor_enrolled_enabled": { - "type": "boolean", - "nullable": true - }, - "mailer_notifications_mfa_factor_unenrolled_enabled": { - "type": "boolean", - "nullable": true + "enum": ["in_use", "standby"] }, - "mailer_notifications_identity_linked_enabled": { - "type": "boolean", - "nullable": true + "private_jwk": { + "oneOf": [ + { + "type": "object", + "properties": { + "kid": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "use": { + "type": "string", + "enum": ["sig"] + }, + "key_ops": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "enum": ["sign", "verify"] + } + }, + "ext": { + "type": "boolean", + "enum": [true] + }, + "kty": { + "type": "string", + "enum": ["RSA"] + }, + "alg": { + "type": "string", + "enum": ["RS256"] + }, + "n": { + "type": "string" + }, + "e": { + "type": "string", + "enum": ["AQAB"] + }, + "d": { + "type": "string" + }, + "p": { + "type": "string" + }, + "q": { + "type": "string" + }, + "dp": { + "type": "string" + }, + "dq": { + "type": "string" + }, + "qi": { + "type": "string" + } + }, + "required": ["kty", "n", "e", "d", "p", "q", "dp", "dq", "qi"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kid": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "use": { + "type": "string", + "enum": ["sig"] + }, + "key_ops": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "enum": ["sign", "verify"] + } + }, + "ext": { + "type": "boolean", + "enum": [true] + }, + "kty": { + "type": "string", + "enum": ["EC"] + }, + "alg": { + "type": "string", + "enum": ["ES256"] + }, + "crv": { + "type": "string", + "enum": ["P-256"] + }, + "x": { + "type": "string" + }, + "y": { + "type": "string" + }, + "d": { + "type": "string" + } + }, + "required": ["kty", "crv", "x", "y", "d"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kid": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "use": { + "type": "string", + "enum": ["sig"] + }, + "key_ops": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "enum": ["sign", "verify"] + } + }, + "ext": { + "type": "boolean", + "enum": [true] + }, + "kty": { + "type": "string", + "enum": ["OKP"] + }, + "alg": { + "type": "string", + "enum": ["EdDSA"] + }, + "crv": { + "type": "string", + "enum": ["Ed25519"] + }, + "x": { + "type": "string" + }, + "d": { + "type": "string" + } + }, + "required": ["kty", "crv", "x", "d"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kid": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "use": { + "type": "string", + "enum": ["sig"] + }, + "key_ops": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string", + "enum": ["sign", "verify"] + } + }, + "ext": { + "type": "boolean", + "enum": [true] + }, + "kty": { + "type": "string", + "enum": ["oct"] + }, + "alg": { + "type": "string", + "enum": ["HS256"] + }, + "k": { + "type": "string", + "minLength": 16 + } + }, + "required": ["kty", "k"], + "additionalProperties": false + } + ] + } + }, + "required": ["algorithm"], + "example": { + "algorithm": "RS256", + "status": "standby" + }, + "additionalProperties": false + }, + "SigningKeysResponse": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "algorithm": { + "type": "string", + "enum": ["EdDSA", "ES256", "RS256", "HS256"] + }, + "status": { + "type": "string", + "enum": ["in_use", "previously_used", "revoked", "standby"] + }, + "public_jwk": { + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": ["id", "algorithm", "status", "public_jwk", "created_at", "updated_at"], + "additionalProperties": false + } + } + }, + "required": ["keys"], + "additionalProperties": false + }, + "UpdateSigningKeyBody": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["in_use", "previously_used", "revoked", "standby"] + } + }, + "required": ["status"], + "example": { + "status": "standby" + }, + "additionalProperties": false + }, + "AuthConfigResponse": { + "type": "object", + "properties": { + "api_max_request_duration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "db_max_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "db_max_pool_size_unit": { + "type": "string", + "enum": ["connections", "percent", null], + "nullable": true + }, + "disable_signup": { + "type": "boolean", + "nullable": true + }, + "external_anonymous_users_enabled": { + "type": "boolean", + "nullable": true + }, + "external_apple_additional_client_ids": { + "type": "string", + "nullable": true + }, + "external_apple_client_id": { + "type": "string", + "nullable": true + }, + "external_apple_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_apple_enabled": { + "type": "boolean", + "nullable": true + }, + "external_apple_secret": { + "type": "string", + "nullable": true + }, + "external_azure_client_id": { + "type": "string", + "nullable": true + }, + "external_azure_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_azure_enabled": { + "type": "boolean", + "nullable": true + }, + "external_azure_secret": { + "type": "string", + "nullable": true + }, + "external_azure_url": { + "type": "string", + "nullable": true + }, + "external_bitbucket_client_id": { + "type": "string", + "nullable": true + }, + "external_bitbucket_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_bitbucket_enabled": { + "type": "boolean", + "nullable": true + }, + "external_bitbucket_secret": { + "type": "string", + "nullable": true + }, + "external_discord_client_id": { + "type": "string", + "nullable": true + }, + "external_discord_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_discord_enabled": { + "type": "boolean", + "nullable": true + }, + "external_discord_secret": { + "type": "string", + "nullable": true + }, + "external_email_enabled": { + "type": "boolean", + "nullable": true + }, + "external_facebook_client_id": { + "type": "string", + "nullable": true + }, + "external_facebook_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_facebook_enabled": { + "type": "boolean", + "nullable": true + }, + "external_facebook_secret": { + "type": "string", + "nullable": true + }, + "external_figma_client_id": { + "type": "string", + "nullable": true + }, + "external_figma_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_figma_enabled": { + "type": "boolean", + "nullable": true + }, + "external_figma_secret": { + "type": "string", + "nullable": true + }, + "external_github_client_id": { + "type": "string", + "nullable": true + }, + "external_github_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_github_enabled": { + "type": "boolean", + "nullable": true + }, + "external_github_secret": { + "type": "string", + "nullable": true + }, + "external_gitlab_client_id": { + "type": "string", + "nullable": true + }, + "external_gitlab_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_gitlab_enabled": { + "type": "boolean", + "nullable": true + }, + "external_gitlab_secret": { + "type": "string", + "nullable": true + }, + "external_gitlab_url": { + "type": "string", + "nullable": true + }, + "external_google_additional_client_ids": { + "type": "string", + "nullable": true + }, + "external_google_client_id": { + "type": "string", + "nullable": true + }, + "external_google_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_google_enabled": { + "type": "boolean", + "nullable": true + }, + "external_google_secret": { + "type": "string", + "nullable": true + }, + "external_google_skip_nonce_check": { + "type": "boolean", + "nullable": true + }, + "external_kakao_client_id": { + "type": "string", + "nullable": true + }, + "external_kakao_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_kakao_enabled": { + "type": "boolean", + "nullable": true + }, + "external_kakao_secret": { + "type": "string", + "nullable": true + }, + "external_keycloak_client_id": { + "type": "string", + "nullable": true + }, + "external_keycloak_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_keycloak_enabled": { + "type": "boolean", + "nullable": true + }, + "external_keycloak_secret": { + "type": "string", + "nullable": true + }, + "external_keycloak_url": { + "type": "string", + "nullable": true + }, + "external_linkedin_oidc_client_id": { + "type": "string", + "nullable": true + }, + "external_linkedin_oidc_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_linkedin_oidc_enabled": { + "type": "boolean", + "nullable": true + }, + "external_linkedin_oidc_secret": { + "type": "string", + "nullable": true + }, + "external_slack_oidc_client_id": { + "type": "string", + "nullable": true + }, + "external_slack_oidc_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_slack_oidc_enabled": { + "type": "boolean", + "nullable": true + }, + "external_slack_oidc_secret": { + "type": "string", + "nullable": true + }, + "external_notion_client_id": { + "type": "string", + "nullable": true + }, + "external_notion_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_notion_enabled": { + "type": "boolean", + "nullable": true + }, + "external_notion_secret": { + "type": "string", + "nullable": true + }, + "external_phone_enabled": { + "type": "boolean", + "nullable": true + }, + "external_slack_client_id": { + "type": "string", + "nullable": true + }, + "external_slack_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_slack_enabled": { + "type": "boolean", + "nullable": true + }, + "external_slack_secret": { + "type": "string", + "nullable": true + }, + "external_spotify_client_id": { + "type": "string", + "nullable": true + }, + "external_spotify_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_spotify_enabled": { + "type": "boolean", + "nullable": true + }, + "external_spotify_secret": { + "type": "string", + "nullable": true + }, + "external_twitch_client_id": { + "type": "string", + "nullable": true + }, + "external_twitch_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_twitch_enabled": { + "type": "boolean", + "nullable": true + }, + "external_twitch_secret": { + "type": "string", + "nullable": true + }, + "external_twitter_client_id": { + "type": "string", + "nullable": true + }, + "external_twitter_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_twitter_enabled": { + "type": "boolean", + "nullable": true + }, + "external_twitter_secret": { + "type": "string", + "nullable": true + }, + "external_x_client_id": { + "type": "string", + "nullable": true + }, + "external_x_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_x_enabled": { + "type": "boolean", + "nullable": true + }, + "external_x_secret": { + "type": "string", + "nullable": true + }, + "external_workos_client_id": { + "type": "string", + "nullable": true + }, + "external_workos_enabled": { + "type": "boolean", + "nullable": true + }, + "external_workos_secret": { + "type": "string", + "nullable": true + }, + "external_workos_url": { + "type": "string", + "nullable": true + }, + "external_web3_solana_enabled": { + "type": "boolean", + "nullable": true + }, + "external_web3_ethereum_enabled": { + "type": "boolean", + "nullable": true + }, + "external_zoom_client_id": { + "type": "string", + "nullable": true + }, + "external_zoom_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_zoom_enabled": { + "type": "boolean", + "nullable": true + }, + "external_zoom_secret": { + "type": "string", + "nullable": true + }, + "hook_custom_access_token_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_custom_access_token_uri": { + "type": "string", + "nullable": true + }, + "hook_custom_access_token_secrets": { + "type": "string", + "nullable": true + }, + "hook_mfa_verification_attempt_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_mfa_verification_attempt_uri": { + "type": "string", + "nullable": true + }, + "hook_mfa_verification_attempt_secrets": { + "type": "string", + "nullable": true + }, + "hook_password_verification_attempt_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_password_verification_attempt_uri": { + "type": "string", + "nullable": true + }, + "hook_password_verification_attempt_secrets": { + "type": "string", + "nullable": true + }, + "hook_send_sms_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_send_sms_uri": { + "type": "string", + "nullable": true + }, + "hook_send_sms_secrets": { + "type": "string", + "nullable": true + }, + "hook_send_email_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_send_email_uri": { + "type": "string", + "nullable": true + }, + "hook_send_email_secrets": { + "type": "string", + "nullable": true + }, + "hook_before_user_created_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_before_user_created_uri": { + "type": "string", + "nullable": true + }, + "hook_before_user_created_secrets": { + "type": "string", + "nullable": true + }, + "hook_after_user_created_enabled": { + "type": "boolean", + "nullable": true + }, + "hook_after_user_created_uri": { + "type": "string", + "nullable": true + }, + "hook_after_user_created_secrets": { + "type": "string", + "nullable": true + }, + "jwt_exp": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "mailer_allow_unverified_email_sign_ins": { + "type": "boolean", + "nullable": true + }, + "mailer_autoconfirm": { + "type": "boolean", + "nullable": true + }, + "mailer_otp_exp": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "mailer_otp_length": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "mailer_secure_email_change_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_subjects_confirmation": { + "type": "string", + "nullable": true + }, + "mailer_subjects_email_change": { + "type": "string", + "nullable": true + }, + "mailer_subjects_invite": { + "type": "string", + "nullable": true + }, + "mailer_subjects_magic_link": { + "type": "string", + "nullable": true + }, + "mailer_subjects_reauthentication": { + "type": "string", + "nullable": true + }, + "mailer_subjects_recovery": { + "type": "string", + "nullable": true + }, + "mailer_subjects_password_changed_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_email_changed_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_phone_changed_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_mfa_factor_enrolled_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_mfa_factor_unenrolled_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_identity_linked_notification": { + "type": "string", + "nullable": true + }, + "mailer_subjects_identity_unlinked_notification": { + "type": "string", + "nullable": true + }, + "mailer_templates_confirmation_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_email_change_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_invite_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_magic_link_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_reauthentication_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_recovery_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_password_changed_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_email_changed_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_phone_changed_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_mfa_factor_enrolled_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_mfa_factor_unenrolled_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_identity_linked_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_templates_identity_unlinked_notification_content": { + "type": "string", + "nullable": true + }, + "mailer_notifications_password_changed_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_notifications_email_changed_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_notifications_phone_changed_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_notifications_mfa_factor_enrolled_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_notifications_mfa_factor_unenrolled_enabled": { + "type": "boolean", + "nullable": true + }, + "mailer_notifications_identity_linked_enabled": { + "type": "boolean", + "nullable": true }, "mailer_notifications_identity_unlinked_enabled": { "type": "boolean", @@ -16396,4469 +17724,7002 @@ "type": "boolean", "nullable": true }, - "external_twitch_secret": { + "external_twitch_secret": { + "type": "string", + "nullable": true + }, + "external_twitter_enabled": { + "type": "boolean", + "nullable": true + }, + "external_twitter_client_id": { + "type": "string", + "nullable": true + }, + "external_twitter_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_twitter_secret": { + "type": "string", + "nullable": true + }, + "external_x_enabled": { + "type": "boolean", + "nullable": true + }, + "external_x_client_id": { + "type": "string", + "nullable": true + }, + "external_x_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_x_secret": { + "type": "string", + "nullable": true + }, + "external_workos_enabled": { + "type": "boolean", + "nullable": true + }, + "external_workos_client_id": { + "type": "string", + "nullable": true + }, + "external_workos_secret": { + "type": "string", + "nullable": true + }, + "external_workos_url": { + "type": "string", + "nullable": true + }, + "external_web3_solana_enabled": { + "type": "boolean", + "nullable": true + }, + "external_web3_ethereum_enabled": { + "type": "boolean", + "nullable": true + }, + "external_zoom_enabled": { + "type": "boolean", + "nullable": true + }, + "external_zoom_client_id": { + "type": "string", + "nullable": true + }, + "external_zoom_email_optional": { + "type": "boolean", + "nullable": true + }, + "external_zoom_secret": { + "type": "string", + "nullable": true + }, + "db_max_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "db_max_pool_size_unit": { + "type": "string", + "enum": ["connections", "percent", null], + "nullable": true + }, + "api_max_request_duration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "mfa_totp_enroll_enabled": { + "type": "boolean", + "nullable": true + }, + "mfa_totp_verify_enabled": { + "type": "boolean", + "nullable": true + }, + "mfa_web_authn_enroll_enabled": { + "type": "boolean", + "nullable": true + }, + "mfa_web_authn_verify_enabled": { + "type": "boolean", + "nullable": true + }, + "passkey_enabled": { + "type": "boolean" + }, + "webauthn_rp_display_name": { + "type": "string", + "nullable": true + }, + "webauthn_rp_id": { + "type": "string", + "nullable": true + }, + "webauthn_rp_origins": { + "type": "string", + "nullable": true + }, + "mfa_phone_enroll_enabled": { + "type": "boolean", + "nullable": true + }, + "mfa_phone_verify_enabled": { + "type": "boolean", + "nullable": true + }, + "mfa_phone_max_frequency": { + "type": "integer", + "minimum": 0, + "maximum": 32767, + "nullable": true + }, + "mfa_phone_otp_length": { + "type": "integer", + "minimum": 0, + "maximum": 32767, + "nullable": true + }, + "mfa_phone_template": { + "type": "string", + "nullable": true + }, + "nimbus_oauth_client_id": { + "type": "string", + "nullable": true + }, + "nimbus_oauth_client_secret": { "type": "string", "nullable": true }, - "external_twitter_enabled": { + "oauth_server_enabled": { "type": "boolean", "nullable": true }, - "external_twitter_client_id": { - "type": "string", - "nullable": true - }, - "external_twitter_email_optional": { + "oauth_server_allow_dynamic_registration": { "type": "boolean", "nullable": true }, - "external_twitter_secret": { + "oauth_server_authorization_path": { "type": "string", "nullable": true }, - "external_x_enabled": { - "type": "boolean", - "nullable": true + "custom_oauth_enabled": { + "type": "boolean" + } + }, + "example": { + "site_url": "https://app.example.com", + "disable_signup": false, + "jwt_exp": 3600 + } + }, + "CreateThirdPartyAuthBody": { + "type": "object", + "properties": { + "oidc_issuer_url": { + "type": "string" }, - "external_x_client_id": { + "jwks_url": { + "type": "string" + }, + "custom_jwks": {} + }, + "example": { + "oidc_issuer_url": "https://login.acme.com", + "jwks_url": "https://login.acme.com/.well-known/jwks.json" + } + }, + "ThirdPartyAuth": { + "type": "object", + "properties": { + "id": { "type": "string", - "nullable": true + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "external_x_email_optional": { - "type": "boolean", + "type": { + "type": "string" + }, + "oidc_issuer_url": { + "type": "string", "nullable": true }, - "external_x_secret": { + "jwks_url": { "type": "string", "nullable": true }, - "external_workos_enabled": { - "type": "boolean", + "custom_jwks": { "nullable": true }, - "external_workos_client_id": { - "type": "string", + "resolved_jwks": { "nullable": true }, - "external_workos_secret": { + "inserted_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "resolved_at": { "type": "string", "nullable": true + } + }, + "required": ["id", "type", "inserted_at", "updated_at"] + }, + "GetProjectAvailableRestoreVersionsResponse": { + "type": "object", + "properties": { + "available_versions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "release_channel": { + "type": "string", + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + }, + "postgres_engine": { + "type": "string", + "enum": ["13", "14", "15", "17", "17-oriole"] + } + }, + "required": ["version", "release_channel", "postgres_engine"] + } + } + }, + "required": ["available_versions"] + }, + "ListProjectAddonsResponseJsonValue": { + "description": "Any JSON-serializable value", + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "nullable": true + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" + } + } + ] + }, + "ListProjectAddonsResponse": { + "type": "object", + "properties": { + "selected_addons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline" + ] + }, + "variant": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory" + ] + }, + { + "type": "string", + "enum": ["cd_default"] + }, + { + "type": "string", + "enum": ["pitr_7", "pitr_14", "pitr_28"] + }, + { + "type": "string", + "enum": ["ipv4_default"] + }, + { + "type": "string", + "enum": ["auth_mfa_phone_default"] + }, + { + "type": "string", + "enum": ["auth_mfa_web_authn_default"] + }, + { + "type": "string", + "enum": ["log_drain_default"] + }, + { + "type": "string", + "enum": ["etl_pipeline_default"] + } + ] + }, + "name": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["fixed", "usage"] + }, + "interval": { + "type": "string", + "enum": ["monthly", "hourly"] + }, + "amount": { + "type": "number" + } + }, + "required": ["description", "type", "interval", "amount"] + }, + "meta": { + "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" + } + }, + "required": ["id", "name", "price"] + } + }, + "required": ["type", "variant"] + } + }, + "available_addons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline" + ] + }, + "name": { + "type": "string" + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory" + ] + }, + { + "type": "string", + "enum": ["cd_default"] + }, + { + "type": "string", + "enum": ["pitr_7", "pitr_14", "pitr_28"] + }, + { + "type": "string", + "enum": ["ipv4_default"] + }, + { + "type": "string", + "enum": ["auth_mfa_phone_default"] + }, + { + "type": "string", + "enum": ["auth_mfa_web_authn_default"] + }, + { + "type": "string", + "enum": ["log_drain_default"] + }, + { + "type": "string", + "enum": ["etl_pipeline_default"] + } + ] + }, + "name": { + "type": "string" + }, + "price": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["fixed", "usage"] + }, + "interval": { + "type": "string", + "enum": ["monthly", "hourly"] + }, + "amount": { + "type": "number" + } + }, + "required": ["description", "type", "interval", "amount"] + }, + "meta": { + "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" + } + }, + "required": ["id", "name", "price"] + } + } + }, + "required": ["type", "name", "variants"] + } + } + }, + "required": ["selected_addons", "available_addons"] + }, + "ApplyProjectAddonBody": { + "type": "object", + "properties": { + "addon_variant": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory" + ] + }, + { + "type": "string", + "enum": ["cd_default"] + }, + { + "type": "string", + "enum": ["pitr_7", "pitr_14", "pitr_28"] + }, + { + "type": "string", + "enum": ["ipv4_default"] + } + ] }, - "external_workos_url": { + "addon_type": { "type": "string", - "nullable": true - }, - "external_web3_solana_enabled": { - "type": "boolean", - "nullable": true + "enum": [ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline" + ] + } + }, + "required": ["addon_variant", "addon_type"], + "example": { + "addon_variant": "pitr_7", + "addon_type": "pitr" + } + }, + "ProjectClaimTokenResponse": { + "type": "object", + "properties": { + "token_alias": { + "type": "string" }, - "external_web3_ethereum_enabled": { - "type": "boolean", - "nullable": true + "expires_at": { + "type": "string" }, - "external_zoom_enabled": { - "type": "boolean", - "nullable": true + "created_at": { + "type": "string" }, - "external_zoom_client_id": { + "created_by": { "type": "string", - "nullable": true + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["token_alias", "expires_at", "created_at", "created_by"] + }, + "CreateProjectClaimTokenResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" }, - "external_zoom_email_optional": { - "type": "boolean", - "nullable": true + "token_alias": { + "type": "string" }, - "external_zoom_secret": { - "type": "string", - "nullable": true + "expires_at": { + "type": "string" }, - "db_max_pool_size": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true + "created_at": { + "type": "string" }, - "db_max_pool_size_unit": { + "created_by": { "type": "string", - "enum": ["connections", "percent", null], - "nullable": true - }, - "api_max_request_duration": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true - }, - "mfa_totp_enroll_enabled": { - "type": "boolean", - "nullable": true - }, - "mfa_totp_verify_enabled": { - "type": "boolean", - "nullable": true + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["token", "token_alias", "expires_at", "created_at", "created_by"] + }, + "V1ProjectAdvisorsResponse": { + "type": "object", + "properties": { + "lints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "enum": [ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "level": { + "type": "string", + "enum": ["ERROR", "WARN", "INFO"] + }, + "facing": { + "type": "string", + "enum": ["EXTERNAL"] + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": ["PERFORMANCE", "SECURITY"] + } + }, + "description": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "remediation": { + "type": "string" + }, + "metadata": { + "type": "object", + "properties": { + "schema": { + "type": "string" + }, + "name": { + "type": "string" + }, + "entity": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["table", "view", "auth", "function", "extension", "compliance"] + }, + "fkey_name": { + "type": "string" + }, + "fkey_columns": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "cache_key": { + "type": "string" + } + }, + "required": [ + "name", + "title", + "level", + "facing", + "categories", + "description", + "detail", + "remediation", + "cache_key" + ] + } + } + }, + "required": ["lints"] + }, + "AnalyticsResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": {} }, - "mfa_web_authn_enroll_enabled": { - "type": "boolean", - "nullable": true + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "location": { + "type": "string" + }, + "locationType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": ["domain", "location", "locationType", "message", "reason"] + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": ["code", "errors", "message", "status"] + } + ] + } + } + }, + "V1GetUsageApiCountResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$" + }, + "total_auth_requests": { + "type": "number" + }, + "total_realtime_requests": { + "type": "number" + }, + "total_rest_requests": { + "type": "number" + }, + "total_storage_requests": { + "type": "number" + } + }, + "required": [ + "timestamp", + "total_auth_requests", + "total_realtime_requests", + "total_rest_requests", + "total_storage_requests" + ] + } }, - "mfa_web_authn_verify_enabled": { - "type": "boolean", - "nullable": true + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "location": { + "type": "string" + }, + "locationType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": ["domain", "location", "locationType", "message", "reason"] + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": ["code", "errors", "message", "status"] + } + ] + } + } + }, + "V1GetUsageApiRequestsCountResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "number" + } + }, + "required": ["count"] + } }, - "passkey_enabled": { + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "location": { + "type": "string" + }, + "locationType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": ["domain", "location", "locationType", "message", "reason"] + } + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": ["code", "errors", "message", "status"] + } + ] + } + } + }, + "CreateRoleBody": { + "type": "object", + "properties": { + "read_only": { "type": "boolean" - }, - "webauthn_rp_display_name": { - "type": "string", - "nullable": true - }, - "webauthn_rp_id": { + } + }, + "required": ["read_only"], + "example": { + "read_only": true + } + }, + "CreateRoleResponse": { + "type": "object", + "properties": { + "role": { "type": "string", - "nullable": true + "minLength": 1 }, - "webauthn_rp_origins": { + "password": { "type": "string", - "nullable": true - }, - "mfa_phone_enroll_enabled": { - "type": "boolean", - "nullable": true - }, - "mfa_phone_verify_enabled": { - "type": "boolean", - "nullable": true - }, - "mfa_phone_max_frequency": { - "type": "integer", - "minimum": 0, - "maximum": 32767, - "nullable": true + "minLength": 1 }, - "mfa_phone_otp_length": { + "ttl_seconds": { "type": "integer", - "minimum": 0, - "maximum": 32767, - "nullable": true - }, - "mfa_phone_template": { - "type": "string", - "nullable": true - }, - "nimbus_oauth_client_id": { + "minimum": 1, + "maximum": 9007199254740991, + "format": "int64" + } + }, + "required": ["role", "password", "ttl_seconds"] + }, + "DeleteRolesResponse": { + "type": "object", + "properties": { + "message": { "type": "string", - "nullable": true + "enum": ["ok"] + } + }, + "required": ["message"] + }, + "V1ListMigrationsResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + } }, - "nimbus_oauth_client_secret": { + "required": ["version"] + } + }, + "V1CreateMigrationBody": { + "type": "object", + "properties": { + "query": { "type": "string", - "nullable": true - }, - "oauth_server_enabled": { - "type": "boolean", - "nullable": true - }, - "oauth_server_allow_dynamic_registration": { - "type": "boolean", - "nullable": true + "minLength": 1 }, - "oauth_server_authorization_path": { - "type": "string", - "nullable": true + "name": { + "type": "string" }, - "custom_oauth_enabled": { - "type": "boolean" + "rollback": { + "type": "string" } }, + "required": ["query"], "example": { - "site_url": "https://app.example.com", - "disable_signup": false, - "jwt_exp": 3600 + "query": "create table public.widgets(id bigint primary key);", + "name": "create_widgets_table", + "rollback": "drop table if exists public.widgets;" } }, - "CreateThirdPartyAuthBody": { + "V1UpsertMigrationBody": { "type": "object", "properties": { - "oidc_issuer_url": { - "type": "string" + "query": { + "type": "string", + "minLength": 1 }, - "jwks_url": { + "name": { "type": "string" }, - "custom_jwks": {} + "rollback": { + "type": "string" + } }, + "required": ["query"], "example": { - "oidc_issuer_url": "https://login.acme.com", - "jwks_url": "https://login.acme.com/.well-known/jwks.json" + "query": "create table public.widgets(id bigint primary key);", + "name": "create_widgets_table", + "rollback": "drop table if exists public.widgets;" } }, - "ThirdPartyAuth": { + "V1GetMigrationResponse": { "type": "object", "properties": { - "id": { + "version": { "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "minLength": 1 }, - "type": { + "name": { "type": "string" }, - "oidc_issuer_url": { - "type": "string", - "nullable": true - }, - "jwks_url": { - "type": "string", - "nullable": true - }, - "custom_jwks": { - "nullable": true + "statements": { + "type": "array", + "items": { + "type": "string" + } }, - "resolved_jwks": { - "nullable": true + "rollback": { + "type": "array", + "items": { + "type": "string" + } }, - "inserted_at": { + "created_by": { "type": "string" }, - "updated_at": { + "idempotency_key": { "type": "string" - }, - "resolved_at": { - "type": "string", - "nullable": true } }, - "required": ["id", "type", "inserted_at", "updated_at"] + "required": ["version"] }, - "GetProjectAvailableRestoreVersionsResponse": { + "V1PatchMigrationBody": { "type": "object", "properties": { - "available_versions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "release_channel": { - "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] - }, - "postgres_engine": { - "type": "string", - "enum": ["13", "14", "15", "17", "17-oriole"] - } - }, - "required": ["version", "release_channel", "postgres_engine"] - } + "name": { + "type": "string" + }, + "rollback": { + "type": "string" } }, - "required": ["available_versions"] + "example": { + "name": "create_widgets_table", + "rollback": "drop table if exists public.widgets;" + } }, - "ListProjectAddonsResponseJsonValue": { - "description": "Any JSON-serializable value", - "anyOf": [ - { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "nullable": true + "V1RunQueryBody": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1 }, - { + "parameters": { "type": "array", - "items": { - "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" - } + "items": {} }, - { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" - } + "read_only": { + "type": "boolean" } - ] + }, + "required": ["query"], + "example": { + "query": "select * from pg_stat_activity limit 1;", + "read_only": true + } }, - "ListProjectAddonsResponse": { + "V1ReadOnlyQueryBody": { "type": "object", "properties": { - "selected_addons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline" - ] - }, - "variant": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory" - ] - }, - { - "type": "string", - "enum": ["cd_default"] - }, - { - "type": "string", - "enum": ["pitr_7", "pitr_14", "pitr_28"] - }, - { - "type": "string", - "enum": ["ipv4_default"] - }, - { - "type": "string", - "enum": ["auth_mfa_phone_default"] - }, - { - "type": "string", - "enum": ["auth_mfa_web_authn_default"] - }, - { - "type": "string", - "enum": ["log_drain_default"] - }, - { - "type": "string", - "enum": ["etl_pipeline_default"] - } - ] - }, - "name": { - "type": "string" - }, - "price": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["fixed", "usage"] - }, - "interval": { - "type": "string", - "enum": ["monthly", "hourly"] - }, - "amount": { - "type": "number" - } - }, - "required": ["description", "type", "interval", "amount"] - }, - "meta": { - "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" - } - }, - "required": ["id", "name", "price"] - } - }, - "required": ["type", "variant"] - } + "query": { + "type": "string", + "minLength": 1 }, - "available_addons": { + "parameters": { + "type": "array", + "items": {} + } + }, + "required": ["query"], + "example": { + "query": "select * from pg_stat_activity limit 1;" + } + }, + "GetProjectDbMetadataResponse": { + "type": "object", + "properties": { + "databases": { "type": "array", "items": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": [ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline" - ] - }, "name": { "type": "string" }, - "variants": { + "schemas": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory" - ] - }, - { - "type": "string", - "enum": ["cd_default"] - }, - { - "type": "string", - "enum": ["pitr_7", "pitr_14", "pitr_28"] - }, - { - "type": "string", - "enum": ["ipv4_default"] - }, - { - "type": "string", - "enum": ["auth_mfa_phone_default"] - }, - { - "type": "string", - "enum": ["auth_mfa_web_authn_default"] - }, - { - "type": "string", - "enum": ["log_drain_default"] - }, - { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": {} + } + } + }, + "required": ["name", "schemas"], + "additionalProperties": {} + } + } + }, + "required": ["databases"] + }, + "V1UpdatePasswordBody": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 4 + } + }, + "required": ["password"], + "example": { + "password": "correct-horse-battery-staple" + } + }, + "V1UpdatePasswordResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + }, + "JitAccessResponse": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { "type": "string", - "enum": ["etl_pipeline_default"] + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" } - ] - }, - "name": { - "type": "string" - }, - "price": { + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["fixed", "usage"] - }, - "interval": { + "cidr": { "type": "string", - "enum": ["monthly", "hourly"] - }, - "amount": { - "type": "number" + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" } }, - "required": ["description", "type", "interval", "amount"] - }, - "meta": { - "$ref": "#/components/schemas/ListProjectAddonsResponseJsonValue" + "required": ["cidr"] } - }, - "required": ["id", "name", "price"] + } } + }, + "branches_only": { + "type": "boolean" } }, - "required": ["type", "name", "variants"] + "required": ["role"] } } }, - "required": ["selected_addons", "available_addons"] + "required": ["user_roles"] }, - "ApplyProjectAddonBody": { + "AuthorizeJitAccessBody": { "type": "object", "properties": { - "addon_variant": { + "role": { + "type": "string", + "minLength": 1 + }, + "rhost": { "anyOf": [ { "type": "string", - "enum": [ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory" - ] - }, - { - "type": "string", - "enum": ["cd_default"] - }, - { - "type": "string", - "enum": ["pitr_7", "pitr_14", "pitr_28"] + "format": "ipv4", + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$" }, { "type": "string", - "enum": ["ipv4_default"] + "format": "ipv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$" } ] - }, - "addon_type": { - "type": "string", - "enum": [ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline" - ] } }, - "required": ["addon_variant", "addon_type"], + "required": ["role", "rhost"], "example": { - "addon_variant": "pitr_7", - "addon_type": "pitr" + "role": "postgres", + "rhost": "203.0.113.10" } }, - "ProjectClaimTokenResponse": { + "JitAuthorizeAccessResponse": { "type": "object", "properties": { - "token_alias": { - "type": "string" - }, - "expires_at": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { + "user_id": { "type": "string", "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - } - }, - "required": ["token_alias", "expires_at", "created_at", "created_by"] - }, - "CreateProjectClaimTokenResponse": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "token_alias": { - "type": "string" - }, - "expires_at": { - "type": "string" - }, - "created_at": { - "type": "string" }, - "created_by": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - } - }, - "required": ["token", "token_alias", "expires_at", "created_at", "created_by"] - }, - "V1ProjectAdvisorsResponse": { - "type": "object", - "properties": { - "lints": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "enum": [ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version" - ], - "type": "string" - }, - "title": { - "type": "string" - }, - "level": { - "type": "string", - "enum": ["ERROR", "WARN", "INFO"] - }, - "facing": { - "type": "string", - "enum": ["EXTERNAL"] - }, - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": ["PERFORMANCE", "SECURITY"] + "user_role": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } + }, + "required": ["cidr"] + } } - }, - "description": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "remediation": { - "type": "string" - }, - "metadata": { + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + }, + "required": ["user_id", "user_role"] + }, + "JitListAccessResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "anyOf": [ + { "type": "object", "properties": { - "schema": { - "type": "string" + "user_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "name": { - "type": "string" + "primary_email": { + "type": "string", + "nullable": true }, - "entity": { + "invite_id": { + "type": "null" + }, + "expires_at": { + "type": "null" + }, + "user_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } + } + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] + }, + { + "type": "object", + "properties": { + "user_id": { + "type": "null" + }, + "primary_email": { "type": "string" }, - "type": { + "invite_id": { "type": "string", - "enum": ["table", "view", "auth", "function", "extension", "compliance"] + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "fkey_name": { + "expires_at": { "type": "string" }, - "fkey_columns": { + "user_roles": { "type": "array", "items": { - "type": "number" + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } + }, + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } + }, + "required": ["cidr"] + } + } + } + }, + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] } } - } - }, - "cache_key": { - "type": "string" + }, + "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] } - }, - "required": [ - "name", - "title", - "level", - "facing", - "categories", - "description", - "detail", - "remediation", - "cache_key" ] } } }, - "required": ["lints"] + "required": ["items"] }, - "AnalyticsResponse": { + "UpdateJitAccessBody": { "type": "object", "properties": { - "result": { - "type": "array", - "items": {} + "user_id": { + "type": "string", + "minLength": 1, + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "code": { - "type": "number" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "location": { - "type": "string" - }, - "locationType": { - "type": "string" + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { + "type": "number" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } }, - "message": { - "type": "string" + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } }, - "reason": { - "type": "string" - } - }, - "required": ["domain", "location", "locationType", "message", "reason"] + "required": ["cidr"] + } } - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" } }, - "required": ["code", "errors", "message", "status"] - } - ] + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } } + }, + "required": ["user_id", "roles"], + "example": { + "user_id": "55555555-5555-4555-8555-555555555555", + "roles": [ + { + "role": "postgres", + "expires_at": 1740787200, + "allowed_networks": { + "allowed_cidrs": [ + { + "cidr": "203.0.113.0/24" + } + ] + }, + "branches_only": false + } + ] } }, - "V1GetUsageApiCountResponse": { + "InviteExternalUserJitAccessBody": { "type": "object", "properties": { - "result": { + "email": { + "type": "string", + "minLength": 1, + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "roles": { "type": "array", "items": { "type": "object", "properties": { - "timestamp": { + "role": { "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$" - }, - "total_auth_requests": { - "type": "number" - }, - "total_realtime_requests": { - "type": "number" + "minLength": 1 }, - "total_rest_requests": { + "expires_at": { "type": "number" }, - "total_storage_requests": { - "type": "number" - } - }, - "required": [ - "timestamp", - "total_auth_requests", - "total_realtime_requests", - "total_rest_requests", - "total_storage_requests" - ] - } - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "code": { - "type": "number" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "location": { - "type": "string" - }, - "locationType": { - "type": "string" + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } }, - "message": { - "type": "string" + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } }, - "reason": { - "type": "string" - } - }, - "required": ["domain", "location", "locationType", "message", "reason"] + "required": ["cidr"] + } } - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" } }, - "required": ["code", "errors", "message", "status"] - } - ] + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } } + }, + "required": ["email", "roles"], + "example": { + "email": "external-user@somedomain.xyz", + "roles": [ + { + "role": "postgres", + "expires_at": 1740787200, + "allowed_networks": { + "allowed_cidrs": [ + { + "cidr": "203.0.113.0/24" + } + ] + }, + "branches_only": false + } + ] } }, - "V1GetUsageApiRequestsCountResponse": { + "InviteExternalUserJitResponse": { "type": "object", "properties": { - "result": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "invite_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "user_roles": { "type": "array", "items": { "type": "object", "properties": { - "count": { + "role": { + "type": "string", + "minLength": 1 + }, + "expires_at": { "type": "number" - } - }, - "required": ["count"] - } - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "code": { - "type": "number" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "location": { - "type": "string" - }, - "locationType": { - "type": "string" + }, + "allowed_networks": { + "type": "object", + "properties": { + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + } }, - "message": { - "type": "string" + "required": ["cidr"] + } + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cidr": { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } }, - "reason": { - "type": "string" - } - }, - "required": ["domain", "location", "locationType", "message", "reason"] + "required": ["cidr"] + } } - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" } }, - "required": ["code", "errors", "message", "status"] - } - ] + "branches_only": { + "type": "boolean" + } + }, + "required": ["role"] + } } - } + }, + "required": ["email", "invite_id", "user_roles"] }, - "CreateRoleBody": { + "AcceptInviteExternalUserJitAccessBody": { "type": "object", "properties": { - "read_only": { - "type": "boolean" + "email": { + "type": "string", + "minLength": 1, + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "token": { + "type": "string", + "minLength": 1 } }, - "required": ["read_only"], + "required": ["email", "token"], "example": { - "read_only": true + "email": "external-user@somedomain.xyz", + "token": "" } }, - "CreateRoleResponse": { + "FunctionResponse": { "type": "object", "properties": { - "role": { - "type": "string", - "minLength": 1 + "id": { + "type": "string" }, - "password": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { "type": "string", - "minLength": 1 + "enum": ["ACTIVE", "REMOVED", "THROTTLED"] }, - "ttl_seconds": { + "version": { "type": "integer", - "minimum": 1, + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "updated_at": { + "type": "integer", + "minimum": -9007199254740991, "maximum": 9007199254740991, "format": "int64" + }, + "verify_jwt": { + "type": "boolean" + }, + "import_map": { + "type": "boolean" + }, + "entrypoint_path": { + "type": "string" + }, + "import_map_path": { + "type": "string", + "nullable": true + }, + "ezbr_sha256": { + "type": "string" } }, - "required": ["role", "password", "ttl_seconds"] + "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] }, - "DeleteRolesResponse": { + "V1CreateFunctionBody": { "type": "object", "properties": { - "message": { + "slug": { "type": "string", - "enum": ["ok"] + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" + }, + "name": { + "type": "string" + }, + "body": { + "type": "string" + }, + "verify_jwt": { + "type": "boolean" } }, - "required": ["message"] + "required": ["slug", "name", "body"], + "example": { + "slug": "hello-world", + "name": "Hello World", + "body": "Deno.serve(() => new Response('Hello, world!'))", + "verify_jwt": true + } }, - "V1ListMigrationsResponse": { + "BulkUpdateFunctionBody": { "type": "array", "items": { "type": "object", "properties": { - "version": { + "id": { + "type": "string" + }, + "slug": { "type": "string", - "minLength": 1 + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" }, "name": { "type": "string" + }, + "status": { + "type": "string", + "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + }, + "version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "integer", + "format": "int64", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "verify_jwt": { + "type": "boolean" + }, + "import_map": { + "type": "boolean" + }, + "entrypoint_path": { + "type": "string" + }, + "import_map_path": { + "type": "string" + }, + "ezbr_sha256": { + "type": "string" } }, - "required": ["version"] - } + "required": ["id", "slug", "name", "status", "version"] + }, + "example": [ + { + "id": "3c078cce-ad70-4148-9f37-4da362789053", + "slug": "hello-world", + "name": "Hello World", + "status": "ACTIVE", + "version": 2, + "verify_jwt": true, + "entrypoint_path": "index.ts" + } + ] + }, + "BulkUpdateFunctionResponse": { + "type": "object", + "properties": { + "functions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + }, + "version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "updated_at": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "verify_jwt": { + "type": "boolean" + }, + "import_map": { + "type": "boolean" + }, + "entrypoint_path": { + "type": "string" + }, + "import_map_path": { + "type": "string", + "nullable": true + }, + "ezbr_sha256": { + "type": "string" + } + }, + "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] + } + } + }, + "required": ["functions"] }, - "V1CreateMigrationBody": { + "FunctionDeployBody": { "type": "object", "properties": { - "query": { - "type": "string", - "minLength": 1 - }, - "name": { - "type": "string" + "file": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } }, - "rollback": { - "type": "string" + "metadata": { + "type": "object", + "properties": { + "entrypoint_path": { + "type": "string" + }, + "import_map_path": { + "type": "string" + }, + "static_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "verify_jwt": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": ["entrypoint_path"] } }, - "required": ["query"], + "required": ["file", "metadata"], "example": { - "query": "create table public.widgets(id bigint primary key);", - "name": "create_widgets_table", - "rollback": "drop table if exists public.widgets;" + "file": ["./supabase/functions/hello-world/index.ts"], + "metadata": { + "entrypoint_path": "index.ts", + "verify_jwt": true, + "name": "Hello World" + } } }, - "V1UpsertMigrationBody": { + "DeployFunctionResponse": { "type": "object", "properties": { - "query": { - "type": "string", - "minLength": 1 + "id": { + "type": "string" + }, + "slug": { + "type": "string" }, "name": { "type": "string" }, - "rollback": { + "status": { + "type": "string", + "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + }, + "version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "integer", + "format": "int64", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updated_at": { + "type": "integer", + "format": "int64", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "verify_jwt": { + "type": "boolean" + }, + "import_map": { + "type": "boolean" + }, + "entrypoint_path": { + "type": "string" + }, + "import_map_path": { + "type": "string", + "nullable": true + }, + "ezbr_sha256": { "type": "string" } }, - "required": ["query"], - "example": { - "query": "create table public.widgets(id bigint primary key);", - "name": "create_widgets_table", - "rollback": "drop table if exists public.widgets;" - } + "required": ["id", "slug", "name", "status", "version"] }, - "V1GetMigrationResponse": { + "FunctionSlugResponse": { "type": "object", "properties": { - "version": { - "type": "string", - "minLength": 1 + "id": { + "type": "string" + }, + "slug": { + "type": "string" }, "name": { "type": "string" }, - "statements": { - "type": "array", - "items": { - "type": "string" - } + "status": { + "type": "string", + "enum": ["ACTIVE", "REMOVED", "THROTTLED"] }, - "rollback": { - "type": "array", - "items": { - "type": "string" - } + "version": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "created_by": { + "created_at": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "updated_at": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "verify_jwt": { + "type": "boolean" + }, + "import_map": { + "type": "boolean" + }, + "entrypoint_path": { "type": "string" }, - "idempotency_key": { + "import_map_path": { + "type": "string", + "nullable": true + }, + "ezbr_sha256": { "type": "string" } }, - "required": ["version"] + "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] }, - "V1PatchMigrationBody": { + "StreamableFile": { + "type": "object", + "properties": {} + }, + "V1UpdateFunctionBody": { "type": "object", "properties": { "name": { "type": "string" }, - "rollback": { + "body": { "type": "string" + }, + "verify_jwt": { + "type": "boolean" } }, "example": { - "name": "create_widgets_table", - "rollback": "drop table if exists public.widgets;" + "name": "Hello World", + "body": "Deno.serve(() => new Response('Hello again!'))", + "verify_jwt": true } }, - "V1RunQueryBody": { + "V1StorageBucketResponse": { "type": "object", "properties": { - "query": { - "type": "string", - "minLength": 1 + "id": { + "type": "string" }, - "parameters": { - "type": "array", - "items": {} + "name": { + "type": "string" }, - "read_only": { + "owner": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "public": { "type": "boolean" } }, - "required": ["query"], - "example": { - "query": "select * from pg_stat_activity limit 1;", - "read_only": true - } + "required": ["id", "name", "owner", "created_at", "updated_at", "public"] }, - "V1ReadOnlyQueryBody": { + "DiskResponse": { "type": "object", "properties": { - "query": { - "type": "string", - "minLength": 1 + "attributes": { + "anyOf": [ + { + "type": "object", + "properties": { + "iops": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "size_gb": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "throughput_mibps": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": ["gp3"] + } + }, + "required": ["iops", "size_gb", "type"] + }, + { + "type": "object", + "properties": { + "iops": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "size_gb": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": ["io2"] + } + }, + "required": ["iops", "size_gb", "type"] + } + ] }, - "parameters": { - "type": "array", - "items": {} + "last_modified_at": { + "type": "string" } }, - "required": ["query"], - "example": { - "query": "select * from pg_stat_activity limit 1;" - } + "required": ["attributes"] }, - "GetProjectDbMetadataResponse": { + "DiskRequestBody": { "type": "object", "properties": { - "databases": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "schemas": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"], - "additionalProperties": {} + "attributes": { + "oneOf": [ + { + "type": "object", + "properties": { + "iops": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "size_gb": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "throughput_mibps": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": ["gp3"] } - } + }, + "required": ["iops", "size_gb", "type"] }, - "required": ["name", "schemas"], - "additionalProperties": {} - } - } - }, - "required": ["databases"] - }, - "V1UpdatePasswordBody": { - "type": "object", - "properties": { - "password": { - "type": "string", - "minLength": 4 + { + "type": "object", + "properties": { + "iops": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "size_gb": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": ["io2"] + } + }, + "required": ["iops", "size_gb", "type"] + } + ] } }, - "required": ["password"], + "required": ["attributes"], "example": { - "password": "correct-horse-battery-staple" + "attributes": { + "type": "gp3", + "size_gb": 100, + "iops": 3000, + "throughput_mibps": 125 + } } }, - "V1UpdatePasswordResponse": { + "DiskUtilMetricsResponse": { "type": "object", "properties": { - "message": { + "timestamp": { "type": "string" - } - }, - "required": ["message"] - }, - "JitAccessResponse": { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } - } - }, - "branches_only": { - "type": "boolean" - } + "metrics": { + "type": "object", + "properties": { + "fs_size_bytes": { + "type": "number" }, - "required": ["role"] - } + "fs_avail_bytes": { + "type": "number" + }, + "fs_used_bytes": { + "type": "number" + } + }, + "required": ["fs_size_bytes", "fs_avail_bytes", "fs_used_bytes"] } }, - "required": ["user_roles"] + "required": ["timestamp", "metrics"] }, - "AuthorizeJitAccessBody": { + "DiskAutoscaleConfig": { "type": "object", "properties": { - "role": { - "type": "string", - "minLength": 1 + "growth_percent": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Growth percentage for disk autoscaling", + "nullable": true }, - "rhost": { - "anyOf": [ - { - "type": "string", - "format": "ipv4", - "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$" - }, - { - "type": "string", - "format": "ipv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$" - } - ] + "min_increment_gb": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Minimum increment size for disk autoscaling in GB", + "nullable": true + }, + "max_size_gb": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Maximum limit the disk size will grow to in GB", + "nullable": true } }, - "required": ["role", "rhost"], - "example": { - "role": "postgres", - "rhost": "203.0.113.10" - } + "required": ["growth_percent", "min_increment_gb", "max_size_gb"] }, - "JitAuthorizeAccessResponse": { + "StorageConfigResponse": { "type": "object", "properties": { - "user_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "fileSizeLimit": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" }, - "user_role": { + "features": { "type": "object", "properties": { - "role": { - "type": "string", - "minLength": 1 + "imageTransformation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] }, - "expires_at": { - "type": "number" + "s3Protocol": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] }, - "allowed_networks": { + "purgeCache": { "type": "object", "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } + "enabled": { + "type": "boolean" } - } + }, + "required": ["enabled"] }, - "branches_only": { - "type": "boolean" - } - }, - "required": ["role"] - } - }, - "required": ["user_id", "user_role"] - }, - "JitListAccessResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "user_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "primary_email": { - "type": "string", - "nullable": true - }, - "invite_id": { - "type": "null" - }, - "expires_at": { - "type": "null" - }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } - } - }, - "branches_only": { - "type": "boolean" - } - }, - "required": ["role"] - } - } + "icebergCatalog": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] + "maxNamespaces": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxTables": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxCatalogs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } }, - { - "type": "object", - "properties": { - "user_id": { - "type": "null" - }, - "primary_email": { - "type": "string" - }, - "invite_id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "expires_at": { - "type": "string" - }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } - } - }, - "branches_only": { - "type": "boolean" - } - }, - "required": ["role"] - } - } + "required": ["enabled", "maxNamespaces", "maxTables", "maxCatalogs"] + }, + "vectorBuckets": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "required": ["user_id", "primary_email", "invite_id", "expires_at", "user_roles"] - } - ] - } + "maxBuckets": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxIndexes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["enabled", "maxBuckets", "maxIndexes"] + } + }, + "required": [ + "imageTransformation", + "s3Protocol", + "purgeCache", + "icebergCatalog", + "vectorBuckets" + ] + }, + "capabilities": { + "type": "object", + "properties": { + "list_v2": { + "type": "boolean" + }, + "iceberg_catalog": { + "type": "boolean" + } + }, + "required": ["list_v2", "iceberg_catalog"] + }, + "external": { + "type": "object", + "properties": { + "upstreamTarget": { + "type": "string", + "enum": ["main", "canary"] + } + }, + "required": ["upstreamTarget"] + }, + "migrationVersion": { + "type": "string" + }, + "databasePoolMode": { + "type": "string" } }, - "required": ["items"] + "required": ["fileSizeLimit", "features", "capabilities", "external", "migrationVersion"] }, - "UpdateJitAccessBody": { + "UpdateStorageConfigBody": { "type": "object", "properties": { - "user_id": { - "type": "string", - "minLength": 1, - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "fileSizeLimit": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 536870912000 }, - "roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 + "features": { + "type": "object", + "properties": { + "imageTransformation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } }, - "expires_at": { - "type": "number" + "required": ["enabled"] + }, + "s3Protocol": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } + "required": ["enabled"] + }, + "purgeCache": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" } }, - "branches_only": { - "type": "boolean" - } + "required": ["enabled"] }, - "required": ["role"] + "icebergCatalog": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxNamespaces": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxTables": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxCatalogs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["enabled", "maxNamespaces", "maxTables", "maxCatalogs"] + }, + "vectorBuckets": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxBuckets": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxIndexes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["enabled", "maxBuckets", "maxIndexes"] + } } + }, + "external": { + "type": "object", + "properties": { + "upstreamTarget": { + "type": "string", + "enum": ["main", "canary"] + } + }, + "required": ["upstreamTarget"] } }, - "required": ["user_id", "roles"], "example": { - "user_id": "55555555-5555-4555-8555-555555555555", - "roles": [ - { - "role": "postgres", - "expires_at": 1740787200, - "allowed_networks": { - "allowed_cidrs": [ - { - "cidr": "203.0.113.0/24" - } - ] - }, - "branches_only": false + "fileSizeLimit": 10485760, + "features": { + "imageTransformation": { + "enabled": true } - ] - } + } + }, + "additionalProperties": false }, - "InviteExternalUserJitAccessBody": { + "V1PgbouncerConfigResponse": { "type": "object", "properties": { - "email": { - "type": "string", - "minLength": 1, - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "default_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } - } - }, - "branches_only": { - "type": "boolean" - } - }, - "required": ["role"] - } + "ignore_startup_parameters": { + "type": "string" + }, + "max_client_conn": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "pool_mode": { + "type": "string", + "enum": ["transaction", "session", "statement"] + }, + "connection_string": { + "type": "string" + }, + "server_idle_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "server_lifetime": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "query_wait_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "reserve_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 } - }, - "required": ["email", "roles"], - "example": { - "email": "external-user@somedomain.xyz", - "roles": [ - { - "role": "postgres", - "expires_at": 1740787200, - "allowed_networks": { - "allowed_cidrs": [ - { - "cidr": "203.0.113.0/24" - } - ] - }, - "branches_only": false - } - ] } }, - "InviteExternalUserJitResponse": { + "SupavisorConfigResponse": { "type": "object", "properties": { - "email": { + "identifier": { + "type": "string" + }, + "database_type": { "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "enum": ["PRIMARY", "READ_REPLICA"] }, - "invite_id": { + "is_using_scram_auth": { + "type": "boolean" + }, + "db_user": { + "type": "string" + }, + "db_host": { + "type": "string" + }, + "db_port": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "db_name": { + "type": "string" + }, + "connection_string": { + "type": "string" + }, + "connectionString": { "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "description": "Use connection_string instead" }, - "user_roles": { - "type": "array", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "minLength": 1 - }, - "expires_at": { - "type": "number" - }, - "allowed_networks": { - "type": "object", - "properties": { - "allowed_cidrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv4", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" - } - }, - "required": ["cidr"] - } - }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "object", - "properties": { - "cidr": { - "type": "string", - "format": "cidrv6", - "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" - } - }, - "required": ["cidr"] - } - } - } - }, - "branches_only": { - "type": "boolean" - } - }, - "required": ["role"] - } + "default_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "max_client_conn": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "pool_mode": { + "type": "string", + "enum": ["transaction", "session"] } }, - "required": ["email", "invite_id", "user_roles"] + "required": [ + "identifier", + "database_type", + "is_using_scram_auth", + "db_user", + "db_host", + "db_port", + "db_name", + "connection_string", + "connectionString", + "default_pool_size", + "max_client_conn", + "pool_mode" + ] }, - "AcceptInviteExternalUserJitAccessBody": { + "UpdateSupavisorConfigBody": { "type": "object", "properties": { - "email": { - "type": "string", - "minLength": 1, - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "default_pool_size": { + "type": "integer", + "minimum": 0, + "maximum": 3000, + "nullable": true }, - "token": { + "pool_mode": { + "description": "Dedicated pooler mode for the project", "type": "string", - "minLength": 1 + "enum": ["transaction", "session"] } }, - "required": ["email", "token"], "example": { - "email": "external-user@somedomain.xyz", - "token": "" + "default_pool_size": 25, + "pool_mode": "transaction" } }, - "FunctionResponse": { + "UpdateSupavisorConfigResponse": { "type": "object", "properties": { - "id": { + "default_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "nullable": true + }, + "pool_mode": { + "type": "string" + } + }, + "required": ["default_pool_size", "pool_mode"] + }, + "PostgresConfigResponse": { + "type": "object", + "properties": { + "effective_cache_size": { "type": "string" }, - "slug": { + "logical_decoding_work_mem": { + "type": "string" + }, + "cron.log_statement": { + "type": "boolean" + }, + "log_autovacuum_min_duration": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "log_checkpoints": { + "type": "boolean" + }, + "log_connections": { + "type": "boolean" + }, + "log_disconnections": { + "type": "boolean" + }, + "log_duration": { + "type": "boolean" + }, + "log_lock_waits": { + "type": "boolean" + }, + "log_recovery_conflict_waits": { + "type": "boolean" + }, + "log_replication_commands": { + "type": "boolean" + }, + "log_startup_progress_interval": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "log_temp_files": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "track_activity_query_size": { "type": "string" }, - "name": { - "type": "string" + "max_connections": { + "type": "integer", + "minimum": 1, + "maximum": 262143 + }, + "max_locks_per_transaction": { + "type": "integer", + "minimum": 10, + "maximum": 2147483640 + }, + "max_logical_replication_workers": { + "type": "integer", + "minimum": 0, + "maximum": 262143 }, - "status": { - "type": "string", - "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + "max_parallel_maintenance_workers": { + "type": "integer", + "minimum": 0, + "maximum": 1024 }, - "version": { + "max_parallel_workers": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "minimum": 0, + "maximum": 1024 }, - "created_at": { + "max_parallel_workers_per_gather": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" + "minimum": 0, + "maximum": 1024 }, - "updated_at": { + "max_replication_slots": { "type": "integer", "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" + "maximum": 9007199254740991 }, - "verify_jwt": { - "type": "boolean" + "max_slot_wal_keep_size": { + "type": "string" }, - "import_map": { - "type": "boolean" + "max_standby_archive_delay": { + "type": "string" }, - "entrypoint_path": { + "max_standby_streaming_delay": { "type": "string" }, - "import_map_path": { - "type": "string", - "nullable": true + "max_sync_workers_per_subscription": { + "type": "integer", + "minimum": 0, + "maximum": 262143 }, - "ezbr_sha256": { + "max_wal_size": { "type": "string" - } - }, - "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] - }, - "V1CreateFunctionBody": { - "type": "object", - "properties": { - "slug": { + }, + "max_wal_senders": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_worker_processes": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "session_replication_role": { "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" + "enum": ["origin", "replica", "local"] }, - "name": { + "shared_buffers": { "type": "string" }, - "body": { - "type": "string" + "statement_timeout": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" }, - "verify_jwt": { + "track_commit_timestamp": { "type": "boolean" - } - }, - "required": ["slug", "name", "body"], - "example": { - "slug": "hello-world", - "name": "Hello World", - "body": "Deno.serve(() => new Response('Hello, world!'))", - "verify_jwt": true - } - }, - "BulkUpdateFunctionBody": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "slug": { - "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["ACTIVE", "REMOVED", "THROTTLED"] - }, - "version": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "created_at": { - "type": "integer", - "format": "int64", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "verify_jwt": { - "type": "boolean" - }, - "import_map": { - "type": "boolean" - }, - "entrypoint_path": { - "type": "string" - }, - "import_map_path": { - "type": "string" - }, - "ezbr_sha256": { - "type": "string" - } - }, - "required": ["id", "slug", "name", "status", "version"] - }, - "example": [ - { - "id": "3c078cce-ad70-4148-9f37-4da362789053", - "slug": "hello-world", - "name": "Hello World", - "status": "ACTIVE", - "version": 2, - "verify_jwt": true, - "entrypoint_path": "index.ts" - } - ] - }, - "BulkUpdateFunctionResponse": { - "type": "object", - "properties": { - "functions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["ACTIVE", "REMOVED", "THROTTLED"] - }, - "version": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "created_at": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" - }, - "updated_at": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" - }, - "verify_jwt": { - "type": "boolean" - }, - "import_map": { - "type": "boolean" - }, - "entrypoint_path": { - "type": "string" - }, - "import_map_path": { - "type": "string", - "nullable": true - }, - "ezbr_sha256": { - "type": "string" - } - }, - "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] - } - } - }, - "required": ["functions"] - }, - "FunctionDeployBody": { - "type": "object", - "properties": { - "file": { - "type": "array", - "items": { - "type": "string", - "format": "binary" - } }, - "metadata": { - "type": "object", - "properties": { - "entrypoint_path": { - "type": "string" - }, - "import_map_path": { - "type": "string" - }, - "static_patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "verify_jwt": { - "type": "boolean" - }, - "name": { - "type": "string" - } - }, - "required": ["entrypoint_path"] - } - }, - "required": ["file", "metadata"], - "example": { - "file": ["./supabase/functions/hello-world/index.ts"], - "metadata": { - "entrypoint_path": "index.ts", - "verify_jwt": true, - "name": "Hello World" + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "work_mem": { + "type": "string" + }, + "checkpoint_timeout": { + "type": "string", + "description": "Default unit: s", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "hot_standby_feedback": { + "type": "boolean" } } }, - "DeployFunctionResponse": { + "UpdatePostgresConfigBody": { "type": "object", "properties": { - "id": { + "effective_cache_size": { "type": "string" }, - "slug": { + "logical_decoding_work_mem": { "type": "string" }, - "name": { - "type": "string" + "cron.log_statement": { + "type": "boolean" }, - "status": { + "log_autovacuum_min_duration": { "type": "string", - "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" }, - "version": { + "log_checkpoints": { + "type": "boolean" + }, + "log_connections": { + "type": "boolean" + }, + "log_disconnections": { + "type": "boolean" + }, + "log_duration": { + "type": "boolean" + }, + "log_lock_waits": { + "type": "boolean" + }, + "log_recovery_conflict_waits": { + "type": "boolean" + }, + "log_replication_commands": { + "type": "boolean" + }, + "log_startup_progress_interval": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "log_temp_files": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "max_connections": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "minimum": 1, + "maximum": 262143 }, - "created_at": { + "max_locks_per_transaction": { + "type": "integer", + "minimum": 10, + "maximum": 2147483640 + }, + "max_logical_replication_workers": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "max_parallel_maintenance_workers": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_parallel_workers": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_parallel_workers_per_gather": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_replication_slots": { "type": "integer", - "format": "int64", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "updated_at": { + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_sync_workers_per_subscription": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "max_wal_size": { + "type": "string" + }, + "max_wal_senders": { "type": "integer", - "format": "int64", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "verify_jwt": { - "type": "boolean" + "max_worker_processes": { + "type": "integer", + "minimum": 0, + "maximum": 262143 }, - "import_map": { + "session_replication_role": { + "type": "string", + "enum": ["origin", "replica", "local"] + }, + "shared_buffers": { + "type": "string" + }, + "statement_timeout": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "track_commit_timestamp": { "type": "boolean" }, - "entrypoint_path": { + "wal_keep_size": { "type": "string" }, - "import_map_path": { + "wal_sender_timeout": { "type": "string", - "nullable": true + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" }, - "ezbr_sha256": { + "work_mem": { "type": "string" + }, + "checkpoint_timeout": { + "type": "string", + "description": "Default unit: s", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "hot_standby_feedback": { + "type": "boolean" + }, + "restart_database": { + "type": "boolean" } }, - "required": ["id", "slug", "name", "status", "version"] + "example": { + "max_connections": 120, + "shared_buffers": "256MB", + "work_mem": "4MB", + "statement_timeout": "60000ms" + }, + "additionalProperties": false + }, + "RealtimeConfigResponse": { + "type": "object", + "properties": { + "private_only": { + "type": "boolean", + "description": "Whether to only allow private channels", + "nullable": true + }, + "connection_pool": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Sets connection pool size for Realtime Authorization", + "nullable": true + }, + "max_concurrent_users": { + "type": "integer", + "minimum": 1, + "maximum": 50000, + "description": "Sets maximum number of concurrent users rate limit", + "nullable": true + }, + "max_events_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 50000, + "description": "Sets maximum number of events per second rate per channel limit", + "nullable": true + }, + "max_bytes_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 10000000, + "description": "Sets maximum number of bytes per second rate per channel limit", + "nullable": true + }, + "max_channels_per_client": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Sets maximum number of channels per client rate limit", + "nullable": true + }, + "max_joins_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "description": "Sets maximum number of joins per second rate limit", + "nullable": true + }, + "max_presence_events_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "description": "Sets maximum number of presence events per second rate limit", + "nullable": true + }, + "max_payload_size_in_kb": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Sets maximum number of payload size in KB rate limit", + "nullable": true + }, + "suspend": { + "type": "boolean", + "description": "Disables the Realtime service for this project when true. Set to false to re-enable it.", + "nullable": true + }, + "presence_enabled": { + "type": "boolean", + "description": "Whether to enable presence" + } + }, + "required": [ + "private_only", + "connection_pool", + "max_concurrent_users", + "max_events_per_second", + "max_bytes_per_second", + "max_channels_per_client", + "max_joins_per_second", + "max_presence_events_per_second", + "max_payload_size_in_kb", + "suspend", + "presence_enabled" + ] }, - "FunctionSlugResponse": { + "UpdateRealtimeConfigBody": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "slug": { - "type": "string" + "private_only": { + "type": "boolean", + "description": "Whether to only allow private channels" }, - "name": { - "type": "string" + "connection_pool": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Sets connection pool size for Realtime Authorization" }, - "status": { - "type": "string", - "enum": ["ACTIVE", "REMOVED", "THROTTLED"] + "max_concurrent_users": { + "type": "integer", + "minimum": 1, + "maximum": 50000, + "description": "Sets maximum number of concurrent users rate limit" }, - "version": { + "max_events_per_second": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "minimum": 1, + "maximum": 50000, + "description": "Sets maximum number of events per second rate per channel limit" }, - "created_at": { + "max_bytes_per_second": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" + "minimum": 1, + "maximum": 10000000, + "description": "Sets maximum number of bytes per second rate per channel limit" }, - "updated_at": { + "max_channels_per_client": { "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" + "minimum": 1, + "maximum": 10000, + "description": "Sets maximum number of channels per client rate limit" }, - "verify_jwt": { - "type": "boolean" + "max_joins_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "description": "Sets maximum number of joins per second rate limit" }, - "import_map": { - "type": "boolean" + "max_presence_events_per_second": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "description": "Sets maximum number of presence events per second rate limit" }, - "entrypoint_path": { - "type": "string" + "max_payload_size_in_kb": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Sets maximum number of payload size in KB rate limit" }, - "import_map_path": { - "type": "string", - "nullable": true + "suspend": { + "type": "boolean", + "description": "Disables the Realtime service for this project when true. Set to false to re-enable it." }, - "ezbr_sha256": { - "type": "string" + "presence_enabled": { + "type": "boolean", + "description": "Whether to enable presence" } }, - "required": ["id", "slug", "name", "status", "version", "created_at", "updated_at"] - }, - "StreamableFile": { - "type": "object", - "properties": {} + "example": { + "private_only": false, + "max_concurrent_users": 1000, + "max_channels_per_client": 100 + }, + "additionalProperties": false }, - "V1UpdateFunctionBody": { + "CreateProviderBody": { "type": "object", "properties": { - "name": { + "type": { + "type": "string", + "enum": ["saml"], + "description": "What type of provider will be created" + }, + "metadata_xml": { "type": "string" }, - "body": { + "metadata_url": { "type": "string" }, - "verify_jwt": { - "type": "boolean" + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "attribute_mapping": { + "type": "object", + "properties": { + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } + } + }, + "required": ["keys"] + }, + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] } }, + "required": ["type"], "example": { - "name": "Hello World", - "body": "Deno.serve(() => new Response('Hello again!'))", - "verify_jwt": true + "type": "saml", + "metadata_url": "https://sso.acme.com/metadata.xml", + "domains": ["acme.com"], + "attribute_mapping": { + "keys": { + "email": { + "name": "email" + }, + "first_name": { + "name": "first_name" + }, + "last_name": { + "name": "last_name" + } + } + } } }, - "V1StorageBucketResponse": { + "CreateProviderResponse": { "type": "object", "properties": { "id": { "type": "string" }, - "name": { - "type": "string" - }, - "owner": { - "type": "string" + "saml": { + "type": "object", + "properties": { + "entity_id": { + "type": "string" + }, + "metadata_url": { + "type": "string" + }, + "metadata_xml": { + "type": "string" + }, + "attribute_mapping": { + "type": "object", + "properties": { + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } + } + }, + "required": [] + }, + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] + } + }, + "required": ["entity_id"] + }, + "domains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } }, "created_at": { "type": "string" }, "updated_at": { "type": "string" - }, - "public": { - "type": "boolean" } }, - "required": ["id", "name", "owner", "created_at", "updated_at", "public"] + "required": ["id"] }, - "DiskResponse": { + "ListProvidersResponse": { "type": "object", "properties": { - "attributes": { - "anyOf": [ - { - "type": "object", - "properties": { - "iops": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "size_gb": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "throughput_mibps": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": ["gp3"] - } + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "required": ["iops", "size_gb", "type"] - }, - { - "type": "object", - "properties": { - "iops": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "size_gb": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 + "saml": { + "type": "object", + "properties": { + "entity_id": { + "type": "string" + }, + "metadata_url": { + "type": "string" + }, + "metadata_xml": { + "type": "string" + }, + "attribute_mapping": { + "type": "object", + "properties": { + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } + } + }, + "required": [] + }, + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] + } }, - "type": { - "type": "string", - "enum": ["io2"] + "required": ["entity_id"] + }, + "domains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } } }, - "required": ["iops", "size_gb", "type"] - } - ] - }, - "last_modified_at": { - "type": "string" + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": ["id"] + } } }, - "required": ["attributes"] + "required": ["items"] }, - "DiskRequestBody": { + "GetProviderResponse": { "type": "object", "properties": { - "attributes": { - "oneOf": [ - { - "type": "object", - "properties": { - "iops": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "size_gb": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "throughput_mibps": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": ["gp3"] - } - }, - "required": ["iops", "size_gb", "type"] + "id": { + "type": "string" + }, + "saml": { + "type": "object", + "properties": { + "entity_id": { + "type": "string" }, - { + "metadata_url": { + "type": "string" + }, + "metadata_xml": { + "type": "string" + }, + "attribute_mapping": { "type": "object", "properties": { - "iops": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "size_gb": { - "type": "integer", - "exclusiveMinimum": true, - "maximum": 9007199254740991, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": ["io2"] + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } } }, - "required": ["iops", "size_gb", "type"] + "required": [] + }, + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] } - ] + }, + "required": ["entity_id"] + }, + "domains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" } }, - "required": ["attributes"], - "example": { - "attributes": { - "type": "gp3", - "size_gb": 100, - "iops": 3000, - "throughput_mibps": 125 - } - } + "required": ["id"] }, - "DiskUtilMetricsResponse": { + "UpdateProviderBody": { "type": "object", "properties": { - "timestamp": { + "metadata_xml": { "type": "string" }, - "metrics": { + "metadata_url": { + "type": "string" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "attribute_mapping": { "type": "object", "properties": { - "fs_size_bytes": { - "type": "number" - }, - "fs_avail_bytes": { - "type": "number" - }, - "fs_used_bytes": { - "type": "number" + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } } }, - "required": ["fs_size_bytes", "fs_avail_bytes", "fs_used_bytes"] - } - }, - "required": ["timestamp", "metrics"] - }, - "DiskAutoscaleConfig": { - "type": "object", - "properties": { - "growth_percent": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Growth percentage for disk autoscaling", - "nullable": true - }, - "min_increment_gb": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Minimum increment size for disk autoscaling in GB", - "nullable": true + "required": ["keys"] }, - "max_size_gb": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Maximum limit the disk size will grow to in GB", - "nullable": true + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] } }, - "required": ["growth_percent", "min_increment_gb", "max_size_gb"] + "example": { + "metadata_url": "https://sso.acme.com/metadata.xml", + "domains": ["acme.com", "contractors.acme.com"] + } }, - "StorageConfigResponse": { + "UpdateProviderResponse": { "type": "object", "properties": { - "fileSizeLimit": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "format": "int64" + "id": { + "type": "string" }, - "features": { + "saml": { "type": "object", "properties": { - "imageTransformation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] - }, - "s3Protocol": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] + "entity_id": { + "type": "string" }, - "purgeCache": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] + "metadata_url": { + "type": "string" }, - "icebergCatalog": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "maxNamespaces": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxTables": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxCatalogs": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["enabled", "maxNamespaces", "maxTables", "maxCatalogs"] + "metadata_xml": { + "type": "string" }, - "vectorBuckets": { + "attribute_mapping": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "maxBuckets": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxIndexes": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } } }, - "required": ["enabled", "maxBuckets", "maxIndexes"] - } - }, - "required": [ - "imageTransformation", - "s3Protocol", - "purgeCache", - "icebergCatalog", - "vectorBuckets" - ] - }, - "capabilities": { - "type": "object", - "properties": { - "list_v2": { - "type": "boolean" + "required": [] }, - "iceberg_catalog": { - "type": "boolean" + "name_id_format": { + "type": "string", + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] } }, - "required": ["list_v2", "iceberg_catalog"] + "required": ["entity_id"] }, - "external": { - "type": "object", - "properties": { - "upstreamTarget": { - "type": "string", - "enum": ["main", "canary"] + "domains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } } - }, - "required": ["upstreamTarget"] + } }, - "migrationVersion": { + "created_at": { "type": "string" }, - "databasePoolMode": { + "updated_at": { "type": "string" } }, - "required": ["fileSizeLimit", "features", "capabilities", "external", "migrationVersion"] + "required": ["id"] }, - "UpdateStorageConfigBody": { + "DeleteProviderResponse": { "type": "object", "properties": { - "fileSizeLimit": { - "type": "integer", - "format": "int64", - "minimum": 0, - "maximum": 536870912000 + "id": { + "type": "string" }, - "features": { + "saml": { "type": "object", "properties": { - "imageTransformation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] + "entity_id": { + "type": "string" }, - "s3Protocol": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] + "metadata_url": { + "type": "string" }, - "purgeCache": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] + "metadata_xml": { + "type": "string" }, - "icebergCatalog": { + "attribute_mapping": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "maxNamespaces": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxTables": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxCatalogs": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "keys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "names": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "array": { + "type": "boolean" + } + } + } } }, - "required": ["enabled", "maxNamespaces", "maxTables", "maxCatalogs"] + "required": [] }, - "vectorBuckets": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "maxBuckets": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "maxIndexes": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["enabled", "maxBuckets", "maxIndexes"] - } - } - }, - "external": { - "type": "object", - "properties": { - "upstreamTarget": { + "name_id_format": { "type": "string", - "enum": ["main", "canary"] + "enum": [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + ] } }, - "required": ["upstreamTarget"] - } - }, - "example": { - "fileSizeLimit": 10485760, - "features": { - "imageTransformation": { - "enabled": true - } - } - }, - "additionalProperties": false - }, - "V1PgbouncerConfigResponse": { - "type": "object", - "properties": { - "default_pool_size": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "ignore_startup_parameters": { - "type": "string" - }, - "max_client_conn": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "required": ["entity_id"] }, - "pool_mode": { - "type": "string", - "enum": ["transaction", "session", "statement"] + "domains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } }, - "connection_string": { + "created_at": { "type": "string" }, - "server_idle_timeout": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "server_lifetime": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "query_wait_timeout": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "reserve_pool_size": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "updated_at": { + "type": "string" } - } + }, + "required": ["id"] }, - "SupavisorConfigResponse": { + "V1BackupsResponse": { "type": "object", "properties": { - "identifier": { + "region": { "type": "string" }, - "database_type": { - "type": "string", - "enum": ["PRIMARY", "READ_REPLICA"] - }, - "is_using_scram_auth": { + "walg_enabled": { "type": "boolean" }, - "db_user": { - "type": "string" - }, - "db_host": { - "type": "string" - }, - "db_port": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "db_name": { - "type": "string" - }, - "connection_string": { - "type": "string" - }, - "connectionString": { - "type": "string", - "description": "Use connection_string instead" - }, - "default_pool_size": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true + "pitr_enabled": { + "type": "boolean" }, - "max_client_conn": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "nullable": true + "backups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "is_physical_backup": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["COMPLETED", "FAILED", "PENDING", "REMOVED", "ARCHIVED", "CANCELLED"] + }, + "inserted_at": { + "type": "string" + } + }, + "required": ["id", "is_physical_backup", "status", "inserted_at"] + } }, - "pool_mode": { - "type": "string", - "enum": ["transaction", "session"] + "physical_backup_data": { + "type": "object", + "properties": { + "earliest_physical_backup_date_unix": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "latest_physical_backup_date_unix": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + } } }, - "required": [ - "identifier", - "database_type", - "is_using_scram_auth", - "db_user", - "db_host", - "db_port", - "db_name", - "connection_string", - "connectionString", - "default_pool_size", - "max_client_conn", - "pool_mode" - ] + "required": ["region", "walg_enabled", "pitr_enabled", "backups", "physical_backup_data"] }, - "UpdateSupavisorConfigBody": { + "V1RestorePitrBody": { "type": "object", "properties": { - "default_pool_size": { + "recovery_time_target_unix": { "type": "integer", "minimum": 0, - "maximum": 3000, - "nullable": true - }, - "pool_mode": { - "description": "Dedicated pooler mode for the project", - "type": "string", - "enum": ["transaction", "session"] - } - }, - "example": { - "default_pool_size": 25, - "pool_mode": "transaction" - } - }, - "UpdateSupavisorConfigResponse": { - "type": "object", - "properties": { - "default_pool_size": { - "type": "integer", - "minimum": -9007199254740991, "maximum": 9007199254740991, - "nullable": true - }, - "pool_mode": { - "type": "string" + "format": "int64" } }, - "required": ["default_pool_size", "pool_mode"] - }, - "PostgresConfigResponse": { - "type": "object", - "properties": { - "effective_cache_size": { - "type": "string" - }, - "logical_decoding_work_mem": { - "type": "string" - }, - "cron.log_statement": { - "type": "boolean" - }, - "log_autovacuum_min_duration": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "log_checkpoints": { - "type": "boolean" - }, - "log_connections": { - "type": "boolean" - }, - "log_disconnections": { - "type": "boolean" - }, - "log_duration": { - "type": "boolean" - }, - "log_lock_waits": { - "type": "boolean" - }, - "log_recovery_conflict_waits": { - "type": "boolean" - }, - "log_replication_commands": { - "type": "boolean" - }, - "log_startup_progress_interval": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "log_temp_files": { - "type": "string" - }, - "maintenance_work_mem": { - "type": "string" - }, - "track_activity_query_size": { - "type": "string" - }, - "max_connections": { - "type": "integer", - "minimum": 1, - "maximum": 262143 - }, - "max_locks_per_transaction": { - "type": "integer", - "minimum": 10, - "maximum": 2147483640 - }, - "max_logical_replication_workers": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "max_parallel_maintenance_workers": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_parallel_workers": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_parallel_workers_per_gather": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_replication_slots": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "max_slot_wal_keep_size": { - "type": "string" - }, - "max_standby_archive_delay": { - "type": "string" - }, - "max_standby_streaming_delay": { + "required": ["recovery_time_target_unix"], + "example": { + "recovery_time_target_unix": 1740787200 + } + }, + "V1RestorePointPostBody": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 20 + } + }, + "required": ["name"], + "example": { + "name": "before-upgrade" + } + }, + "V1RestorePointResponse": { + "type": "object", + "properties": { + "name": { "type": "string" }, - "max_sync_workers_per_subscription": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "max_wal_size": { - "type": "string" + "status": { + "type": "string", + "enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"] }, - "max_wal_senders": { + "completed_on": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "nullable": true + } + }, + "required": ["name", "status", "completed_on"] + }, + "V1RestoreBackupBody": { + "type": "object", + "properties": { + "id": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 - }, - "max_worker_processes": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "session_replication_role": { + } + }, + "required": ["id"], + "example": { + "id": 12345 + } + }, + "V1BackupScheduleResponse": { + "type": "object", + "properties": { + "schedule_for": { "type": "string", - "enum": ["origin", "replica", "local"] - }, - "shared_buffers": { - "type": "string" + "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" }, - "statement_timeout": { + "updated_at": { "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "track_commit_timestamp": { - "type": "boolean" - }, - "wal_keep_size": { - "type": "string" - }, - "wal_sender_timeout": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "description": "Timestamp of when the backup schedule was last updated.", + "example": "2026-05-04T14:40:44+00:00" + } + }, + "required": ["schedule_for", "updated_at"] + }, + "V1UpdateBackupScheduleBody": { + "type": "object", + "properties": { + "schedule_for": { "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "work_mem": { - "type": "string" - }, - "checkpoint_timeout": { + "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", + "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", + "example": "04:00:00" + } + }, + "required": ["schedule_for"], + "example": { + "schedule_for": "04:00:00" + } + }, + "V1UndoBody": { + "type": "object", + "properties": { + "name": { "type": "string", - "description": "Default unit: s", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "hot_standby_feedback": { - "type": "boolean" + "maxLength": 20 + } + }, + "required": ["name"], + "example": { + "name": "before-upgrade" + } + }, + "V1ListEntitlementsResponse": { + "type": "object", + "properties": { + "entitlements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "feature": { + "type": "object", + "properties": { + "key": { + "type": "string", + "enum": [ + "instances.compute_update_available_sizes", + "instances.read_replicas", + "instances.disk_modifications", + "instances.high_availability", + "instances.orioledb", + "replication.etl", + "storage.max_file_size", + "storage.max_file_size.configurable", + "storage.image_transformations", + "storage.vector_buckets", + "storage.iceberg_catalog", + "storage.purge_cache", + "security.audit_logs_days", + "security.questionnaire", + "security.soc2_report", + "security.iso27001_certificate", + "security.private_link", + "security.enforce_mfa", + "log.retention_days", + "custom_domain", + "vanity_subdomain", + "ipv4", + "pitr.available_variants", + "log_drains", + "audit_log_drains", + "branching_limit", + "branching_persistent", + "auth.mfa_phone", + "auth.mfa_web_authn", + "auth.mfa_enhanced_security", + "auth.hooks", + "auth.platform.sso", + "auth.custom_jwt_template", + "auth.saml_2", + "auth.user_sessions", + "auth.leaked_password_protection", + "auth.advanced_auth_settings", + "auth.performance_settings", + "auth.password_hibp", + "auth.custom_oauth.max_providers", + "backup.retention_days", + "backup.restore_to_new_project", + "backup.schedule", + "function.max_count", + "function.size_limit_mb", + "realtime.max_concurrent_users", + "realtime.max_events_per_second", + "realtime.max_joins_per_second", + "realtime.max_channels_per_client", + "realtime.max_bytes_per_second", + "realtime.max_presence_events_per_second", + "realtime.max_payload_size_in_kb", + "project_scoped_roles", + "security.member_roles", + "project_pausing", + "project_cloning", + "project_restore_after_expiry", + "assistant.advance_model", + "integrations.github_connections", + "dedicated_pooler", + "observability.dashboard_advanced_metrics", + "api.members.invitations", + "api.members.roles" + ] + }, + "type": { + "type": "string", + "enum": ["boolean", "numeric", "set"] + } + }, + "required": ["key", "type"] + }, + "hasAccess": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": ["boolean", "numeric", "set"] + }, + "config": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "value": { + "type": "number" + }, + "unlimited": { + "type": "boolean" + }, + "unit": { + "type": "string" + } + }, + "required": ["enabled", "value", "unlimited", "unit"] + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "set": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["enabled", "set"] + } + ] + } + }, + "required": ["feature", "hasAccess", "type", "config"] + } } - } + }, + "required": ["entitlements"] }, - "UpdatePostgresConfigBody": { + "V1OrganizationMemberResponse": { "type": "object", "properties": { - "effective_cache_size": { - "type": "string" - }, - "logical_decoding_work_mem": { - "type": "string" - }, - "cron.log_statement": { - "type": "boolean" - }, - "log_autovacuum_min_duration": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "log_checkpoints": { - "type": "boolean" - }, - "log_connections": { - "type": "boolean" - }, - "log_disconnections": { - "type": "boolean" - }, - "log_duration": { - "type": "boolean" - }, - "log_lock_waits": { - "type": "boolean" - }, - "log_recovery_conflict_waits": { - "type": "boolean" - }, - "log_replication_commands": { - "type": "boolean" - }, - "log_startup_progress_interval": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "log_temp_files": { - "type": "string" - }, - "maintenance_work_mem": { - "type": "string" - }, - "track_activity_query_size": { - "type": "string" - }, - "max_connections": { - "type": "integer", - "minimum": 1, - "maximum": 262143 - }, - "max_locks_per_transaction": { - "type": "integer", - "minimum": 10, - "maximum": 2147483640 - }, - "max_logical_replication_workers": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "max_parallel_maintenance_workers": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_parallel_workers": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_parallel_workers_per_gather": { - "type": "integer", - "minimum": 0, - "maximum": 1024 - }, - "max_replication_slots": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "max_slot_wal_keep_size": { - "type": "string" - }, - "max_standby_archive_delay": { + "user_id": { "type": "string" }, - "max_standby_streaming_delay": { + "user_name": { "type": "string" }, - "max_sync_workers_per_subscription": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "max_wal_size": { + "email": { "type": "string" }, - "max_wal_senders": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "max_worker_processes": { - "type": "integer", - "minimum": 0, - "maximum": 262143 - }, - "session_replication_role": { - "type": "string", - "enum": ["origin", "replica", "local"] - }, - "shared_buffers": { + "role_name": { "type": "string" }, - "statement_timeout": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "track_commit_timestamp": { + "mfa_enabled": { "type": "boolean" }, - "wal_keep_size": { - "type": "string" - }, - "wal_sender_timeout": { - "type": "string", - "description": "Default unit: ms", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "work_mem": { - "type": "string" - }, - "checkpoint_timeout": { + "avatar_url": { "type": "string", - "description": "Default unit: s", - "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" - }, - "hot_standby_feedback": { - "type": "boolean" - }, - "restart_database": { - "type": "boolean" - } - }, - "example": { - "max_connections": 120, - "shared_buffers": "256MB", - "work_mem": "4MB", - "statement_timeout": "60000ms" - }, - "additionalProperties": false - }, - "RealtimeConfigResponse": { - "type": "object", - "properties": { - "private_only": { - "type": "boolean", - "description": "Whether to only allow private channels", - "nullable": true - }, - "connection_pool": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "Sets connection pool size for Realtime Authorization", - "nullable": true - }, - "max_concurrent_users": { - "type": "integer", - "minimum": 1, - "maximum": 50000, - "description": "Sets maximum number of concurrent users rate limit", - "nullable": true - }, - "max_events_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 50000, - "description": "Sets maximum number of events per second rate per channel limit", - "nullable": true - }, - "max_bytes_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 10000000, - "description": "Sets maximum number of bytes per second rate per channel limit", - "nullable": true - }, - "max_channels_per_client": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "description": "Sets maximum number of channels per client rate limit", - "nullable": true - }, - "max_joins_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 5000, - "description": "Sets maximum number of joins per second rate limit", - "nullable": true - }, - "max_presence_events_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 5000, - "description": "Sets maximum number of presence events per second rate limit", - "nullable": true - }, - "max_payload_size_in_kb": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "description": "Sets maximum number of payload size in KB rate limit", - "nullable": true - }, - "suspend": { - "type": "boolean", - "description": "Disables the Realtime service for this project when true. Set to false to re-enable it.", - "nullable": true - }, - "presence_enabled": { - "type": "boolean", - "description": "Whether to enable presence" - } - }, - "required": [ - "private_only", - "connection_pool", - "max_concurrent_users", - "max_events_per_second", - "max_bytes_per_second", - "max_channels_per_client", - "max_joins_per_second", - "max_presence_events_per_second", - "max_payload_size_in_kb", - "suspend", - "presence_enabled" - ] + "nullable": true + } + }, + "required": ["user_id", "user_name", "role_name", "mfa_enabled", "avatar_url"] }, - "UpdateRealtimeConfigBody": { + "V1OrganizationSlugResponse": { "type": "object", "properties": { - "private_only": { - "type": "boolean", - "description": "Whether to only allow private channels" - }, - "connection_pool": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "Sets connection pool size for Realtime Authorization" + "id": { + "type": "string" }, - "max_concurrent_users": { - "type": "integer", - "minimum": 1, - "maximum": 50000, - "description": "Sets maximum number of concurrent users rate limit" + "name": { + "type": "string" }, - "max_events_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 50000, - "description": "Sets maximum number of events per second rate per channel limit" + "plan": { + "type": "string", + "enum": ["free", "pro", "team", "enterprise", "platform"] }, - "max_bytes_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 10000000, - "description": "Sets maximum number of bytes per second rate per channel limit" + "opt_in_tags": { + "type": "array", + "items": { + "enum": [ + "AI_SQL_GENERATOR_OPT_IN", + "AI_DATA_GENERATOR_OPT_IN", + "AI_LOG_GENERATOR_OPT_IN" + ] + } }, - "max_channels_per_client": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "description": "Sets maximum number of channels per client rate limit" + "allowed_release_channels": { + "type": "array", + "items": { + "type": "string", + "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + } + } + }, + "required": ["id", "name", "opt_in_tags", "allowed_release_channels"] + }, + "OrganizationProjectClaimResponse": { + "type": "object", + "properties": { + "project": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["ref", "name"] }, - "max_joins_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 5000, - "description": "Sets maximum number of joins per second rate limit" + "preview": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "info": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "members_exceeding_free_project_limit": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "limit": { + "type": "number" + } + }, + "required": ["name", "limit"] + } + }, + "source_subscription_plan": { + "type": "string", + "enum": ["free", "pro", "team", "enterprise", "platform"] + }, + "target_subscription_plan": { + "type": "string", + "enum": ["free", "pro", "team", "enterprise", "platform", null], + "nullable": true + } + }, + "required": [ + "valid", + "warnings", + "errors", + "info", + "members_exceeding_free_project_limit", + "source_subscription_plan", + "target_subscription_plan" + ] }, - "max_presence_events_per_second": { - "type": "integer", - "minimum": 1, - "maximum": 5000, - "description": "Sets maximum number of presence events per second rate limit" + "expires_at": { + "type": "string" }, - "max_payload_size_in_kb": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "description": "Sets maximum number of payload size in KB rate limit" + "created_at": { + "type": "string" }, - "suspend": { - "type": "boolean", - "description": "Disables the Realtime service for this project when true. Set to false to re-enable it." + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "required": ["project", "preview", "expires_at", "created_at", "created_by"] + }, + "OrganizationProjectsResponse": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "name": { + "type": "string" + }, + "cloud_provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "is_branch": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ] + }, + "inserted_at": { + "type": "string" + }, + "databases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "infra_compute_size": { + "type": "string", + "enum": [ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory" + ] + }, + "region": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING" + ] + }, + "cloud_provider": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["PRIMARY", "READ_REPLICA"] + }, + "disk_volume_size_gb": { + "type": "number" + }, + "disk_type": { + "type": "string", + "enum": ["gp3", "io2"] + }, + "disk_throughput_mbps": { + "type": "number" + }, + "disk_last_modified_at": { + "type": "string" + } + }, + "required": ["region", "status", "cloud_provider", "identifier", "type"] + } + } + }, + "required": [ + "ref", + "name", + "cloud_provider", + "region", + "is_branch", + "status", + "inserted_at", + "databases" + ] + } }, - "presence_enabled": { - "type": "boolean", - "description": "Whether to enable presence" + "pagination": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "Total number of projects. Use this to calculate the total number of pages." + }, + "limit": { + "type": "number", + "description": "Maximum number of projects per page" + }, + "offset": { + "type": "number", + "description": "Number of projects skipped in this response" + } + }, + "required": ["count", "limit", "offset"] } }, - "example": { - "private_only": false, - "max_concurrent_users": 1000, - "max_channels_per_client": 100 - }, - "additionalProperties": false + "required": ["projects", "pagination"] }, - "CreateProviderBody": { + "ListLogDrainsResponse": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["saml"], - "description": "What type of provider will be created" - }, - "metadata_xml": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "domains": { + "data": { "type": "array", "items": { - "type": "string" - } - }, - "attribute_mapping": { - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["log_drain"] + }, + "id": { + "type": "string" + }, + "attributes": { "type": "object", "properties": { "name": { "type": "string" }, - "names": { - "type": "array", - "items": { - "type": "string" - } + "description": { + "type": "string" }, - "default": { + "config": { "anyOf": [ { "type": "object", - "properties": {} + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] + } + }, + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + }, + "CreateLogDrainRequestOpenApi": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["log_drain"] + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } }, - { - "type": "number" + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } }, - { - "type": "string" + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } }, - { - "type": "boolean" - } - ] - }, - "array": { - "type": "boolean" - } + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" + } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] } - } + }, + "required": ["name", "config", "backend_type"] } }, - "required": ["keys"] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] + "required": ["type", "attributes"] } }, - "required": ["type"], - "example": { - "type": "saml", - "metadata_url": "https://sso.acme.com/metadata.xml", - "domains": ["acme.com"], - "attribute_mapping": { - "keys": { - "email": { - "name": "email" - }, - "first_name": { - "name": "first_name" - }, - "last_name": { - "name": "last_name" - } - } - } - } + "required": ["data"] }, - "CreateProviderResponse": { + "LogDrainResponse": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "saml": { + "data": { "type": "object", "properties": { - "entity_id": { - "type": "string" - }, - "metadata_url": { - "type": "string" + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["log_drain"] }, - "metadata_xml": { + "id": { "type": "string" }, - "attribute_mapping": { + "attributes": { "type": "object", "properties": { - "keys": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string" + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } }, - "names": { - "type": "array", - "items": { + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "webhook" + }, + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" + }, + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { "type": "string" } }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "boolean" - } - ] - }, - "array": { - "type": "boolean" - } + "additionalProperties": false, + "title": "syslog" } - } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] } }, - "required": [] + "required": ["name", "config", "backend_type"] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + }, + "PlanGateErrorBodyV2": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "HTTP status-derived error code, e.g. \"payment_required\"" }, - "name_id_format": { + "message": { "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] + "description": "Human-readable explanation of the plan gate" } }, - "required": ["entity_id"] - }, - "domains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - } - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" + "required": ["code", "message"], + "description": "Plan-gate error object" } }, - "required": ["id"] + "required": ["error"] }, - "ListProvidersResponse": { + "UpdateLogDrainRequestOpenApi": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "saml": { - "type": "object", - "properties": { - "entity_id": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "metadata_xml": { - "type": "string" - }, - "attribute_mapping": { - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["log_drain"] + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "schema": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "port": { + "type": "number", + "nullable": true + }, + "hostname": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "postgres" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "http": { + "type": "string", + "enum": ["http1", "http2"] + }, + "gzip": { + "type": "boolean" + }, + "headers": { "type": "object", - "properties": { - "name": { - "type": "string" - }, - "names": { - "type": "array", - "items": { - "type": "string" - } - }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "boolean" - } - ] - }, - "array": { - "type": "boolean" - } + "additionalProperties": { + "type": "string" } } - } + }, + "additionalProperties": false, + "title": "webhook" }, - "required": [] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] - } - }, - "required": ["entity_id"] - }, - "domains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" + { + "type": "object", + "properties": { + "project_id": { + "type": "string" + }, + "dataset_id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "bigquery" }, - "created_at": { - "type": "string" + { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "datadog" }, - "updated_at": { - "type": "string" + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "loki" + }, + { + "type": "object", + "properties": { + "dsn": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "sentry" + }, + { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "axiom" + }, + { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "default": false, + "type": "boolean" + }, + "structured_data": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "ca_cert": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "syslog" } - } + ] + }, + "backend_type": { + "type": "string", + "enum": [ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog" + ] } }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": ["id"] - } + "required": ["backend_type"] + } + }, + "required": ["type", "attributes"] } }, - "required": ["items"] + "required": ["data"] }, - "GetProviderResponse": { + "V2ProjectConfigResponse": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "saml": { + "data": { "type": "object", "properties": { - "entity_id": { - "type": "string" - }, - "metadata_url": { - "type": "string" + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["project_config"] }, - "metadata_xml": { - "type": "string" + "id": { + "type": "string", + "description": "Project ref." }, - "attribute_mapping": { + "attributes": { "type": "object", "properties": { - "keys": { + "database": { "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "names": { - "type": "array", - "items": { + "properties": { + "ssl_enforced": { + "type": "boolean", + "description": "Whether the database rejects plaintext connections" + }, + "network_restrictions": { + "type": "object", + "properties": { + "entitlement": { + "type": "string", + "enum": ["disallowed", "allowed"] + }, + "status": { + "type": "string", + "enum": ["stored", "applied"], + "description": "Whether the allowlist below is applied to the project or only stored." + }, + "allowed_cidrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["v4", "v6"] + } + }, + "required": ["address", "type"] + } + }, + "updated_at": { + "type": "string" + }, + "applied_at": { "type": "string" } }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "boolean" - } - ] + "required": ["entitlement", "status", "allowed_cidrs"] + }, + "postgres_settings": { + "type": "object", + "properties": { + "effective_cache_size": { + "type": "string" + }, + "logical_decoding_work_mem": { + "type": "string" + }, + "log_autovacuum_min_duration": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "log_checkpoints": { + "type": "boolean" + }, + "log_connections": { + "type": "boolean" + }, + "log_disconnections": { + "type": "boolean" + }, + "log_duration": { + "type": "boolean" + }, + "log_lock_waits": { + "type": "boolean" + }, + "log_recovery_conflict_waits": { + "type": "boolean" + }, + "log_replication_commands": { + "type": "boolean" + }, + "log_startup_progress_interval": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "log_temp_files": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "max_connections": { + "type": "integer", + "minimum": 1, + "maximum": 262143 + }, + "max_locks_per_transaction": { + "type": "integer", + "minimum": 10, + "maximum": 2147483640 + }, + "max_logical_replication_workers": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "max_parallel_maintenance_workers": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_parallel_workers": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_parallel_workers_per_gather": { + "type": "integer", + "minimum": 0, + "maximum": 1024 + }, + "max_replication_slots": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_sync_workers_per_subscription": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "max_wal_size": { + "type": "string" + }, + "max_wal_senders": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_worker_processes": { + "type": "integer", + "minimum": 0, + "maximum": 262143 + }, + "session_replication_role": { + "type": "string", + "enum": ["origin", "replica", "local"] + }, + "shared_buffers": { + "type": "string" + }, + "statement_timeout": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "track_commit_timestamp": { + "type": "boolean" + }, + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "type": "string", + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "work_mem": { + "type": "string" + }, + "checkpoint_timeout": { + "type": "string", + "description": "Default unit: s", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$" + }, + "hot_standby_feedback": { + "type": "boolean" + }, + "cron_log_statement": { + "type": "boolean" + } }, - "array": { - "type": "boolean" - } + "description": "Postgres parameter overrides. Empty when the project runs entirely on defaults." } - } - } - }, - "required": [] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] - } - }, - "required": ["entity_id"] - }, - "domains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - } - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": ["id"] - }, - "UpdateProviderBody": { - "type": "object", - "properties": { - "metadata_xml": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "attribute_mapping": { - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string" }, - "names": { - "type": "array", - "items": { + "required": ["ssl_enforced", "network_restrictions", "postgres_settings"] + }, + "pooler": { + "type": "object", + "properties": { + "pool_mode": { + "type": "string", + "enum": ["transaction", "session", "statement"] + }, + "ignore_startup_parameters": { + "type": "string" + }, + "server_idle_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "server_lifetime": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "query_wait_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "reserve_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "default_pool_size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Defaults to the pooler's size for the project's compute when not overridden." + }, + "max_client_conn": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Defaults to the pooler's size for the project's compute when not overridden." + } + }, + "required": [ + "pool_mode", + "ignore_startup_parameters", + "server_idle_timeout", + "server_lifetime", + "query_wait_timeout", + "reserve_pool_size", + "default_pool_size", + "max_client_conn" + ] + }, + "auth": { + "type": "object", + "additionalProperties": {}, + "description": "Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext." + }, + "api": { + "type": "object", + "properties": { + "db_schema": { + "type": "string", + "description": "Schemas exposed through the Data API" + }, + "db_extra_search_path": { "type": "string" + }, + "max_rows": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "db_pool_acquisition_timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "db_pool": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", + "nullable": true + } + }, + "required": [ + "db_schema", + "db_extra_search_path", + "max_rows", + "db_pool_acquisition_timeout", + "db_pool" + ] + }, + "realtime": { + "type": "object", + "properties": { + "private_only": { + "type": "boolean" + }, + "max_concurrent_users": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_events_per_second": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_bytes_per_second": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_channels_per_client": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_joins_per_second": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_presence_events_per_second": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_payload_size_in_kb": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "presence_enabled": { + "type": "boolean" + }, + "suspend": { + "type": "boolean" + }, + "connection_pool": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Defaults to Realtime's pool size for the project's compute when not overridden." + }, + "postgres_changes_pool": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "If `null`, no override is stored and Realtime applies its own default.", + "nullable": true } }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" + "required": [ + "private_only", + "max_concurrent_users", + "max_events_per_second", + "max_bytes_per_second", + "max_channels_per_client", + "max_joins_per_second", + "max_presence_events_per_second", + "max_payload_size_in_kb", + "presence_enabled", + "suspend", + "connection_pool", + "postgres_changes_pool" + ] + }, + "storage": { + "type": "object", + "properties": { + "file_size_limit": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "format": "int64" + }, + "features": { + "type": "object", + "properties": { + "image_transformation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + }, + "s3_protocol": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + }, + "purge_cache": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + }, + "iceberg_catalog": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "max_namespaces": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_tables": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_catalogs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["enabled", "max_namespaces", "max_tables", "max_catalogs"] + }, + "vector_buckets": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "max_buckets": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max_indexes": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": ["enabled", "max_buckets", "max_indexes"] + } }, - { - "type": "string" + "required": [ + "image_transformation", + "s3_protocol", + "purge_cache", + "iceberg_catalog", + "vector_buckets" + ] + }, + "capabilities": { + "type": "object", + "properties": { + "list_v2": { + "type": "boolean" + }, + "iceberg_catalog": { + "type": "boolean" + } }, - { - "type": "boolean" - } - ] + "required": ["list_v2", "iceberg_catalog"] + }, + "upstream_target": { + "type": "string", + "enum": ["main", "canary"] + }, + "migration_version": { + "type": "string" + }, + "database_pool_mode": { + "type": "string" + } }, - "array": { - "type": "boolean" - } + "required": [ + "file_size_limit", + "features", + "capabilities", + "upstream_target", + "migration_version", + "database_pool_mode" + ], + "description": "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config." } - } + }, + "required": ["database", "pooler", "auth", "api", "realtime", "storage"] } }, - "required": ["keys"] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] + "required": ["type", "id", "attributes"] } }, - "example": { - "metadata_url": "https://sso.acme.com/metadata.xml", - "domains": ["acme.com", "contractors.acme.com"] - } + "required": ["data"] }, - "UpdateProviderResponse": { + "V2TransferProjectBody": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "saml": { + "data": { "type": "object", "properties": { - "entity_id": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "metadata_xml": { - "type": "string" + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["project_transfer_input"] }, - "attribute_mapping": { + "attributes": { "type": "object", "properties": { - "keys": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "names": { - "type": "array", - "items": { - "type": "string" - } - }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "boolean" - } - ] - }, - "array": { - "type": "boolean" - } - } - } + "target_organization_slug": { + "type": "string" } }, - "required": [] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] + "required": ["target_organization_slug"] } }, - "required": ["entity_id"] - }, - "domains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - } - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" + "required": ["type", "attributes"] } }, - "required": ["id"] + "required": ["data"] }, - "DeleteProviderResponse": { + "V2PreviewProjectTransferResponse": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "saml": { + "data": { "type": "object", "properties": { - "entity_id": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "metadata_xml": { - "type": "string" + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["project_transfer_result"] }, - "attribute_mapping": { + "attributes": { "type": "object", "properties": { - "keys": { - "type": "object", - "additionalProperties": { + "valid": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { "type": "object", "properties": { - "name": { + "key": { "type": "string" }, - "names": { - "type": "array", - "items": { - "type": "string" - } + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" }, - "default": { - "anyOf": [ - { - "type": "object", - "properties": {} - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "boolean" - } - ] + "message": { + "type": "string" + } + }, + "required": ["key", "message"] + } + }, + "info": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" }, - "array": { - "type": "boolean" + "message": { + "type": "string" } - } + }, + "required": ["key", "message"] } } }, - "required": [] - }, - "name_id_format": { - "type": "string", - "enum": [ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" - ] + "required": ["valid", "warnings", "errors", "info"] } }, - "required": ["entity_id"] - }, - "domains": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - } - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" + "required": ["type", "attributes"] } }, - "required": ["id"] + "required": ["data"] }, - "V1BackupsResponse": { + "V2ListPrivateLinkAssociationsResponse": { "type": "object", "properties": { - "region": { - "type": "string" - }, - "walg_enabled": { - "type": "boolean" - }, - "pitr_enabled": { - "type": "boolean" - }, - "backups": { + "data": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "is_physical_backup": { - "type": "boolean" - }, - "status": { + "type": { "type": "string", - "enum": ["COMPLETED", "FAILED", "PENDING", "REMOVED", "ARCHIVED", "CANCELLED"] + "description": "Resource type.", + "enum": ["private_link_association"] }, - "inserted_at": { + "id": { "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "aws_account_id": { + "type": "string", + "minLength": 12, + "maxLength": 12, + "pattern": "^\\d{12}$", + "description": "The AWS account ID this PrivateLink share is associated with." + }, + "account_name": { + "description": "Human-readable name for the AWS account.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING" + ], + "description": "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n" + }, + "shared_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + "nullable": true + }, + "database_type": { + "type": "string", + "enum": ["PRIMARY", "READ_REPLICA"], + "description": "Whether this PrivateLink share targets the primary database or a read replica." + }, + "database_identifier": { + "type": "string", + "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." + } + }, + "required": [ + "aws_account_id", + "status", + "shared_at", + "database_type", + "database_identifier" + ] } }, - "required": ["id", "is_physical_backup", "status", "inserted_at"] - } - }, - "physical_backup_data": { - "type": "object", - "properties": { - "earliest_physical_backup_date_unix": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "latest_physical_backup_date_unix": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } + "required": ["type", "id", "attributes"] } } }, - "required": ["region", "walg_enabled", "pitr_enabled", "backups", "physical_backup_data"] - }, - "V1RestorePitrBody": { - "type": "object", - "properties": { - "recovery_time_target_unix": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "format": "int64" - } - }, - "required": ["recovery_time_target_unix"], - "example": { - "recovery_time_target_unix": 1740787200 - } - }, - "V1RestorePointPostBody": { - "type": "object", - "properties": { - "name": { - "type": "string", - "maxLength": 20 - } - }, - "required": ["name"], - "example": { - "name": "before-upgrade" - } + "required": ["data"] }, - "V1RestorePointResponse": { + "V2CreatePrivateLinkAssociationRequest": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["AVAILABLE", "PENDING", "REMOVED", "FAILED"] - }, - "completed_on": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "nullable": true + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["private_link_association"] + }, + "attributes": { + "type": "object", + "properties": { + "aws_account_id": { + "type": "string", + "minLength": 12, + "maxLength": 12, + "pattern": "^\\d{12}$", + "description": "The AWS account ID to add to the project PrivateLink share." + }, + "account_name": { + "description": "Optional human-readable name for the AWS account.", + "type": "string", + "maxLength": 128 + }, + "database_identifier": { + "description": "Identifier of the read replica this PrivateLink share should target. Omit to target the primary database.", + "type": "string" + } + }, + "required": ["aws_account_id"] + } + }, + "required": ["type", "attributes"] } }, - "required": ["name", "status", "completed_on"] + "required": ["data"] }, - "V1RestoreBackupBody": { + "V2PrivateLinkAssociationResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["private_link_association"] + }, + "id": { + "type": "string" + }, + "attributes": { + "type": "object", + "properties": { + "aws_account_id": { + "type": "string", + "minLength": 12, + "maxLength": 12, + "pattern": "^\\d{12}$", + "description": "The AWS account ID this PrivateLink share is associated with." + }, + "account_name": { + "description": "Human-readable name for the AWS account.", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING" + ], + "description": "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n" + }, + "shared_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + "nullable": true + }, + "database_type": { + "type": "string", + "enum": ["PRIMARY", "READ_REPLICA"], + "description": "Whether this PrivateLink share targets the primary database or a read replica." + }, + "database_identifier": { + "type": "string", + "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." + } + }, + "required": [ + "aws_account_id", + "status", + "shared_at", + "database_type", + "database_identifier" + ] + } + }, + "required": ["type", "id", "attributes"] } }, - "required": ["id"], - "example": { - "id": 12345 - } + "required": ["data"] }, - "V1BackupScheduleResponse": { + "V2ListMembersResponse": { "type": "object", "properties": { - "schedule_for": { - "type": "string", - "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", - "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", - "example": "04:00:00" + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_member"] + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" + }, + "attributes": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Member's username", + "nullable": true + }, + "primary_email": { + "type": "string", + "description": "Member's primary email", + "nullable": true + }, + "mfa_enabled": { + "type": "boolean", + "description": "Whether Multi-Factor Authentication is enabled for this member" + }, + "is_sso_user": { + "type": "boolean", + "description": "Whether this member is a Single Sign-On user" + }, + "avatar_url": { + "type": "string", + "description": "Member's avatar URL", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Role name. For project-scoped roles this is the base role name.", + "example": "developer" + }, + "scope": { + "type": "string", + "enum": ["organization", "project"], + "description": "Whether this role applies org-wide or is scoped to specific projects for the user." + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["ref", "name"] + }, + "description": "Project refs this role is scoped to. Empty array for org-level roles." + } + }, + "required": ["name", "scope", "projects"] + }, + "description": "Roles assigned to this member. Includes both org-level and project-scoped roles." + } + }, + "required": [ + "username", + "primary_email", + "mfa_enabled", + "is_sso_user", + "avatar_url", + "roles" + ] + } + }, + "required": ["type", "id", "attributes"] + } }, - "updated_at": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "description": "Timestamp of when the backup schedule was last updated.", - "example": "2026-05-04T14:40:44+00:00" + "links": { + "type": "object", + "properties": { + "first": { + "type": "string", + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/members?page[size]=10", + "nullable": true + }, + "prev": { + "type": "string", + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true + }, + "next": { + "type": "string", + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true + }, + "last": { + "type": "string", + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "nullable": true + } + }, + "required": ["prev", "next"] } }, - "required": ["schedule_for", "updated_at"] + "required": ["data", "links"] }, - "V1UpdateBackupScheduleBody": { + "V2AssignOrganizationMemberRoleRequest": { "type": "object", "properties": { - "schedule_for": { - "type": "string", - "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", - "description": "Time of day to schedule daily backups, in UTC. Format: HH:MM:SS.", - "example": "04:00:00" + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_member_role"] + }, + "attributes": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["owner", "administrator", "developer", "read-only"], + "description": "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", + "example": "developer" + }, + "projects": { + "description": "The projects to assign a project-scoped role for. If omitted, assigns an org-wide role.", + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "Project ref", + "example": "abcjuqabhgwjjutfvtpa" + } + }, + "required": ["ref"] + } + } + }, + "required": ["role"] + } + }, + "required": ["type", "attributes"] } }, - "required": ["schedule_for"], - "example": { - "schedule_for": "04:00:00" - } + "required": ["data"] }, - "V1UndoBody": { + "OrganizationMemberRoleResponse": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 20 + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_member_role"] + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Role name. For project-scoped assignments this is the base role name.", + "example": "developer" + }, + "scope": { + "type": "string", + "enum": ["organization", "project"], + "description": "Whether this role applies org-wide or is scoped to specific projects for the user." + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["ref", "name"] + }, + "description": "Project refs this role is scoped to. Empty array for org-level roles." + } + }, + "required": ["name", "scope", "projects"] + } + }, + "required": ["type", "attributes"] } }, - "required": ["name"], - "example": { - "name": "before-upgrade" - } + "required": ["data"] }, - "V1ListEntitlementsResponse": { + "V2ListRolesResponse": { "type": "object", "properties": { - "entitlements": { + "data": { "type": "array", "items": { "type": "object", "properties": { - "feature": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_role"] + }, + "attributes": { "type": "object", "properties": { - "key": { - "type": "string", - "enum": [ - "instances.compute_update_available_sizes", - "instances.read_replicas", - "instances.disk_modifications", - "instances.high_availability", - "instances.orioledb", - "replication.etl", - "storage.max_file_size", - "storage.max_file_size.configurable", - "storage.image_transformations", - "storage.vector_buckets", - "storage.iceberg_catalog", - "storage.purge_cache", - "security.audit_logs_days", - "security.questionnaire", - "security.soc2_report", - "security.iso27001_certificate", - "security.private_link", - "security.enforce_mfa", - "log.retention_days", - "custom_domain", - "vanity_subdomain", - "ipv4", - "pitr.available_variants", - "log_drains", - "audit_log_drains", - "branching_limit", - "branching_persistent", - "auth.mfa_phone", - "auth.mfa_web_authn", - "auth.mfa_enhanced_security", - "auth.hooks", - "auth.platform.sso", - "auth.custom_jwt_template", - "auth.saml_2", - "auth.user_sessions", - "auth.leaked_password_protection", - "auth.advanced_auth_settings", - "auth.performance_settings", - "auth.password_hibp", - "auth.custom_oauth.max_providers", - "backup.retention_days", - "backup.restore_to_new_project", - "backup.schedule", - "function.max_count", - "function.size_limit_mb", - "realtime.max_concurrent_users", - "realtime.max_events_per_second", - "realtime.max_joins_per_second", - "realtime.max_channels_per_client", - "realtime.max_bytes_per_second", - "realtime.max_presence_events_per_second", - "realtime.max_payload_size_in_kb", - "project_scoped_roles", - "security.member_roles", - "project_pausing", - "project_cloning", - "project_restore_after_expiry", - "assistant.advance_model", - "integrations.github_connections", - "dedicated_pooler", - "observability.dashboard_advanced_metrics", - "api.members.invitations", - "api.members.roles" - ] - }, - "type": { + "name": { "type": "string", - "enum": ["boolean", "numeric", "set"] + "description": "Role name.", + "example": "developer" } }, - "required": ["key", "type"] - }, - "hasAccess": { - "type": "boolean" - }, - "type": { - "type": "string", - "enum": ["boolean", "numeric", "set"] - }, - "config": { - "anyOf": [ - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] - }, - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "value": { - "type": "number" - }, - "unlimited": { - "type": "boolean" - }, - "unit": { - "type": "string" - } - }, - "required": ["enabled", "value", "unlimited", "unit"] - }, - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "set": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["enabled", "set"] - } - ] + "required": ["name"] } }, - "required": ["feature", "hasAccess", "type", "config"] + "required": ["type", "attributes"] } } }, - "required": ["entitlements"] - }, - "V1OrganizationMemberResponse": { - "type": "object", - "properties": { - "user_id": { - "type": "string" - }, - "user_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "role_name": { - "type": "string" - }, - "mfa_enabled": { - "type": "boolean" - }, - "avatar_url": { - "type": "string", - "nullable": true - } - }, - "required": ["user_id", "user_name", "role_name", "mfa_enabled", "avatar_url"] + "required": ["data"] }, - "V1OrganizationSlugResponse": { + "V2CreateInvitationsRequest": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "plan": { - "type": "string", - "enum": ["free", "pro", "team", "enterprise", "platform"] - }, - "opt_in_tags": { - "type": "array", - "items": { - "enum": [ - "AI_SQL_GENERATOR_OPT_IN", - "AI_DATA_GENERATOR_OPT_IN", - "AI_LOG_GENERATOR_OPT_IN" - ] - } - }, - "allowed_release_channels": { + "data": { + "minItems": 1, + "maxItems": 50, "type": "array", "items": { - "type": "string", - "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_invitation"] + }, + "attributes": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "role": { + "type": "string", + "enum": ["owner", "administrator", "developer", "read-only"], + "description": "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "example": "developer" + }, + "projects": { + "description": "The projects to limit a user to. If omitted, user will have org-wide access with the provided role.", + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "Project ref", + "example": "abcjuqabhgwjjutfvtpa" + } + }, + "required": ["ref"] + } + }, + "require_sso": { + "type": "boolean" + } + }, + "required": ["email", "role"] + } + }, + "required": ["type", "attributes"] } } }, - "required": ["id", "name", "opt_in_tags", "allowed_release_channels"] + "required": ["data"] }, - "OrganizationProjectClaimResponse": { + "V2CreateInvitationsResponse": { "type": "object", "properties": { - "project": { + "error": { "type": "object", "properties": { - "ref": { + "id": { "type": "string" }, - "name": { + "code": { "type": "string" - } - }, - "required": ["ref", "name"] - }, - "preview": { - "type": "object", - "properties": { - "valid": { - "type": "boolean" }, - "warnings": { - "type": "array", - "items": { + "message": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "additionalProperties": { "type": "object", "properties": { - "key": { + "href": { "type": "string" }, - "message": { + "rel": { "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "additionalProperties": {} } }, - "required": ["key", "message"] + "required": ["href"] } }, - "errors": { + "meta": { + "type": "object", + "additionalProperties": {} + }, + "issues": { "type": "array", "items": { "type": "object", "properties": { - "key": { + "id": { + "type": "string" + }, + "code": { "type": "string" }, "message": { "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + }, + "required": ["email"] + } + }, + "required": ["code", "message", "meta"] + } + } + }, + "required": ["code", "message"] + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_invitation"] + }, + "attributes": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + }, + "required": ["email"] + } + }, + "required": ["type", "attributes"] + } + } + }, + "required": ["data"] + }, + "V2DeleteInvitationsRequest": { + "type": "object", + "properties": { + "data": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_invitation"] + }, + "attributes": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + }, + "required": ["email"] + } + }, + "required": ["type", "attributes"] + } + } + }, + "required": ["data"] + }, + "V2DeleteInvitationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["organization_invitation"] + }, + "attributes": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + }, + "required": ["email"] + } + }, + "required": ["type", "attributes"] + } + } + }, + "required": ["data"] + }, + "V2ListProjectsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Resource type.", + "enum": ["project"] + }, + "id": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + "attributes": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING" + ], + "description": "Project status" + }, + "cloud_provider": { + "type": "string", + "description": "Cloud provider hosting the project" + }, + "region": { + "type": "string", + "description": "Region the project is hosted in" + }, + "inserted_at": { + "type": "string", + "description": "When the project was created" + }, + "databases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cloud_provider": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "region": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING" + ] + }, + "type": { + "type": "string", + "enum": ["PRIMARY", "READ_REPLICA"] + }, + "infra_compute_size": { + "type": "string", + "enum": [ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory" + ] + }, + "disk_volume_size_gb": { + "type": "number" + }, + "disk_type": { + "type": "string", + "enum": ["gp3", "io2"] + }, + "disk_throughput_mbps": { + "type": "number" + }, + "disk_last_modified_at": { + "type": "string" + } + }, + "required": ["cloud_provider", "identifier", "region", "status", "type"] + }, + "description": "The project's databases including compute and disk attributes." } }, - "required": ["key", "message"] + "required": [ + "name", + "status", + "cloud_provider", + "region", + "inserted_at", + "databases" + ] } }, - "info": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["key", "message"] - } + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": "string", + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/projects?page[size]=10", + "nullable": true }, - "members_exceeding_free_project_limit": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "limit": { - "type": "number" - } - }, - "required": ["name", "limit"] - } + "prev": { + "type": "string", + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true }, - "source_subscription_plan": { + "next": { "type": "string", - "enum": ["free", "pro", "team", "enterprise", "platform"] + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true }, - "target_subscription_plan": { + "last": { "type": "string", - "enum": ["free", "pro", "team", "enterprise", "platform", null], + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", "nullable": true } }, - "required": [ - "valid", - "warnings", - "errors", - "info", - "members_exceeding_free_project_limit", - "source_subscription_plan", - "target_subscription_plan" - ] - }, - "expires_at": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "required": ["prev", "next"] } }, - "required": ["project", "preview", "expires_at", "created_at", "created_by"] + "required": ["data", "links"] }, - "OrganizationProjectsResponse": { + "V2ListGitHubConnectionsResponse": { "type": "object", "properties": { - "projects": { + "data": { "type": "array", "items": { "type": "object", "properties": { - "ref": { - "type": "string" - }, - "name": { - "type": "string" - }, - "cloud_provider": { - "type": "string" - }, - "region": { - "type": "string" - }, - "is_branch": { - "type": "boolean" - }, - "status": { + "type": { "type": "string", - "enum": [ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING" - ] + "description": "Resource type.", + "enum": ["github_connection"] }, - "inserted_at": { - "type": "string" + "id": { + "type": "string", + "description": "Connection id.", + "example": "7" }, - "databases": { - "type": "array", - "items": { - "type": "object", - "properties": { - "infra_compute_size": { - "type": "string", - "enum": [ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory" - ] - }, - "region": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UNKNOWN", - "INIT_READ_REPLICA", - "INIT_READ_REPLICA_FAILED", - "RESTARTING", - "RESIZING" - ] - }, - "cloud_provider": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["PRIMARY", "READ_REPLICA"] - }, - "disk_volume_size_gb": { - "type": "number" - }, - "disk_type": { - "type": "string", - "enum": ["gp3", "io2"] + "attributes": { + "type": "object", + "properties": { + "inserted_at": { + "type": "string", + "description": "When the connection was created" + }, + "updated_at": { + "type": "string", + "description": "When the connection was last updated" + }, + "installation_id": { + "type": "number", + "description": "GitHub App installation id" + }, + "workdir": { + "type": "string", + "description": "Directory within the repository the project lives in" + }, + "supabase_changes_only": { + "type": "boolean", + "description": "Whether branches are only created for changes under `supabase/`" + }, + "branch_limit": { + "type": "number", + "description": "Maximum number of preview branches" + }, + "new_branch_per_pr": { + "type": "boolean", + "description": "Whether a preview branch is created for every pull request" + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "ref": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + "name": { + "type": "string" + } }, - "disk_throughput_mbps": { - "type": "number" + "required": ["id", "ref", "name"], + "description": "The connected Supabase project" + }, + "repository": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } }, - "disk_last_modified_at": { - "type": "string" - } + "required": ["id", "name"], + "description": "The connected GitHub repository" }, - "required": ["region", "status", "cloud_provider", "identifier", "type"] - } + "user": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "username": { + "type": "string" + }, + "primary_email": { + "type": "string", + "nullable": true + } + }, + "required": ["id", "username", "primary_email"], + "description": "The user who created the connection, if still known", + "nullable": true + } + }, + "required": [ + "inserted_at", + "updated_at", + "installation_id", + "workdir", + "supabase_changes_only", + "branch_limit", + "new_branch_per_pr", + "project", + "repository", + "user" + ] } }, - "required": [ - "ref", - "name", - "cloud_provider", - "region", - "is_branch", - "status", - "inserted_at", - "databases" - ] + "required": ["type", "id", "attributes"] } }, - "pagination": { + "links": { "type": "object", "properties": { - "count": { - "type": "number", - "description": "Total number of projects. Use this to calculate the total number of pages." + "first": { + "type": "string", + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10", + "nullable": true }, - "limit": { - "type": "number", - "description": "Maximum number of projects per page" + "prev": { + "type": "string", + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true }, - "offset": { - "type": "number", - "description": "Number of projects skipped in this response" + "next": { + "type": "string", + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true + }, + "last": { + "type": "string", + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "nullable": true } }, - "required": ["count", "limit", "offset"] + "required": ["prev", "next"] } }, - "required": ["projects", "pagination"] + "required": ["data", "links"] } } } diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 24dc02ed8b..6aa2928b20 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -47,7 +47,8 @@ export interface SupabaseApiClientOptions { export type SupabaseApiError = | HttpBody.HttpBodyError | HttpClientError.HttpClientError - | SchemaError; + | SchemaError + | SupabaseApiInputError; export interface SupabaseApiClientShape { readonly execute: ( @@ -82,6 +83,40 @@ export class SupabaseApiConfigError extends Error { } } +export type SupabaseApiInputErrorSource = "generated_client" | "user_input"; + +/** + * The generated client's input schema rejected the request input before any + * request was sent. This defaults to `generated_client` because a schema + * rejection can be caused by a request assembled incorrectly by its caller; + * command boundaries may opt a confirmed user-derived request into + * `user_input` without inspecting the schema error message. The original + * schema failure is preserved as `cause`. + */ +export class SupabaseApiInputError extends Error { + readonly _tag = "SupabaseApiInputError"; + #source: SupabaseApiInputErrorSource = "generated_client"; + + get source(): SupabaseApiInputErrorSource { + return this.#source; + } + + constructor(message: string, options?: { readonly cause?: unknown }) { + super(message, options); + this.name = "SupabaseApiInputError"; + } + + static markAsUserInput(error: T): T { + error.#source = "user_input"; + return error; + } +} + +/** Mark a confirmed user-derived request while preserving error identity. */ +export function markSupabaseApiInputErrorAsUserInput(error: T): T { + return SupabaseApiInputError.markAsUserInput(error); +} + function resolveSupabaseApiConfig( config: SupabaseApiConfig = {}, ): Effect.Effect { @@ -516,7 +551,9 @@ export function makeSupabaseApiClient( return { execute: (definition, input) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); const response = yield* executeRequest(prepared, definition, validated); if (isJsonOperation(definition)) { return yield* decodeJsonResponse(definition, response); @@ -531,7 +568,9 @@ export function makeSupabaseApiClient( }), executeRaw: (definition, input, headers) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); + const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); const request = yield* buildRequest(definition, validated).pipe( Effect.map((request) => headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 9887879346..cda2b8fd61 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -8,7 +9,11 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import * as Schema from "effect/Schema"; import { operationDefinitions } from "../generated/contracts.ts"; -import { makeSupabaseApiClient } from "./client.ts"; +import { + makeSupabaseApiClient, + markSupabaseApiInputErrorAsUserInput, + SupabaseApiInputError, +} from "./client.ts"; const textDecoder = new TextDecoder(); @@ -155,6 +160,83 @@ const config = { } as const; describe("makeSupabaseApiClient", () => { + test("defaults request-schema failures to generated-client provenance", async () => { + let requests = 0; + const client = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + + const executeError = await Effect.runPromise( + client + .execute(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip), + ); + const executeRawError = await Effect.runPromise( + client + .executeRaw(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip), + ); + + for (const error of [executeError, executeRawError]) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + if (!(error instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(error.source).toBe("generated_client"); + } + + if (!(executeRawError instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError); + expect(executeRawError.source).toBe("user_input"); + expect(requests).toBe(0); + }); + + test("fails request-body construction before sending a request", async () => { + class BrokenBlob extends Blob { + override arrayBuffer(): Promise { + return Promise.reject(new Error("body read failed")); + } + } + + let requests = 0; + const error = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1CreateAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + body: new BrokenBlob([]), + }), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(functionResponse(request, 201)); + }), + ), + Effect.flip, + ), + ); + + expect(error).toBeInstanceOf(HttpBody.HttpBodyError); + expect(requests).toBe(0); + }); + test("retries transport errors for POST requests", async () => { let attempts = 0; @@ -934,4 +1016,131 @@ describe("makeSupabaseApiClient", () => { }), ).toThrow(); }); + + test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", async () => { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 404, { message: "Organization not found" }), + ); + }), + ), + ), + ); + + const error = await Effect.runPromise( + client + .execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" }) + .pipe(Effect.flip), + ); + + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error)) { + throw new Error("expected HttpClientError"); + } + expect(error.reason._tag).toBe("StatusCodeError"); + if (error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(404); + + expect(seenRequest).toBeDefined(); + expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members"); + expect(seenRequest?.headers.authorization).toBe("Bearer test-token"); + }); + + test("decodes a nested v2GetProjectConfig payload through the unified execute path", async () => { + const result = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + type: "project_config", + id: "abcdefghijklmnopqrst", + attributes: { + database: { + ssl_enforced: true, + network_restrictions: { + entitlement: "disallowed", + status: "stored", + allowed_cidrs: [], + }, + postgres_settings: {}, + }, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 0, + max_client_conn: 0, + }, + auth: {}, + api: { + db_schema: "public", + db_extra_search_path: "", + max_rows: 1000, + db_pool_acquisition_timeout: 0, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 0, + max_events_per_second: 0, + max_bytes_per_second: 0, + max_channels_per_client: 0, + max_joins_per_second: 0, + max_presence_events_per_second: 0, + max_payload_size_in_kb: 0, + presence_enabled: true, + suspend: false, + connection_pool: 0, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 0, + features: { + image_transformation: { enabled: true }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: true }, + iceberg_catalog: { + enabled: false, + max_namespaces: 0, + max_tables: 0, + max_catalogs: 0, + }, + vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, + }, + capabilities: { list_v2: true, iceberg_catalog: true }, + upstream_target: "main", + migration_version: "1", + database_pool_mode: "transaction", + }, + }, + }, + }), + ), + ), + ), + ), + ); + + expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); + expect(result.data.attributes.storage.upstream_target).toBe("main"); + expect(result.data.attributes.api.db_pool).toBeNull(); + }); }); diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index bf29f44dcc..8be16c76a8 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; export interface ProjectPaths { readonly projectRoot: string; @@ -8,6 +9,18 @@ export interface ProjectPaths { readonly envLocalPath: string; } +// A stat failure (e.g. ENOTDIR when this root has a FILE named `supabase`) +// means "no config here" — Go's getProjectRoot keeps climbing on any stat +// error (apps/cli-go/internal/utils/misc.go:216-231). The failed probe is +// logged at Debug as a structured hook for future diagnostics — the CLI does +// not currently lower its minimum log level for `--debug`, so this is not +// yet Go-parity debug visibility. +const probeExists = (self: Effect.Effect) => + self.pipe( + Effect.tapError((error) => Effect.logDebug("config probe failed", error)), + Effect.orElseSucceed(() => false), + ); + const findConfigInRoot = Effect.fnUntraced(function* (root: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -15,8 +28,8 @@ const findConfigInRoot = Effect.fnUntraced(function* (root: string) { const jsonPath = path.join(supabaseDir, "config.json"); const tomlPath = path.join(supabaseDir, "config.toml"); - const jsonExists = yield* fs.exists(jsonPath); - const tomlExists = yield* fs.exists(tomlPath); + const jsonExists = yield* probeExists(fs.exists(jsonPath)); + const tomlExists = yield* probeExists(fs.exists(tomlPath)); if (!jsonExists && !tomlExists) { return null; @@ -36,11 +49,11 @@ export interface FindProjectPathsOptions { * When `false`, only `cwd` itself is checked for `supabase/config.{json,toml}` — * no ancestor climb. Go's own resolution never searches twice: an explicit * `--workdir`/`SUPABASE_WORKDIR` is used exactly as given (`ChangeWorkDir`, - * `apps/cli-go/internal/utils/misc.go:231-247`), and once `os.Chdir`'d there, + * `apps/cli-go/internal/utils/misc.go:238-257`), and once `os.Chdir`'d there, * `config.toml` is read as a plain relative path with no further ancestor * search (`NewPathBuilder`, `pkg/config/utils.go:43-48`). Ancestor climbing in * Go only ever happens once, as the *default* when workdir is unset - * (`getProjectRoot`, `internal/utils/misc.go:209-224`). + * (`getProjectRoot`, `internal/utils/misc.go:216-231`). * * Callers that already hold an authoritative, Go-equivalent project root * (e.g. the legacy `stop`/`status` ports' `cliConfig.workdir`, which mirrors diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index 9f29094bc2..c96d5d29b5 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -51,7 +51,7 @@ describe("project discovery and lazy env resolution", () => { }); test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { - // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-247`): + // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:238-257`): // an explicit workdir is used exactly as given, with no ancestor climb — // callers that already hold a Go-equivalent project root (e.g. the legacy // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid @@ -85,6 +85,44 @@ describe("project discovery and lazy env resolution", () => { } }); + test("climbs past a FILE named `supabase` in the starting directory instead of failing with ENOTDIR", async () => { + // Go's getProjectRoot keeps climbing on any stat error + // (apps/cli-go/internal/utils/misc.go:216-231) — a stray FILE named + // `supabase` (not a directory) must read as "no config here", not crash. + const cwd = makeTempProject(); + const nestedCwd = join(cwd, "child"); + + try { + await mkdir(nestedCwd, { recursive: true }); + await writeFile(join(nestedCwd, "supabase"), "not a directory\n"); + + const paths = await runConfigEffect(findProjectPaths(nestedCwd)); + + expect(paths).toBeNull(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("returns the parent's project when the starting directory has a FILE named `supabase` but the parent has a real config", async () => { + const cwd = makeTempProject(); + const child = join(cwd, "child"); + + try { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await mkdir(child, { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "parent"\n'); + await writeFile(join(child, "supabase"), "not a directory\n"); + + const paths = await runConfigEffect(findProjectPaths(child)); + + expect(paths?.projectRoot).toBe(cwd); + expect(paths?.configPath).toBe(join(cwd, "supabase", "config.toml")); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test("loads env from the discovered supabase directory with the right precedence", async () => { const cwd = makeTempProject(); const repoRoot = join(cwd, "repo"); diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts index 4cdd6321eb..4d150bccc1 100644 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts @@ -195,56 +195,65 @@ describe("supervisor-runtime", () => { expect(childEnv).toEqual({ KEEP_ME: "value" }); }); - test("bounds a cleanup command tree by its timeout and continues remaining cleanup", async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); - const childScriptPath = path.join(tempDir, "child.mjs"); - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, "process.exit(0);\n"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { spawn } = require("node:child_process");`, - `const { writeFileSync } = require("node:fs");`, - `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, - `writeFileSync(${JSON.stringify(cleanupWorkerPidFile)}, String(worker.pid));`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ], - timeoutMs: 100, - }, - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - ], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); + test( + "bounds a cleanup command tree by its timeout and continues remaining cleanup", + { timeout: 12_000 }, + async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); + const childScriptPath = path.join(tempDir, "child.mjs"); + mkdirSync(cleanupDir); + writeFileSync(childScriptPath, "process.exit(0);\n"); + const encodedConfig = Buffer.from( + JSON.stringify({ + command: process.execPath, + args: [childScriptPath], + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { spawn } = require("node:child_process");`, + `const { writeFileSync } = require("node:fs");`, + `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(cleanupWorkerPidFile)}, String(worker.pid));`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ], + // Budget starts at spawn, so it must outlast node booting, spawning + // the worker, and writing the pid file. 100ms lost that race on CI. + timeoutMs: 2_000, + }, + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + ], + }), + ).toString("base64url"); + const supervisor = spawnSupervisor("source path", encodedConfig); - try { - await waitFor(() => supervisor.exitCode != null); - expect(supervisor.exitCode).toBe(0); - expect(existsSync(cleanupDir)).toBe(false); - const cleanupWorkerPid = Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10); - expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); - await waitFor(() => !isPidAlive(cleanupWorkerPid)); - } finally { - supervisor.kill("SIGKILL"); - if (existsSync(cleanupWorkerPidFile)) { - try { - process.kill(Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10), "SIGKILL"); - } catch {} + try { + await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); + expect(supervisor.exitCode).toBe(0); + expect(existsSync(cleanupDir)).toBe(false); + const cleanupWorkerPid = Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10); + expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); + await waitFor(() => !isPidAlive(cleanupWorkerPid), { timeoutMs: 10_000 }); + } finally { + supervisor.kill("SIGKILL"); + if (existsSync(cleanupWorkerPidFile)) { + try { + process.kill( + Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10), + "SIGKILL", + ); + } catch {} + } + rmSync(tempDir, { recursive: true, force: true }); } - rmSync(tempDir, { recursive: true, force: true }); - } - }); + }, + ); test("bounds a cleanup command when no timeout is configured", { timeout: 12_000 }, async () => { const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); diff --git a/packages/stack/README.md b/packages/stack/README.md index 6d056e4908..61255536a1 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -2,6 +2,11 @@ Programmatic local Supabase stack for TypeScript. Create a local Supabase runtime from code, then control lifecycle, status, and logs through a small async handle. +The package also exposes `@supabase/stack/managed` for applications that need durable, +system-aware stack identity and discovery. The managed surface is intentionally separate from +`createStack()`: direct stacks never inspect Git, create workspace markers, or mutate the global +registry. + ## Features - **Single entry point** -- `createStack()` resolves config and returns a handle; `start()` prepares assets, starts services, and waits for readiness @@ -34,6 +39,71 @@ const supabase = createClient(stack.url, stack.publishableKey); await stack.dispose(); ``` +### Managed ordinary-folder state + +The managed registry is an Effect API. `ManagedStackService` is the policy layer and +`ManagedStackRepository` is the storage contract; each has layer factories, failures arrive in the +error channel, and the registry handle is owned by a scope: + +```typescript +import { BunFileSystem } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; +import { + bunSqliteManagedStackRepositoryLayer, + managedRegistryPath, + ManagedStackService, +} from "@supabase/stack/managed"; + +const stateRoot = "/absolute/managed-state"; +const managedLayer = ManagedStackService.make({ stateRoot }).pipe( + Layer.provide(bunSqliteManagedStackRepositoryLayer(managedRegistryPath(stateRoot))), + Layer.provide(BunFileSystem.layer), +); + +const program = Effect.gen(function* () { + const managed = yield* ManagedStackService; + const result = yield* managed.provisionOrdinaryStack({ + workspacePath: "/absolute/project", + configuration: { + runtimeRequest: "docker", + serviceVersions: { postgres: "17.6.1.143" }, + }, + }); + console.log(result.stack.id, result.stack.paths.data); +}).pipe( + // Every method declares the failures it can raise, so recovery is typed. + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => + Effect.sync(() => console.log(`another process never published ${error.stackId}`)), + ), +); + +// The layer's scope owns the registry handle, so it closes with the scope. +await Effect.runPromise(Effect.scoped(Effect.provide(program, managedLayer))); +``` + +Callers that do not run an Effect runtime can use the Promise edge over the same layers. Acquiring it +is I/O, so it is awaited and a registry this process cannot open rejects there rather than at the +first call that touches it. The handle is an `AsyncDisposable`, so `await using` closes it: + +```typescript +import { createManagedStackService } from "@supabase/stack/managed"; + +await using managed = await createManagedStackService(); +const result = await managed.provisionOrdinaryStack({ + workspacePath: "/absolute/project", +}); + +console.log((await managed.inspectStack(result.stack.id))?.status); +``` + +Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores +only its three identity UUIDs in `.supabase/identity.json`; mutable state, logs, runtime metadata, +ports, and lifecycle ownership live under the user-level managed state root. Callers can inject an +isolated state root for tests, or the in-memory repository from `@supabase/stack/testing`. Stopped stacks keep sticky port +assignments without holding a host-wide lease; exact configuration takes precedence when a stopped +or failed stack is updated. A stack may change port numbers as part of one transition out of a +port-occupying lifecycle; intent-only updates never count as runtime port drift. + ### With explicit config ```typescript diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 847f705351..67e7c2d672 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -7,12 +7,14 @@ delegated to [`@supabase/process-compose`](../../process-compose/docs/architectu ## Public entrypoints -The package exposes two levels of Interface: +The package exposes three levels of Interface: - `@supabase/stack` selects `bun.ts` or `node.ts` through export conditions and exposes the Promise-oriented `createStack()` / `StackHandle` Interface plus prefetch helpers. - `@supabase/stack/effect` selects a runtime Adapter through the same export conditions and exposes Effect Interfaces plus platform-bound layer factories used by the CLI and advanced callers. +- `@supabase/stack/managed` selects the Node or Bun SQLite Adapter and exposes managed identity, + discovery, persistence, and lifecycle coordination. Its repository can be replaced by a caller. - `@supabase/stack/testing` exposes only the service tags needed to replace daemon transport in consumer tests. Runtime implementation tags do not leak through the root or Effect barrels. @@ -20,6 +22,11 @@ Internal runtime Adapters provide Effect filesystem, path, child-process, HTTP-s socket HTTP implementations. `createStack.ts` and the layer factories remain platform-agnostic; the conditional root and Effect entries bind them to their selected runtime. +The direct and managed surfaces compose in one direction only: managed policy resolves one opaque +stack identity and concrete roots, ports, and runtime selection, then a caller may pass those +resolved values to the core runtime. The core runtime never discovers workspaces or opens the +global registry. + ```mermaid flowchart LR Input["StackConfig"] --> Resolve["StackConfigResolver"] @@ -267,9 +274,300 @@ Unix-socket transport, not the public Supabase API proxy. See [detach mode](./detach-mode.md) for paths, process startup, and compiled executable dispatch. -## Managed paths +## Managed identity and state + +Here, **managed state** means the centralized registry API exposed from +`@supabase/stack/managed`. It is distinct from the older `ManagedStack` daemon-discovery record in +`managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API is +Effect-native: its services are `Context.Service` tags, its failures live in the effect error +channel, and its resources are owned by scopes. A Promise facade sits at the edge for callers that +do not run an Effect runtime; see "Managed service composition" below. + +Managed errors are `Data.TaggedError` classes carrying stable `code` fields, and there is no shared +base class: `ManagedStackError` is a union type over the seventeen failures, with +`isManagedStackError` as the runtime guard. `_tag` is the Effect-native discriminant, so a consumer +can `catchTag` them directly against the union a given method declares; `code` is the wire-level +contract that survives identifier minification, so Node and Bun callers — and the CLI's telemetry +classifier — can branch on failures without requiring an Effect runtime at this persistence +boundary. `MANAGED_ERROR_TAG_BY_CODE` links the two so a consumer keying a table by one and +dispatching on the other cannot drift. + +The managed surface owns a versioned SQLite registry with separate records for projects, +checkouts, checkout locations, development contexts, stacks, port reservations, and operations. +The public repository contract contains no SQLite types, so the same service runs with the +in-memory test repository and the Node or Bun persistent Adapter. Both adapters owe identical +observable semantics, so record ordering — port assignments by key, active operations by start time +then operation token — and input validation such as refusing an operation owner PID that could never +be probed live in shared helpers rather than in either adapter. + +For an ordinary non-Git folder, the first mutating managed operation atomically publishes: + +```text +/.supabase/identity.json + version + projectId + checkoutId + contextId +``` -With the default cache root (`~/.supabase`), durable data is project-keyed: +That marker protocol is the one place in the managed surface that uses raw `node:fs/promises` instead +of the `FileSystem` service the policy layer reclaims stack state through: writing a temporary file, +hardlinking it into place, re-reading the winning marker on `EEXIST`, and removing the temporary path +is a single indivisible claim, and the hardlink with that `EEXIST` contract is not part of the platform +service's surface. The claim itself is `claimFileAtomically` in `managed/atomic-claim.ts`, shared with +`StateManager`'s single-stack state claim so both settle a race the same way; a filesystem without +hardlinks (`EPERM` or `ENOTSUP`) falls back to an exclusive create, which still decides the race but +publishes without the hardlink's all-or-nothing guarantee. The marker protocol owns what a lost race +means: the identity claim adopts the winning marker, while a claimed stack state is a failure. + +No mutable runtime state or credential value is stored in that marker. Read-only discovery does +not create it. The registry stores only an opaque credential reference, never resolved plaintext +credentials. Discovery returns the marker identity even when it has no stack records, but reports +`registered: false` until at least one stack exists for the marker's complete project, checkout, +and context identity. + +The managed state root is explicitly injectable. Otherwise it resolves from `SUPABASE_HOME` or +the platform application-state directory. Every physical stack path is keyed only by its opaque +stack UUID: + +```text +/ + registry-v3.sqlite3 + stacks// + data/ + logs/ + runtime/ +``` + +Schema v3 intentionally has no migration path for this unreleased POC. Before first use of v3, +developers holding any earlier `registry-v*.sqlite3` must remove the old managed state root, +including its shared `stacks/` directory; registry generations must not be kept side by side. +The state root is required to be a non-empty path wherever it is passed explicitly, so a blank +value fails instead of silently anchoring managed state to the process' working directory. An +explicit root is a decision and a blank one is a caller bug; a blank environment value is instead +treated as unset and falls through to the next source. +Recovery can also leave an +unregistered UUID stack root when a provisioner writes after its pending row was concurrently +aborted. The provision error reports the failed ownership cleanup, but there is no automatic orphan +garbage collection; remove that root only after independently confirming its runtime is stopped. + +Stack publication and operation claims are transactional; "Managed service composition" below +describes how that transaction boundary and the wait for a concurrent publisher are expressed. A new +stack remains `pending` while its +directories and caller-supplied initialization are validated, then becomes `active` atomically. +Concurrent callers resolve the published record rather than creating aliases. Recovery first +retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either +publishes a running pending stack or aborts a stopped pending stack so the same identity can retry. +An abandoned claim over an already tombstoned row is a deletion that died before releasing it: +recovery finishes that deletion instead of reconciling a lifecycle, without consulting runtime +inspection at all. Tombstoning already zeroed the runtime metadata an inspector would read, so +requiring an answer there would retain every crashed deletion forever. It never revives the row and +never drops the tombstone, since idempotent deletion depends on it; it releases the claim and +reclaims the leaked stack directory, reporting a failed removal like any other reclamation failure. +Reconciliation is therefore repeatable: a second pass over the same crashed deletion is a no-op. +Ownership races are isolated per operation so one completed claim does not stop the recovery pass. +PID liveness is deliberately conservative and assumes the managed root stays within one host PID +namespace. A stored PID that is not a probeable PID counts as no owner at all, both when recovery +walks abandoned claims and when provision decides whether to wait for a publisher, since probing it +could report a dead owner as alive. Because a PID is not a permanent process identity, callers can request forced recovery +after trustworthy runtime inspection; this is also the required integration path for a state root +shared across PID namespaces. Forced recovery requires an exact stack ID and operation token and +processes only that claim. It bypasses the PID gate, and tombstoned rows are reclaimed without +runtime inspection because tombstoning already cleared the runtime metadata an inspector would +read; forcing a claim whose owner is genuinely still finishing a delete can therefore race it—the +delete still completes and reports success, but the two processes may both attempt the same +directory removal. Forced recovery and the `startedBefore` age filter are mutually exclusive. +Recovery results distinguish live owners, unknown or failed liveness/runtime inspection, concurrent +skips, reconciliation failures, reclaimed tombstones from finished deletions, and post-abort +data-reclamation failures. An aborted or reclaimed stack ID is reported only after its leaked +directory is actually removed, so the two lists never claim data is gone while it is still on disk. +A failed removal is reported as a data-reclamation failure either way, but the two cases diverge +afterward: a reclaimed (tombstoned) stack's row survives in the registry, so its removal stays +retryable through ordinary `deleteStack` idempotency, while a discarded pending stack's row is +already gone by the time removal is attempted, so a failed removal leaves an orphaned directory +that is reported once and never revisited automatically—like any other orphan root, there is no +automatic garbage collection, so it requires manual cleanup. A failed reconciliation of an active +stack marks its lifecycle +`failed` before best-effort claim release, preserving the requirement for an explicit stop path +before deletion. A failed pending-stack adoption retains its claim so a later pass can retry without +losing potentially live unpublished data. That claim blocks other mutations, including deletion, +until normal reconciliation succeeds or the caller obtains its stack ID and token from +`repository.listActiveOperations()` and performs a scoped forced recovery after trustworthy runtime +inspection. + +Port assignments are sticky metadata, while port ownership is a lifecycle lease. Stopped stacks +retain their assigned numbers without blocking other stopped stacks. Entering `starting`, +`running`, or `stopping` claims those ports host-wide; a collision fails without relocating a +sticky automatic assignment. On a stopped stack, exact configuration replaces persisted automatic +state, while an automatic request reuses the current number and changes only its intent. Failed +stacks follow the same non-occupying rules. Intent-only changes are accepted, and a lifecycle update +can release a lease and change ports atomically; port-number drift is rejected only while a stack +continues to occupy its ports. + +Explicit deletion re-reads lifecycle after claiming the operation, safely stops when needed, +tombstones, and removes only the UUID-derived selected stack root. Repeating deletion retries any +leftover tombstoned data reclamation. Once tombstoned, unsafe or failed filesystem cleanup is +reported as retained data rather than making future deletion non-idempotent. Prune removes checkout +location metadata only. The delete outcome describes the registry tombstone, not guaranteed disk +reclamation: callers must inspect `dataReclamation`, surface retained errors, and arrange a later +retry. Lifecycle transitions likewise trust the caller to stop the real runtime before declaring a +port-occupying stack stopped and releasing its lease. Runtime qualification, legacy bootstrap +selection, and credential resolution remain outside this persistence boundary and are composed by +later CLI slices. + +## Managed service composition + +The managed surface is two `Context.Service` tags, each with layer factories: + +- `ManagedStackRepository` is the storage contract. It is provided by + `bunSqliteManagedStackRepositoryLayer(path)` or `nodeSqliteManagedStackRepositoryLayer(path)` — + re-exported from `managed-bun.ts` and `managed-node.ts` respectively — or, in tests, by + `Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository())`, since the + in-memory factory from `@supabase/stack/testing` returns the Effect-shaped service object directly. + The contract contains no SQLite types, so the adapter is swappable without the policy layer + noticing. Opening a registry whose schema version is neither zero nor the supported version fails + the layer with `UnsupportedManagedRegistryVersionError`. +- `ManagedStackService` is the policy layer described above: identity markers, provisioning order, + publication waiting, deletion, and recovery. `ManagedStackService.make(options)` returns a layer + requiring `FileSystem.FileSystem | ManagedStackRepository` and failing with + `InvalidManagedOwnerPidError | UnsafeManagedStackPathError`, so a blank state root or an owner PID + that could never be probed is refused while the layer is being built rather than at whichever call + first touches a path. + +`managedStackLayer(options)` — exported from `managed-bun.ts` and `managed-node.ts` — is those two +composed with the platform filesystem and the state root resolved by the one resolver that owns that +policy. It is the assembly an Effect consumer provides _and_ the one the Promise facade runs behind its +handle, so the two cannot drift apart. It fails with `ManagedStackLayerFailure`: the state-root and +owner-PID refusals above plus `UnsupportedManagedRegistryVersionError`. Nothing on that path is turned +into a defect, so the one registry failure a caller can act on — the registry was written by a newer +CLI — stays recoverable with `catchTag` instead of being unreachable behind an `orDie`. + +Each method declares only the failures it can actually raise, rather than one service-wide union: +`provisionOrdinaryStack` carries `ProvisionManagedStackFailure`, `updateStack` carries +`UpdateManagedStackConfigurationFailure`, `deleteStack` carries `DeleteManagedStackFailure`, +`inspectOrdinaryWorkspace` carries only `InvalidManagedIdentityError`, and `inspectStack` and +`listStacks` cannot fail at all. `deleteStack` and `pruneCheckoutLocations` are additionally generic +in their callback's error type, so a `stop` callback's own failure reaches the caller unchanged — a +stack that refused to stop was not deleted. Recovery reports rather than fails: only a forced target +that is not a pair of managed UUIDs refuses a whole pass, so `reconcileAbandonedOperations` declares +just `InvalidManagedIdentityError` and returns retained claims, skips, and failures in its result. + +Registry decisions are transactions that run as one synchronous block: `Effect.try` wraps a closure +that issues `BEGIN IMMEDIATE` (or `BEGIN` for read paths), runs the decision, and commits, rolling +back and rethrowing the original cause if any statement refuses. Atomicity rests on the drivers being +synchronous and the handle being single-threaded, so that boundary must never be split across +effects: the fiber scheduler preempts at its operation budget, and a fiber parked between `BEGIN` and +`COMMIT` would let another fiber `BEGIN IMMEDIATE` on the same connection — SQLite refuses the nested +transaction, and either fiber's `COMMIT` could publish the other's writes. Keeping the whole +transaction in one JavaScript turn is therefore what makes a partially applied decision +unobservable and keeps interruption from ever landing inside a transaction. Synchrony cannot rule out +the other way two transactions could meet, a decision that re-enters the repository, so the handles +currently inside a transaction are tracked and a re-entering `BEGIN` is refused before it runs: +SQLite has no nested transactions, and unwinding the inner attempt would roll back the outer +decision's writes. + +The database handle's lifetime is a scope. `sqliteManagedStackRepositoryLayer` acquires the handle +with `Effect.acquireRelease`, so opening the file and registering its close are one step nothing can +land between, including on the path where schema initialization refuses the registry: no failure path +leaks an open handle. Closing the scope that built the layer closes the registry. + +Waiting for a concurrent publisher is `Schedule`-driven. One look at the pending row is a retryable +step — a still-pending row asks for another look, while a vanished or tombstoned row is a final +answer — repeated on `Schedule.exponential` from `publicationPollMs` with a 250 ms ceiling, so a slow +publisher is not polled hundreds of times per second for the whole window. The ceiling only ever +slows polling down, so a caller asking for a slower interval keeps its own. `publicationTimeoutMs` is +the caller's bound on the entire wait and is applied as a timeout around the repeat, so it interrupts +the poll instead of being checked between polls. Both shipped adapters answer synchronously, so a look +at the pending row always completes; with an embedder-supplied asynchronous repository that timeout can +preempt a look that is still in flight. That is safe — a look has no side effects — but it means the +option bounds the wait, not the number of looks that finish. The answer the repeat stops on is checked +rather than asserted through a type refinement: a recurrence bound added to that schedule later would +hand back the final still-pending answer, and the check turns that into a defect instead of an +unpublished stack presented as a published one. + +Interruption is part of the contract, not an afterthought. Provisioning owns a pending row, an +operation claim, and the directories it created, so its create path runs under +`Effect.uninterruptibleMask`: only the provisioning steps themselves are interruptible, and the +compensation that aborts the pending row and removes the leaked directory always runs. Deletion +releases its claim the same way. An interrupted call stays interrupted rather than being reported as a +failure of the work — a caller's own timeout is not a `ManagedStackInitializationError` — and recovery +re-raises interruption instead of recording a retained claim or a reconciliation failure that never +happened, so the operation the next pass should still recover does not look like one recovery already +gave up on. That rule covers the steps whose exits recovery absorbs one at a time — the liveness probe, +the runtime inspection, the state reclamation — not just the pass as a whole. + +The single deliberate exception is the claim release on a failed operation's way out: it discards +whatever it raises, its own interruption included, because the caller's outcome is the failure the +operation actually suffered and a release reporting interruption would replace it. The mask itself +begins after the pending row and its claim exist, which is sound only because both shipped adapters +decide synchronously and offer no suspension point during that write. An asynchronous embedder +repository interrupted mid-prepare would leave a pending row and a claim nothing compensates, so the +mask has to be extended over row creation before asynchronous repositories become real. + +An Effect consumer provides the composed layer, which is the primary API: + +```typescript +import { Effect } from "effect"; +import { managedStackLayer, ManagedStackService } from "@supabase/stack/managed"; + +// The policy service, the registry adapter it decides over, and the platform +// filesystem it reclaims stack state through. It fails with +// `ManagedStackLayerFailure`, so a registry written by a newer CLI is a typed +// failure an embedder can recover from rather than a defect. +const managedLayer = managedStackLayer({ stateRoot }); + +const program = Effect.gen(function* () { + const managed = yield* ManagedStackService; + return yield* managed.provisionOrdinaryStack({ workspacePath }); +}).pipe( + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => + Effect.fail(`another process never published ${error.stackId}`), + ), +); +``` + +`createManagedStackService()` — and `makeManagedStackService()` over a repository the caller already +has — is a thin `ManagedRuntime` edge over exactly that layer, for consumers that do not run an +Effect runtime. It exists to serve the Promise-oriented `createStack()` boundary; the runtime +lifecycle beneath it is Effect-based either way. Three properties of that edge are contracts rather +than incidental: + +- **Acquisition is asynchronous.** Both factories return a `Promise` and + build the runtime's context through `runtime.context()`, because opening the registry is I/O: a + file is created and hardened, its schema read, and a cold start may have to wait out another + process' WAL conversion. Everything that can refuse the acquisition arrives as a rejection — a + blank state root, an owner PID that could never be probed, and a registry written by an + unsupported schema version all reject with the same typed error instances, so a caller has one + failure channel instead of a throw plus a rejection. +- **Reads are Promises too.** `inspectStack` and `listStacks` return Promises rather than answering + inline. A handle that read synchronously would only be hiding the registry's I/O from its caller, + and it is what forced the cold-start retry below to block. The `repository` accessor stays a plain + property: the context is already resolved by the time a caller holds the handle. +- **The cold-start WAL retry is a schedule, not a blocking wait.** Converting a fresh registry to + WAL can lose a race with another process doing the same thing, so `enableWriteAheadLogging` + retries exactly the `SQLITE_BUSY`/`SQLITE_LOCKED` classification on `Schedule.exponential` from + 10 ms, capped at 100 ms per wait and bounded to a total ~4 s budget. Contention that never clears + surfaces the driver's own busy error, as an immediate non-busy failure of that pragma always has. + Because the retry suspends the fiber instead of spinning on `Atomics.wait`, a process opening the + registry no longer stalls the event loop that every other caller in it depends on. + +`close()` disposes the `ManagedRuntime`, which interrupts whatever is still in flight and closes the +scope that owns the database handle. Outstanding calls therefore reject, and because that scope closes +alongside those interruptions rather than after them, a statement already on its way to the driver can +race the close and fail against a closed handle: a caller that closes while work is outstanding must +read those rejections as "did not complete", not as evidence about the registry. A call made after +`close()` rejects with an `Error` saying the handle is closed, rather than with the runtime's own bare +internal string. That diagnosis comes from the handle's own closed state, never from what a rejection +says, so a caller's callback that refuses with a string mentioning disposal still reaches the caller +as itself. The handle is also an `AsyncDisposable`, so +`await using service = await createManagedStackService()` closes it on every path out of the block. The +facade hands back the very repository the service uses, so an embedder can read the registry without +opening a second handle on it. + +## Legacy daemon paths + +The pre-managed daemon implementation still reads its project-keyed state as a legacy/bootstrap +input for later CLI integration: ```text /projects//stacks// @@ -292,11 +590,23 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l `/stacks/`; managed daemon callers may not directly override individual `stackRoot` or `runtimeRoot` values. +These path hashes and stack-name directories are not identities in the new managed model and must +not be used for new managed records. + ## Runtime entrypoints and exports - `bun.ts` and `node.ts` are root export-condition targets. - `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. +- `managed-bun.ts` and `managed-node.ts` bind the same storage-independent managed service to the + runtime's built-in SQLite implementation. Both delegate to one shared factory + (`managed/create-service.ts`) parameterized by how a registry file is opened, so their option + surfaces cannot drift apart. The in-memory repository is not part of this entrypoint; it is a test + seam published through `@supabase/stack/testing`. +- `managed/model.ts` is exported as `@supabase/stack/managed-model` because it has no runtime + imports: consumers can read `MANAGED_ERROR_CODES` under either runtime without pulling in a SQLite + driver. The CLI's telemetry classifier types its managed dispatch table against that union, so a + new managed error code cannot be added without classifying it. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. - `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter @@ -311,6 +621,11 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l factories, topology, projection, cleanup metadata, and protocol schemas. - Integration tests exercise binary publication, lifecycle coordination, daemon HTTP/SSE, remote stack behavior, state persistence, and Unix socket streaming with stateful Effect Adapters. +- The managed registry is covered from both of its surfaces. `managed-service.integration.test.ts` + carries the behavioral load through the Promise facade against the in-memory and both SQLite + adapters, while `managed-effect.integration.test.ts` uses `@effect/vitest` to hold the Effect + surface itself to account: the tags composed as layers, typed failures recovered with `catchTag`, + and the scoped registry handle released when its scope closes. - Targeted e2e tests own the expensive process/container Seam for full stack startup, parallel stacks, daemon lifecycle, and cleanup behavior. diff --git a/packages/stack/package.json b/packages/stack/package.json index d8c3f695d8..7b8124105d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -12,6 +12,11 @@ "bun": "./src/effect-bun.ts", "default": "./src/effect-node.ts" }, + "./managed": { + "bun": "./src/managed-bun.ts", + "default": "./src/managed-node.ts" + }, + "./managed-model": "./src/managed/model.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, @@ -56,8 +61,7 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx", - "ps" + "nx" ] } } diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts index 88262482cb..d7b32bafa9 100644 --- a/packages/stack/src/DaemonProtocol.ts +++ b/packages/stack/src/DaemonProtocol.ts @@ -8,12 +8,19 @@ const DaemonErrorCodeSchema = Schema.Literals([ "STACK_BUILD_ERROR", ]); +const StackBuildReasonSchema = Schema.Literals([ + "invalid_config", + "docker_not_running", + "asset_preparation", +]); + export const DaemonErrorResponseSchema = Schema.Struct({ code: DaemonErrorCodeSchema, error: Schema.String, service: Schema.optionalKey(Schema.String), exitCode: Schema.optionalKey(Schema.Number), timeoutMs: Schema.optionalKey(Schema.Number), + reason: Schema.optionalKey(StackBuildReasonSchema), }); export type DaemonErrorResponse = typeof DaemonErrorResponseSchema.Type; diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index fa836f3d69..d07be33648 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -51,8 +51,15 @@ export class DaemonServer extends Context.Service< }, 500, ); - const buildErrorResponse = (detail: string) => - errorResponse({ code: "STACK_BUILD_ERROR", error: detail }, 500); + const buildErrorResponse = (detail: string, reason?: DaemonErrorResponse["reason"]) => + errorResponse( + { + code: "STACK_BUILD_ERROR", + error: detail, + ...(reason === undefined ? {} : { reason }), + }, + 500, + ); const invalidReloadPayloadResponse = () => errorResponse( { code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" }, @@ -144,7 +151,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -168,7 +175,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -252,7 +259,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -280,7 +287,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -300,7 +307,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notFoundResponse(e.name)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), ), ), @@ -320,7 +327,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -347,7 +354,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), @@ -374,7 +381,7 @@ export class DaemonServer extends Context.Service< Effect.succeed(notReadyResponse(e.name, e.reason, e.exitCode)), ), Effect.catchTag("StackBuildError", (e) => - Effect.succeed(buildErrorResponse(e.detail)), + Effect.succeed(buildErrorResponse(e.detail, e.reason)), ), Effect.catchTag("StackReadinessError", (e) => terminalReadinessResponse(e.target, e.timeoutMs, e.detail), diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index e10c6ab23f..e2f7e8d24f 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -19,7 +19,12 @@ import { import { ChildProcessSpawner } from "effect/unstable/process"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; -import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; +import { + DockerPullError, + StackBuildError, + StackNotRunningError, + StackReadinessError, +} from "./errors.ts"; import { clearFunctionsRuntimeConfig, configureFunctionsRuntime, @@ -273,6 +278,10 @@ export const localStackLayer = ( new StackBuildError({ detail: "Failed to prepare stack assets", cause, + reason: + cause instanceof DockerPullError && cause.daemonDown + ? "docker_not_running" + : "asset_preparation", }), ), ) @@ -435,6 +444,7 @@ export const localStackLayer = ( new StackBuildError({ detail: "Invalid Edge Functions bundle", cause, + reason: "invalid_config", }), ), ); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 7d2fc3f781..88ee7121cd 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -1,6 +1,6 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; -import { Effect, Fiber, Layer, ManagedRuntime, Stream } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime, Result, Stream } from "effect"; import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; @@ -75,8 +75,13 @@ const MOCK_LOGS: ReadonlyArray = [ function mockStack( options: { readonly startServiceBuildError?: string; + readonly startServiceBuildReason?: + | "invalid_config" + | "docker_not_running" + | "asset_preparation"; readonly startServiceReadyError?: string; readonly waitReadyBuildError?: string; + readonly waitReadyBuildReason?: "invalid_config" | "docker_not_running" | "asset_preparation"; readonly waitReadyTimeoutMs?: number; readonly restartServiceReadyError?: string; } = {}, @@ -102,7 +107,14 @@ function mockStack( name === "unknown" ? Effect.fail(new ServiceNotFoundError({ name })) : options.startServiceBuildError !== undefined - ? Effect.fail(new StackBuildError({ detail: options.startServiceBuildError })) + ? Effect.fail( + new StackBuildError({ + detail: options.startServiceBuildError, + ...(options.startServiceBuildReason === undefined + ? {} + : { reason: options.startServiceBuildReason }), + }), + ) : options.startServiceReadyError !== undefined ? Effect.fail( new ServiceReadyError({ @@ -158,7 +170,14 @@ function mockStack( const match = MOCK_STATES.find((s) => s.name === name); if (match === undefined) return Effect.fail(new ServiceNotFoundError({ name })); if (options.waitReadyBuildError !== undefined) { - return Effect.fail(new StackBuildError({ detail: options.waitReadyBuildError })); + return Effect.fail( + new StackBuildError({ + detail: options.waitReadyBuildError, + ...(options.waitReadyBuildReason === undefined + ? {} + : { reason: options.waitReadyBuildReason }), + }), + ); } if (options.waitReadyTimeoutMs !== undefined) { return Effect.fail( @@ -242,7 +261,7 @@ function buildClientLayer(url: string): Layer.Layer { request: (socketPath, path, init) => Effect.tryPromise({ try: () => fetch(`${url}${path}`, init), - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause, reason: "transport" }), }), }); return RemoteStack.layer("test.sock").pipe(Layer.provide(clientLayer)); @@ -398,7 +417,8 @@ describe("RemoteStack integration", () => { { once: true }, ); }), - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + catch: (cause) => + new UnixHttpClientError({ socketPath, path, cause, reason: "transport" }), }), }); const runtime = ManagedRuntime.make( @@ -414,11 +434,92 @@ describe("RemoteStack integration", () => { } }); + test("distinguishes daemon status failures from protocol failures", async () => { + const scenarios = [ + { response: new Response("failed", { status: 500 }), reason: "status" }, + { + response: new Response("not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + reason: "protocol", + }, + ] as const; + + for (const scenario of scenarios) { + const clientLayer = Layer.succeed(UnixHttpClient, { + request: () => Effect.succeed(scenario.response), + }); + const runtime = ManagedRuntime.make( + RemoteStack.layer("test.sock").pipe(Layer.provide(clientLayer)), + ); + try { + const exit = await runtime.runPromise( + Effect.flatMap(Stack, (stack) => stack.getInfo()).pipe(Effect.exit), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const defect = Cause.findDefect(exit.cause); + expect(Result.isSuccess(defect)).toBe(true); + if (Result.isSuccess(defect)) { + expect(defect.success).toBeInstanceOf(UnixHttpClientError); + expect(defect.success).toMatchObject({ reason: scenario.reason, path: "/status" }); + } + } + } finally { + await runtime.dispose(); + } + } + }); + + test("preserves daemon identity for invalid SSE responses", async () => { + const scenarios = [ + { response: () => new Response("failed", { status: 500 }), reason: "status" }, + { + response: () => + new Response("data: not-json\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + reason: "protocol", + }, + ] as const; + + for (const scenario of scenarios) { + const clientLayer = Layer.succeed(UnixHttpClient, { + request: () => Effect.succeed(scenario.response()), + }); + const runtime = ManagedRuntime.make( + RemoteStack.layer("test.sock").pipe(Layer.provide(clientLayer)), + ); + try { + const exit = await runtime.runPromise( + Effect.flatMap(Stack, (stack) => Stream.runCollect(stack.subscribeAllLogs())).pipe( + Effect.exit, + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const defect = Cause.findDefect(exit.cause); + expect(Result.isSuccess(defect)).toBe(true); + if (Result.isSuccess(defect)) { + expect(defect.success).toBeInstanceOf(UnixHttpClientError); + expect(defect.success).toMatchObject({ reason: scenario.reason, path: "/logs" }); + } + } + } finally { + await runtime.dispose(); + } + } + }); + test("preserves StackBuildError across remote service operations", async () => { const failingMock = mockStack({ restartServiceReadyError: "restart failed readiness", startServiceBuildError: "stack is stopped", + startServiceBuildReason: "docker_not_running", waitReadyBuildError: "service has not been activated", + waitReadyBuildReason: "invalid_config", }); const failingServer = ManagedRuntime.make(buildServerLayer(failingMock)); let failingClient: ManagedRuntime.ManagedRuntime | undefined; @@ -433,11 +534,17 @@ describe("RemoteStack integration", () => { Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip), ); expect(startError._tag).toBe("StackBuildError"); + if (startError._tag === "StackBuildError") { + expect(startError.reason).toBe("docker_not_running"); + } const readyError = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip), ); expect(readyError._tag).toBe("StackBuildError"); + if (readyError._tag === "StackBuildError") { + expect(readyError.reason).toBe("invalid_config"); + } const restartError = await failingClient.runPromise( Effect.flatMap(Stack, (stack) => stack.restartService("auth")).pipe(Effect.flip), diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 7bbd6fb72a..08fad493d1 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,7 +1,7 @@ import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; -import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts"; import { StackBuildError, StackReadinessError } from "./errors.ts"; import { Stack, StackInfoSchema } from "./Stack.ts"; @@ -93,6 +93,33 @@ function unixResponse(socketPath: string, path: string, init?: RequestInit) { ); } +/** Preserve daemon RPC identity when an HTTP status or body cannot be decoded. */ +function dieOnNonOkStatus( + socketPath: string, + path: string, + effect: Effect.Effect, +) { + return effect.pipe( + Effect.mapError( + (cause) => new UnixHttpClientError({ socketPath, path, cause, reason: "status" }), + ), + Effect.orDie, + ); +} + +function dieOnBodyDecodeError( + socketPath: string, + path: string, + effect: Effect.Effect, +) { + return effect.pipe( + Effect.mapError( + (cause) => new UnixHttpClientError({ socketPath, path, cause, reason: "protocol" }), + ), + Effect.orDie, + ); +} + function withAbortSignal( effect: (signal: AbortSignal) => Effect.Effect, ): Effect.Effect { @@ -104,6 +131,8 @@ function withAbortSignal( } const failDaemonResponse = ( + socketPath: string, + path: string, response: HttpClientResponse.HttpClientResponse, fallbackName: string, ): Effect.Effect< @@ -111,8 +140,10 @@ const failDaemonResponse = ( ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError > => Effect.gen(function* () { - const body = yield* HttpClientResponse.schemaBodyJson(DaemonErrorResponseSchema)(response).pipe( - Effect.orDie, + const body = yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(DaemonErrorResponseSchema)(response), ); switch (body.code) { case "SERVICE_NOT_FOUND": @@ -124,7 +155,10 @@ const failDaemonResponse = ( ...(body.exitCode === undefined ? {} : { exitCode: body.exitCode }), }); case "STACK_BUILD_ERROR": - return yield* new StackBuildError({ detail: body.error }); + return yield* new StackBuildError({ + detail: body.error, + ...(body.reason === undefined ? {} : { reason: body.reason }), + }); case "STACK_READINESS_TIMEOUT": return yield* new StackReadinessError({ target: body.service ?? fallbackName, @@ -135,6 +169,8 @@ const failDaemonResponse = ( }); const expectDaemonOk = ( + socketPath: string, + path: string, response: HttpClientResponse.HttpClientResponse, fallbackName: string, ): Effect.Effect< @@ -143,15 +179,21 @@ const expectDaemonOk = ( > => response.status >= 200 && response.status < 300 ? Effect.void - : failDaemonResponse(response, fallbackName); + : failDaemonResponse(socketPath, path, response, fallbackName); /** Fetch JSON from the daemon, dying on HTTP errors. */ function fetchStatus(socketPath: string, path: string, method = "GET") { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path, { method }); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); - return yield* HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse).pipe( - Effect.orDie, + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); + return yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse), ); }); } @@ -159,9 +201,15 @@ function fetchStatus(socketPath: string, path: string, method = "GET") { function fetchLogEntries(socketPath: string, path: string) { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); - return yield* HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse).pipe( - Effect.orDie, + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); + return yield* dieOnBodyDecodeError( + socketPath, + path, + HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse), ); }); } @@ -190,8 +238,22 @@ function sseStream(socketPath: string, path: string, parse: (data: string) => Effect.gen(function* () { const controller = new AbortController(); const response = yield* unixFetch(socketPath, path, { signal: controller.signal }); - if (!response.ok || !response.body) { - return yield* Effect.die(new Error(`SSE request failed: ${response.status}`)); + if (!response.ok) { + return yield* new UnixHttpClientError({ + socketPath, + path, + cause: new Error(`SSE request failed: ${response.status}`), + reason: "status", + }); + } + const body = response.body; + if (body === null) { + return yield* new UnixHttpClientError({ + socketPath, + path, + cause: new Error("SSE response body is missing"), + reason: "protocol", + }); } // State shared across chunks — parser is stateful, accumulates partial events @@ -203,15 +265,22 @@ function sseStream(socketPath: string, path: string, parse: (data: string) => }); return Stream.fromReadableStream({ - evaluate: () => response.body!, - onError: (error) => (error instanceof Error ? error : new Error(String(error))), + evaluate: () => body, + onError: (cause) => + new UnixHttpClientError({ socketPath, path, cause, reason: "transport" }), }).pipe( - Stream.flatMap((chunk: Uint8Array) => { - collected.length = 0; - parser.feed(new TextDecoder().decode(chunk, { stream: true })); - return Stream.fromIterable(Array.from(collected)); - }), - Stream.orDie, + Stream.mapEffect((chunk: Uint8Array) => + Effect.try({ + try: () => { + collected.length = 0; + parser.feed(new TextDecoder().decode(chunk, { stream: true })); + return Array.from(collected); + }, + catch: (cause) => + new UnixHttpClientError({ socketPath, path, cause, reason: "protocol" }), + }), + ), + Stream.flatMap(Stream.fromIterable), Stream.ensuring(Effect.sync(() => controller.abort())), ); }), @@ -271,8 +340,9 @@ export const RemoteStack = { start: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/start", { method: "POST" }); - yield* expectDaemonOk(response, "stack").pipe( + const path = "/start"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* expectDaemonOk(socketPath, path, response, "stack").pipe( Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), ); }), @@ -281,16 +351,26 @@ export const RemoteStack = { stop: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), dispose: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), @@ -298,10 +378,11 @@ export const RemoteStack = { withUnixHttpClient( Effect.gen(function* () { const servicePath = yield* publicServicePath(name); - const response = yield* unixResponse(socketPath, `/services/${servicePath}/start`, { + const path = `/services/${servicePath}/start`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); - yield* expectDaemonOk(response, name); + yield* expectDaemonOk(socketPath, path, response, name); }), ), @@ -309,10 +390,11 @@ export const RemoteStack = { withUnixHttpClient( Effect.gen(function* () { const servicePath = yield* publicServicePath(name); - const response = yield* unixResponse(socketPath, `/services/${servicePath}/stop`, { + const path = `/services/${servicePath}/stop`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); - yield* expectDaemonOk(response, name).pipe( + yield* expectDaemonOk(socketPath, path, response, name).pipe( Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), ); @@ -323,38 +405,37 @@ export const RemoteStack = { withUnixHttpClient( Effect.gen(function* () { const servicePath = yield* publicServicePath(name); - const response = yield* unixResponse( - socketPath, - `/services/${servicePath}/restart`, - { - method: "POST", - }, - ); - yield* expectDaemonOk(response, name); + const path = `/services/${servicePath}/restart`; + const response = yield* unixResponse(socketPath, path, { + method: "POST", + }); + yield* expectDaemonOk(socketPath, path, response, name); }), ), reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/functions/reload", { + const path = "/functions/reload"; + const response = yield* unixResponse(socketPath, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(opts ?? {}), }); - yield* expectDaemonOk(response, "edge-runtime"); + yield* expectDaemonOk(socketPath, path, response, "edge-runtime"); }), ), reloadEdgeRuntime: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/edge-runtime/reload", { + const path = "/edge-runtime/reload"; + const response = yield* unixResponse(socketPath, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(opts), }); - yield* expectDaemonOk(response, "edge-runtime"); + yield* expectDaemonOk(socketPath, path, response, "edge-runtime"); }), ), @@ -407,17 +488,14 @@ export const RemoteStack = { withAbortSignal((signal) => Effect.gen(function* () { const servicePath = yield* publicServicePath(name); - const response = yield* unixResponse( - socketPath, - `/services/${servicePath}/ready`, - { - method: "POST", - signal, - headers: { "content-type": "application/json" }, - body: JSON.stringify(opts ?? inheritReadyOptions), - }, - ); - yield* expectDaemonOk(response, name); + const path = `/services/${servicePath}/ready`; + const response = yield* unixResponse(socketPath, path, { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? inheritReadyOptions), + }); + yield* expectDaemonOk(socketPath, path, response, name); }), ), ), @@ -426,13 +504,14 @@ export const RemoteStack = { withUnixHttpClient( withAbortSignal((signal) => Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/ready", { + const path = "/ready"; + const response = yield* unixResponse(socketPath, path, { method: "POST", signal, headers: { "content-type": "application/json" }, body: JSON.stringify(opts ?? inheritReadyOptions), }); - yield* expectDaemonOk(response, "stack").pipe( + yield* expectDaemonOk(socketPath, path, response, "stack").pipe( Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)), ); }), diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 924aaecab7..a17542f8e6 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -108,6 +108,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: `mode "native" only supports postgres, auth, and postgrest. Disable ${enabledDockerOnly.join(", ")} or switch to "auto" or "docker".`, + reason: "invalid_config", }), ); } @@ -117,6 +118,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", }), ); } @@ -125,6 +127,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "vector requires analytics to be enabled", + reason: "invalid_config", }), ); } @@ -133,6 +136,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "studio requires pgmeta to be enabled", + reason: "invalid_config", }), ); } diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index e777a2e91b..644c7e0a34 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -307,7 +307,11 @@ async function resolveFunctionsConfig(config: StackConfig, projectDir: string) { config.functions, ); } catch (cause) { - throw new StackBuildError({ detail: "Invalid Edge Functions bundle", cause }); + throw new StackBuildError({ + detail: "Invalid Edge Functions bundle", + cause, + reason: "invalid_config", + }); } } diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index c0feb4924d..1d8171e700 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -2,7 +2,7 @@ import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError } from "./errors.ts"; +import { DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; import { isDockerOnlyService } from "./ServiceCatalog.ts"; import { DEFAULT_VERSIONS, @@ -202,6 +202,7 @@ const pullImage = ( yield* callbacks?.onDownloadStart ?? Effect.void; const failures: PullAttemptFailure[] = []; + let spawnFailed = false; for (const image of images) { for ( @@ -212,6 +213,9 @@ const pullImage = ( const attempt = attemptIndex + 1; const result = yield* Effect.exit(runPullCommand(spawner, image)); if (Exit.isSuccess(result)) { + // A successful spawn proves the runtime is usable; an earlier + // transient spawn failure must not taint the final classification. + spawnFailed = false; if (result.value.exitCode === 0) { return image; } @@ -226,6 +230,10 @@ const pullImage = ( break; } } else { + // A failed effect (rather than a non-zero exit) means the container + // runtime could not be spawned at all — a local Docker setup + // problem, not a registry failure. + spawnFailed = true; const cause = Cause.squash(result.cause); const message = cause instanceof Error ? cause.message : String(cause); failures.push({ image, attempt, message }); @@ -251,6 +259,8 @@ const pullImage = ( image: images[0] ?? "unknown", detail: `Failed to pull Docker image from all registries. ${detail}`, cause: new Error(detail), + daemonDown: + spawnFailed || failures.some((failure) => isDockerDaemonDownMessage(failure.message)), }), ); }); diff --git a/packages/stack/src/StateManager.ts b/packages/stack/src/StateManager.ts index 5b8b40e314..af1c3dbda7 100644 --- a/packages/stack/src/StateManager.ts +++ b/packages/stack/src/StateManager.ts @@ -1,9 +1,8 @@ import { Data, Effect, Layer, Schema, Context } from "effect"; import { FileSystem, Path } from "effect"; import { execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { existsSync, rmSync } from "node:fs"; -import { link, unlink, writeFile } from "node:fs/promises"; +import { claimFileAtomically } from "./managed/atomic-claim.ts"; import { AllocatedPortsSchema, type AllocatedPorts } from "./PortAllocator.ts"; import { PartialVersionManifestSchema, @@ -345,27 +344,24 @@ function makeClaim(deps: StateManagerDeps) { const dir = deps.stackDir(state.name); yield* deps.fs.makeDirectory(dir, { recursive: true }); const statePath = deps.stateFile(state.name); - const temporaryPath = `${statePath}.claim-${process.pid}-${randomUUID()}`; - yield* Effect.tryPromise({ - try: async () => { - await writeFile(temporaryPath, encodePrettyJson(encodeStackState(state)), { flag: "wx" }); - try { - await link(temporaryPath, statePath); - } finally { - await unlink(temporaryPath).catch(() => undefined); - } - }, + const outcome = yield* Effect.tryPromise({ + try: () => claimFileAtomically(statePath, encodePrettyJson(encodeStackState(state))), catch: (cause) => new StateClaimError({ name: state.name, path: statePath, - reason: - cause instanceof Error && "code" in cause && cause.code === "EEXIST" - ? "already-claimed" - : "io-error", + reason: "io-error", cause, }), }); + if (outcome === "already-exists") { + return yield* new StateClaimError({ + name: state.name, + path: statePath, + reason: "already-claimed", + cause: undefined, + }); + } }).pipe( Effect.catchTag("PlatformError", (cause) => Effect.fail( diff --git a/packages/stack/src/UnixHttpClient.ts b/packages/stack/src/UnixHttpClient.ts index 1273642c66..4e8c529fb7 100644 --- a/packages/stack/src/UnixHttpClient.ts +++ b/packages/stack/src/UnixHttpClient.ts @@ -4,6 +4,7 @@ export class UnixHttpClientError extends Data.TaggedError("UnixHttpClientError") readonly socketPath: string; readonly path: string; readonly cause: unknown; + readonly reason: "transport" | "status" | "protocol"; }> {} export class UnixHttpClient extends Context.Service< diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index a8d7c72a3e..f6ffb62778 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { candidateCleanupTargets } from "./cleanup.ts"; +import { basename, dirname, join } from "node:path"; +import { candidateCleanupTargets, cleanupAutoManagedPaths } from "./cleanup.ts"; import { dockerContainerName } from "./CleanupTargets.ts"; import { runForegroundOperation, type StackHandle } from "./createStack.ts"; import { StackReadinessError } from "./errors.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; -import { DEFAULT_MANAGED_STACK_NAME, projectKeyForProjectDir } from "./paths.ts"; +import { + DEFAULT_MANAGED_STACK_NAME, + projectKeyForProjectDir, + shortTempPrefixRoot, +} from "./paths.ts"; import { stackMetadata } from "./StackMetadata.ts"; import type { AuthConfig, @@ -354,6 +358,27 @@ describe("resolveConfig startup mode", () => { }); }); +describe("resolveConfig state roots", () => { + it("uses disposable temporary roots when direct callers omit them", async () => { + const config = await resolveConfig({ startupMode: "lazy" }); + + try { + expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); + expect(dirname(config.stackRoot)).toBe(shortTempPrefixRoot()); + expect(dirname(config.runtimeRoot)).toBe(shortTempPrefixRoot()); + expect(basename(config.stackRoot)).toMatch(/^sb-stack-/); + expect(basename(config.runtimeRoot)).toMatch(/^sb-run-/); + expect(existsSync(config.stackRoot)).toBe(true); + expect(existsSync(config.runtimeRoot)).toBe(true); + } finally { + cleanupAutoManagedPaths(config); + } + + expect(existsSync(config.stackRoot)).toBe(false); + expect(existsSync(config.runtimeRoot)).toBe(false); + }); +}); + describe("resolveConfig readiness policy", () => { it("uses a finite package default", async () => { const config = await resolveConfig(); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index f1ea9b8407..8a5110f6da 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -9,6 +9,7 @@ export { ChecksumMismatchError, DockerPullError, DownloadError, + isDockerDaemonDownMessage, PortConflictError, StackBuildError, StackError, diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 06b9b548ed..f8c6dc0a71 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -7,6 +7,7 @@ import * as bunRoot from "./bun.ts"; import * as bunEffect from "./effect-bun.ts"; import * as nodeEffect from "./effect-node.ts"; import * as nodeRoot from "./node.ts"; +import * as managed from "./managed-bun.ts"; import type { StackHandle } from "./createStack.ts"; import type { Stack } from "./Stack.ts"; import * as testing from "./testing.ts"; @@ -40,6 +41,11 @@ describe("@supabase/stack entrypoints", () => { bun: "./src/effect-bun.ts", default: "./src/effect-node.ts", }, + "./managed": { + bun: "./src/managed-bun.ts", + default: "./src/managed-node.ts", + }, + "./managed-model": "./src/managed/model.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts", }); @@ -55,6 +61,63 @@ describe("@supabase/stack entrypoints", () => { expectTypeOf(bunRoot.createStack).returns.toEqualTypeOf>(); }); + it("exposes managed policy through its own entrypoint", () => { + expect(managed).toHaveProperty("createManagedStackService"); + expect(managed).toHaveProperty("makeManagedStackService"); + expect(managed).toHaveProperty("ManagedStackService"); + expect(managed).toHaveProperty("managedStackLayer"); + expect(managed).toHaveProperty("bunSqliteManagedStackRepositoryLayer"); + expect(nodeRoot).not.toHaveProperty("createManagedStackService"); + }); + + it("pins the managed runtime surface so internals cannot leak into it", () => { + // The in-memory repository is a test seam and belongs to `./testing` only; + // the adapters' shared port and update guards stay module-internal. + expect(Object.keys(managed).sort()).toEqual([ + "DEFAULT_MANAGED_STACK_NAME", + "DuplicateManagedIdentityError", + "DuplicateManagedPortKeyError", + "InvalidManagedIdentityError", + "InvalidManagedOwnerPidError", + "InvalidManagedPortError", + "InvalidManagedStackNameError", + "MANAGED_ERROR_CODES", + "MANAGED_ERROR_TAG_BY_CODE", + "MANAGED_REGISTRY_SCHEMA_VERSION", + "ManagedAbandonedOperationError", + "ManagedOperationInProgressError", + "ManagedOperationOwnershipError", + "ManagedPendingStackUpdateError", + "ManagedPortReservationError", + "ManagedRunningStackPortChangeError", + "ManagedStackInitializationError", + "ManagedStackNotFoundError", + "ManagedStackNotStoppedError", + "ManagedStackPublicationTimeoutError", + "ManagedStackRepository", + "ManagedStackService", + "ORDINARY_WORKSPACE_IDENTITY_VERSION", + "UnsafeManagedStackPathError", + "UnsupportedManagedRegistryVersionError", + "assertManagedStackRoot", + "assertManagedUuid", + "bunSqliteManagedStackRepositoryLayer", + "canonicalizeOrdinaryWorkspacePath", + "createManagedStackService", + "createManagedUuid", + "ensureOrdinaryWorkspaceIdentity", + "isManagedStackError", + "makeManagedStackService", + "managedRegistryPath", + "managedStackLayer", + "managedStackPaths", + "ordinaryWorkspaceIdentityPath", + "readOrdinaryWorkspaceIdentity", + "requireExplicitManagedStateRoot", + "resolveManagedStateRoot", + ]); + }); + it("binds consumer Effect layers without exposing implementation tags", () => { expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); @@ -71,6 +134,15 @@ describe("@supabase/stack entrypoints", () => { }); it("isolates consumer test seams in the testing entry", () => { - expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); + expect(Object.keys(testing).sort()).toEqual([ + "DaemonServer", + "UnixHttpClient", + "createInMemoryManagedStackRepository", + "managedNativePlatformByNodeTarget", + "managedNativePlatformFromNode", + "managedNativeServiceMatrix", + "managedStackContractFixtures", + "validateManagedStackContractFixtures", + ]); }); }); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 937d3330db..4d30ce2c47 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -20,11 +20,47 @@ export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly image: string; readonly detail: string; readonly cause: unknown; + /** + * Whether the pull failed because the container runtime itself is unusable + * locally — the daemon is unreachable (detected from the runtime's output + * at the boundary where it is produced) or the docker binary could not be + * spawned at all. Consumers must branch on this instead of sniffing + * `detail` text. + */ + readonly daemonDown: boolean; }> {} +/** + * Whether a container runtime's output indicates the daemon itself is not + * running. This is the boundary vocabulary for `DockerPullError.daemonDown` + * and shared with the CLI's legacy docker-run layer so both paths agree on + * what "daemon down" looks like. + */ +export const isDockerDaemonDownMessage = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("docker daemon is not running") || + normalized.includes("docker desktop is not running") || + normalized.includes("is the docker daemon running") || + // Spawn succeeds but the socket is not accessible (e.g. a Linux user + // missing docker group membership) — a local setup problem, not a + // registry failure. + normalized.includes("permission denied while trying to connect to the docker daemon") + ); +}; + export class StackBuildError extends Data.TaggedError("StackBuildError")<{ readonly detail: string; readonly cause?: unknown; + /** + * Structured discriminant for consumers that need to distinguish failure + * classes without parsing `detail`: `invalid_config` for user-fixable + * configuration problems, `docker_not_running` for an unavailable local + * runtime, and `asset_preparation` for other download/registry failures. + * Absent for internal invariant violations. + */ + readonly reason?: "invalid_config" | "docker_not_running" | "asset_preparation"; }> {} export class StackNotRunningError extends Data.TaggedError("StackNotRunningError")<{ @@ -64,6 +100,7 @@ export function toStackError(err: unknown): StackError { return new StackError({ code: "SERVICE_NOT_FOUND", message: taggedMessage, + cause: err, }); case "StackBuildError": return new StackError({ diff --git a/packages/stack/src/managed-atomic-claim.unit.test.ts b/packages/stack/src/managed-atomic-claim.unit.test.ts new file mode 100644 index 0000000000..63434089ca --- /dev/null +++ b/packages/stack/src/managed-atomic-claim.unit.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { claimFileAtomically } from "./managed/atomic-claim.ts"; + +const temporaryRoots: Array = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "atomic-claim-test-")); + temporaryRoots.push(root); + return root; +}; + +const codedError = (code: string): Error => Object.assign(new Error(code), { code }); + +const refusingLink = (code: string) => (): Promise => Promise.reject(codedError(code)); + +const strayTemporaryFiles = (root: string): ReadonlyArray => + readdirSync(root).filter((entry) => entry.includes(".tmp.")); + +describe("atomic file claim", () => { + it("publishes the content when nothing holds the path yet", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + + await expect(claimFileAtomically(target, "mine\n", { mode: 0o600 })).resolves.toBe("claimed"); + + expect(readFileSync(target, "utf8")).toBe("mine\n"); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(strayTemporaryFiles(root)).toEqual([]); + }); + + it("reports a claim someone else already published and leaves it untouched", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + writeFileSync(target, "theirs\n"); + + await expect(claimFileAtomically(target, "mine\n")).resolves.toBe("already-exists"); + + expect(readFileSync(target, "utf8")).toBe("theirs\n"); + expect(strayTemporaryFiles(root)).toEqual([]); + }); + + it.each(["EPERM", "ENOTSUP"])( + "claims through an exclusive create where hardlinks refuse with %s", + async (code) => { + const root = makeRoot(); + const target = join(root, "claim.json"); + + await expect( + claimFileAtomically(target, "mine\n", { mode: 0o600, linkFile: refusingLink(code) }), + ).resolves.toBe("claimed"); + + expect(readFileSync(target, "utf8")).toBe("mine\n"); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(strayTemporaryFiles(root)).toEqual([]); + }, + ); + + it("still settles the race for a loser on a filesystem without hardlinks", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + writeFileSync(target, "theirs\n"); + + await expect( + claimFileAtomically(target, "mine\n", { linkFile: refusingLink("EPERM") }), + ).resolves.toBe("already-exists"); + + expect(readFileSync(target, "utf8")).toBe("theirs\n"); + expect(strayTemporaryFiles(root)).toEqual([]); + }); + + it("propagates a publication failure that is neither a lost race nor a missing hardlink", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + + await expect( + claimFileAtomically(target, "mine\n", { linkFile: refusingLink("EACCES") }), + ).rejects.toThrow("EACCES"); + + expect(readdirSync(root)).toEqual([]); + }); + + it("claims over a temporary file stranded by a killed run that reused the same id", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + writeFileSync(`${target}.tmp.fixed-id`, "stranded\n"); + + await expect(claimFileAtomically(target, "mine\n", { temporaryId: "fixed-id" })).resolves.toBe( + "claimed", + ); + + expect(readFileSync(target, "utf8")).toBe("mine\n"); + expect(strayTemporaryFiles(root)).toEqual([]); + }); + + it("names the temporary file from an injected identifier so a run stays reproducible", async () => { + const root = makeRoot(); + const target = join(root, "claim.json"); + const observed: Array = []; + + await claimFileAtomically(target, "mine\n", { + temporaryId: "fixed-id", + linkFile: (existingPath) => { + observed.push(existingPath); + return Promise.reject(codedError("EPERM")); + }, + }); + + expect(observed).toEqual([`${target}.tmp.fixed-id`]); + expect(strayTemporaryFiles(root)).toEqual([]); + }); +}); diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts new file mode 100644 index 0000000000..36e22d5220 --- /dev/null +++ b/packages/stack/src/managed-bun.ts @@ -0,0 +1,32 @@ +import type { Layer } from "effect"; +import { BunFileSystem } from "@effect/platform-bun"; +import { + createManagedStackServiceWith, + makeManagedStackServiceWith, + managedStackLayerWith, + type CreateManagedStackServiceOptions, + type MakeManagedStackServiceOptions, + type ManagedStackLayerFailure, + type ManagedStackServiceHandle, +} from "./managed/create-service.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import type { ManagedStackService } from "./managed/service.ts"; +import { bunSqliteManagedStackRepositoryLayer } from "./managed/sqlite-bun.ts"; + +export * from "./managed.ts"; +export { bunSqliteManagedStackRepositoryLayer }; + +/** The managed assembly an Effect consumer provides, bound to the Bun runtime. */ +export const managedStackLayer = ( + options: CreateManagedStackServiceOptions = {}, +): Layer.Layer => + managedStackLayerWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); + +export const createManagedStackService = ( + options: CreateManagedStackServiceOptions = {}, +): Promise => + createManagedStackServiceWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): Promise => makeManagedStackServiceWith(BunFileSystem.layer, options); diff --git a/packages/stack/src/managed-effect.integration.test.ts b/packages/stack/src/managed-effect.integration.test.ts new file mode 100644 index 0000000000..cdd78fa1a3 --- /dev/null +++ b/packages/stack/src/managed-effect.integration.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it } from "@effect/vitest"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach } from "vitest"; +import { Cause, Duration, Effect, Exit } from "effect"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { + ManagedStackInitializationError, + ManagedStackPublicationTimeoutError, +} from "./managed/model.ts"; +import { managedRegistryPath, managedStackPaths } from "./managed/paths.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; +import { ManagedStackRepository, type ManagedStackRepositoryShape } from "./managed/repository.ts"; +import { ManagedStackService } from "./managed/service.ts"; +import { managedStackLayer, type CreateManagedStackServiceOptions } from "./managed-bun.ts"; + +/** + * The Effect surface of the managed registry, exercised as an Effect consumer + * uses it: `yield* ManagedStackService` over a repository layer, typed failures + * recovered with `Effect.catchTag`, and the registry handle owned by a scope. + * + * The Promise facade's suite in `managed-service.integration.test.ts` carries the + * behavioral load. This suite exists to prove the Effect API is a first-class + * entrypoint rather than an implementation detail behind that facade. + */ + +const temporaryRoots: Array = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "managed-effect-test-")); + temporaryRoots.push(root); + return root; +}; + +const makeWorkspace = (root: string, name = "workspace"): string => { + const workspace = join(root, name); + mkdirSync(workspace, { recursive: true }); + return workspace; +}; + +type ServiceOverrides = Omit; + +/** + * The layer an Effect consumer provides — the composed one the package exports, + * not a private re-assembly of it, so this suite fails if that assembly drifts. + * The repository is part of it, so a test can drive the registry directly to + * stage a scenario. + */ +const managedLayer = (stateRoot: string, overrides: ServiceOverrides) => + managedStackLayer({ stateRoot, publicationPollMs: 1, ...overrides }); + +const setupInMemory = (overrides: ServiceOverrides = {}) => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + return { + root, + stateRoot, + workspace: makeWorkspace(root), + layer: managedLayer(stateRoot, { + repository: createInMemoryManagedStackRepository(), + ...overrides, + }), + }; +}; + +const setupSqlite = (overrides: ServiceOverrides = {}) => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + return { + root, + stateRoot, + workspace: makeWorkspace(root), + /** A fresh handle on the same registry file, the way a second process opens it. */ + openRegistry: () => managedLayer(stateRoot, overrides), + }; +}; + +/** + * Stages a pending stack whose publisher is alive but will never publish, so the + * next provision of that workspace has to wait for a publication that never lands. + */ +const stagePendingStack = (workspace: string, stateRoot: string) => + Effect.gen(function* () { + const repository = yield* ManagedStackRepository; + const { identity } = yield* ensureOrdinaryWorkspaceIdentity(workspace); + const stackId = crypto.randomUUID(); + const prepared = yield* repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(stateRoot, stackId), + operationToken: crypto.randomUUID(), + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + configuration: {}, + }); + if (prepared.outcome !== "create") { + return yield* Effect.die(new Error("Expected to stage a pending managed stack")); + } + mkdirSync(prepared.stack.paths.data, { recursive: true }); + return prepared.stack; + }); + +describe("managed stack Effect surface", () => { + it.effect("provisions a stack for a new workspace and reuses it on the next call", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const created = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(created.stack.id); + expect(reused.selection).toEqual(created.selection); + expect(existsSync(created.stack.paths.data)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("adopts a caller's configuration when it reuses a stack", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = yield* managed.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { runtimeRequest: "docker" }, + }); + + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.runtimeRequest).toBe("docker"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports an unregistered workspace before anything is provisioned", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const before = yield* managed.inspectOrdinaryWorkspace(workspace); + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const after = yield* managed.inspectOrdinaryWorkspace(workspace); + + expect(before).toEqual({ registered: false, stacks: [] }); + expect(after.registered).toBe(true); + expect(after.stacks.map((candidate) => candidate.id)).toEqual([stack.id]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets a caller recover from a rejected stack name with catchTag", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + // The failure is in the effect's error channel, so the recovery is typed: + // `catchTag` narrows to the one failure and its payload without a cast. + const outcome = yield* managed + .provisionOrdinaryStack({ workspacePath: workspace, stackName: "Not A Name" }) + .pipe( + Effect.catchTag("InvalidManagedStackNameError", (error) => + Effect.succeed(`rejected ${error.stackName}`), + ), + ); + const stacks = yield* managed.listStacks(); + + expect(outcome).toBe("rejected Not A Name"); + expect(stacks).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a stopped-stack requirement rather than deleting a running stack", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const refused = yield* managed + .deleteStack(stack.id) + .pipe( + Effect.catchTag("ManagedStackNotStoppedError", (error) => Effect.succeed(error._tag)), + ); + const survivor = yield* managed.inspectStack(stack.id); + + expect(refused).toBe("ManagedStackNotStoppedError"); + expect(survivor?.lifecycle).toBe("running"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("deletes a stack once and treats a repeated delete as a no-op", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + + const deleted = yield* managed.deleteStack(stack.id); + const repeated = yield* managed.deleteStack(stack.id); + + expect(deleted.outcome).toBe("delete"); + expect(deleted.dataReclamation.outcome).toBe("removed"); + expect(repeated.outcome).toBe("no-op"); + expect(existsSync(stack.paths.root)).toBe(false); + expect((yield* managed.inspectStack(stack.id))?.status).toBe("tombstoned"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("propagates a stop callback's own failure type out of deleteStack", () => { + const { workspace, layer } = setupInMemory(); + class StopRefused { + readonly _tag = "StopRefused"; + } + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const exit = yield* managed + .deleteStack(stack.id, { stop: () => Effect.fail(new StopRefused()) }) + .pipe(Effect.exit); + const survivor = yield* managed.inspectStack(stack.id); + + expect(Exit.isFailure(exit)).toBe(true); + expect(survivor?.status).toBe("active"); + }).pipe(Effect.provide(layer)); + }); + + // `it.live` rather than `it.effect`: this is the one test that drives the real + // SQLite adapter, whose cold start waits out another process' WAL conversion on + // a schedule. Under `TestClock` such a wait would never be released and the test + // would hang instead of failing. + it.live("keeps a stack visible to a registry handle opened after the first one closed", () => { + const { workspace, stateRoot, openRegistry } = setupSqlite(); + return Effect.gen(function* () { + // The registry handle belongs to the layer's scope, which `Effect.provide` + // owns, so each block opens the file, uses it, and closes it before the + // next block runs. + const provisioned = yield* Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + expect(yield* repository.getStack(stack.id)).toMatchObject({ id: stack.id }); + return stack; + }).pipe(Effect.provide(openRegistry())); + + expect(existsSync(managedRegistryPath(stateRoot))).toBe(true); + + const reopened = yield* Effect.gen(function* () { + const managed = yield* ManagedStackService; + return yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + }).pipe(Effect.provide(openRegistry())); + + expect(reopened.outcome).toBe("reuse"); + expect(reopened.stack.id).toBe(provisioned.id); + }); + }); + + it.live("rolls a provision back when the caller interrupts it mid-initialization", () => { + // A caller that times out or closes the service while initialization is + // running still owns the pending row, the operation claim, and the stack + // directory the provision created, so the compensation has to run even + // though the fiber it belongs to is being interrupted. The interruption + // itself must stay an interruption: a provision this caller abandoned is + // not an initialization that failed. + const { workspace, stateRoot, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + + const exit = yield* managed + .provisionOrdinaryStack({ + workspacePath: workspace, + initialize: () => Effect.sleep(Duration.seconds(5)), + }) + .pipe(Effect.timeout(Duration.millis(50)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + expect(failure).not.toBeInstanceOf(ManagedStackInitializationError); + expect(yield* repository.listStacks({ includeTombstoned: true })).toEqual([]); + expect(yield* repository.listActiveOperations()).toEqual([]); + const stackRoots = join(stateRoot, "stacks"); + expect(existsSync(stackRoots) ? readdirSync(stackRoots) : []).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("releases the delete claim when the caller interrupts a stop that never returns", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const exit = yield* managed + .deleteStack(stack.id, { stop: () => Effect.sleep(Duration.seconds(5)) }) + .pipe(Effect.timeout(Duration.millis(50)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + // The claim is gone, so the next caller can delete the stack instead of + // being refused by an operation nobody will ever finish. + expect(yield* repository.listActiveOperations()).toEqual([]); + expect((yield* managed.inspectStack(stack.id))?.status).toBe("active"); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps a delete's own failure when releasing its claim reports interruption", () => { + // Releasing the claim on the way out is best effort in the strongest sense. + // An embedder repository whose `finishOperation` is cancelled must not turn + // the failure the caller actually suffered into an interruption the caller + // never asked for — the release has no outcome of its own to report. + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + let releaseIsCancelled = false; + const cancelling: ManagedStackRepositoryShape = { + ...repository, + finishOperation: (stackId, operationToken, outcome, at, error) => + releaseIsCancelled + ? Effect.interrupt + : repository.finishOperation(stackId, operationToken, outcome, at, error), + }; + const layer = managedLayer(stateRoot, { repository: cancelling }); + class StopRefused { + readonly _tag = "StopRefused"; + } + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + releaseIsCancelled = true; + + const exit = yield* managed + .deleteStack(stack.id, { stop: () => Effect.fail(new StopRefused()) }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(false); + expect(Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined).toBeInstanceOf( + StopRefused, + ); + expect((yield* managed.inspectStack(stack.id))?.status).toBe("active"); + }).pipe(Effect.provide(layer)); + }); + + it.live("propagates an interrupted runtime inspection instead of retaining the operation", () => { + // The absorbed steps inside a recovery pass follow the same rule as the pass + // itself: an interrupted inspection has no answer about the runtime, so + // retaining the operation on its behalf would report a decision recovery + // never made. + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const claimed = yield* repository.claimOperation({ + token: crypto.randomUUID(), + stackId: stack.id, + kind: "start", + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + return yield* Effect.die(new Error("Expected to stage an abandoned operation")); + } + + const exit = yield* managed + .reconcileAbandonedOperations({ inspectRuntime: () => Effect.interrupt }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + // The claim survives for the next pass, exactly as it would had the pass + // never looked at it. + expect( + (yield* repository.listActiveOperations()).map((operation) => operation.token), + ).toEqual([claimed.operation.token]); + }).pipe(Effect.provide(layer)); + }); + + it.live("propagates an interrupted recovery pass instead of recording it as a failure", () => { + // Recovery reports rather than fails, but an interrupted step has no outcome + // to report: recording one would mark a stack failed and release a claim on + // behalf of a caller that is no longer there, and the operation the next pass + // should still recover would look like one recovery already gave up on. + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + // An embedder-supplied repository may be asynchronous, and a call into one + // can be cancelled: the step then reports interruption rather than a refusal. + const cancelling: ManagedStackRepositoryShape = { + ...repository, + reconcileOperation: () => Effect.interrupt, + }; + const layer = managedLayer(stateRoot, { repository: cancelling }); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + // An abandoned claim with no owner to probe, so recovery goes straight to + // reconciling it. + const claimed = yield* repository.claimOperation({ + token: crypto.randomUUID(), + stackId: stack.id, + kind: "start", + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + return yield* Effect.die(new Error("Expected to stage an abandoned operation")); + } + + const exit = yield* managed + .reconcileAbandonedOperations({ inspectRuntime: () => Effect.succeed("stopped") }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + expect((yield* managed.inspectStack(stack.id))?.lifecycle).not.toBe("failed"); + expect( + (yield* repository.listActiveOperations()).map((operation) => operation.token), + ).toEqual([claimed.operation.token]); + }).pipe(Effect.provide(layer)); + }); + + it.live("gives up on a pending stack whose publisher never publishes", () => { + // Deliberately `it.live` with a tiny window rather than `TestClock`. + // `TestClock.adjust` only releases sleeps that are already registered, and + // provision does real identity and registry I/O before it reaches its first + // poll, so a forked provision has not parked yet when the adjustment runs: + // the advance passes through, no sleep is released, and the join never + // returns. A two-millisecond real deadline is the honest bound here. + const { workspace, stateRoot, layer } = setupInMemory({ + publicationTimeoutMs: 2, + publicationPollMs: 1, + isProcessAlive: () => true, + }); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const pending = yield* stagePendingStack(workspace, stateRoot); + + const timedOut = yield* managed + .provisionOrdinaryStack({ workspacePath: workspace }) + .pipe( + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => Effect.succeed(error)), + ); + const stacks = yield* managed.listStacks(); + + expect(timedOut).toBeInstanceOf(ManagedStackPublicationTimeoutError); + expect(stacks.map((stack) => stack.id)).toEqual([pending.id]); + expect(stacks[0]?.status).toBe("pending"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses to build a service over a blank state root", () => { + // A blank root would anchor every managed path to the process' working + // directory, so the layer must fail while it is being built rather than at + // whichever call first touches a path. + const layer = managedLayer("", { repository: createInMemoryManagedStackRepository() }); + return Effect.gen(function* () { + const exit = yield* Effect.gen(function* () { + return yield* ManagedStackService; + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }); + }); +}); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts new file mode 100644 index 0000000000..d0b5545a9b --- /dev/null +++ b/packages/stack/src/managed-node.ts @@ -0,0 +1,36 @@ +import type { Layer } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; +import { + createManagedStackServiceWith, + makeManagedStackServiceWith, + managedStackLayerWith, + type CreateManagedStackServiceOptions, + type MakeManagedStackServiceOptions, + type ManagedStackLayerFailure, + type ManagedStackServiceHandle, +} from "./managed/create-service.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import type { ManagedStackService } from "./managed/service.ts"; +import { nodeSqliteManagedStackRepositoryLayer } from "./managed/sqlite-node.ts"; + +export * from "./managed.ts"; +export { nodeSqliteManagedStackRepositoryLayer }; + +/** The managed assembly an Effect consumer provides, bound to the Node runtime. */ +export const managedStackLayer = ( + options: CreateManagedStackServiceOptions = {}, +): Layer.Layer => + managedStackLayerWith(NodeFileSystem.layer, nodeSqliteManagedStackRepositoryLayer, options); + +export const createManagedStackService = ( + options: CreateManagedStackServiceOptions = {}, +): Promise => + createManagedStackServiceWith( + NodeFileSystem.layer, + nodeSqliteManagedStackRepositoryLayer, + options, + ); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): Promise => makeManagedStackServiceWith(NodeFileSystem.layer, options); diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts new file mode 100644 index 0000000000..7d83a9bb4c --- /dev/null +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -0,0 +1,157 @@ +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { assertManagedUuid } from "./managed/ids.ts"; +import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; +import { + assertManagedStackRoot, + managedStackPaths, + resolveManagedStateRoot, +} from "./managed/paths.ts"; + +describe("managed paths", () => { + it.each([ + ["empty", ""], + ["wrong-length", "018f8b4e-8e5c-7e32-a956-6f297fd05a2"], + ["non-hex", "018f8b4g-8e5c-7e32-a956-6f297fd05a2d"], + ["unsupported version", "018f8b4e-8e5c-0e32-a956-6f297fd05a2d"], + ["invalid variant", "018f8b4e-8e5c-7e32-7956-6f297fd05a2d"], + ])("rejects %s managed UUIDs", (_case, value) => { + expect(() => assertManagedUuid(value, "test id")).toThrow(InvalidManagedIdentityError); + }); + + it("isolates managed records beneath SUPABASE_HOME", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/configured/supabase/managed"); + }); + + it("trims surrounding whitespace from a configured SUPABASE_HOME", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: " /configured/supabase " }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/configured/supabase/managed"); + }); + + it("treats whitespace-only state-root environment values as unset", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: " " }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "\t" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { LOCALAPPDATA: " " }, + homeDir: "C:\\Users\\user", + platform: "win32", + }), + ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); + }); + + it("uses platform application-state directories by default", () => { + expect(resolveManagedStateRoot({ env: {}, homeDir: "/home/user", platform: "linux" })).toBe( + "/home/user/.local/state/supabase/managed", + ); + expect(resolveManagedStateRoot({ env: {}, homeDir: "/Users/user", platform: "darwin" })).toBe( + "/Users/user/Library/Application Support/supabase/managed", + ); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { LOCALAPPDATA: "" }, + homeDir: "C:\\Users\\user", + platform: "win32", + }), + ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); + }); + + it("anchors caller- and environment-supplied state roots to an absolute path", () => { + expect(resolveManagedStateRoot({ stateRoot: "relative/managed" })).toBe( + resolve("relative/managed"), + ); + expect(resolveManagedStateRoot({ stateRoot: "/absolute/managed" })).toBe("/absolute/managed"); + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: "relative/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe(join(resolve("relative/supabase"), "managed")); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "relative/state" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe(join(resolve("relative/state"), "supabase", "managed")); + }); + + it("refuses a blank explicit state root instead of falling back", () => { + // `resolve("")` silently yields the process' cwd, which would scatter + // managed state across whatever directory the caller happened to run in. + // An explicit root is a decision, so a blank one is a caller bug rather + // than a request for the default — the same policy the service applies. + for (const stateRoot of ["", " ", "\t"]) { + expect(() => + resolveManagedStateRoot({ stateRoot, env: {}, homeDir: "/home/user", platform: "linux" }), + ).toThrow(UnsafeManagedStackPathError); + } + expect(() => + resolveManagedStateRoot({ + stateRoot: "", + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toThrow(UnsafeManagedStackPathError); + }); + + it("names the blank root it refused instead of an empty message tail", () => { + expect(() => resolveManagedStateRoot({ stateRoot: "\t" })).toThrow(/"\\t"/); + }); + + it("trims surrounding whitespace from an explicit state root", () => { + expect(resolveManagedStateRoot({ stateRoot: " /absolute/managed " })).toBe( + "/absolute/managed", + ); + }); + + it("keys every mutable stack path by opaque stack ID", () => { + expect(managedStackPaths("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d")).toEqual({ + root: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d", + data: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/data", + logs: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/logs", + runtime: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/runtime", + }); + }); + + it("rejects non-UUID IDs and registry paths that do not match the derived root", () => { + expect(() => managedStackPaths("/state", "../../tmp/escaped")).toThrow( + InvalidManagedIdentityError, + ); + expect(() => + assertManagedStackRoot("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", "/tmp/escaped"), + ).toThrow(UnsafeManagedStackPathError); + }); +}); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts new file mode 100644 index 0000000000..0625f624dd --- /dev/null +++ b/packages/stack/src/managed-service.integration.test.ts @@ -0,0 +1,2745 @@ +import { Database } from "bun:sqlite"; +import { + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { Cause, Context, Effect, Exit, ManagedRuntime } from "effect"; +import { managedStackContractFixtures } from "./managed-stack-contract.ts"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { + managedRegistryPath, + managedStackPaths, + ordinaryWorkspaceIdentityPath, +} from "./managed/paths.ts"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedIdentityError, + MANAGED_REGISTRY_SCHEMA_VERSION, + InvalidManagedOwnerPidError, + ManagedAbandonedOperationError, + InvalidManagedPortError, + InvalidManagedStackNameError, + ManagedPendingStackUpdateError, + ManagedOperationInProgressError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackNotStoppedError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, + UnsupportedManagedRegistryVersionError, + type ManagedStackConfiguration, + type ManagedStackRecord, +} from "./managed/model.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; +import { ManagedStackRepository, type ManagedStackRepositoryShape } from "./managed/repository.ts"; +import { sqliteManagedStackRepositoryLayer, type ManagedSqliteDatabase } from "./managed/sqlite.ts"; +import type { MakeManagedStackServiceOptions, ManagedStackServiceHandle } from "./managed-bun.ts"; +import { + bunSqliteManagedStackRepositoryLayer, + createManagedStackService, + makeManagedStackService, +} from "./managed-bun.ts"; + +/** + * Both registry adapters decide synchronously once they are open, so a test can + * run a contract call inline instead of awaiting it. + */ +const runRepo = Effect.runSync; + +/** + * Opens a registry the way production does, as a scoped layer, for the tests that + * exercise the SQLite adapter itself rather than a managed stack service. Opening + * it is I/O — a cold start may wait out another process' WAL conversion — so the + * layer is built through a Promise, and the layer's scope owns the database + * handle until `close`. + */ +const openRegistry = async ( + databasePath: string, +): Promise<{ + readonly repository: ManagedStackRepositoryShape; + readonly close: () => Promise; +}> => { + const runtime = ManagedRuntime.make(bunSqliteManagedStackRepositoryLayer(databasePath)); + return { + repository: Context.get(await runtime.context(), ManagedStackRepository), + close: () => runtime.dispose(), + }; +}; + +/** + * An in-memory registry handle that runs `reenterOnce`'s callback the first time + * a decision reads a row, so a test can re-enter the repository from inside a + * transaction the way a mistaken caller would. + */ +const reentrantRegistry = (): { + readonly handle: ManagedSqliteDatabase; + readonly reenterOnce: (reentry: () => void) => void; +} => { + const database = new Database(":memory:"); + let pending: (() => void) | undefined; + const trigger = (): void => { + const reentry = pending; + pending = undefined; + reentry?.(); + }; + return { + reenterOnce: (reentry) => { + pending = reentry; + }, + handle: { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.query(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + trigger(); + return statement.get(...parameters) ?? undefined; + }, + all(parameters = []) { + trigger(); + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }, + }; +}; + +const temporaryRoots: Array = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "managed-stack-test-")); + temporaryRoots.push(root); + return root; +}; + +const makeWorkspace = (root: string, name = "workspace"): string => { + const workspace = join(root, name); + mkdirSync(workspace, { recursive: true }); + return workspace; +}; + +const findNodeBinary = (): string => { + const executable = process.platform === "win32" ? "node.exe" : "node"; + for (const directory of (process.env["PATH"] ?? "").split(delimiter)) { + const candidate = join(directory, executable); + if (!existsSync(candidate)) { + continue; + } + const result = Bun.spawnSync([candidate, "--version"]); + const version = new TextDecoder().decode(result.stdout).trim(); + if (result.exitCode === 0 && /^v\d+\./.test(version)) { + return candidate; + } + } + throw new Error("Node is required for the managed SQLite adapter test"); +}; + +type ServiceOverrides = Omit; + +const makeInMemoryService = ( + root: string, + overrides: ServiceOverrides = {}, +): Promise => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + publicationPollMs: 1, + ...overrides, + }); + +const makePersistentService = ( + root: string, + overrides: ServiceOverrides = {}, +): Promise => + createManagedStackService({ + stateRoot: join(root, "managed"), + publicationPollMs: 1, + ...overrides, + }); + +/** + * Valid managed UUIDs whose lexicographic order is the reverse of the order + * they are handed out in, so a repository that returns insertion order instead + * of sorting cannot accidentally pass an ordering assertion. + */ +const descendingIdFactory = (): (() => string) => { + let next = 0xff_ff_ff_00; + return () => { + next -= 1; + return `${next.toString(16).padStart(8, "0")}-0000-7000-8000-000000000000`; + }; +}; + +const fixture = (id: string) => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`Missing managed stack contract fixture ${id}`); + } + return scenario; +}; + +const portFacts = (id: string) => + fixture(id).given.flatMap((fact) => (fact.kind === "config-port" ? [fact] : [])); + +const portAssignmentFacts = (id: string) => + fixture(id).given.flatMap((fact) => (fact.kind === "port-assignment" ? [fact] : [])); + +const requirePortFact = (id: string, key: string) => { + const fact = portFacts(id).find((candidate) => candidate.key === key); + if (fact === undefined || !("value" in fact) || typeof fact.value !== "number") { + throw new Error(`Fixture ${id} does not define ${key}`); + } + return { key: fact.key, port: fact.value, intent: fact.intent }; +}; + +const stackNames = (id: string): ReadonlyArray => + fixture(id).given.flatMap((fact) => (fact.kind === "stack-names" ? fact.names : [])); + +const invalidStackNameCases = managedStackContractFixtures + .filter(({ id }) => id.startsWith("identity.invalid-stack-name-")) + .flatMap((scenario) => stackNames(scenario.id).map((name) => [scenario.id, name] as const)); + +const prepareAbandonedStack = async ( + service: ManagedStackServiceHandle, + workspace: string, + ownerPid?: number, + configuration: ManagedStackConfiguration = {}, +) => { + const identity = (await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace))).identity; + const stackId = crypto.randomUUID(); + const prepared = runRepo( + service.repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(service.stateRoot, stackId), + operationToken: crypto.randomUUID(), + ownerPid, + now: "2026-08-11T00:00:00.000Z", + configuration, + }), + ); + if (prepared.outcome !== "create") { + throw new Error("Expected an abandoned pending stack"); + } + mkdirSync(prepared.stack.paths.data, { recursive: true }); + return prepared; +}; + +describe("ordinary-folder managed stack contract", () => { + it("restricts registry and stack state permissions to the owning user", async () => { + const root = makeRoot(); + const service = await makePersistentService(root); + const stateRoot = join(root, "managed"); + const { stack } = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + await service.close(); + + const modeOf = (path: string): number => statSync(path).mode & 0o777; + expect(modeOf(stateRoot)).toBe(0o700); + expect(modeOf(managedRegistryPath(stateRoot))).toBe(0o600); + expect(modeOf(stack.paths.data)).toBe(0o700); + expect(modeOf(stack.paths.logs)).toBe(0o700); + expect(modeOf(stack.paths.runtime)).toBe(0o700); + }); + + it("retightens managed state permissions left loose by an earlier build", async () => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const registryPath = managedRegistryPath(stateRoot); + mkdirSync(stateRoot, { recursive: true, mode: 0o755 }); + writeFileSync(registryPath, "", { mode: 0o644 }); + + const service = await makePersistentService(root); + await service.close(); + + const modeOf = (path: string): number => statSync(path).mode & 0o777; + expect(modeOf(stateRoot)).toBe(0o700); + expect(modeOf(registryPath)).toBe(0o600); + }); + + it("keeps read-only discovery registration-free", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makeInMemoryService(root); + + const result = await service.inspectOrdinaryWorkspace(workspace); + + expect(result).toEqual({ registered: false, stacks: [] }); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); + expect(runRepo(service.repository.listStacks())).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); + }); + + it("reports an existing identity without stacks as not yet registered", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makeInMemoryService(root); + const marker = await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); + + const result = await service.inspectOrdinaryWorkspace(workspace); + + expect(result).toEqual({ registered: false, identity: marker.identity, stacks: [] }); + }); + + it("filters inspected stacks by the complete project, checkout, and context identity", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let foreignContextStack: ManagedStackRecord | undefined; + const filteringRepository: ManagedStackRepositoryShape = { + ...repository, + listStacks: (options) => + Effect.map(repository.listStacks(options), (stacks) => + foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack], + ), + }; + const service = await makeManagedStackService({ + repository: filteringRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + foreignContextStack = { + ...created.stack, + id: crypto.randomUUID(), + contextId: crypto.randomUUID(), + }; + + const result = await service.inspectOrdinaryWorkspace(join(root, "workspace")); + + expect(result.registered).toBe(true); + expect(result.stacks).toEqual([created.stack]); + }); + + it("fails safely on an unknown newer workspace identity marker", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makeInMemoryService(root); + const markerPath = ordinaryWorkspaceIdentityPath(workspace); + mkdirSync(join(workspace, ".supabase")); + writeFileSync( + markerPath, + JSON.stringify({ + version: 999, + projectId: crypto.randomUUID(), + checkoutId: crypto.randomUUID(), + contextId: crypto.randomUUID(), + }), + ); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(runRepo(service.repository.listStacks())).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); + }); + + it.each(invalidStackNameCases)("rejects %s", async (_fixtureId, stackName) => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makeInMemoryService(root); + + const provision = service.provisionOrdinaryStack({ workspacePath: workspace, stackName }); + await expect(provision).rejects.toBeInstanceOf(InvalidManagedStackNameError); + await expect(provision).rejects.toThrow(`Invalid managed stack name: ${stackName}`); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); + expect(await service.listStacks()).toEqual([]); + }); + + it("resolves every valid fixture stack name within one ordinary context", async () => { + const names = stackNames("identity.valid-stack-names-resolve-deterministically"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makeInMemoryService(root); + + const results = await Promise.all( + names.map((stackName) => + service.provisionOrdinaryStack({ workspacePath: workspace, stackName }), + ), + ); + + expect(results.map(({ stack }) => stack.name)).toEqual(names); + expect(new Set(results.map(({ stack }) => stack.id)).size).toBe(names.length); + }); + + it("executes the first-start and persisted-identity M1 fixtures against SQLite", async () => { + const firstStart = fixture("identity.non-git-folder-first-start-persists-identity"); + const recoveredStart = fixture("identity.non-git-folder-recovers-persisted-identity"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root); + + const created = await service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { + runtimeRequest: "docker", + runtime: "docker", + ports: [{ key: "api.port", port: 54_321, intent: "automatic" }], + serviceVersions: { postgres: "17.6.1" }, + runtimeMetadata: { + pid: process.pid, + socketPath: join(root, "daemon.sock"), + processIds: { postgres: process.pid }, + containerIds: { auth: "container-auth" }, + }, + configFingerprint: "config-v1", + credentialsReference: "credentials-v1", + }, + }); + + expect(created.outcome).toBe(firstStart.expected.outcome); + expect(created.identityMarkerCreated).toBe(true); + expect(created.stack.status).toBe("active"); + expect(created.stack.paths.root).toBe(join(service.stateRoot, "stacks", created.stack.id)); + expect(created.stack.paths.root.startsWith(workspace)).toBe(false); + expect(created.stack.ports).toEqual([{ key: "api.port", port: 54_321, intent: "automatic" }]); + expect(created.stack.serviceVersions).toEqual({ postgres: "17.6.1" }); + expect(created.stack.runtimeMetadata).toEqual({ + pid: process.pid, + socketPath: join(root, "daemon.sock"), + processIds: { postgres: process.pid }, + containerIds: { auth: "container-auth" }, + }); + expect(existsSync(created.stack.paths.data)).toBe(true); + expect(existsSync(created.stack.paths.logs)).toBe(true); + expect(existsSync(created.stack.paths.runtime)).toBe(true); + + const marker = JSON.parse(readFileSync(ordinaryWorkspaceIdentityPath(workspace), "utf8")); + expect(Object.keys(marker).sort()).toEqual(["checkoutId", "contextId", "projectId", "version"]); + expect(marker).toMatchObject({ + projectId: created.selection.projectId, + checkoutId: created.selection.checkoutId, + contextId: created.selection.contextId, + }); + + await service.close(); + const reopened = await makePersistentService(root); + const reused = await reopened.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(reused.outcome).toBe(recoveredStart.expected.outcome); + expect(reused.identityMarkerCreated).toBe(false); + expect(reused.selection).toEqual(created.selection); + expect(reused.stack.ports).toEqual(created.stack.ports); + expect(await reopened.listStacks()).toHaveLength(1); + await reopened.close(); + + const registry = new Database(managedRegistryPath(join(root, "managed"))); + const columns = registry.query("PRAGMA table_info(stacks)").all(); + const columnNames = columns.map((column) => + typeof column === "object" && column !== null ? Reflect.get(column, "name") : undefined, + ); + expect(columnNames).not.toContain("credentials"); + expect(columnNames).not.toContain("secret_key"); + expect(columnNames).toContain("credentials_reference"); + registry.close(); + }); + + it("accepts an injected repository and isolated state root without CLI ownership", async () => { + const contract = fixture("api-boundary.managed-api-accepts-injected-repository"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + const stateRoot = join(root, "isolated-managed-state"); + const service = await makeManagedStackService({ repository, stateRoot }); + + const result = await service.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(contract.expected.outcome).toBe("create"); + expect(result.outcome).toBe("create"); + expect(service.repository).toBe(repository); + expect(result.stack.paths.root.startsWith(stateRoot)).toBe(true); + }); + + it("publishes one stack when two callers provision the same identity concurrently", async () => { + const contract = fixture("identity.concurrent-create-publishes-once"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root); + let releaseInitialization: () => void = () => {}; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + let initializerCalls = 0; + + const first = service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async () => { + initializerCalls += 1; + await initializationGate; + }, + }); + while (runRepo(service.repository.listStacks()).length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const second = service.provisionOrdinaryStack({ workspacePath: workspace }); + releaseInitialization(); + const results = await Promise.all([first, second]); + + expect(contract.expected.outcome).toBe("create"); + expect(results.map((result) => result.outcome).sort()).toEqual(["create", "reuse"]); + expect(new Set(results.map((result) => result.stack.id))).toHaveProperty("size", 1); + expect(initializerCalls).toBe(1); + expect(runRepo(service.repository.listStacks())).toHaveLength(1); + await service.close(); + }); + + it("applies the requested configuration after awaiting another caller's publication", async () => { + const requested = { key: "api.port", port: 55_451, intent: "exact" } as const; + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root); + let releaseInitialization: () => void = () => {}; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + + const first = service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async () => { + await initializationGate; + }, + }); + while (runRepo(service.repository.listStacks()).length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const second = service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: [requested], serviceVersions: { postgres: "17.6.1.143" } }, + }); + releaseInitialization(); + const [created, reused] = await Promise.all([first, second]); + + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(created.stack.id); + expect(reused.stack.ports).toEqual([requested]); + expect(reused.stack.serviceVersions).toEqual({ postgres: "17.6.1.143" }); + expect(await service.inspectStack(created.stack.id)).toMatchObject({ + ports: [requested], + serviceVersions: { postgres: "17.6.1.143" }, + }); + await service.close(); + }); + + it("rolls back failed initialization and makes the same start retryable", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root); + let failedRoot: string | undefined; + + await expect( + service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async (stack) => { + failedRoot = stack.paths.root; + throw new Error("initialization failed"); + }, + }), + ).rejects.toBeInstanceOf(ManagedStackInitializationError); + + expect(failedRoot).toBeDefined(); + expect(existsSync(failedRoot ?? "")).toBe(false); + expect(await service.listStacks()).toEqual([]); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(true); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(await service.listStacks()).toHaveLength(1); + await service.close(); + }); + + it("rejects a copied ordinary-folder identity claim", async () => { + const root = makeRoot(); + const firstWorkspace = makeWorkspace(root, "first"); + const secondWorkspace = makeWorkspace(root, "copy"); + const service = await makePersistentService(root); + await service.provisionOrdinaryStack({ workspacePath: firstWorkspace }); + mkdirSync(join(secondWorkspace, ".supabase"), { recursive: true }); + copyFileSync( + ordinaryWorkspaceIdentityPath(firstWorkspace), + ordinaryWorkspaceIdentityPath(secondWorkspace), + ); + + await expect( + service.provisionOrdinaryStack({ workspacePath: secondWorkspace }), + ).rejects.toBeInstanceOf(DuplicateManagedIdentityError); + expect(await service.listStacks()).toHaveLength(1); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); + await service.close(); + }); + + it("times out without adopting a pending stack owned by another caller", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root, { + publicationTimeoutMs: 2, + publicationPollMs: 1, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); + expect(await service.listStacks()).toHaveLength(1); + await service.close(); + }); + + it.each([0, -1, 1.5])( + "reports an abandoned claim instead of waiting on a corrupt stored owner pid %s", + async (ownerPid) => { + // A stored pid that is not a pid cannot be asked about: `kill(0, 0)` + // signals the caller's own process group and a fractional pid throws, + // either of which would report a dead owner as alive and make provision + // wait out the whole publication timeout for a publisher that is gone. + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + let livenessProbes = 0; + const corruptedRepository: ManagedStackRepositoryShape = { + ...repository, + prepareOrdinaryStack: (input) => + Effect.map(repository.prepareOrdinaryStack(input), (prepared) => + prepared.outcome === "existing" && prepared.operation !== undefined + ? { ...prepared, operation: { ...prepared.operation, ownerPid } } + : prepared, + ), + }; + const service = await makeManagedStackService({ + repository: corruptedRepository, + stateRoot: join(root, "managed"), + publicationTimeoutMs: 5_000, + publicationPollMs: 1, + isProcessAlive: () => { + livenessProbes += 1; + return true; + }, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedAbandonedOperationError); + + expect(livenessProbes).toBe(0); + await service.close(); + }, + ); + + it("keeps polling at a configured interval slower than the internal backoff ceiling", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + const pollTimes: Array = []; + const observedRepository: ManagedStackRepositoryShape = { + ...repository, + getStack: (stackId) => + Effect.suspend(() => { + pollTimes.push(performance.now()); + return repository.getStack(stackId); + }), + }; + const service = await makeManagedStackService({ + repository: observedRepository, + stateRoot: join(root, "managed"), + publicationTimeoutMs: 1_600, + publicationPollMs: 400, + isProcessAlive: () => true, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); + + // The backoff ceiling must never poll a publisher faster than the caller + // asked for; only the last wait may be shortened, by the deadline. + expect(pollTimes.length).toBeGreaterThanOrEqual(2); + const gaps = pollTimes.slice(1).map((time, index) => time - (pollTimes[index] ?? 0)); + expect(gaps.slice(0, 2).every((gap) => gap >= 350)).toBe(true); + await service.close(); + }); + + it("rejects a non-UUID stack factory result before deriving state paths", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); + const service = await makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + idFactory: () => "../../outside", + }); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(existsSync(join(root, "outside"))).toBe(false); + expect(await service.listStacks()).toEqual([]); + }); +}); + +describe("managed service options", () => { + it.each([ + ["empty", ""], + ["whitespace", " "], + ["tab", "\t"], + ])( + "refuses an %s state root instead of falling back to the working directory", + async (_case, stateRoot) => { + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot, + }), + ).rejects.toBeInstanceOf(UnsafeManagedStackPathError); + }, + ); + + it("refuses an undefined state root instead of falling back to SUPABASE_HOME or the home directory", async () => { + // `stateRoot` is required in the option type, but a caller bypassing the + // type system (or a plain-JS caller) could still pass `undefined`. That + // must fail loudly instead of silently resolving against SUPABASE_HOME or + // the user's home directory. + const root = makeRoot(); + const configuredHome = join(root, "unused-supabase-home"); + const originalSupabaseHome = process.env["SUPABASE_HOME"]; + process.env["SUPABASE_HOME"] = configuredHome; + try { + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: undefined, + } as unknown as MakeManagedStackServiceOptions), + ).rejects.toBeInstanceOf(UnsafeManagedStackPathError); + expect(existsSync(configuredHome)).toBe(false); + } finally { + if (originalSupabaseHome === undefined) { + delete process.env["SUPABASE_HOME"]; + } else { + process.env["SUPABASE_HOME"] = originalSupabaseHome; + } + } + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "refuses %s as an operation owner pid", + async (ownerPid) => { + const root = makeRoot(); + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid, + }), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + }, + ); + + it("validates owner pids on the shared entrypoint options path too", async () => { + const root = makeRoot(); + await expect( + createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 0, + }), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + + const service = await createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 4321, + }); + expect(service.stateRoot).toBe(join(root, "managed")); + await service.close(); + }); + + it("awaits an initialize callback that answers with a thenable rather than a Promise", async () => { + // A caller whose promises come from another implementation — a bundled + // polyfill, a Bluebird-style library — answers with a thenable that is not + // `instanceof Promise`. Publishing on such an answer would mean publishing a + // stack whose initialization has not run yet. + const root = makeRoot(); + const service = await makePersistentService(root); + let initialized = false; + // Answering `then` through a proxy rather than declaring the property: the + // lint rule that guards against accidental thenables forbids writing one, + // and being a thenable on purpose is this fixture's whole point. + const thenable = new Proxy( + {}, + { + get: (_target, property) => + property === "then" + ? (resolve: (value: undefined) => void) => { + setTimeout(() => { + initialized = true; + resolve(undefined); + }, 5); + } + : undefined, + }, + ) as unknown as Promise; + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + initialize: () => thenable, + }); + + expect(initialized).toBe(true); + expect(created.stack.status).toBe("active"); + await service.close(); + }); + + it("rejects a call made after close with an error that says the handle is closed", async () => { + // A caller that reaches for a closed handle — a stray promise, a shutdown + // race — must get a diagnosable rejection rather than the runtime's bare + // internal string, which has neither a name nor a stack. + const root = makeRoot(); + const service = await makePersistentService(root); + await service.close(); + + await expect(service.listStacks()).rejects.toBeInstanceOf(Error); + await expect(service.listStacks()).rejects.toThrow(/closed/i); + }); + + it("reports a callback's own rejection as itself even when it mentions disposal", async () => { + // Whether the handle is closed is the handle's own state, never something + // read back out of what a rejection happens to say: a caller's callback that + // refuses with a string mentioning disposal must reach that caller unchanged. + const root = makeRoot(); + const service = await makePersistentService(root); + const { stack } = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root) }); + await service.updateStack(stack.id, { lifecycle: "running" }); + + let rejection: unknown; + try { + await service.deleteStack(stack.id, { + stop: () => Promise.reject("the container was disposed"), + }); + } catch (error: unknown) { + rejection = error; + } + + expect(String(rejection)).toContain("the container was disposed"); + expect(String(rejection)).not.toContain("handle is closed"); + expect(await service.inspectStack(stack.id)).toMatchObject({ status: "active" }); + await service.close(); + }); + + it("closes a service acquired with await using when its block ends", async () => { + const root = makeRoot(); + let acquired: ManagedStackServiceHandle | undefined; + { + await using service = await makePersistentService(root); + acquired = service; + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + expect(await service.inspectStack(created.stack.id)).toMatchObject({ status: "active" }); + } + + if (acquired === undefined) { + throw new Error("Expected the disposed handle to be captured"); + } + // Leaving the block disposed the runtime that owns the registry, so the + // repository the service handed out is closed along with it. + const disposed = acquired; + expect(() => runRepo(disposed.repository.listStacks())).toThrow(); + + const reopened = await makePersistentService(root); + expect(await reopened.listStacks()).toHaveLength(1); + await reopened.close(); + }); +}); + +describe("managed repository and lifecycle", () => { + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`orders records identically byte-for-byte with the ${adapter} adapter`, async () => { + // Both adapters must agree on ordering: SQLite sorts `created_at, id` + // with BINARY collation, so the in-memory repository may not use + // `localeCompare`, whose case-insensitive collation disagrees on + // mixed-case paths. Descending IDs make insertion order the wrong answer. + const root = makeRoot(); + const overrides = { + clock: () => new Date("2026-08-11T00:00:00.000Z"), + idFactory: descendingIdFactory(), + }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "Projects"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "apps"), + }); + + expect(first.stack.createdAt).toBe(second.stack.createdAt); + expect(second.stack.id < first.stack.id).toBe(true); + expect((await service.listStacks()).map((stack) => stack.id)).toEqual( + [first.stack.id, second.stack.id].sort(), + ); + + const paths = runRepo(service.repository.listCheckoutLocations()).map( + (location) => location.canonicalPath, + ); + expect(paths).toEqual([...paths].sort()); + expect(paths).toHaveLength(2); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`keeps repository decisions storage-agnostic for the ${adapter} adapter`, async () => { + const contract = fixture("api-boundary.repository-contract-is-storage-agnostic"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(contract.expected.outcome).toBe("report"); + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.selection).toEqual(created.selection); + await service.close(); + }); + } + + it("anchors an injected relative state root so a later chdir cannot split stack state", async () => { + const service = await makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: "relative-managed-state", + }); + expect(service.stateRoot).toBe(resolve("relative-managed-state")); + await service.close(); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`rejects unusable port numbers with a coded failure for the ${adapter} adapter`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const workspace = makeWorkspace(root); + + await expect( + service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: [{ key: "api.port", port: 54_321.5, intent: "exact" }] }, + }), + ).rejects.toBeInstanceOf(InvalidManagedPortError); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + await expect( + service.updateStack(created.stack.id, { + ports: [{ key: "api.port", port: 70_000, intent: "exact" }], + }), + ).rejects.toBeInstanceOf(InvalidManagedPortError); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`rejects duplicate port keys with a coded failure for the ${adapter} adapter`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const workspace = makeWorkspace(root); + const duplicateKeyPorts = [ + { key: "api.port", port: 54_401, intent: "automatic" as const }, + { key: "api.port", port: 54_402, intent: "automatic" as const }, + ]; + + const provisionFailure = await service + .provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: duplicateKeyPorts }, + }) + .catch((error: unknown) => error); + expect(provisionFailure).toBeInstanceOf(DuplicateManagedPortKeyError); + expect((provisionFailure as DuplicateManagedPortKeyError).code).toBe( + "MANAGED_DUPLICATE_PORT_KEY", + ); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + const updateFailure = await service + .updateStack(created.stack.id, { ports: duplicateKeyPorts }) + .catch((error: unknown) => error); + expect(updateFailure).toBeInstanceOf(DuplicateManagedPortKeyError); + expect((updateFailure as DuplicateManagedPortKeyError).code).toBe( + "MANAGED_DUPLICATE_PORT_KEY", + ); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); + await service.close(); + }); + } + + it("persists stack configuration and reserves ports globally", async () => { + const root = makeRoot(); + const service = await makePersistentService(root); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + }); + + const configured = await service.updateStack(first.stack.id, { + runtimeRequest: "native", + runtime: "native", + lifecycle: "running", + ports: [{ key: "db.port", port: 54_322, intent: "automatic" }], + serviceVersions: { postgres: "17.6.1.143", storage: "1.28.0" }, + runtimeMetadata: { + pid: 42, + socketPath: "/tmp/managed.sock", + processIds: { postgres: 43 }, + containerIds: { storage: "storage-container" }, + }, + configFingerprint: "fingerprint-v2", + credentialsReference: "credential-record-v2", + }); + + expect(configured).toMatchObject({ + runtimeRequest: "native", + runtime: "native", + lifecycle: "running", + serviceVersions: { postgres: "17.6.1.143", storage: "1.28.0" }, + configFingerprint: "fingerprint-v2", + credentialsReference: "credential-record-v2", + }); + expect(configured.runtimeMetadata.processIds).toEqual({ postgres: 43 }); + + await expect( + service.updateStack(second.stack.id, { + lifecycle: "starting", + ports: [{ key: "db.port", port: 54_322, intent: "exact" }], + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect((await service.inspectStack(second.stack.id))?.ports).toEqual([]); + await service.close(); + }); + + it("rolls back an in-memory registration when its initial port reservation conflicts", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, + }); + const secondWorkspace = makeWorkspace(root, "second"); + + await expect( + service.provisionOrdinaryStack({ + workspacePath: secondWorkspace, + configuration: { + lifecycle: "starting", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); + + const retried = await service.provisionOrdinaryStack({ workspacePath: secondWorkspace }); + expect(retried.outcome).toBe("create"); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(2); + }); + + it("requires actual runtime inspection before recovering an abandoned operation", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected to claim an abandoned operation"); + } + runRepo( + service.repository.updateStack({ + stackId: created.stack.id, + operationToken: claimed.operation.token, + lifecycle: "starting", + now: "2026-08-11T00:00:01.000Z", + }), + ); + + const unknown = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "unknown", + }); + expect(unknown.recovered).toEqual([]); + expect(unknown.abortedStackIds).toEqual([]); + expect(unknown.retained).toEqual([{ operation: claimed.operation, reason: "runtime-unknown" }]); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("starting"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + expect(reconciled.retained).toEqual([]); + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toHaveLength(1); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("stopped"); + }); + + it("aborts a crashed pending provision and makes the identity retryable", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root, { + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, workspace, 987_650); + writeFileSync(join(pending.stack.paths.data, "partial"), "incomplete"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.retained).toEqual([]); + expect(existsSync(pending.stack.paths.root)).toBe(false); + expect(await service.listStacks()).toEqual([]); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(retried.stack.id).not.toBe(pending.stack.id); + await service.close(); + }); + + it("publishes a crashed pending provision when runtime inspection finds it running", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root, { + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, workspace, 987_651); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toHaveLength(1); + expect(reconciled.recovered[0]).toMatchObject({ status: "active", lifecycle: "running" }); + const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(pending.stack.id); + await service.close(); + }); + + it("retains operations while their owner process is still alive", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { + isProcessAlive: (pid) => pid === 987_652, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); + let inspected = false; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + inspected = true; + return "stopped"; + }, + }); + + expect(inspected).toBe(false); + expect(reconciled.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); + expect((await service.inspectStack(pending.stack.id))?.status).toBe("pending"); + }); + + it("force-recovers an operation when a stale or reused PID still appears alive", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); + + const retained = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + expect(retained.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); + + const forced = await service.reconcileAbandonedOperations({ + force: { + stackId: pending.stack.id, + operationToken: pending.operation.token, + }, + inspectRuntime: async () => "stopped", + }); + expect(forced.abortedStackIds).toEqual([pending.stack.id]); + expect(forced.retained).toEqual([]); + expect(await service.listStacks()).toEqual([]); + }); + + it.each([ + ["stack ID", { stackId: "not-a-uuid", operationToken: crypto.randomUUID() }], + ["operation token", { stackId: crypto.randomUUID(), operationToken: "not-a-uuid" }], + ])("rejects a forced recovery with an invalid %s", async (_label, force) => { + const root = makeRoot(); + const service = await makeInMemoryService(root); + let inspected = false; + + await expect( + service.reconcileAbandonedOperations({ + force, + inspectRuntime: async () => { + inspected = true; + return "stopped"; + }, + }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(inspected).toBe(false); + }); + + it("scopes forced recovery to one exact operation", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await Promise.all( + ["first", "target", "third"].map((name, index) => + prepareAbandonedStack(service, makeWorkspace(root, name), 987_660 + index), + ), + ); + const target = pending[1]; + if (target === undefined) { + throw new Error("Expected a target operation"); + } + const inspected: Array = []; + + const staleTarget = await service.reconcileAbandonedOperations({ + force: { + stackId: target.stack.id, + operationToken: crypto.randomUUID(), + }, + inspectRuntime: async (stack) => { + inspected.push(stack.id); + return "stopped"; + }, + }); + + expect(staleTarget.abortedStackIds).toEqual([]); + expect(inspected).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toHaveLength(3); + + const forced = await service.reconcileAbandonedOperations({ + force: { + stackId: target.stack.id, + operationToken: target.operation.token, + }, + inspectRuntime: async (stack) => { + inspected.push(stack.id); + return "stopped"; + }, + }); + + expect(inspected).toEqual([target.stack.id]); + expect(forced.abortedStackIds).toEqual([target.stack.id]); + expect( + runRepo(service.repository.listActiveOperations()) + .map(({ token }) => token) + .sort(), + ).toEqual( + pending + .filter(({ stack }) => stack.id !== target.stack.id) + .map(({ operation }) => operation.token) + .sort(), + ); + expect((await service.listStacks()).map(({ id }) => id).sort()).toEqual( + pending + .filter(({ stack }) => stack.id !== target.stack.id) + .map(({ stack }) => stack.id) + .sort(), + ); + }); + + it("reconciles repository operations that have no owner PID", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root)); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + expect(reconciled.retained).toEqual([]); + }); + + it("does not reclaim data when another recovery pass adopts the pending stack", async () => { + const root = makeRoot(); + const service = await makePersistentService(root, { isProcessAlive: () => false }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_653); + const dataFile = join(pending.stack.paths.data, "database"); + writeFileSync(dataFile, "live data"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + runRepo( + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ), + ); + return "stopped"; + }, + }); + + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.skippedOperationIds).toEqual([pending.operation.token]); + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(readFileSync(dataFile, "utf8")).toBe("live data"); + await service.close(); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`keeps provisioned data when recovery adopts the stack first with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, { isProcessAlive: () => false }) + : await makePersistentService(root, { isProcessAlive: () => false }); + let stackRoot: string | undefined; + let dataFile: string | undefined; + + await expect( + service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + initialize: async (stack) => { + stackRoot = stack.paths.root; + dataFile = join(stack.paths.data, "database"); + writeFileSync(dataFile, "live data"); + const operation = runRepo(service.repository.listActiveOperations()).find( + (candidate) => candidate.stackId === stack.id, + ); + if (operation === undefined) { + throw new Error("Expected the provision operation to remain active"); + } + runRepo( + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ), + ); + }, + }), + ).rejects.toMatchObject({ + cleanupErrors: [expect.any(ManagedOperationOwnershipError)], + }); + + expect(stackRoot).toBeDefined(); + expect(dataFile).toBeDefined(); + expect(existsSync(stackRoot ?? "")).toBe(true); + expect(readFileSync(dataFile ?? "", "utf8")).toBe("live data"); + expect(await service.listStacks()).toEqual([ + expect.objectContaining({ status: "active", lifecycle: "running" }), + ]); + await service.close(); + }); + } + + it("retains an operation when owner liveness cannot be determined", async () => { + const root = makeRoot(); + const livenessError = new Error("liveness unavailable"); + const service = await makeInMemoryService(root, { + isProcessAlive: () => { + throw livenessError; + }, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_670); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.retained).toEqual([ + { + operation: pending.operation, + reason: "owner-liveness-unknown", + error: livenessError, + }, + ]); + }); + + it("retains an operation when runtime inspection fails", async () => { + const root = makeRoot(); + const inspectionError = new Error("runtime unavailable"); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_671); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + throw inspectionError; + }, + }); + + expect(reconciled.retained).toEqual([ + { + operation: pending.operation, + reason: "runtime-inspection-failed", + error: inspectionError, + }, + ]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([pending.operation]); + }); + + it("reports a failed post-abort state reclamation", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let returnUnsafePath = false; + const unsafeRoot = join(root, "outside"); + const guardedRepository: ManagedStackRepositoryShape = { + ...repository, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !returnUnsafePath + ? stack + : { + ...stack, + paths: { + root: unsafeRoot, + data: join(unsafeRoot, "data"), + logs: join(unsafeRoot, "logs"), + runtime: join(unsafeRoot, "runtime"), + }, + }, + ), + }; + const service = await makeManagedStackService({ + repository: guardedRepository, + stateRoot: join(root, "managed"), + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_672); + returnUnsafePath = true; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + // The claim is released and the pending row is gone, but the leaked data is + // still there, so the stack is reported as a reclamation failure only. + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.failures).toEqual([ + { + operation: pending.operation, + phase: "state-reclamation", + operationReleased: true, + error: expect.any(UnsafeManagedStackPathError), + }, + ]); + expect(await service.listStacks()).toEqual([]); + }); + + it("continues recovery when an owner finishes one operation during inspection", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + }); + const firstOperation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: first.stack.id, + kind: "start", + ownerPid: 987_653, + now: "2026-08-11T00:00:00.000Z", + }), + ); + const secondOperation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: second.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:01.000Z", + }), + ); + if (!firstOperation.acquired || !secondOperation.acquired) { + throw new Error("Expected both recovery operations to be claimed"); + } + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + if (stack.id === first.stack.id) { + runRepo( + service.repository.finishOperation( + stack.id, + operation.token, + "completed", + "2026-08-11T00:00:02.000Z", + ), + ); + } + return "stopped"; + }, + }); + + expect(reconciled.retained).toEqual([]); + expect(reconciled.recovered.map((stack) => stack.id)).toEqual([second.stack.id]); + expect(reconciled.skippedOperationIds).toEqual([firstOperation.operation.token]); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`keeps a failed pending adoption retryable with ${adapter}`, async () => { + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const owner = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "owner"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_409, intent: "exact" }], + }, + }); + const pending = await prepareAbandonedStack( + service, + makeWorkspace(root, "pending"), + 987_673, + { ports: [{ key: "api.port", port: 55_409, intent: "exact" }] }, + ); + + const blocked = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(blocked.failures).toEqual([ + { + operation: pending.operation, + phase: "reconciliation", + operationReleased: false, + error: expect.any(ManagedPortReservationError), + }, + ]); + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + expect(runRepo(service.repository.listActiveOperations())).toEqual([pending.operation]); + + await service.updateStack(owner.stack.id, { lifecycle: "stopped" }); + const retried = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(retried.recovered).toEqual([ + expect.objectContaining({ + id: pending.stack.id, + status: "active", + lifecycle: "running", + }), + ]); + expect(retried.failures).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`releases a failed runtime adoption operation with ${adapter}`, async () => { + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "owner"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_410, intent: "exact" }], + }, + }); + const blocked = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "blocked"), + configuration: { + ports: [{ key: "api.port", port: 55_410, intent: "exact" }], + }, + }); + const operation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: blocked.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!operation.acquired) { + throw new Error("Expected the abandoned start operation to be claimed"); + } + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(reconciled.failures).toHaveLength(1); + expect(reconciled.failures[0]).toMatchObject({ + operation: operation.operation, + phase: "reconciliation", + operationReleased: true, + error: expect.any(ManagedPortReservationError), + }); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + expect((await service.inspectStack(blocked.stack.id))?.lifecycle).toBe("failed"); + await expect( + service.deleteStack(blocked.stack.id, { stop: async () => {} }), + ).resolves.toMatchObject({ + outcome: "delete", + }); + await service.close(); + }); + } + + it("applies exact stopped-stack ports and makes removed exact keys sticky", async () => { + const changedFixtureId = "ports.config-change-on-stopped-stack-applies"; + const removedFixtureId = "ports.removing-exact-key-keeps-current-port-sticky"; + const previous = portAssignmentFacts(changedFixtureId)[0]; + const requested = requirePortFact(changedFixtureId, "api.port"); + if (previous === undefined) { + throw new Error(`Fixture ${changedFixtureId} has no persisted assignment`); + } + const root = makeRoot(); + const service = await makePersistentService(root); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { + ports: [{ key: previous.key, port: previous.port, intent: previous.intent }], + }, + }); + + const changed = await service.provisionOrdinaryStack({ + workspacePath: join(root, "workspace"), + configuration: { ports: [requested] }, + }); + expect(changed.outcome).toBe("reuse"); + expect(changed.stack.ports).toEqual([requested]); + + const removed = portFacts(removedFixtureId).find((fact) => fact.key === "api.port"); + if (removed === undefined) { + throw new Error(`Fixture ${removedFixtureId} has no api.port intent`); + } + const sticky = await service.provisionOrdinaryStack({ + workspacePath: join(root, "workspace"), + configuration: { + ports: [{ key: removed.key, port: 60_000, intent: removed.intent }], + }, + }); + expect(sticky.outcome).toBe("reuse"); + expect(sticky.stack.ports).toEqual([{ ...requested, intent: "automatic" }]); + await service.close(); + }); + + it("rejects port drift while running without overwriting persisted exact intent", async () => { + const fixtureId = "ports.config-change-on-running-stack-reports-drift"; + const previous = portAssignmentFacts(fixtureId)[0]; + const requested = requirePortFact(fixtureId, "api.port"); + if (previous === undefined) { + throw new Error(`Fixture ${fixtureId} has no persisted assignment`); + } + const root = makeRoot(); + const service = await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { + lifecycle: "running", + ports: [{ key: previous.key, port: previous.port, intent: previous.intent }], + }, + }); + + await expect( + service.updateStack(created.stack.id, { ports: [requested] }), + ).rejects.toBeInstanceOf(ManagedRunningStackPortChangeError); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([ + { key: previous.key, port: previous.port, intent: previous.intent }, + ]); + await service.close(); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`allows failed-stack recovery and intent-only updates with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const failed = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "failed"), + configuration: { + lifecycle: "failed", + ports: [{ key: "api.port", port: 55_401, intent: "exact" }], + }, + }); + + const restarted = await service.updateStack(failed.stack.id, { + lifecycle: "starting", + ports: [{ key: "api.port", port: 55_402, intent: "exact" }], + }); + expect(restarted).toMatchObject({ + lifecycle: "starting", + ports: [{ key: "api.port", port: 55_402, intent: "exact" }], + }); + + const running = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "running"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_403, intent: "automatic" }], + }, + }); + const pinned = await service.updateStack(running.stack.id, { + ports: [{ key: "api.port", port: 55_403, intent: "exact" }], + }); + expect(pinned.ports).toEqual([{ key: "api.port", port: 55_403, intent: "exact" }]); + + const stoppedAndChanged = await service.updateStack(running.stack.id, { + lifecycle: "stopped", + ports: [{ key: "api.port", port: 55_404, intent: "exact" }], + }); + expect(stoppedAndChanged).toMatchObject({ + lifecycle: "stopped", + ports: [{ key: "api.port", port: 55_404, intent: "exact" }], + }); + await service.close(); + }); + } + + it("keeps stopped sticky assignments soft and claims them only while starting", async () => { + const stickyContract = fixture("ports.sticky-ports-reuse-on-return"); + const collisionContract = fixture("ports.later-sticky-port-collision-fails"); + const stickyAssignment = portAssignmentFacts(stickyContract.id)[0]; + const collisionAssignment = portAssignmentFacts(collisionContract.id)[0]; + if (stickyAssignment === undefined || collisionAssignment === undefined) { + throw new Error("Sticky-port fixtures must provide persisted assignments"); + } + expect(stickyAssignment.port).toBe(collisionAssignment.port); + const root = makeRoot(); + const service = await makePersistentService(root); + const assignment = { + key: stickyAssignment.key, + port: stickyAssignment.port, + intent: stickyAssignment.intent, + }; + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + configuration: { ports: [assignment] }, + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + configuration: { ports: [assignment] }, + }); + + await service.updateStack(first.stack.id, { lifecycle: "starting" }); + await expect( + service.updateStack(second.stack.id, { lifecycle: "starting" }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(collisionContract.expected.outcome).toBe("error"); + + await service.updateStack(first.stack.id, { lifecycle: "stopped" }); + const startedSecond = await service.updateStack(second.stack.id, { lifecycle: "starting" }); + expect(startedSecond.ports).toEqual([assignment]); + expect(stickyContract.expected.outcome).toBe("reuse"); + await service.close(); + }); + + it("reports duplicate ports inside one stack as a managed reservation error", async () => { + const root = makeRoot(); + const service = await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + + await expect( + service.updateStack(created.stack.id, { + lifecycle: "starting", + ports: [ + { key: "api.port", port: 55_421, intent: "automatic" }, + { key: "db.port", port: 55_421, intent: "automatic" }, + ], + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); + await service.close(); + }); + + it("rejects a second operation claim without mutating the stack", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected the first operation claim to succeed"); + } + + await expect( + service.updateStack(created.stack.id, { lifecycle: "running" }), + ).rejects.toBeInstanceOf(ManagedOperationInProgressError); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("stopped"); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reports missing stacks and operation ownership mismatches with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + + await expect( + service.updateStack(crypto.randomUUID(), { lifecycle: "stopped" }), + ).rejects.toBeInstanceOf(ManagedStackNotFoundError); + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "update", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected the update operation to be claimed"); + } + + expect(() => + runRepo( + service.repository.finishOperation( + created.stack.id, + crypto.randomUUID(), + "completed", + "2026-08-11T00:00:01.000Z", + ), + ), + ).toThrow(ManagedOperationOwnershipError); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to resurrect a tombstoned stack with ${adapter}`, async () => { + const reserved = { key: "api.port", port: 55_461, intent: "exact" } as const; + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const deleted = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "deleted"), + configuration: { lifecycle: "running", ports: [reserved] }, + }); + await service.deleteStack(deleted.stack.id, { stop: async () => {} }); + + await expect( + service.updateStack(deleted.stack.id, { lifecycle: "running", ports: [reserved] }), + ).rejects.toBeInstanceOf(ManagedStackNotFoundError); + + expect(await service.inspectStack(deleted.stack.id)).toMatchObject({ + status: "tombstoned", + lifecycle: "stopped", + ports: [], + }); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + + const successor = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "successor"), + configuration: { lifecycle: "running", ports: [reserved] }, + }); + expect(successor.stack.ports).toEqual([reserved]); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + for (const runtime of ["running", "stopped", "unknown"] as const) { + it(`finishes a crashed delete without resurrecting its tombstone with ${adapter} (${runtime} runtime)`, async () => { + // A tombstoned row under a claimed operation is a delete that died + // between tombstoning and releasing its claim. Recovery must finish the + // deletion, never revive the row into a lifecycle — whatever the + // runtime inspection reports about the dead owner's processes, + // including nothing at all. + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_680, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), + ); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => runtime, + }); + + expect(reconciled.reclaimedStackIds).toEqual([created.stack.id]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.failures).toEqual([]); + expect(reconciled.retained).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + // The tombstone itself survives: idempotent deletion depends on it. + expect(await service.inspectStack(created.stack.id)).toMatchObject({ + status: "tombstoned", + lifecycle: "stopped", + ports: [], + }); + expect(existsSync(created.stack.paths.root)).toBe(false); + + const repeated = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => runtime, + }); + + expect(repeated).toEqual({ + recovered: [], + abortedStackIds: [], + reclaimedStackIds: [], + retained: [], + skippedOperationIds: [], + failures: [], + }); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); + await expect(service.deleteStack(created.stack.id)).resolves.toMatchObject({ + outcome: "no-op", + }); + await service.close(); + }); + } + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reclaims a crashed delete without consulting the runtime with ${adapter}`, async () => { + // Tombstoning zeroes the runtime metadata, so a real inspector can only + // ever answer "unknown" — or fail — about a crashed deletion. Gating the + // reclamation on an answer the tombstone destroyed would leak the + // directory forever, and the tombstoned branch ignores the lifecycle. + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_681, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), + ); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + throw new Error("runtime inspection is unavailable for a deleted stack"); + }, + }); + + expect(reconciled.reclaimedStackIds).toEqual([created.stack.id]); + expect(reconciled.retained).toEqual([]); + expect(reconciled.failures).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + expect(existsSync(created.stack.paths.root)).toBe(false); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reports a crashed delete as reclaimed only once its data is gone with ${adapter}`, async () => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const outsideRoot = join(root, "outside"); + mkdirSync(outsideRoot, { recursive: true }); + writeFileSync(join(outsideRoot, "preserve"), "safe"); + const registry = + adapter === "in-memory" ? undefined : await openRegistry(managedRegistryPath(stateRoot)); + const repository = registry?.repository ?? createInMemoryManagedStackRepository(); + let forgePath = false; + const guardedRepository: ManagedStackRepositoryShape = { + ...repository, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !forgePath + ? stack + : { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }, + ), + }; + const service = await makeManagedStackService({ + repository: guardedRepository, + stateRoot, + isProcessAlive: () => false, + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_682, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), + ); + forgePath = true; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + // Reporting the stack as reclaimed before the removal succeeded would tell + // the caller its leaked data is gone while it is still on disk. + expect(reconciled.reclaimedStackIds).toEqual([]); + expect(reconciled.failures).toEqual([ + { + operation: claimed.operation, + phase: "state-reclamation", + operationReleased: true, + error: expect.any(UnsafeManagedStackPathError), + }, + ]); + expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); + await service.close(); + await registry?.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`stores port assignments in one canonical key order with ${adapter}`, async () => { + // SQLite reads ports back with `ORDER BY key`, so the shared reconciler + // must hand both adapters the same order or a caller's request order + // would leak into one adapter's records and not the other's. + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const studio = { key: "studio.port", port: 55_501, intent: "exact" } as const; + const api = { key: "api.port", port: 55_502, intent: "exact" } as const; + const db = { key: "db.port", port: 55_503, intent: "exact" } as const; + const sorted = [api, db, studio]; + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { ports: [studio, api, db] }, + }); + + expect(created.stack.ports).toEqual(sorted); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual(sorted); + + const updated = await service.updateStack(created.stack.id, { ports: [db, studio, api] }); + + expect(updated.ports).toEqual(sorted); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual(sorted); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`breaks active-operation ordering ties by token with ${adapter}`, async () => { + // Recovery walks this list, so two claims sharing one `startedAt` must not + // depend on insertion order: SQLite would return rowid order and the + // in-memory adapter its map order. Descending tokens make insertion order + // the wrong answer. + const root = makeRoot(); + const overrides = { clock: () => new Date("2026-08-11T00:00:00.000Z") }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const nextToken = descendingIdFactory(); + const tokens: Array = []; + for (const name of ["first", "second", "third"]) { + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, name), + }); + const token = nextToken(); + const claimed = runRepo( + service.repository.claimOperation({ + token, + stackId: created.stack.id, + kind: "start", + ownerPid: 987_683, + now: "2026-08-11T00:00:00.000Z", + }), + ); + if (!claimed.acquired) { + throw new Error("Expected each recovery operation to be claimed"); + } + tokens.push(token); + } + + expect(tokens).toEqual([...tokens].sort().reverse()); + expect(runRepo(service.repository.listActiveOperations()).map(({ token }) => token)).toEqual( + [...tokens].sort(), + ); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to persist an unusable owner pid with ${adapter}`, async () => { + // The pid is only useful because recovery asks the operating system about + // it, and a value that is not a pid cannot be asked about safely. The + // repository is the boundary that must never store one. + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "claimed"), + }); + + for (const ownerPid of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => + runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid, + now: "2026-08-11T00:00:00.000Z", + }), + ), + ).toThrow(InvalidManagedOwnerPidError); + } + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + + await expect( + prepareAbandonedStack(service, makeWorkspace(root, "prepared"), 0), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + expect(await service.listStacks()).toHaveLength(1); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to reconfigure an unpublished pending stack with ${adapter}`, async () => { + // A pending row belongs to its publisher's provisioning flow. Letting a + // holder of the claim mutate its lifecycle would give a stack that no + // reader can see a port-occupying lease. + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), process.pid); + + expect(() => + runRepo( + service.repository.updateStack({ + stackId: pending.stack.id, + operationToken: pending.operation.token, + now: "2026-08-11T00:00:02.000Z", + lifecycle: "running", + }), + ), + ).toThrow(ManagedPendingStackUpdateError); + + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to delete a running stack without a stop path with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { lifecycle: "running" }, + }); + + await expect(service.deleteStack(created.stack.id)).rejects.toBeInstanceOf( + ManagedStackNotStoppedError, + ); + + expect(await service.inspectStack(created.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + await service.close(); + }); + } + + it("re-reads lifecycle after claiming delete before deciding whether to stop", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let promoteBeforeDelete = true; + const racingRepository: ManagedStackRepositoryShape = { + ...repository, + claimOperation: (input) => + Effect.suspend(() => { + if (input.kind === "delete" && promoteBeforeDelete) { + promoteBeforeDelete = false; + const start = runRepo( + repository.claimOperation({ + token: crypto.randomUUID(), + stackId: input.stackId, + kind: "start", + ownerPid: 123, + now: input.now, + }), + ); + if (!start.acquired) { + throw new Error("Expected the racing start operation to be claimed"); + } + runRepo( + repository.updateStack({ + stackId: input.stackId, + operationToken: start.operation.token, + lifecycle: "running", + now: input.now, + }), + ); + runRepo( + repository.finishOperation( + input.stackId, + start.operation.token, + "completed", + input.now, + ), + ); + } + return repository.claimOperation(input); + }), + }; + const service = await makeManagedStackService({ + repository: racingRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + let stoppedLifecycle: string | undefined; + + await service.deleteStack(created.stack.id, { + stop: async (stack) => { + stoppedLifecycle = stack.lifecycle; + }, + }); + + expect(stoppedLifecycle).toBe("running"); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); + }); + + it("treats a delete as successful when a concurrent forced recovery already resolved its operation", async () => { + // Data removal already happened by the time this call closes out the + // operation, so a concurrent forced recovery racing to resolve the same + // claim first must not turn an already-completed delete into a failure. + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + const racingRepository: ManagedStackRepositoryShape = { + ...repository, + finishOperation: (stackId, operationToken, outcome, now, error) => + outcome === "completed" + ? Effect.fail(new ManagedOperationOwnershipError({ stackId })) + : repository.finishOperation(stackId, operationToken, outcome, now, error), + }; + const service = await makeManagedStackService({ + repository: racingRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + + const deleted = await service.deleteStack(created.stack.id); + + expect(deleted).toMatchObject({ + outcome: "delete", + dataReclamation: { outcome: "removed" }, + }); + expect(existsSync(created.stack.paths.root)).toBe(false); + await service.close(); + }); + + it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { + const contract = fixture("reclamation.delete-repeat-is-idempotent"); + const root = makeRoot(); + const service = await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { lifecycle: "running" }, + }); + writeFileSync(join(created.stack.paths.data, "database"), "owned data"); + let stoppedStackId: string | undefined; + + const deleted = await service.deleteStack(created.stack.id, { + stop: async (stack) => { + stoppedStackId = stack.id; + }, + }); + mkdirSync(created.stack.paths.data, { recursive: true }); + writeFileSync(join(created.stack.paths.data, "orphaned-after-delete"), "retry removal"); + const repeated = await service.deleteStack(created.stack.id); + + expect(deleted.outcome).toBe("delete"); + expect(deleted.dataReclamation).toEqual({ outcome: "removed" }); + expect(stoppedStackId).toBe(created.stack.id); + expect(existsSync(created.stack.paths.root)).toBe(false); + expect(repeated.outcome).toBe(contract.expected.outcome); + expect(repeated.dataReclamation).toEqual({ outcome: "removed" }); + expect(await service.listStacks()).toEqual([]); + expect(await service.listStacks({ includeTombstoned: true })).toHaveLength(1); + await service.close(); + }); + + it("reports unsafe tombstone data as retained without deleting it", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let forgePath = false; + const outsideRoot = join(root, "outside"); + mkdirSync(outsideRoot); + writeFileSync(join(outsideRoot, "preserve"), "safe"); + const guardedRepository: ManagedStackRepositoryShape = { + ...repository, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !forgePath + ? stack + : { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }, + ), + }; + const service = await makeManagedStackService({ + repository: guardedRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + await service.deleteStack(created.stack.id); + forgePath = true; + + const repeated = await service.deleteStack(created.stack.id); + + expect(repeated).toMatchObject({ + outcome: "no-op", + dataReclamation: { + outcome: "retained", + error: expect.any(UnsafeManagedStackPathError), + }, + }); + expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); + }); + + it("prunes checkout location metadata without touching stack data", async () => { + const contract = fixture("reclamation.prune-removes-metadata-only"); + const root = makeRoot(); + const service = await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const dataFile = join(created.stack.paths.data, "database"); + writeFileSync(dataFile, "preserve me"); + + const pruned = await service.pruneCheckoutLocations(() => true); + + expect(contract.expected.outcome).toBe("update"); + expect(pruned).toBe(1); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); + expect((await service.inspectStack(created.stack.id))?.status).toBe("active"); + expect(readFileSync(dataFile, "utf8")).toBe("preserve me"); + await service.close(); + }); + + it("persists and reuses managed state through the real Node SQLite adapter", async () => { + const root = makeRoot(); + const stateRoot = join(root, "node-managed"); + const workspace = makeWorkspace(root, "node-workspace"); + // The Node entrypoint is exercised end to end, `node:sqlite` driver and all: + // it is the only place the Node registry adapter and its service wiring run. + const entrypointUrl = pathToFileURL(join(process.cwd(), "src/managed-node.ts")).href; + const source = ` + import assert from "node:assert/strict"; + import { randomUUID } from "node:crypto"; + import { Effect } from "effect"; + import { createManagedStackService } from ${JSON.stringify(entrypointUrl)}; + const runRepo = Effect.runSync; + const stateRoot = ${JSON.stringify(stateRoot)}; + const workspacePath = ${JSON.stringify(workspace)}; + const firstService = await createManagedStackService({ stateRoot }); + assert.equal(runRepo(firstService.repository.getStack(randomUUID())), undefined); + const first = await firstService.provisionOrdinaryStack({ + workspacePath, + configuration: { + ports: [{ key: "api.port", port: 55431, intent: "exact" }], + }, + }); + const starting = await firstService.updateStack(first.stack.id, { lifecycle: "starting" }); + assert.equal(starting.ports[0]?.port, 55431); + await firstService.updateStack(first.stack.id, { lifecycle: "stopped" }); + const abandoned = runRepo(firstService.repository.claimOperation({ + token: randomUUID(), + stackId: first.stack.id, + kind: "start", + now: new Date().toISOString(), + })); + assert.equal(abandoned.acquired, true); + const recovery = await firstService.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + assert.equal(recovery.recovered.length, 1); + assert.equal(recovery.failures.length, 0); + await firstService.close(); + const secondService = await createManagedStackService({ stateRoot }); + const second = await secondService.provisionOrdinaryStack({ workspacePath }); + assert.equal(first.outcome, "create"); + assert.equal(second.outcome, "reuse"); + assert.equal(second.stack.id, first.stack.id); + const conflicting = runRepo(secondService.repository.claimOperation({ + token: randomUUID(), + stackId: second.stack.id, + kind: "update", + ownerPid: process.pid, + now: new Date().toISOString(), + })); + assert.equal(conflicting.acquired, true); + await assert.rejects( + secondService.updateStack(second.stack.id, { lifecycle: "running" }), + { name: "ManagedOperationInProgressError" }, + ); + if (!conflicting.acquired) throw new Error("Expected operation ownership"); + runRepo(secondService.repository.finishOperation( + second.stack.id, + conflicting.operation.token, + "completed", + new Date().toISOString(), + )); + const deleted = await secondService.deleteStack(second.stack.id); + const repeated = await secondService.deleteStack(second.stack.id); + assert.equal(deleted.outcome, "delete"); + assert.equal(deleted.dataReclamation.outcome, "removed"); + assert.equal(repeated.outcome, "no-op"); + await secondService.close(); + `; + const command = [ + findNodeBinary(), + "--no-warnings", + "--experimental-transform-types", + "--input-type=module", + "--eval", + source, + ]; + const child = Bun.spawn(command, { + stdout: "ignore", + stderr: "pipe", + }); + + const exitCode = await child.exited; + const stderr = await new Response(child.stderr).text(); + + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); + }); + + it("initializes one fresh registry safely across concurrent Bun processes", async () => { + const root = makeRoot(); + const databasePath = managedRegistryPath(join(root, "cold")); + const entrypointUrl = pathToFileURL(join(process.cwd(), "src/managed-bun.ts")).href; + const source = ` + import { Context, Effect, ManagedRuntime } from "effect"; + import { + bunSqliteManagedStackRepositoryLayer, + ManagedStackRepository, + } from ${JSON.stringify(entrypointUrl)}; + const layer = bunSqliteManagedStackRepositoryLayer(${JSON.stringify(databasePath)}); + const runtime = ManagedRuntime.make(layer); + const context = await runtime.context(); + Effect.runSync(Context.get(context, ManagedStackRepository).listStacks()); + await runtime.dispose(); + `; + const children = Array.from({ length: 8 }, () => + Bun.spawn([process.execPath, "--eval", source], { stdout: "ignore", stderr: "pipe" }), + ); + + const results = await Promise.all( + children.map(async (child) => ({ + exitCode: await child.exited, + stderr: await new Response(child.stderr).text(), + })), + ); + + expect(results).toEqual(Array.from({ length: 8 }, () => ({ exitCode: 0, stderr: "" }))); + const registry = await openRegistry(databasePath); + expect(runRepo(registry.repository.listStacks())).toEqual([]); + await registry.close(); + }); + + it("fails safely when a registry has a newer schema version", async () => { + const root = makeRoot(); + const databasePath = join(root, "future.sqlite3"); + const database = new Database(databasePath, { create: true }); + database.exec("PRAGMA user_version = 999"); + database.close(); + + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }); + + it("refuses the production entrypoint over a registry written by a newer CLI", async () => { + // The one registry failure a caller can act on has to survive the whole + // production path — layer, runtime, facade — as itself, so an embedder can + // tell "upgrade your CLI" apart from a bug in this one. + const root = makeRoot(); + const stateRoot = join(root, "managed"); + mkdirSync(stateRoot, { recursive: true }); + const database = new Database(managedRegistryPath(stateRoot), { create: true }); + database.exec("PRAGMA user_version = 999"); + database.close(); + + await expect(createManagedStackService({ stateRoot })).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }); + + it.each([1, 2])( + "fails clearly instead of opening obsolete development schema v%i", + async (version) => { + const root = makeRoot(); + const databasePath = join(root, `obsolete-v${version}.sqlite3`); + const database = new Database(databasePath, { create: true }); + database.exec(`PRAGMA user_version = ${version}`); + database.close(); + + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }, + ); + + it("keeps registry transactions atomic while concurrent fibers share one handle", async () => { + // A registry decision is a transaction on a single connection, so its + // `BEGIN`, statements, and `COMMIT` must run without a suspension point + // between them: a fiber parked mid-transaction would let another fiber's + // `BEGIN IMMEDIATE` nest on the same handle, and either fiber's `COMMIT` + // could then publish the other's writes. Each fiber runs far more + // sequential decisions than the scheduler's operation budget, so it is + // preempted many times over the course of the pass. + const root = makeRoot(); + const registry = await openRegistry(managedRegistryPath(join(root, "concurrent"))); + const rounds = Array.from({ length: 2_000 }, (_, index) => index); + const hammerRegistry = Effect.forEach( + rounds, + () => + // A read transaction and a write transaction, so neither boundary is + // covered by the other's locking. + Effect.flatMap(registry.repository.listStacks(), () => + registry.repository.pruneCheckoutLocations([]), + ), + { discard: true }, + ); + + const exit = await Effect.runPromiseExit( + Effect.all([hammerRegistry, hammerRegistry, hammerRegistry, hammerRegistry], { + concurrency: "unbounded", + }), + ); + + expect(Exit.isSuccess(exit) ? "committed" : Cause.pretty(exit.cause)).toBe("committed"); + await registry.close(); + }); + + it("refuses a registry decision that re-enters the repository, keeping its own writes", async () => { + // SQLite has no nested transactions, so a decision that calls back into the + // repository can only lose: the inner `BEGIN` is refused, and unwinding the + // inner attempt would roll back the writes the outer decision has already + // made. The guard therefore refuses before any statement runs. + const root = makeRoot(); + const workspace = makeWorkspace(root); + const identity = (await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace))).identity; + const sqlite = reentrantRegistry(); + const runtime = ManagedRuntime.make(sqliteManagedStackRepositoryLayer(() => sqlite.handle)); + const repository = Context.get(await runtime.context(), ManagedStackRepository); + + let nested: Exit.Exit> | undefined; + sqlite.reenterOnce(() => { + nested = Effect.runSyncExit(repository.listStacks()); + }); + + const stackId = crypto.randomUUID(); + const prepared = runRepo( + repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(join(root, "managed"), stackId), + operationToken: crypto.randomUUID(), + now: "2026-08-11T00:00:00.000Z", + configuration: {}, + }), + ); + + if (nested === undefined || !Exit.isFailure(nested)) { + throw new Error("Expected the nested decision to be refused"); + } + expect(Cause.pretty(nested.cause)).toContain("A registry transaction is already open"); + expect(prepared.outcome).toBe("create"); + // The refusal never touched the transaction in flight, so the outer + // decision committed and the handle is free for the next one. + expect(runRepo(repository.listStacks()).map((stack) => stack.id)).toEqual([stackId]); + await runtime.dispose(); + }); + + it("writes the current schema version into a fresh registry", async () => { + const root = makeRoot(); + const databasePath = managedRegistryPath(join(root, "fresh")); + await (await openRegistry(databasePath)).close(); + + const database = new Database(databasePath, { readonly: true }); + expect(database.query("PRAGMA user_version").get()).toEqual({ + user_version: MANAGED_REGISTRY_SCHEMA_VERSION, + }); + database.close(); + expect(databasePath.endsWith("registry-v3.sqlite3")).toBe(true); + }); +}); diff --git a/packages/stack/src/managed-stack-contract-validation.ts b/packages/stack/src/managed-stack-contract-validation.ts new file mode 100644 index 0000000000..3fd4c7283e --- /dev/null +++ b/packages/stack/src/managed-stack-contract-validation.ts @@ -0,0 +1,506 @@ +import { isDeepStrictEqual } from "node:util"; +import type { + ManagedStackContractFact, + ManagedStackContractJson, + ManagedStackContractScenario, +} from "./managed-stack-contract.ts"; + +const factPrimaryId = (fact: ManagedStackContractFact): string | undefined => { + switch (fact.kind) { + case "checkout": + return fact.checkoutId; + case "credential-state": + return fact.valuesId; + case "direct-stack-state": + return fact.handle; + case "identity-claim": + return `${fact.scope}:${fact.id}`; + case "identity-marker": + return fact.markerId; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + case "stack": + return fact.stackId; + case "port-assignment": + return `${fact.stackId}:${fact.key}`; + default: + return undefined; + } +}; + +const containsNonFiniteNumber = (value: unknown): boolean => { + if (value === undefined) { + return false; + } + if (typeof value === "number") { + return !Number.isFinite(value); + } + if (Array.isArray(value)) { + return value.some(containsNonFiniteNumber); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some(containsNonFiniteNumber); + } + return false; +}; + +const snakeToCamel = (key: string): string => + key.replace(/_([a-z0-9])/g, (_match, character: string) => character.toUpperCase()); + +export const validateManagedStackContractFixtures = ( + fixtures: ReadonlyArray, +): ReadonlyArray => { + const errors: Array = []; + const knownScenarioIds = new Set(fixtures.map((scenario) => scenario.id)); + const scenarioIds = new Set(); + + for (const scenario of fixtures) { + if (scenarioIds.has(scenario.id)) { + errors.push(`${scenario.id}: duplicate scenario ID`); + } + scenarioIds.add(scenario.id); + + if (!scenario.id.startsWith(`${scenario.area}.`)) { + errors.push(`${scenario.id}: ID must start with ${scenario.area}.`); + } + if (scenario.title.trim().length === 0) { + errors.push(`${scenario.id}: title is required`); + } + if (scenario.given.length === 0) { + errors.push(`${scenario.id}: at least one given fact is required`); + } + if (containsNonFiniteNumber(scenario.given)) { + errors.push(`${scenario.id}: given facts contain a non-finite number`); + } + + if (scenario.when.interface === "cli" || scenario.when.interface === "git") { + if (scenario.when.argv.length === 0 || scenario.when.argv[0]?.trim().length === 0) { + errors.push(`${scenario.id}: argv must start with a public command`); + } + if (scenario.when.cwd.trim().length === 0) { + errors.push(`${scenario.id}: cwd is required for command scenarios`); + } + const givenPaths = scenario.given.flatMap((fact) => + fact.kind === "workspace" || fact.kind === "checkout" ? [fact.path] : [], + ); + if (givenPaths.length > 0 && !givenPaths.includes(scenario.when.cwd)) { + errors.push( + `${scenario.id}: cwd ${scenario.when.cwd} does not match a given workspace or checkout path`, + ); + } + } else { + if (scenario.when.method.trim().length === 0) { + errors.push(`${scenario.id}: public API method is required`); + } + const referencedScenarioId = scenario.when.input.scenarioId; + if ( + referencedScenarioId !== undefined && + (typeof referencedScenarioId !== "string" || !knownScenarioIds.has(referencedScenarioId)) + ) { + errors.push(`${scenario.id}: references unknown scenario ID ${referencedScenarioId}`); + } + if (containsNonFiniteNumber(scenario.when.input)) { + errors.push(`${scenario.id}: public API input contains a non-finite number`); + } + } + + const { output } = scenario.expected; + if (scenario.when.interface === "cli") { + const cliArgv = scenario.when.argv; + const jsonRequested = cliArgv.some( + (argument, index) => + argument === "--output-format=json" || + (argument === "--output-format" && cliArgv[index + 1] === "json"), + ); + if (jsonRequested && output.json === undefined) { + errors.push(`${scenario.id}: JSON CLI invocation requires a JSON projection`); + } else if (!jsonRequested && output.human === undefined) { + errors.push(`${scenario.id}: default CLI invocation requires a human projection`); + } + } + const returnsVoid = + scenario.when.interface === "stack-api" && scenario.when.method === "dispose"; + if ( + !returnsVoid && + output.human === undefined && + output.json === undefined && + output.api === undefined + ) { + errors.push(`${scenario.id}: at least one observable output is required`); + } + if (output.human !== undefined && output.human.summary.trim().length === 0) { + errors.push(`${scenario.id}: human summary is required`); + } + for (const key of Object.keys(output.json ?? {})) { + if (!/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(key)) { + errors.push(`${scenario.id}: JSON projection key ${key} must use snake_case`); + } + } + for (const key of Object.keys(output.api ?? {})) { + if (key.includes("_")) { + errors.push(`${scenario.id}: API projection key ${key} must not use snake_case`); + } + } + const jsonValues: ReadonlyArray< + readonly [label: string, value: ManagedStackContractJson | undefined] + > = [ + ["managed detail data", scenario.expected.details], + ["JSON projection", output.json], + ["API projection", output.api], + ]; + for (const [label, value] of jsonValues) { + if (containsNonFiniteNumber(value)) { + errors.push(`${scenario.id}: ${label} contains a non-finite number`); + } + } + + if (scenario.expected.outcome === "error") { + if (scenario.expected.error === undefined) { + errors.push(`${scenario.id}: error outcome requires structured error metadata`); + } else if (scenario.expected.error.recovery.length === 0) { + errors.push(`${scenario.id}: error outcome requires recovery guidance`); + } + } else if (scenario.expected.error !== undefined) { + errors.push(`${scenario.id}: non-error outcome cannot include error metadata`); + } + + if (scenario.expected.warning !== undefined) { + if (scenario.expected.outcome === "error") { + errors.push(`${scenario.id}: error outcome cannot also include warning metadata`); + } + if (scenario.expected.warning.recovery.length === 0) { + errors.push(`${scenario.id}: warning metadata requires recovery guidance`); + } + } + + for (const diagnostic of [scenario.expected.error, scenario.expected.warning]) { + if (diagnostic === undefined) { + continue; + } + if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(diagnostic.code)) { + errors.push( + `${scenario.id}: diagnostic code ${diagnostic.code} must use SCREAMING_SNAKE_CASE`, + ); + } + if (diagnostic.message.trim().length === 0) { + errors.push(`${scenario.id}: diagnostic message is required`); + } + if (diagnostic.recovery.some((step) => step.trim().length === 0)) { + errors.push(`${scenario.id}: diagnostic recovery steps must not be blank`); + } + } + + const hasMutation = + scenario.expected.writes.length > 0 || scenario.expected.runtimeEffects.length > 0; + if ( + (scenario.expected.outcome === "report" || scenario.expected.outcome === "no-op") && + hasMutation + ) { + errors.push(`${scenario.id}: ${scenario.expected.outcome} outcome must not mutate state`); + } + if (scenario.expected.outcome === "error" && hasMutation) { + const isBootstrapRollback = scenario.expected.error?.code === "LEGACY_BOOTSTRAP_FAILED"; + const containsOnlyRollbackCleanup = + scenario.expected.writes.every( + (write) => write.target === "managed-state" && write.operation === "delete", + ) && scenario.expected.runtimeEffects.every((effect) => effect.operation === "delete"); + if (!isBootstrapRollback || !containsOnlyRollbackCleanup) { + errors.push(`${scenario.id}: error outcome must not mutate state outside rollback cleanup`); + } + } + + const declaredIds = new Set(); + const factsByPrimaryId = new Map(); + const declareId = (id: string): void => { + if (id.trim().length === 0) { + errors.push(`${scenario.id}: declared ID is required`); + } else { + declaredIds.add(id); + } + }; + for (const fact of scenario.given) { + const primaryId = factPrimaryId(fact); + if (primaryId !== undefined) { + const factKey = `${fact.kind}:${primaryId}`; + const previousFact = factsByPrimaryId.get(factKey); + if (previousFact !== undefined && !isDeepStrictEqual(previousFact, fact)) { + errors.push(`${scenario.id}: conflicting ${fact.kind} facts for ID ${primaryId}`); + } else if (previousFact === undefined) { + factsByPrimaryId.set(factKey, fact); + } + } + + switch (fact.kind) { + case "branch": + declareId(fact.contextId); + break; + case "checkout": + declareId(fact.projectId); + declareId(fact.checkoutId); + break; + case "credential-state": + declareId(fact.valuesId); + if (fact.previousValuesId !== undefined) { + declareId(fact.previousValuesId); + } + break; + case "direct-stack-state": + declareId(fact.handle); + for (const root of fact.temporaryRoots) { + declareId(root.stateId); + } + break; + case "identity-claim": + declareId(fact.id); + break; + case "identity-marker": + declareId(fact.markerId); + declareId(fact.projectId); + declareId(fact.checkoutId); + declareId(fact.contextId); + break; + case "managed-record": + case "managed-target": + case "operation-result": + case "persisted-runtime": + declareId(fact.stackId); + break; + case "occupied-port": + if (fact.ownerId !== undefined) { + declareId(fact.ownerId); + } + break; + case "port-assignment": + declareId(fact.stackId); + break; + case "stack": + declareId(fact.checkoutId); + declareId(fact.contextId); + declareId(fact.stackId); + break; + default: + break; + } + } + + for (const write of scenario.expected.writes) { + if (write.id.trim().length === 0) { + errors.push(`${scenario.id}: write ID is required`); + continue; + } + if ( + write.operation === "copy" || + write.operation === "create" || + write.operation === "publish" + ) { + declareId(write.id); + } + + if (write.target === "identity-marker") { + declareId(write.projectId); + declareId(write.checkoutId); + declareId(write.contextId); + } + } + for (const write of scenario.expected.writes) { + if ( + write.operation !== "copy" && + write.operation !== "create" && + write.operation !== "publish" && + !declaredIds.has(write.id) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} references undeclared ID ${write.id}`, + ); + } + } + + const selection = scenario.expected.selection; + if (selection !== undefined) { + for (const id of [ + selection.projectId, + selection.checkoutId, + selection.contextId, + selection.stackId, + ]) { + if (!declaredIds.has(id)) { + errors.push(`${scenario.id}: selection references undeclared ID ${id}`); + } + } + const selectedStackFact = factsByPrimaryId.get(`stack:${selection.stackId}`); + if (selectedStackFact?.kind === "stack" && selectedStackFact.name !== selection.stackName) { + errors.push( + `${scenario.id}: selected stack name ${selection.stackName} disagrees with stack ${selection.stackId}`, + ); + } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.contextId !== selection.contextId + ) { + errors.push( + `${scenario.id}: selected context ${selection.contextId} disagrees with stack ${selection.stackId}`, + ); + } + if ( + selectedStackFact?.kind === "stack" && + selectedStackFact.checkoutId !== selection.checkoutId + ) { + errors.push( + `${scenario.id}: selected checkout ${selection.checkoutId} disagrees with stack ${selection.stackId}`, + ); + } + } + + for (const effect of scenario.expected.runtimeEffects) { + if (effect.stackId.trim().length === 0) { + errors.push(`${scenario.id}: runtime effect stack ID is required`); + continue; + } + if (!declaredIds.has(effect.stackId)) { + errors.push(`${scenario.id}: runtime effect references undeclared ID ${effect.stackId}`); + } + + const hasWrite = scenario.expected.writes.some((write) => { + if (write.id !== effect.stackId) { + return false; + } + switch (effect.operation) { + case "copy": + return write.target === "managed-state" && write.operation === "copy"; + case "delete": + return write.target === "managed-state" && write.operation === "delete"; + case "start": + return write.target === "runtime-state" && write.operation === "start"; + case "stop": + return ( + write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ); + } + }); + if (!hasWrite) { + errors.push( + `${scenario.id}: ${effect.operation} runtime effect requires a matching state write`, + ); + } + } + + for (const write of scenario.expected.writes) { + const requiredRuntimeOperation = + write.target === "runtime-state" && write.operation === "start" + ? "start" + : write.target === "runtime-state" && + (write.operation === "delete" || write.operation === "update") + ? "stop" + : write.target === "managed-state" && write.operation === "copy" + ? "copy" + : write.target === "managed-state" && write.operation === "delete" + ? "delete" + : undefined; + if ( + requiredRuntimeOperation !== undefined && + !scenario.expected.runtimeEffects.some( + (effect) => effect.operation === requiredRuntimeOperation && effect.stackId === write.id, + ) + ) { + errors.push( + `${scenario.id}: ${write.target} ${write.operation} requires a matching runtime effect`, + ); + } + } + + const checkProjection = ( + projection: Readonly> | undefined, + key: string, + expected: ManagedStackContractJson, + ): void => { + if (projection?.[key] !== undefined && !isDeepStrictEqual(projection[key], expected)) { + errors.push(`${scenario.id}: projected ${key} disagrees with the managed result`); + } + }; + + if (output.json !== undefined && output.json.outcome === undefined) { + errors.push(`${scenario.id}: JSON projection requires an outcome`); + } + const diagnosticCode = scenario.expected.error?.code ?? scenario.expected.warning?.code; + if ( + output.json !== undefined && + diagnosticCode !== undefined && + output.json.code === undefined + ) { + errors.push(`${scenario.id}: JSON projection requires a code`); + } + if (output.api !== undefined && diagnosticCode !== undefined && output.api.code === undefined) { + errors.push(`${scenario.id}: API projection requires a code`); + } + for (const projection of [output.json, output.api]) { + checkProjection(projection, "outcome", scenario.expected.outcome); + if (diagnosticCode !== undefined) { + checkProjection(projection, "code", diagnosticCode); + } + } + for (const [key, value] of Object.entries(scenario.expected.details ?? {})) { + checkProjection(output.json, key, value); + checkProjection(output.api, key, value); + const apiKey = snakeToCamel(key); + if (apiKey !== key) { + checkProjection(output.api, apiKey, value); + } + } + + if (selection !== undefined) { + checkProjection(output.json, "project_id", selection.projectId); + checkProjection(output.json, "checkout_id", selection.checkoutId); + checkProjection(output.json, "context_id", selection.contextId); + checkProjection(output.json, "stack_id", selection.stackId); + checkProjection(output.json, "stack_name", selection.stackName); + checkProjection(output.api, "projectId", selection.projectId); + checkProjection(output.api, "checkoutId", selection.checkoutId); + checkProjection(output.api, "contextId", selection.contextId); + checkProjection(output.api, "stackId", selection.stackId); + checkProjection(output.api, "stackName", selection.stackName); + checkProjection(output.human?.fields, "projectId", selection.projectId); + checkProjection(output.human?.fields, "checkoutId", selection.checkoutId); + checkProjection(output.human?.fields, "contextId", selection.contextId); + checkProjection(output.human?.fields, "stackId", selection.stackId); + checkProjection(output.human?.fields, "stack", selection.stackName); + checkProjection(output.human?.fields, "stackName", selection.stackName); + } + + const expectedRecovery = + scenario.expected.error?.recovery ?? scenario.expected.warning?.recovery; + if ( + output.human !== undefined && + expectedRecovery !== undefined && + (output.human.recovery === undefined || + output.human.recovery.length !== expectedRecovery.length || + output.human.recovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: human recovery disagrees with the managed result`); + } + const jsonRecovery = output.json?.recovery; + if ( + output.json !== undefined && + expectedRecovery !== undefined && + (!Array.isArray(jsonRecovery) || + jsonRecovery.length !== expectedRecovery.length || + jsonRecovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: JSON recovery disagrees with the managed result`); + } + const apiRecovery = output.api?.recovery; + if ( + output.api !== undefined && + expectedRecovery !== undefined && + (!Array.isArray(apiRecovery) || + apiRecovery.length !== expectedRecovery.length || + apiRecovery.some((step, index) => step !== expectedRecovery[index])) + ) { + errors.push(`${scenario.id}: API recovery disagrees with the managed result`); + } + } + + return errors; +}; diff --git a/packages/stack/src/managed-stack-contract.integration.test.ts b/packages/stack/src/managed-stack-contract.integration.test.ts new file mode 100644 index 0000000000..8013009334 --- /dev/null +++ b/packages/stack/src/managed-stack-contract.integration.test.ts @@ -0,0 +1,1401 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createStack } from "./node.ts"; +import { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, + managedNativeServiceMatrix, + managedStackContractFixtures, + type ManagedStackContractScenario, + validateManagedStackContractFixtures, +} from "./testing.ts"; +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; + +const { createdTempRoots } = vi.hoisted(() => ({ createdTempRoots: new Array() })); + +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + mkdtempSync(prefix: string) { + const root = fs.mkdtempSync(prefix); + createdTempRoots.push(root); + return root; + }, + }; +}); + +const projectDirectStackHandle = (stack: { readonly url: string; readonly dbUrl: string }) => ({ + url: stack.url.replace(/:\d+$/, ":"), + dbUrl: stack.dbUrl.replace(/:\d+\//, ":/"), +}); + +const snapshotDirectoryTree = (root: string): ReadonlyArray => { + const paths: Array = []; + const visit = (directory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relativePath = join(relativeDirectory, entry.name); + paths.push(entry.isDirectory() ? `${relativePath}/` : relativePath); + if (entry.isDirectory()) { + visit(join(directory, entry.name), relativePath); + } + } + }; + + visit(root, ""); + return paths.sort(); +}; + +describe("managed stack acceptance contract", () => { + it("keeps every shared scenario readable and executable through a public interface", () => { + expect(validateManagedStackContractFixtures(managedStackContractFixtures)).toEqual([]); + }); + + it("lints structural, referential, effect, and projection mistakes", () => { + const findScenario = (id: string): ManagedStackContractScenario => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + (candidate) => candidate.id === id, + ); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + + const reuse = findScenario("identity.return-to-branch-reuses-stack"); + const portConflict = findScenario("ports.explicit-port-conflict-fails"); + const readOnly = findScenario("identity.branch-copy-read-only-does-not-write"); + const noOp = findScenario("reclamation.delete-repeat-is-idempotent"); + const freshBootstrap = findScenario("bootstrap.absent-legacy-starts-fresh"); + const failedBootstrap = findScenario("bootstrap.failed-copy-rolls-back"); + const repositoryContract = findScenario("api-boundary.repository-contract-is-storage-agnostic"); + const persistedRuntime = findScenario("runtime.persisted-runtime-reused-for-auto"); + const defaultOutputCli = findScenario("identity.non-git-folder-first-start-persists-identity"); + const jsonOutputCli = findScenario("identity.read-only-unregistered-checkout-does-not-write"); + const repositoryAction = repositoryContract.when; + const reusedStack = reuse.given.find((fact) => fact.kind === "stack"); + if ( + reuse.expected.selection === undefined || + reuse.when.interface !== "cli" || + reuse.expected.output.json === undefined || + reuse.expected.output.human === undefined || + reusedStack === undefined || + portConflict.expected.error === undefined || + portConflict.expected.output.json === undefined || + freshBootstrap.expected.details === undefined || + freshBootstrap.expected.output.json === undefined || + failedBootstrap.expected.error === undefined || + failedBootstrap.expected.output.api === undefined || + jsonOutputCli.when.interface !== "cli" || + repositoryAction.interface !== "managed-api" + ) { + throw new Error("lint examples require selected and structured fixture outputs"); + } + + const cases: ReadonlyArray<{ + readonly fixtures: ReadonlyArray; + readonly expectedError: string | ReadonlyArray; + }> = [ + { + fixtures: [ + { + ...reuse, + expected: { ...reuse.expected, writes: [] }, + }, + ], + expectedError: `${reuse.id}: start runtime effect requires a matching state write`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackId: "stack-undeclared" }, + }, + }, + ], + expectedError: `${reuse.id}: selection references undeclared ID stack-undeclared`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, outcome: "create" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: projected outcome disagrees with the managed result`, + }, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { ...portConflict.expected.error, code: "exact_port_occupied" }, + output: { + ...portConflict.expected.output, + json: { ...portConflict.expected.output.json, code: "exact_port_occupied" }, + }, + }, + }, + ], + expectedError: `${portConflict.id}: diagnostic code exact_port_occupied must use SCREAMING_SNAKE_CASE`, + }, + { + fixtures: [ + { + ...failedBootstrap, + expected: { + ...failedBootstrap.expected, + output: { + ...failedBootstrap.expected.output, + api: { outcome: "error" }, + }, + }, + }, + ], + expectedError: [ + `${failedBootstrap.id}: API projection requires a code`, + `${failedBootstrap.id}: API recovery disagrees with the managed result`, + ], + }, + { + fixtures: [ + { + ...readOnly, + expected: { + ...readOnly.expected, + writes: [{ target: "registry", operation: "update", id: "context-main" }], + }, + }, + ], + expectedError: `${readOnly.id}: report outcome must not mutate state`, + }, + { + fixtures: [reuse, reuse], + expectedError: `${reuse.id}: duplicate scenario ID`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + writes: [ + ...reuse.expected.writes, + { target: "registry", operation: "publish", id: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: write ID is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + runtimeEffects: [ + ...reuse.expected.runtimeEffects, + { operation: "start", stackId: "" }, + ], + }, + }, + ], + expectedError: `${reuse.id}: runtime effect stack ID is required`, + }, + { + fixtures: [ + { + ...reuse, + given: reuse.given.map((fact) => + fact.kind === "checkout" ? { ...fact, projectId: "" } : fact, + ), + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, projectId: "" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, project_id: "" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: declared ID is required`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + output: { + ...freshBootstrap.expected.output, + json: { + ...freshBootstrap.expected.output.json, + legacy_state_mutated: { value: true }, + }, + }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: projected legacy_state_mutated disagrees with the managed result`, + }, + { + fixtures: [ + { + ...portConflict, + expected: { + ...portConflict.expected, + error: { + ...portConflict.expected.error, + message: " ", + recovery: [" "], + }, + }, + }, + ], + expectedError: [ + `${portConflict.id}: diagnostic message is required`, + `${portConflict.id}: diagnostic recovery steps must not be blank`, + ], + }, + { + fixtures: [ + { + ...noOp, + expected: { + ...noOp.expected, + writes: [{ target: "registry", operation: "update", id: "stack-orphan" }], + }, + }, + ], + expectedError: `${noOp.id}: no-op outcome must not mutate state`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, stackName: "review" }, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stack_name: "review" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected stack name review disagrees with stack stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + given: [...reuse.given, { ...reusedStack, lifecycle: "running" }], + }, + ], + expectedError: `${reuse.id}: conflicting stack facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + human: { ...reuse.expected.output.human, summary: " " }, + }, + }, + }, + ], + expectedError: `${reuse.id}: human summary is required`, + }, + { + fixtures: [ + { + ...reuse, + expected: { + ...reuse.expected, + output: { + ...reuse.expected.output, + json: { ...reuse.expected.output.json, stackName: "default" }, + api: { stack_id: "stack-main-default" }, + }, + }, + }, + ], + expectedError: [ + `${reuse.id}: JSON projection key stackName must use snake_case`, + `${reuse.id}: API projection key stack_id must not use snake_case`, + ], + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, cwd: "another-checkout" } }], + expectedError: `${reuse.id}: cwd another-checkout does not match a given workspace or checkout path`, + }, + { + fixtures: [ + { + ...reuse, + given: [ + ...reuse.given, + { + kind: "checkout", + path: "checkout-b", + projectId: "project-a", + checkoutId: "checkout-b", + }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, checkoutId: "checkout-b" }, + }, + }, + ], + expectedError: `${reuse.id}: selected checkout checkout-b disagrees with stack stack-main-default`, + }, + { + fixtures: managedStackContractFixtures.map((scenario) => + scenario.id === repositoryContract.id + ? { + ...repositoryContract, + when: { + ...repositoryAction, + input: { + ...repositoryAction.input, + scenarioId: "identity.missing-contract-scenario", + }, + }, + } + : scenario, + ), + expectedError: `${repositoryContract.id}: references unknown scenario ID identity.missing-contract-scenario`, + }, + { + fixtures: [{ ...reuse, when: { ...reuse.when, argv: [" "] } }], + expectedError: `${reuse.id}: argv must start with a public command`, + }, + { + fixtures: [ + { + ...persistedRuntime, + given: [ + ...persistedRuntime.given, + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + ], + }, + ], + expectedError: `${persistedRuntime.id}: conflicting persisted-runtime facts for ID stack-main-default`, + }, + { + fixtures: [ + { + ...freshBootstrap, + given: [ + ...freshBootstrap.given, + { + kind: "concurrent-operation", + operation: "create-stack", + target: "stack-main-default", + contenders: Number.POSITIVE_INFINITY, + }, + ], + }, + ], + expectedError: `${freshBootstrap.id}: given facts contain a non-finite number`, + }, + { + fixtures: [ + { + ...freshBootstrap, + expected: { + ...freshBootstrap.expected, + details: { ...freshBootstrap.expected.details, invalid_number: Number.NaN }, + }, + }, + ], + expectedError: `${freshBootstrap.id}: managed detail data contains a non-finite number`, + }, + { + fixtures: [ + { + ...defaultOutputCli, + expected: { + ...defaultOutputCli.expected, + output: { ...defaultOutputCli.expected.output, human: undefined }, + }, + }, + ], + expectedError: `${defaultOutputCli.id}: default CLI invocation requires a human projection`, + }, + { + fixtures: [ + { + ...jsonOutputCli, + expected: { + ...jsonOutputCli.expected, + output: { ...jsonOutputCli.expected.output, json: undefined }, + }, + }, + ], + expectedError: `${jsonOutputCli.id}: JSON CLI invocation requires a JSON projection`, + }, + { + fixtures: [ + { + ...jsonOutputCli, + when: { + ...jsonOutputCli.when, + argv: ["status", "--experimental", "--output-format=json"], + }, + expected: { + ...jsonOutputCli.expected, + output: { ...jsonOutputCli.expected.output, json: undefined }, + }, + }, + ], + expectedError: `${jsonOutputCli.id}: JSON CLI invocation requires a JSON projection`, + }, + { + fixtures: [ + { + ...reuse, + given: [ + ...reuse.given, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + ], + expected: { + ...reuse.expected, + selection: { ...reuse.expected.selection, contextId: "context-feat" }, + output: { + ...reuse.expected.output, + human: { + ...reuse.expected.output.human, + fields: { ...reuse.expected.output.human.fields, contextId: "context-feat" }, + }, + json: { ...reuse.expected.output.json, context_id: "context-feat" }, + }, + }, + }, + ], + expectedError: `${reuse.id}: selected context context-feat disagrees with stack stack-main-default`, + }, + ]; + + for (const testCase of cases) { + const expectedErrors = + typeof testCase.expectedError === "string" + ? [testCase.expectedError] + : testCase.expectedError; + for (const expectedError of expectedErrors) { + expect(validateManagedStackContractFixtures(testCase.fixtures)).toContain(expectedError); + } + } + }); + + it("covers the approved identity journeys through public commands and APIs", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "identity") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "identity.branch-commit-preserves-context", + "identity.branch-copy-ambiguous-read-only", + "identity.branch-copy-known-owner-creates-context-on-mutation", + "identity.branch-copy-read-only-does-not-write", + "identity.branch-create-and-switch-is-no-op", + "identity.branch-delete-recreate-creates-context", + "identity.branch-rebase-preserves-context", + "identity.branch-rename-preserves-context", + "identity.branch-reset-preserves-context", + "identity.concurrent-create-publishes-once", + "identity.copied-checkout-reports-duplicate-claim", + "identity.detached-commits-reuse-checkout-context", + "identity.folder-to-git-ambiguous-claim-fails", + "identity.folder-to-git-exact-claim-preserves-identity", + "identity.folder-to-git-without-claim-creates-git-identity", + "identity.fresh-clone-creates-project-and-checkout", + "identity.fresh-clone-ignores-tracked-marker", + "identity.inaccessible-previous-path-fails", + "identity.invalid-stack-name-double-dot-fails", + "identity.invalid-stack-name-leading-hyphen-fails", + "identity.invalid-stack-name-repeated-dot-fails", + "identity.invalid-stack-name-single-dot-fails", + "identity.invalid-stack-name-too-long-fails", + "identity.invalid-stack-name-trailing-hyphen-fails", + "identity.invalid-stack-name-uppercase-underscore-fails", + "identity.linked-worktrees-share-project-not-checkout", + "identity.manual-ref-replacement-orphans-context", + "identity.missing-previous-path-rebinds-checkout", + "identity.moved-checkout-reuses-identity", + "identity.named-stacks-are-context-scoped", + "identity.new-branch-first-start-creates-stack", + "identity.non-git-folder-first-start-persists-identity", + "identity.non-git-folder-recovers-persisted-identity", + "identity.original-gone-turns-copy-into-rename", + "identity.read-only-unregistered-checkout-does-not-write", + "identity.return-to-branch-reuses-stack", + "identity.same-branch-in-two-worktrees-is-isolated", + "identity.same-checkout-branch-and-name-reuses-stack", + "identity.same-commit-different-branches-are-independent", + "identity.symlink-alias-reuses-checkout", + "identity.valid-stack-names-resolve-deterministically", + "identity.bare-repository-linked-worktrees-share-project", + ].sort(), + ); + }); + + it("shares branch contexts across worktrees while checkout identity keeps stacks isolated", () => { + const findIdentityScenario = (id: string): ManagedStackContractScenario => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`${id} fixture is required`); + } + return scenario; + }; + const linkedWorktrees = findIdentityScenario( + "identity.linked-worktrees-share-project-not-checkout", + ); + const forcedBranch = findIdentityScenario("identity.same-branch-in-two-worktrees-is-isolated"); + const bareWorktrees = findIdentityScenario( + "identity.bare-repository-linked-worktrees-share-project", + ); + + expect(linkedWorktrees).toMatchObject({ + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + writes: expect.arrayContaining([ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + ]), + }, + }); + expect(forcedBranch).toMatchObject({ + given: expect.arrayContaining([ + expect.objectContaining({ kind: "branch", name: "main", contextId: "context-main" }), + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ]), + expected: { + selection: { + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }); + expect(forcedBranch.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + expect(bareWorktrees).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ]), + expected: { + selection: { checkoutId: "checkout-b", contextId: "context-main" }, + }, + }); + expect(bareWorktrees.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + }); + + it("does not rewrite branch context after Git has preserved a rename", () => { + for (const id of [ + "identity.branch-rename-preserves-context", + "identity.original-gone-turns-copy-into-rename", + ]) { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + expect(scenario?.expected.writes.filter((write) => write.target === "git-config")).toEqual( + [], + ); + } + }); + + it("persists and recovers ordinary-folder identity across starts", () => { + const firstStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-first-start-persists-identity", + ); + const laterStart = managedStackContractFixtures.find( + ({ id }) => id === "identity.non-git-folder-recovers-persisted-identity", + ); + + expect(firstStart).toMatchObject({ + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + writes: expect.arrayContaining([ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + ]), + }, + }); + expect(laterStart).toMatchObject({ + given: expect.arrayContaining([ + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, + }, + ]), + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + }, + }, + }); + }); + + it("executes every invalid stack name through the public CLI action", () => { + const invalidNameScenarios = managedStackContractFixtures.filter(({ id }) => + id.startsWith("identity.invalid-stack-name-"), + ); + + expect( + invalidNameScenarios.map((invalidNameScenario) => ({ + action: + invalidNameScenario.when.interface === "cli" ? invalidNameScenario.when.argv : undefined, + names: invalidNameScenario.given.flatMap((fact) => + fact.kind === "stack-names" ? fact.names : [], + ), + })), + ).toEqual([ + { + action: ["start", "--experimental", "--stack", "Feature_A"], + names: ["Feature_A"], + }, + { action: ["start", "--experimental", "--stack", "-review"], names: ["-review"] }, + { + action: ["start", "--experimental", "--stack", "review..two"], + names: ["review..two"], + }, + { + action: ["start", "--experimental", "--stack", "."], + names: ["."], + }, + { + action: ["start", "--experimental", "--stack", ".."], + names: [".."], + }, + { + action: ["start", "--experimental", "--stack", "review-"], + names: ["review-"], + }, + { + action: ["start", "--experimental", "--stack", "a".repeat(64)], + names: ["a".repeat(64)], + }, + ]); + }); + + it("covers exact declarative ports and sticky automatic allocation", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "ports") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "ports.config-change-on-running-stack-reports-drift", + "ports.config-change-on-stopped-stack-applies", + "ports.env-and-remote-values-remain-exact", + "ports.exact-default-value-differs-from-omitted-default", + "ports.explicit-free-port-is-used", + "ports.explicit-port-conflict-fails", + "ports.explicit-port-conflict-with-sibling-fails", + "ports.later-sticky-port-collision-fails", + "ports.new-target-allocates-and-persists-omitted-ports", + "ports.removing-exact-key-keeps-current-port-sticky", + "ports.running-legacy-source-fails-before-allocation", + "ports.sibling-targets-allocate-independent-ports", + "ports.sticky-ports-reuse-on-return", + ].sort(), + ); + }); + + it("freezes runtime selection and the atomic native service graph", () => { + expect(managedNativePlatformByNodeTarget).toEqual({ + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", + }); + expect(managedNativePlatformFromNode("linux", "x64")).toBe("linux-amd64"); + expect(managedNativePlatformFromNode("linux", "arm64")).toBe("linux-arm64"); + expect(managedNativePlatformFromNode("win32", "x64")).toBe("windows-amd64"); + expect(managedNativePlatformByNodeTarget).toHaveProperty(`${process.platform}-${process.arch}`); + + expect(managedNativeServiceMatrix).toEqual({ + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: SERVICE_NAMES.map((service) => [service, DEFAULT_VERSIONS[service]]), + }); + + expect( + managedStackContractFixtures + .filter(({ area }) => area === "runtime" || area === "native-qualification") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "native-qualification.all-services-qualify-platform", + "native-qualification.one-service-failure-disables-platform", + "native-qualification.unsupported-platform-fails-preflight", + "runtime.auto-fails-when-neither-runtime-is-available", + "runtime.auto-prefers-docker", + "runtime.auto-selects-fully-qualified-native", + "runtime.config-overrides-default-auto", + "runtime.explicit-and-config-conflict-fails", + "runtime.explicit-api-overrides-auto", + "runtime.explicit-runtime-is-strict", + "runtime.missing-persisted-prerequisite-fails", + "runtime.persisted-runtime-conflict-fails", + "runtime.persisted-runtime-reused-for-auto", + "runtime.status-reports-one-stack-wide-runtime", + ].sort(), + ); + }); + + it("covers read-compatible bootstrap without coupling managed and legacy timelines", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "bootstrap") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "bootstrap.absent-legacy-starts-fresh", + "bootstrap.existing-managed-target-ignores-legacy", + "bootstrap.failed-copy-rolls-back", + "bootstrap.first-start-copies-compatible-legacy-state", + "bootstrap.incompatible-legacy-starts-fresh", + "bootstrap.managed-and-legacy-diverge-after-copy", + "bootstrap.retry-after-failed-copy-succeeds", + "bootstrap.running-legacy-source-fails-without-mutation", + ].sort(), + ); + }); + + it("covers credential authority, stability, drift, and secret boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "credentials") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "credentials.compatible-legacy-auth-is-retained", + "credentials.configured-values-are-authoritative", + "credentials.explicit-change-applies-after-stop", + "credentials.omitted-values-use-stable-defaults", + "credentials.plaintext-secrets-stay-out-of-global-state", + "credentials.running-change-reports-drift", + "credentials.unchanged-values-survive-restart", + ].sort(), + ); + }); + + it("covers preservation, global deletion, tombstones, prune, and engine-scoped stop", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "reclamation") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "reclamation.branch-delete-does-not-delete-data", + "reclamation.default-stop-preserves-data", + "reclamation.delete-orphan-by-stack-id", + "reclamation.delete-repeat-is-idempotent", + "reclamation.prune-removes-metadata-only", + "reclamation.selectors-stack-and-all-conflict", + "reclamation.selectors-stack-and-stack-id-conflict", + "reclamation.selectors-stack-id-and-all-conflict", + "reclamation.stop-is-engine-scoped", + ].sort(), + ); + }); + + it("rejects every pair of explicit stop selectors through the public CLI action", () => { + expect( + managedStackContractFixtures + .filter(({ id }) => id.startsWith("reclamation.selectors-")) + .map((scenario) => (scenario.when.interface === "cli" ? scenario.when.argv : undefined)), + ).toEqual([ + ["stop", "--experimental", "--stack", "review", "--stack-id", "stack-main-default"], + ["stop", "--experimental", "--stack", "review", "--all"], + ["stop", "--experimental", "--stack-id", "stack-main-default", "--all"], + ]); + }); + + it("freezes the direct, managed, repository, CLI, and portable runtime boundaries", () => { + expect( + managedStackContractFixtures + .filter(({ area }) => area === "api-boundary") + .map(({ id }) => id) + .sort(), + ).toEqual( + [ + "api-boundary.cli-projects-shared-managed-results", + "api-boundary.direct-create-stack-is-ephemeral", + "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", + "api-boundary.direct-dispose-removes-temporary-roots", + "api-boundary.managed-api-accepts-injected-repository", + "api-boundary.managed-api-accepts-isolated-state-root", + "api-boundary.managed-surface-is-node-and-bun-portable", + "api-boundary.repository-contract-is-storage-agnostic", + ].sort(), + ); + }); + + it("keeps public createStack usage isolated when state roots are omitted", async () => { + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + if (scenario?.expected.output.api === undefined) { + throw new Error("direct createStack fixture requires its public API projection"); + } + const testRoot = mkdtempSync(join(tmpdir(), "supabase-direct-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const gitConfig = join(projectDir, ".git", "config"); + const identityMarker = join(projectDir, ".supabase", "identity.json"); + const registrySentinel = join(cacheRoot, "managed-registry.json"); + + mkdirSync(join(projectDir, ".git"), { recursive: true }); + mkdirSync(join(projectDir, ".supabase"), { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + writeFileSync(gitConfig, "[core]\n\trepositoryformatversion = 0\n"); + writeFileSync(identityMarker, '{"sentinel":true}\n'); + writeFileSync(registrySentinel, '{"sentinel":true}\n'); + const gitTreeBefore = snapshotDirectoryTree(join(projectDir, ".git")); + + try { + const createdRootIndex = createdTempRoots.length; + const stack = await createStack({ cacheRoot, projectDir, startupMode: "lazy" }); + const generatedRoots = createdTempRoots.slice(createdRootIndex); + try { + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); + expect(generatedRoots).toHaveLength(2); + expect(generatedRoots.every(existsSync)).toBe(true); + } finally { + expect(await stack.dispose()).toBeUndefined(); + } + + expect(generatedRoots.every((root) => !existsSync(root))).toBe(true); + expect(readFileSync(gitConfig, "utf8")).toBe("[core]\n\trepositoryformatversion = 0\n"); + expect(snapshotDirectoryTree(join(projectDir, ".git"))).toEqual(gitTreeBefore); + expect(readFileSync(identityMarker, "utf8")).toBe('{"sentinel":true}\n'); + expect(readFileSync(registrySentinel, "utf8")).toBe('{"sentinel":true}\n'); + expect(existsSync(join(cacheRoot, "projects"))).toBe(false); + expect(readdirSync(cacheRoot).sort()).toEqual(["managed-registry.json"]); + expect(readdirSync(projectDir).sort()).toEqual([".git", ".supabase"]); + expect(readdirSync(join(projectDir, ".supabase")).sort()).toEqual(["identity.json"]); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it("keeps explicitly supplied state roots while disposing each omitted root independently", async () => { + const explicitRootKinds: ReadonlyArray<"runtime" | "stack"> = ["stack", "runtime"]; + + for (const explicitRootKind of explicitRootKinds) { + const scenarioId = + explicitRootKind === "stack" + ? "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary" + : "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary"; + const scenario: ManagedStackContractScenario | undefined = managedStackContractFixtures.find( + ({ id }) => id === scenarioId, + ); + if (scenario?.expected.output.api === undefined) { + throw new Error(`${scenarioId} fixture requires its public API projection`); + } + const testRoot = mkdtempSync(join(tmpdir(), "supabase-partial-root-contract-")); + const projectDir = join(testRoot, "project"); + const cacheRoot = join(testRoot, "cache"); + const explicitRoot = join(testRoot, `${explicitRootKind}-root`); + const sentinel = join(explicitRoot, "caller-owned"); + + mkdirSync(projectDir, { recursive: true }); + mkdirSync(cacheRoot, { recursive: true }); + mkdirSync(explicitRoot, { recursive: true }); + writeFileSync(sentinel, "caller-owned\n"); + + try { + const createdRootIndex = createdTempRoots.length; + const explicitConfig = + explicitRootKind === "stack" + ? { stackRoot: explicitRoot } + : { runtimeRoot: explicitRoot }; + const stack = await createStack({ + cacheRoot, + projectDir, + startupMode: "lazy", + ...explicitConfig, + }); + const generatedRoots = createdTempRoots.slice(createdRootIndex); + const generatedRoot = generatedRoots[0]; + if (generatedRoot === undefined) { + throw new Error("createStack must generate the omitted state root"); + } + + try { + expect(projectDirectStackHandle(stack)).toEqual(scenario.expected.output.api); + expect(generatedRoots).toHaveLength(1); + expect(existsSync(generatedRoot)).toBe(true); + } finally { + expect(await stack.dispose()).toBeUndefined(); + } + + expect(existsSync(generatedRoot)).toBe(false); + expect(readFileSync(sentinel, "utf8")).toBe("caller-owned\n"); + expect(existsSync(explicitRoot)).toBe(true); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + } + }); + + it("reuses the existing stack when a developer returns to a branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.return-to-branch-reuses-stack", + ); + + expect(scenario).toEqual({ + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + contextId: "context-main", + checkoutId: "checkout-a", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }); + }); + + it("reports an ambiguous copied branch without mutating either branch", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "identity.branch-copy-ambiguous-read-only", + ); + + expect(scenario?.when).toEqual({ + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }); + expect(scenario?.given).toEqual( + expect.arrayContaining([ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, + ]), + ); + expect(scenario?.expected).toEqual({ + outcome: "error", + error: { + code: "AMBIGUOUS_CONTEXT_OWNER", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_CONTEXT_OWNER", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }); + }); + + it("fails on an occupied declarative port instead of relocating the stack", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "ports.explicit-port-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "error", + error: { + code: "EXACT_PORT_OCCUPIED", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + }, + }, + }, + }); + }); + + it("keeps an existing stack on its persisted runtime", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "runtime.persisted-runtime-conflict-fails", + ); + + expect(scenario).toMatchObject({ + area: "runtime", + given: expect.arrayContaining([ + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ]), + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + }, + expected: { + outcome: "error", + error: { + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + persisted_runtime: "docker", + requested_runtime: "native", + }, + }, + }, + }); + }); + + it("bootstraps compatible stopped legacy state without mutating it", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "bootstrap.first-start-copies-compatible-legacy-state", + ); + + expect(scenario).toMatchObject({ + area: "bootstrap", + given: expect.arrayContaining([ + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ]), + when: { + interface: "cli", + argv: ["start", "--experimental"], + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + }, + }, + }, + }); + }); + + it("deletes an orphaned stack by opaque ID without a checkout", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "reclamation.delete-orphan-by-stack-id", + ); + + expect(scenario).toMatchObject({ + area: "reclamation", + given: [ + { + kind: "stack", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + output: { + json: { + outcome: "delete", + stack_id: "stack-orphan", + tombstoned: true, + }, + }, + }, + }); + }); + + it("keeps direct createStack usage isolated from system-wide managed state", () => { + const scenario = managedStackContractFixtures.find( + ({ id }) => id === "api-boundary.direct-create-stack-is-ephemeral", + ); + + expect(scenario).toMatchObject({ + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack", "runtime"], + }, + output: { + api: { + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", + }, + }, + }, + }); + }); +}); diff --git a/packages/stack/src/managed-stack-contract.ts b/packages/stack/src/managed-stack-contract.ts new file mode 100644 index 0000000000..9b3f6e7d6a --- /dev/null +++ b/packages/stack/src/managed-stack-contract.ts @@ -0,0 +1,5048 @@ +import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; +import type { ServiceName } from "./ServiceName.ts"; + +export type ManagedStackContractArea = + | "api-boundary" + | "bootstrap" + | "credentials" + | "identity" + | "native-qualification" + | "ports" + | "reclamation" + | "runtime"; + +export type ManagedStackContractJson = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: ManagedStackContractJson }; + +export type ManagedStackContractFact = + | { + readonly kind: "workspace"; + readonly mode: "bare-worktree" | "git" | "linked-worktree" | "ordinary-folder"; + readonly path: string; + readonly canonicalPath?: string; + readonly previousPath?: string; + readonly previousPathAccess?: "inaccessible" | "missing" | "reachable"; + readonly copiedFrom?: string; + readonly clonedFrom?: string; + } + | { + readonly kind: "workspace-history"; + readonly path: string; + readonly previousMode: "ordinary-folder"; + } + | { + readonly kind: "git-state"; + readonly workspacePath: string; + readonly commonDirectory: string; + readonly gitDirectory: string; + readonly head: "branch" | "detached"; + readonly branch?: string; + readonly commit: string; + readonly trackedIdentityMarker?: boolean; + } + | { + readonly kind: "identity-claim"; + readonly scope: "checkout" | "context" | "project"; + readonly id: string; + readonly path?: string; + readonly owner?: string; + readonly status: "absent" | "ambiguous" | "duplicate" | "exact"; + } + | { + readonly kind: "identity-transition"; + readonly operation: + | "branch-commit" + | "branch-copy" + | "branch-delete-recreate" + | "branch-rebase" + | "branch-rename" + | "branch-reset" + | "checkout-copy" + | "checkout-move" + | "clone" + | "detached-commit" + | "folder-to-git" + | "ref-replacement" + | "symlink-alias"; + readonly from?: string; + readonly to?: string; + readonly originalExists?: boolean; + } + | { + readonly kind: "concurrent-operation"; + readonly operation: "create-stack"; + readonly target: string; + readonly contenders: number; + } + | { + readonly kind: "operation-result"; + readonly operation: "legacy-bootstrap"; + readonly stackId: string; + readonly outcome: "rolled-back"; + } + | { + readonly kind: "stack-names"; + readonly names: ReadonlyArray; + } + | { + readonly kind: "checkout"; + readonly path: string; + readonly projectId: string; + readonly checkoutId: string; + } + | { + readonly kind: "branch"; + readonly name: string; + readonly contextId: string; + readonly checkedOut: boolean; + } + | { + readonly kind: "branch-ref"; + readonly name: string; + readonly commit: string; + } + | { + readonly kind: "branch-history"; + readonly branch: string; + readonly operation: "commit" | "rebase" | "reset"; + readonly fromCommit: string; + readonly toCommit: string; + } + | { + readonly kind: "stack"; + readonly name: string; + readonly stackId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly lifecycle: "running" | "stopped"; + readonly orphaned?: boolean; + } + | { + readonly kind: "config-port"; + readonly key: string; + readonly intent: "automatic" | "exact"; + readonly value?: number; + readonly previousValue?: number; + readonly source?: "environment" | "local" | "omitted" | "remote"; + } + | { + readonly kind: "port-assignment"; + readonly stackId: string; + readonly key: string; + readonly port: number; + readonly intent: "automatic" | "exact"; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "managed-stack"; + readonly ownerId: string; + } + | { + readonly kind: "occupied-port"; + readonly port: number; + readonly owner: "external-process" | "legacy-stack"; + readonly ownerId?: string; + } + | { + readonly kind: "persisted-runtime"; + readonly stackId: string; + readonly runtime: "docker" | "native"; + } + | { + readonly kind: "runtime-request"; + readonly source: "cli" | "config" | "default" | "managed-api"; + readonly runtime: "auto" | "docker" | "native"; + } + | { + readonly kind: "runtime-availability"; + readonly runtime: "docker" | "native"; + readonly available: boolean; + readonly reason?: string; + } + | { + readonly kind: "native-qualification"; + readonly platform: string; + readonly qualifiedServices: ReadonlyArray; + readonly failedServices: ReadonlyArray; + } + | { + readonly kind: "managed-target"; + readonly stackId: string; + readonly exists: boolean; + } + | { + readonly kind: "managed-record"; + readonly stackId: string; + readonly status: "active" | "orphaned" | "tombstoned"; + } + | { + readonly kind: "legacy-state"; + readonly lifecycle: "absent" | "running" | "stopped"; + readonly database: "absent" | "compatible" | "incompatible"; + readonly storage: "absent" | "compatible" | "incompatible"; + readonly credentials: "absent" | "compatible" | "incompatible"; + } + | { + readonly kind: "credential-state"; + readonly source: "configured" | "legacy" | "local-default" | "persisted"; + readonly valuesId: string; + readonly previousValuesId?: string; + readonly plaintextPresentInGlobalState?: boolean; + } + | { + readonly kind: "identity-marker"; + readonly markerId: string; + readonly workspacePath: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly tracked: false; + } + | { + readonly kind: "direct-stack-options"; + readonly stackRoot: "explicit" | "omitted"; + readonly runtimeRoot: "explicit" | "omitted"; + } + | { + readonly kind: "direct-stack-state"; + readonly handle: string; + readonly temporaryRoots: ReadonlyArray<{ + readonly root: "stack" | "runtime"; + readonly stateId: string; + }>; + readonly lifecycle: "created"; + } + | { + readonly kind: "managed-api-options"; + readonly stateRoot: "default" | "isolated"; + readonly stateRootPath?: string; + readonly repository: "in-memory" | "injected" | "persistent-adapter"; + readonly repositoryId?: string; + readonly runtime: "bun" | "node"; + }; + +export interface ManagedStackContractOutput { + readonly human?: { + readonly summary: string; + readonly fields: Readonly>; + readonly recovery?: ReadonlyArray; + }; + readonly json?: Readonly>; + readonly api?: Readonly>; +} + +type ManagedStackContractWrite = + | { + readonly target: "git-config"; + readonly operation: "create" | "update"; + readonly id: string; + readonly scope: "common"; + readonly owner?: string; + } + | { + readonly target: "git-checkout-id"; + readonly operation: "create" | "update"; + readonly id: string; + } + | { + readonly target: "identity-marker"; + readonly operation: "create" | "update"; + readonly id: string; + readonly storage: "project-local-untracked"; + readonly workspacePath: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + } + | { + readonly target: "ephemeral-state"; + readonly operation: "create"; + readonly id: string; + } + | { + readonly target: "temporary-root"; + readonly operation: "create" | "delete"; + readonly id: string; + readonly root: "stack" | "runtime"; + } + | { + readonly target: "managed-state"; + readonly operation: "copy" | "create" | "delete" | "update"; + readonly id: string; + } + | { + readonly target: "registry"; + readonly operation: "delete" | "publish" | "tombstone" | "update"; + readonly id: string; + } + | { + readonly target: "runtime-state"; + readonly operation: "delete" | "start" | "update"; + readonly id: string; + }; + +export interface ManagedStackContractEffects { + readonly writes: ReadonlyArray; + readonly runtimeEffects: ReadonlyArray<{ + readonly operation: "copy" | "delete" | "start" | "stop"; + readonly stackId: string; + }>; + readonly output: ManagedStackContractOutput; +} + +export interface ManagedStackContractExpectation extends ManagedStackContractEffects { + readonly outcome: "create" | "delete" | "error" | "no-op" | "report" | "reuse" | "update"; + readonly selection?: { + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly stackId: string; + readonly stackName: string; + }; + readonly error?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; + readonly warning?: { + readonly code: string; + readonly message: string; + readonly recovery: ReadonlyArray; + }; + readonly details?: Readonly>; +} + +export type ManagedStackContractAction = + | { + readonly interface: "cli"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "git"; + readonly argv: ReadonlyArray; + readonly cwd: string; + } + | { + readonly interface: "managed-api"; + readonly method: string; + readonly input: Readonly>; + } + | { + readonly interface: "stack-api"; + readonly method: string; + readonly input: Readonly>; + }; + +export interface ManagedStackContractScenario { + readonly id: string; + readonly title: string; + readonly area: ManagedStackContractArea; + readonly given: ReadonlyArray; + readonly when: ManagedStackContractAction; + readonly expected: ManagedStackContractExpectation; +} + +export interface ManagedNativeServiceMatrix { + readonly targetPlatforms: ReadonlyArray; + readonly unsupportedPlatforms: ReadonlyArray; + readonly services: ReadonlyArray; +} + +export const managedNativePlatformByNodeTarget: Readonly> = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-arm64": "linux-arm64", + "linux-x64": "linux-amd64", + "win32-arm64": "windows-arm64", + "win32-x64": "windows-amd64", +}; + +export const managedNativePlatformFromNode = ( + os: string, + architecture: string, +): string | undefined => managedNativePlatformByNodeTarget[`${os}-${architecture}`]; + +export const managedNativeServiceMatrix: ManagedNativeServiceMatrix = { + targetPlatforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + unsupportedPlatforms: ["darwin-x64", "windows-amd64", "windows-arm64"], + services: SERVICE_NAMES.map((service): readonly [ServiceName, string] => [ + service, + DEFAULT_VERSIONS[service], + ]), +}; + +const directStackApiProjection = { + url: "http://127.0.0.1:", + dbUrl: "postgresql://postgres:postgres@127.0.0.1:/postgres", +}; + +const defineManagedStackContractFixtures = < + const Fixtures extends ReadonlyArray, +>( + fixtures: Fixtures, +): Fixtures => fixtures; + +const branchHistoryFixture = ( + label: "commit" | "rebase" | "reset", + operation: "branch-commit" | "branch-rebase" | "branch-reset", +): ManagedStackContractScenario => ({ + id: `identity.branch-${label}-preserves-context`, + title: `A branch ${label} preserves its context and stack`, + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "branch-history", + branch: "feat-a", + operation: label, + fromCommit: "commit-a", + toCommit: "commit-b", + }, + { kind: "identity-transition", operation, from: "commit-a", to: "commit-b" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" } }, + }, +}); + +const invalidStackNameFixture = ( + label: + | "double-dot" + | "leading-hyphen" + | "repeated-dot" + | "single-dot" + | "too-long" + | "trailing-hyphen" + | "uppercase-underscore", + stackName: string, +): ManagedStackContractScenario => ({ + id: `identity.invalid-stack-name-${label}-fails`, + title: `The invalid stack name ${stackName} fails before registration`, + area: "identity", + given: [{ kind: "stack-names", names: [stackName] }], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", stackName], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "INVALID_STACK_NAME", + message: `${stackName} is not a lowercase DNS-label name`, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: `Invalid stack name: ${stackName}`, + fields: { stack: stackName }, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + json: { + outcome: "error", + code: "INVALID_STACK_NAME", + stack_name: stackName, + recovery: ["Use default or a lowercase DNS-label name such as feature-a"], + }, + }, + }, +}); + +const mainCheckoutContextFacts = [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, +] satisfies ReadonlyArray; + +const freshManagedStartFacts = (stackId: string): ReadonlyArray => [ + { kind: "managed-target", stackId, exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, +]; + +const freshMainManagedStartFacts = freshManagedStartFacts("stack-main-default"); + +const mainDefaultSelection = { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", +}; + +const additionalIdentityContractFixtures = defineManagedStackContractFixtures([ + { + id: "identity.same-checkout-branch-and-name-reuses-stack", + title: "The same checkout, branch, and stack name resolve the same stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + }, + }, + }, + { + id: "identity.branch-create-and-switch-is-no-op", + title: "Creating and switching Git branches alone does not touch managed state", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + ], + when: { + interface: "git", + argv: ["switch", "-c", "feat-a"], + cwd: "checkout-a", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { managed_command_ran: false }, + output: { + human: { summary: "Switched to a new branch 'feat-a'", fields: {} }, + }, + }, + }, + { + id: "identity.new-branch-first-start-creates-stack", + title: "First start on a new branch creates an independent context and stack", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-a-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-a", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-feat-a", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-feat-a-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-a-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-a-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-a-default" }], + output: { + human: { + summary: "Created feat-a/default", + fields: { branch: "feat-a", stack: "default", stackId: "stack-feat-a-default" }, + }, + json: { + outcome: "create", + context_id: "context-feat-a", + stack_id: "stack-feat-a-default", + }, + }, + }, + }, + { + id: "identity.branch-rename-preserves-context", + title: "A standard branch rename preserves its context and stack", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "identity-transition", operation: "branch-rename", from: "feature", to: "feat-a" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + api: { outcome: "reuse", contextId: "context-feat", stackId: "stack-feat-default" }, + }, + }, + }, + { + id: "identity.branch-delete-recreate-creates-context", + title: "Deleting and recreating a branch name creates a new context", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-new-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-new", + }, + { + kind: "identity-transition", + operation: "branch-delete-recreate", + from: "feat-a", + to: "feat-a", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old" }, + output: { + human: { + summary: "Created default stack with a new branch context", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + branchHistoryFixture("commit", "branch-commit"), + branchHistoryFixture("rebase", "branch-rebase"), + branchHistoryFixture("reset", "branch-reset"), + { + id: "identity.same-commit-different-branches-are-independent", + title: "Two branches at one commit retain independent contexts", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "branch-ref", name: "main", commit: "shared-commit" }, + { kind: "branch-ref", name: "feat-a", commit: "shared-commit" }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "running", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "shared-commit", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "status" }, + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + api: { + outcome: "report", + contextId: "context-feat", + stackId: "stack-feat-default", + otherContextId: "context-main", + }, + }, + }, + }, + { + id: "identity.manual-ref-replacement-orphans-context", + title: "Replacing a branch ref manually creates a new context and orphans the old one", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-new-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "ref-replacement", + from: "commit-a", + to: "commit-b", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "feat-a", + commit: "commit-b", + }, + { + kind: "identity-claim", + scope: "context", + id: "context-old", + owner: "feat-a", + status: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-new", + stackId: "stack-new-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-new", + scope: "common", + owner: "feat-a", + }, + { target: "registry", operation: "publish", id: "stack-new-default" }, + { target: "managed-state", operation: "create", id: "stack-new-default" }, + { target: "runtime-state", operation: "start", id: "stack-new-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-new-default" }], + details: { orphaned_context_id: "context-old", adoption_required: true }, + output: { + human: { + summary: "Created default stack after manual ref replacement", + fields: { contextId: "context-new", stackId: "stack-new-default", stack: "default" }, + }, + json: { + outcome: "create", + context_id: "context-new", + stack_id: "stack-new-default", + orphaned_context_id: "context-old", + }, + }, + }, + }, + { + id: "identity.detached-commits-reuse-checkout-context", + title: "Different detached commits in one checkout reuse its detached context", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "(detached)", contextId: "context-detached", checkedOut: true }, + { + kind: "identity-transition", + operation: "detached-commit", + from: "commit-a", + to: "commit-b", + }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "detached", + commit: "commit-b", + }, + { + kind: "stack", + name: "default", + stackId: "stack-detached-default", + checkoutId: "checkout-a", + contextId: "context-detached", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-detached", + stackId: "stack-detached-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-detached-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-detached-default" }], + output: { + api: { outcome: "reuse", contextId: "context-detached", stackId: "stack-detached-default" }, + }, + }, + }, + { + id: "identity.non-git-folder-first-start-persists-identity", + title: "First start in a non-Git folder persists an untracked local identity", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-workspace-default"), + { + kind: "workspace", + mode: "ordinary-folder", + path: "/work/project-a", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [ + { + target: "identity-marker", + operation: "create", + id: "marker-project-a", + storage: "project-local-untracked", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + }, + { target: "registry", operation: "publish", id: "stack-workspace-default" }, + { target: "managed-state", operation: "create", id: "stack-workspace-default" }, + { target: "runtime-state", operation: "start", id: "stack-workspace-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + details: { identity_marker_tracked: false }, + output: { + human: { + summary: "Created workspace/default", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.non-git-folder-recovers-persisted-identity", + title: "A later start in a non-Git folder recovers its persisted local identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "ordinary-folder", + path: "/work/project-a", + }, + { + kind: "identity-marker", + markerId: "marker-project-a", + workspacePath: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + tracked: false, + }, + { + kind: "stack", + name: "default", + stackId: "stack-workspace-default", + checkoutId: "checkout-a", + contextId: "context-workspace", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-workspace", + stackId: "stack-workspace-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-workspace-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-workspace-default" }], + output: { + human: { + summary: "Started workspace/default", + fields: { + contextId: "context-workspace", + stackId: "stack-workspace-default", + stack: "default", + }, + }, + json: { + outcome: "reuse", + context_id: "context-workspace", + stack_id: "stack-workspace-default", + }, + }, + }, + }, + { + id: "identity.linked-worktrees-share-project-not-checkout", + title: "Sibling linked worktrees share a project and use independent checkouts", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-b", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }, + }, + { + id: "identity.same-branch-in-two-worktrees-is-isolated", + title: "The same branch forced into two worktrees remains checkout-isolated", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "linked-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "linked-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-a", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + output: { + api: { + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + }, + }, + }, + }, + { + id: "identity.named-stacks-are-context-scoped", + title: "Named stacks are scoped inside the active branch context", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-review"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "running", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--stack", "review"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-review", + stackName: "review", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-review" }, + { target: "managed-state", operation: "create", id: "stack-feat-review" }, + { target: "runtime-state", operation: "start", id: "stack-feat-review" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-review" }], + output: { + human: { + summary: "Created feat-a/review", + fields: { stack: "review", stackId: "stack-feat-review" }, + }, + json: { outcome: "create", context_id: "context-feat", stack_id: "stack-feat-review" }, + }, + }, + }, + { + id: "identity.moved-checkout-reuses-identity", + title: "Moving a checkout rebinds its existing identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + canonicalPath: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-transition", + operation: "checkout-move", + from: "/old/project-a", + to: "/new/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "/new/project-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Started main/default after moving the checkout", + fields: { checkoutId: "checkout-a", stackId: "stack-main-default", stack: "default" }, + }, + json: { outcome: "reuse", checkout_id: "checkout-a", rebound_from: "/old/project-a" }, + }, + }, + }, + { + id: "identity.symlink-alias-reuses-checkout", + title: "A symlink alias resolves the canonical checkout identity", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/alias/project-a", + canonicalPath: "/work/project-a", + }, + { + kind: "identity-transition", + operation: "symlink-alias", + from: "/work/project-a", + to: "/alias/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/work/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "/alias/project-a", + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + output: { + json: { outcome: "report", checkout_id: "checkout-a", canonical_path: "/work/project-a" }, + }, + }, + }, + { + id: "identity.copied-checkout-reports-duplicate-claim", + title: "A copied checkout reports a duplicate identity while its source exists", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "/copy/project-a", copiedFrom: "/work/project-a" }, + { + kind: "identity-transition", + operation: "checkout-copy", + from: "/work/project-a", + to: "/copy/project-a", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "duplicate", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "/copy/project-a", + }, + expected: { + outcome: "error", + error: { + code: "DUPLICATE_CHECKOUT_CLAIM", + message: "Two live paths claim checkout-a", + recovery: [ + "Use the original checkout at /work/project-a", + "Recreate the copy with git clone and run supabase start --experimental", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "error", + code: "DUPLICATE_CHECKOUT_CLAIM", + checkout_id: "checkout-a", + paths: ["/copy/project-a", "/work/project-a"], + recovery: [ + "Use the original checkout at /work/project-a", + "Recreate the copy with git clone and run supabase start --experimental", + ], + }, + }, + }, + }, + { + id: "identity.fresh-clone-creates-project-and-checkout", + title: "A fresh clone receives new project and checkout identities", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-clone-main-default"), + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/clone/project-a", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "clone-commit", + }, + { + kind: "identity-transition", + operation: "clone", + from: "/work/project-a", + to: "/clone/project-a", + }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + details: { project_identity_storage: "git-local", git_index_mutated: false }, + output: { + human: { + summary: "Created main/default for the fresh clone", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + }, + }, + }, + }, + { + id: "identity.missing-previous-path-rebinds-checkout", + title: "A missing previous checkout path is rebound automatically", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/old/project-a", + previousPathAccess: "missing", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/old/project-a", + status: "exact", + }, + { + kind: "checkout", + path: "/old/project-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "/new/project-a", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "update", id: "checkout-a" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { api: { outcome: "reuse", checkoutId: "checkout-a", rebound: true } }, + }, + }, + { + id: "identity.inaccessible-previous-path-fails", + title: "An inaccessible previous path fails instead of guessing ownership", + area: "identity", + given: [ + { + kind: "workspace", + mode: "git", + path: "/new/project-a", + previousPath: "/mnt/project-a", + previousPathAccess: "inaccessible", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/mnt/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/new/project-a" }, + expected: { + outcome: "error", + error: { + code: "CHECKOUT_PATH_INACCESSIBLE", + message: "Cannot verify whether /mnt/project-a still owns checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot safely rebind checkout-a", + fields: { previousPath: "/mnt/project-a", currentPath: "/new/project-a" }, + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + json: { + outcome: "error", + code: "CHECKOUT_PATH_INACCESSIBLE", + checkout_id: "checkout-a", + recovery: [ + "Restore access to /mnt/project-a and retry", + "Explicitly adopt checkout-a for /new/project-a", + ], + }, + }, + }, + }, + { + id: "identity.concurrent-create-publishes-once", + title: "Concurrent creation publishes one stack without aliases", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "concurrent-operation", + operation: "create-stack", + target: "context-feat/default", + contenders: 2, + }, + ], + when: { + interface: "managed-api", + method: "startConcurrently", + input: { cwd: "checkout-a", stackName: "default", contenders: 2 }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { published_stack_count: 1, alias_count: 0, contender_results: ["create", "reuse"] }, + output: { + api: { + stackId: "stack-feat-default", + publishedStackCount: 1, + aliasCount: 0, + contenderResults: ["create", "reuse"], + }, + }, + }, + }, + invalidStackNameFixture("uppercase-underscore", "Feature_A"), + invalidStackNameFixture("leading-hyphen", "-review"), + invalidStackNameFixture("repeated-dot", "review..two"), + invalidStackNameFixture("single-dot", "."), + invalidStackNameFixture("double-dot", ".."), + invalidStackNameFixture("trailing-hyphen", "review-"), + invalidStackNameFixture("too-long", "a".repeat(64)), + { + id: "identity.valid-stack-names-resolve-deterministically", + title: "Default and lowercase DNS-label stack names resolve deterministically", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { kind: "stack-names", names: ["default", "review-42"] }, + ], + when: { + interface: "managed-api", + method: "resolveStackNames", + input: { cwd: "checkout-a", stackNames: ["default", "review-42"] }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { + default_stack_id: "stack-feat-default", + review_42_stack_id: "stack-feat-review-42", + }, + output: { + api: { + default: { contextId: "context-feat", stackId: "stack-feat-default" }, + "review-42": { contextId: "context-feat", stackId: "stack-feat-review-42" }, + }, + }, + }, + }, + { + id: "identity.read-only-unregistered-checkout-does-not-write", + title: "Read-only discovery of an unregistered checkout performs no writes", + area: "identity", + given: [ + { kind: "workspace", mode: "git", path: "checkout-new" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-unregistered", status: "absent" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-new", + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { registered: false, identity_marker_created: false }, + output: { json: { outcome: "report", registered: false, stacks: [] } }, + }, + }, + { + id: "identity.branch-copy-known-owner-creates-context-on-mutation", + title: "A copied branch with a known owner gets a new context on first mutation", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-copy-default"), + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + { kind: "identity-claim", scope: "context", id: "context-copy", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-copy", + stackId: "stack-copy-default", + stackName: "default", + }, + writes: [ + { + target: "git-config", + operation: "create", + id: "context-copy", + scope: "common", + owner: "feat-copy", + }, + { target: "registry", operation: "publish", id: "stack-copy-default" }, + { target: "managed-state", operation: "create", id: "stack-copy-default" }, + { target: "runtime-state", operation: "start", id: "stack-copy-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-copy-default" }], + details: { original_context_id: "context-main", original_owner: "main" }, + output: { + human: { + summary: "Created feat-copy/default with a new branch context", + fields: { contextId: "context-copy", stackId: "stack-copy-default", stack: "default" }, + }, + json: { + outcome: "create", + branch: "feat-copy", + context_id: "context-copy", + original_context_id: "context-main", + stack_id: "stack-copy-default", + }, + }, + }, + }, + { + id: "identity.branch-copy-read-only-does-not-write", + title: "Read-only discovery reports a copied-branch conflict without resolving it", + area: "identity", + given: [ + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: false }, + { kind: "branch", name: "feat-copy", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + warning: { + code: "COPIED_BRANCH_CONTEXT_CONFLICT", + message: "feat-copy copied context-main from main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + json: { + outcome: "report", + code: "COPIED_BRANCH_CONTEXT_CONFLICT", + branch: "feat-copy", + owner: "main", + context_id: "context-main", + recovery: [ + "Run supabase start --experimental to create an independent context for feat-copy", + ], + }, + }, + }, + }, + { + id: "identity.original-gone-turns-copy-into-rename", + title: "A copied context is preserved as a rename when its original branch is gone", + area: "identity", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "renamed", + originalExists: false, + }, + { kind: "branch", name: "renamed", contextId: "context-main", checkedOut: true }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + owner: "main", + status: "absent", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Started renamed/default with its preserved context", + fields: { contextId: "context-main", stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "reuse", + branch: "renamed", + context_id: "context-main", + rename_detected: true, + }, + }, + }, + }, + { + id: "identity.fresh-clone-ignores-tracked-marker", + title: "A tracked non-Git identity marker is inert in a fresh Git clone", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-clone-main-default"), + { kind: "workspace", mode: "git", path: "/clone/project-a", clonedFrom: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/clone/project-a", + commonDirectory: "/clone/project-a/.git", + gitDirectory: "/clone/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + trackedIdentityMarker: true, + }, + { kind: "identity-claim", scope: "project", id: "project-from-marker", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/clone/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-clone", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-clone" }, + { + target: "git-config", + operation: "create", + id: "context-clone-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-clone-main-default" }, + { target: "managed-state", operation: "create", id: "stack-clone-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-clone-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-clone-main-default" }], + details: { + project_identity_storage: "git-local", + tracked_marker_ignored: true, + tracked_marker_mutated: false, + git_index_mutated: false, + }, + output: { + human: { + summary: "Created main/default without inheriting the tracked marker", + fields: { + projectId: "project-clone", + checkoutId: "checkout-clone", + contextId: "context-clone-main", + stackId: "stack-clone-main-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-clone", + checkout_id: "checkout-clone", + context_id: "context-clone-main", + stack_id: "stack-clone-main-default", + tracked_marker_ignored: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-exact-claim-preserves-identity", + title: "Folder-to-Git conversion preserves one exact live path claim", + area: "identity", + given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-a", + path: "/work/project-a", + status: "exact", + }, + { + kind: "identity-claim", + scope: "checkout", + id: "checkout-a", + path: "/work/project-a", + status: "exact", + }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { git_index_mutated: false }, + output: { + human: { + summary: "Started main/default after converting the folder to Git", + fields: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stack: "default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + converted_to_git: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-without-claim-creates-git-identity", + title: "Folder-to-Git conversion without a live claim creates Git-owned identities", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-git-default"), + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "git-state", + workspacePath: "/work/project-a", + commonDirectory: "/work/project-a/.git", + gitDirectory: "/work/project-a/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "identity-claim", scope: "project", id: "project-folder", status: "absent" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "create", + selection: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-git", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-git" }, + { + target: "git-config", + operation: "create", + id: "context-git-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-git-default" }, + { target: "managed-state", operation: "create", id: "stack-git-default" }, + { target: "runtime-state", operation: "start", id: "stack-git-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-git-default" }], + details: { project_identity_storage: "git-local", git_index_mutated: false }, + output: { + human: { + summary: "Created main/default with fresh Git identity", + fields: { + projectId: "project-git", + checkoutId: "checkout-git", + contextId: "context-git-main", + stackId: "stack-git-default", + stack: "default", + }, + }, + json: { + outcome: "create", + project_id: "project-git", + checkout_id: "checkout-git", + converted_to_git: true, + }, + }, + }, + }, + { + id: "identity.folder-to-git-ambiguous-claim-fails", + title: "Folder-to-Git conversion fails on ambiguous live identity claims", + area: "identity", + given: [ + { + kind: "workspace-history", + path: "/work/project-a", + previousMode: "ordinary-folder", + }, + { + kind: "identity-transition", + operation: "folder-to-git", + from: "ordinary-folder", + to: "git", + }, + { kind: "workspace", mode: "git", path: "/work/project-a", canonicalPath: "/work/project-a" }, + { + kind: "identity-claim", + scope: "project", + id: "project-folder", + path: "/work/project-a", + status: "ambiguous", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "/work/project-a" }, + expected: { + outcome: "error", + error: { + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", + message: "Multiple live claims can preserve the folder identity", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + writes: [], + runtimeEffects: [], + details: { git_index_mutated: false }, + output: { + human: { + summary: "Cannot choose a folder identity for the Git repository", + fields: { code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY" }, + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_FOLDER_TO_GIT_IDENTITY", + recovery: [ + "Inspect the claims and explicitly adopt one identity or create a fresh Git identity", + ], + }, + }, + }, + }, + { + id: "identity.bare-repository-linked-worktrees-share-project", + title: "Bare-repository worktrees share common project identity without a primary worktree", + area: "identity", + given: [ + ...freshManagedStartFacts("stack-b-main-default"), + { kind: "workspace", mode: "bare-worktree", path: "worktree-a" }, + { kind: "workspace", mode: "bare-worktree", path: "worktree-b" }, + { + kind: "git-state", + workspacePath: "worktree-b", + commonDirectory: "repo.git", + gitDirectory: "repo.git/worktrees/worktree-b", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { kind: "checkout", path: "worktree-a", projectId: "project-bare", checkoutId: "checkout-a" }, + { kind: "checkout", path: "worktree-b", projectId: "project-bare", checkoutId: "checkout-b" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-a-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "worktree-b", stackName: "default", operation: "start" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-bare", + checkoutId: "checkout-b", + contextId: "context-main", + stackId: "stack-b-main-default", + stackName: "default", + }, + writes: [ + { target: "registry", operation: "publish", id: "stack-b-main-default" }, + { target: "managed-state", operation: "create", id: "stack-b-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-b-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-b-main-default" }], + details: { + project_identity_location: "repo.git", + checkout_identity_location: "repo.git/worktrees/worktree-b", + }, + output: { + api: { + projectId: "project-bare", + checkoutId: "checkout-b", + contextId: "context-main", + primaryWorktreeRequired: false, + }, + }, + }, + }, +]); + +const additionalPortContractFixtures = defineManagedStackContractFixtures([ + { + id: "ports.exact-default-value-differs-from-omitted-default", + title: "A present default port is exact while the same omitted default is automatic", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { + config: { "api.port": 54321 }, + decodedDefaults: { "api.port": 54321, "db.port": 54322 }, + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 54321, source: "local" }, + "db.port": { intent: "automatic", source: "omitted" }, + }, + }, + }, + }, + { + id: "ports.env-and-remote-values-remain-exact", + title: "Environment-backed and selected remote ports remain exact after resolution", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + source: "environment", + }, + { kind: "config-port", key: "db.port", intent: "exact", value: 55322, source: "remote" }, + ], + when: { + interface: "managed-api", + method: "resolvePortIntents", + input: { effectiveConfig: { "api.port": 55321, "db.port": 55322 } }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + output: { + api: { + "api.port": { intent: "exact", port: 55321, source: "environment" }, + "db.port": { intent: "exact", port: 55322, source: "remote" }, + }, + }, + }, + }, + { + id: "ports.explicit-free-port-is-used", + title: "A free declarative port is used exactly", + area: "ports", + given: [ + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-main-default", + portIntents: { "api.port": { intent: "exact", port: 54321 } }, + }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + api: { + outcome: "update", + stackId: "stack-main-default", + ports: { api: 54321 }, + intent: "exact", + }, + }, + }, + }, + { + id: "ports.new-target-allocates-and-persists-omitted-ports", + title: "A new target allocates and persists host-wide ports for omitted keys", + area: "ports", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { host_wide: true, sticky: true }, + output: { + api: { + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55421, db: 55422 }, + intents: { api: "automatic", db: "automatic" }, + }, + }, + }, + }, + { + id: "ports.sibling-targets-allocate-independent-ports", + title: "A new sibling target allocates around existing host-wide port ownership", + area: "ports", + given: [ + ...freshManagedStartFacts("stack-feat-default"), + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { kind: "config-port", key: "db.port", intent: "automatic", source: "omitted" }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { + kind: "port-assignment", + stackId: "stack-main-review", + key: "db.port", + port: 55422, + intent: "automatic", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { + stackId: "stack-feat-default", + portIntents: { "api.port": "automatic", "db.port": "automatic" }, + }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-feat-default" }, + { target: "registry", operation: "publish", id: "stack-feat-default" }, + { target: "runtime-state", operation: "start", id: "stack-feat-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + details: { + host_wide: true, + sticky: true, + avoided_sibling_stack_ids: ["stack-main-default", "stack-main-review"], + }, + output: { + api: { + outcome: "create", + stackId: "stack-feat-default", + ports: { api: 55423, db: 55424 }, + intents: { api: "automatic", db: "automatic" }, + }, + }, + }, + }, + { + id: "ports.sticky-ports-reuse-on-return", + title: "Returning to an existing target reuses its sticky automatic ports", + area: "ports", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + { kind: "config-port", key: "api.port", intent: "automatic", source: "omitted" }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-feat-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-feat-default" }], + output: { + human: { summary: "Started feat-a/default", fields: { apiUrl: "http://127.0.0.1:55421" } }, + json: { + outcome: "reuse", + stack_id: "stack-feat-default", + ports: { api: 55421 }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.later-sticky-port-collision-fails", + title: "A later collision on a sticky automatic port fails without relocation", + area: "ports", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + { + kind: "port-assignment", + stackId: "stack-feat-default", + key: "api.port", + port: 55421, + intent: "automatic", + }, + { kind: "occupied-port", port: 55421, owner: "external-process" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-feat", + stackId: "stack-feat-default", + stackName: "default", + }, + error: { + code: "STICKY_PORT_OCCUPIED", + message: "stack-feat-default owns sticky api.port 55421, but it is in use", + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start default because its sticky port is occupied", + fields: { code: "STICKY_PORT_OCCUPIED", stackId: "stack-feat-default", port: "55421" }, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + json: { + outcome: "error", + code: "STICKY_PORT_OCCUPIED", + stack_id: "stack-feat-default", + port: 55421, + config_key: "api.port", + relocated: false, + recovery: [ + "Stop the process using port 55421", + "Delete and recreate the stack to allocate new automatic ports", + ], + }, + }, + }, + }, + { + id: "ports.config-change-on-stopped-stack-applies", + title: "Changing an exact port on a stopped stack applies on next start", + area: "ports", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { summary: "Started main/default", fields: { apiUrl: "http://127.0.0.1:55321" } }, + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_port: 54321, + port: 55321, + }, + }, + }, + }, + { + id: "ports.config-change-on-running-stack-reports-drift", + title: "Changing an exact port on a running stack reports drift", + area: "ports", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 55321, + previousValue: 54321, + source: "local", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_CONFIG_DRIFT", + message: "api.port is running on 54321 but config requires 55321", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "main/default is running with unapplied port configuration", + fields: { + stackId: "stack-main-default", + configKey: "api.port", + runningPort: "54321", + configuredPort: "55321", + drift: "true", + }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_CONFIG_DRIFT", + stack_id: "stack-main-default", + config_key: "api.port", + running_port: 54321, + requested_port: 55321, + drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + }, + }, + }, + { + id: "ports.removing-exact-key-keeps-current-port-sticky", + title: "Removing an exact key keeps the current port as sticky automatic state", + area: "ports", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "config-port", + key: "api.port", + intent: "automatic", + previousValue: 54321, + source: "omitted", + }, + { + kind: "port-assignment", + stackId: "stack-main-default", + key: "api.port", + port: 54321, + intent: "exact", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", portIntents: { "api.port": "automatic" } }, + }, + expected: { + outcome: "update", + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { sibling_allocation_independent: true }, + output: { + api: { + stackId: "stack-main-default", + ports: { api: 54321 }, + intents: { api: "automatic" }, + sticky: true, + }, + }, + }, + }, + { + id: "ports.running-legacy-source-fails-before-allocation", + title: "A running legacy source fails before bootstrap or port allocation", + area: "ports", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "occupied-port", port: 54321, owner: "legacy-stack", ownerId: "legacy-project-a" }, + { kind: "config-port", key: "api.port", intent: "exact", value: 54321, source: "local" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "LEGACY_SOURCE_RUNNING", + message: "The matching legacy stack is still running on api.port 54321", + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { + allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, + output: { + human: { + summary: "Cannot start while the matching legacy stack is running", + fields: { code: "LEGACY_SOURCE_RUNNING", port: "54321" }, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + json: { + outcome: "error", + code: "LEGACY_SOURCE_RUNNING", + port: 54321, + config_key: "api.port", + allocation_attempted: false, + legacy_source_stopped: false, + managed_target_published: false, + recovery: ["Stop the legacy stack, then retry supabase start --experimental"], + }, + }, + }, + }, +]); + +const nativeServiceNames = managedNativeServiceMatrix.services.map(([service]) => service); + +const additionalRuntimeContractFixtures = defineManagedStackContractFixtures([ + { + id: "runtime.explicit-api-overrides-auto", + title: "An explicit managed-API runtime overrides the default automatic selection", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "managed-api", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "native" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "managed-api", stack_wide: true }, + output: { + api: { stackId: "stack-main-default", runtime: "native", runtimeSource: "managed-api" }, + }, + }, + }, + { + id: "runtime.config-overrides-default-auto", + title: "A config runtime overrides automatic selection when no explicit override exists", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "config", runtime: "native" }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "native", source: "config" }, + output: { + human: { + summary: "Started main/default with native runtime", + fields: { runtime: "native" }, + }, + json: { + outcome: "create", + stack_id: "stack-main-default", + runtime: "native", + runtime_source: "config", + }, + }, + }, + }, + { + id: "runtime.explicit-and-config-conflict-fails", + title: "Conflicting explicit and config runtimes fail before services start", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "RUNTIME_SELECTION_CONFLICT", + message: "CLI requests docker while config.toml requests native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Conflicting runtime selections", + fields: { + code: "RUNTIME_SELECTION_CONFLICT", + cliRuntime: "docker", + configRuntime: "native", + }, + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + json: { + outcome: "error", + code: "RUNTIME_SELECTION_CONFLICT", + cli_runtime: "docker", + config_runtime: "native", + recovery: ["Remove one runtime override", "Make the CLI and config runtime values agree"], + }, + }, + }, + }, + { + id: "runtime.auto-prefers-docker", + title: "Automatic selection prefers usable Docker", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { resolved_runtime: "docker", persisted: true }, + output: { api: { stackId: "stack-main-default", runtime: "docker", runtimeSource: "auto" } }, + }, + }, + { + id: "runtime.auto-selects-fully-qualified-native", + title: + "Automatic selection uses native only when Docker is unusable and the full graph qualifies", + area: "runtime", + given: [ + ...freshMainManagedStartFacts, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", runtime: "auto" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + resolved_runtime: "native", + qualified_service_count: nativeServiceNames.length, + mixed_runtime: false, + persisted: true, + }, + output: { + api: { + stackId: "stack-main-default", + runtime: "native", + qualifiedServiceCount: nativeServiceNames.length, + }, + }, + }, + }, + { + id: "runtime.auto-fails-when-neither-runtime-is-available", + title: "Automatic selection reports both availability failures", + area: "runtime", + given: [ + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "platform graph not qualified", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "NO_RUNTIME_AVAILABLE", + message: "Neither Docker nor native can run this stack", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "No runtime is available", + fields: { docker: "daemon unavailable", native: "platform graph not qualified" }, + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + json: { + outcome: "error", + code: "NO_RUNTIME_AVAILABLE", + docker_reason: "daemon unavailable", + native_reason: "platform graph not qualified", + recovery: [ + "Start or install Docker", + "Use a platform with a fully qualified native service graph", + ], + }, + }, + }, + }, + { + id: "runtime.explicit-runtime-is-strict", + title: "An explicit runtime fails strictly when its prerequisite is missing", + area: "runtime", + given: [ + { kind: "runtime-request", source: "cli", runtime: "docker" }, + { + kind: "runtime-availability", + runtime: "docker", + available: false, + reason: "daemon unavailable", + }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "docker"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "DOCKER_UNAVAILABLE", + message: "Docker was explicitly requested but its daemon is unavailable", + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + writes: [], + runtimeEffects: [], + details: { fallback_attempted: false }, + output: { + human: { + summary: "Docker is unavailable", + fields: { code: "DOCKER_UNAVAILABLE", requestedRuntime: "docker" }, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + json: { + outcome: "error", + code: "DOCKER_UNAVAILABLE", + requested_runtime: "docker", + reason: "daemon unavailable", + fallback_attempted: false, + recovery: ["Start Docker", "Remove --runtime docker to use automatic selection"], + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-reused-for-auto", + title: "An existing stack reuses its persisted runtime for omitted or automatic selection", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { kind: "runtime-availability", runtime: "docker", available: true }, + { kind: "runtime-availability", runtime: "native", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { runtime: "native", auto_re_evaluated: false }, + output: { + human: { + summary: "Started main/default with its persisted runtime", + fields: { stackId: "stack-main-default", stack: "default", runtime: "native" }, + }, + json: { + outcome: "reuse", + stack_id: "stack-main-default", + runtime: "native", + persisted: true, + }, + }, + }, + }, + { + id: "runtime.missing-persisted-prerequisite-fails", + title: "A missing prerequisite for the persisted runtime fails without switching", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "native" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "runtime-request", source: "default", runtime: "auto" }, + { + kind: "runtime-availability", + runtime: "native", + available: false, + reason: "artifact missing", + }, + { kind: "runtime-availability", runtime: "docker", available: true }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + selection: mainDefaultSelection, + error: { + code: "PERSISTED_RUNTIME_UNAVAILABLE", + message: "stack-main-default uses native, but a required artifact is missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + writes: [], + runtimeEffects: [], + details: { switched_to_docker: false }, + output: { + human: { + summary: "The persisted native runtime is unavailable", + fields: { code: "PERSISTED_RUNTIME_UNAVAILABLE", stackId: "stack-main-default" }, + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + json: { + outcome: "error", + code: "PERSISTED_RUNTIME_UNAVAILABLE", + stack_id: "stack-main-default", + runtime: "native", + reason: "artifact missing", + recovery: [ + "Restore the native prerequisite", + "Create a new Docker named stack", + "Delete and recreate this stack", + ], + }, + }, + }, + }, + { + id: "runtime.status-reports-one-stack-wide-runtime", + title: "Status reports one persisted stack-wide runtime and any drift", + area: "runtime", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, + { kind: "runtime-request", source: "config", runtime: "native" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_RUNTIME_DRIFT", + message: "stack-main-default runs with docker but config requests native", + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + writes: [], + runtimeEffects: [], + details: { mixed_runtime: false }, + output: { + human: { + summary: "main/default is running with Docker", + fields: { runtime: "docker", configuredRuntime: "native", drift: "true" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_RUNTIME_DRIFT", + stack_id: "stack-main-default", + runtime: "docker", + configured_runtime: "native", + drift: true, + services: { runtime: "docker" }, + recovery: [ + "Keep using Docker by restoring runtime = docker", + "Create a new native named stack", + "Delete and recreate stack-main-default with native", + ], + }, + }, + }, + }, + { + id: "native-qualification.all-services-qualify-platform", + title: `A platform is native-supported only when all ${nativeServiceNames.length} services qualify`, + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "darwin-arm64", + qualifiedServices: nativeServiceNames, + failedServices: [], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "darwin-arm64" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { + qualified: true, + qualified_service_count: nativeServiceNames.length, + failed_service_count: 0, + }, + output: { api: { platform: "darwin-arm64", qualified: true, services: nativeServiceNames } }, + }, + }, + { + id: "native-qualification.one-service-failure-disables-platform", + title: "One failed service disables native mode for the whole platform", + area: "native-qualification", + given: [ + { + kind: "native-qualification", + platform: "linux-amd64", + qualifiedServices: nativeServiceNames.filter((service) => service !== "imgproxy"), + failedServices: ["imgproxy"], + }, + ], + when: { + interface: "managed-api", + method: "preflightNative", + input: { platform: "linux-amd64" }, + }, + expected: { + outcome: "error", + error: { + code: "NATIVE_PLATFORM_NOT_QUALIFIED", + message: "linux-amd64 is missing qualification for imgproxy", + recovery: ["Use Docker", "Complete imgproxy qualification for linux-amd64"], + }, + writes: [], + runtimeEffects: [], + details: { + qualified: false, + qualified_service_count: nativeServiceNames.length - 1, + failed_service_count: 1, + reduced_graph: false, + docker_fallback_per_service: false, + }, + output: { + api: { + platform: "linux-amd64", + qualified: false, + code: "NATIVE_PLATFORM_NOT_QUALIFIED", + failedServices: ["imgproxy"], + availableServices: [], + recovery: ["Use Docker", "Complete imgproxy qualification for linux-amd64"], + }, + }, + }, + }, + { + id: "native-qualification.unsupported-platform-fails-preflight", + title: "An unsupported native platform fails deterministic preflight", + area: "native-qualification", + given: [ + { kind: "runtime-request", source: "cli", runtime: "native" }, + { + kind: "native-qualification", + platform: "darwin-x64", + qualifiedServices: [], + failedServices: nativeServiceNames, + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "NATIVE_PLATFORM_UNSUPPORTED", + message: "Native mode is not qualified on darwin-x64", + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Native mode is unsupported on darwin-x64", + fields: { code: "NATIVE_PLATFORM_UNSUPPORTED", platform: "darwin-x64" }, + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + json: { + outcome: "error", + code: "NATIVE_PLATFORM_UNSUPPORTED", + platform: "darwin-x64", + supported_platforms: ["darwin-arm64", "linux-amd64", "linux-arm64"], + recovery: ["Use Docker", "Use darwin-arm64, linux-amd64, or linux-arm64"], + }, + }, + }, + }, +]); + +const selectorConflictFixture = ( + id: string, + title: string, + selectors: ReadonlyArray, + selectorSummary: string, +): ManagedStackContractScenario => ({ + id, + title, + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-main-default", status: "active" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", ...selectors], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", + message: "Choose exactly one of contextual, --stack, --stack-id, or --all selection", + recovery: ["Remove all but one stack selector"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Stack selectors cannot be combined", + fields: { selectors: selectorSummary }, + recovery: ["Remove all but one stack selector"], + }, + json: { + outcome: "error", + code: "MUTUALLY_EXCLUSIVE_STACK_SELECTORS", + selectors: selectorSummary.split(", "), + recovery: ["Remove all but one stack selector"], + }, + }, + }, +}); + +const additionalLifecycleContractFixtures = defineManagedStackContractFixtures([ + { + id: "bootstrap.existing-managed-target-ignores-legacy", + title: "An existing managed target starts without reading legacy state", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "reuse", + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false }, + output: { + api: { stackId: "stack-main-default", bootstrap: "not-attempted", legacyStateRead: false }, + }, + }, + }, + { + id: "bootstrap.incompatible-legacy-starts-fresh", + title: "A first start with incompatible stopped legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "incompatible", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + bootstrap: "fresh", + legacy_state: "incompatible", + legacy_state_mutated: false, + }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.absent-legacy-starts-fresh", + title: "A first start without legacy state creates a fresh managed target", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "absent", + database: "absent", + storage: "absent", + credentials: "absent", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { bootstrap: "fresh", legacy_state: "absent", legacy_state_mutated: false }, + output: { + human: { summary: "Created a fresh main/default stack", fields: { bootstrap: "fresh" } }, + json: { + outcome: "create", + stack_id: "stack-main-default", + bootstrap: "fresh", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "bootstrap.running-legacy-source-fails-without-mutation", + title: "A running legacy source fails without stopping, copying, or publishing", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "error", + error: { + code: "LEGACY_SOURCE_RUNNING", + message: "The legacy stack must be stopped before it can be copied", + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + details: { + legacy_source_stopped: false, + managed_target_published: false, + partial_state: false, + }, + output: { + human: { + summary: "The legacy stack must be stopped before bootstrap", + fields: { code: "LEGACY_SOURCE_RUNNING" }, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + json: { + outcome: "error", + code: "LEGACY_SOURCE_RUNNING", + legacy_source_stopped: false, + managed_target_published: false, + recovery: ["Stop the legacy stack", "Retry supabase start --experimental"], + }, + }, + }, + }, + { + id: "bootstrap.failed-copy-rolls-back", + title: "A failed bootstrap removes partial managed state before publication", + area: "bootstrap", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", injectCopyFailure: true }, + }, + expected: { + outcome: "error", + error: { + code: "LEGACY_BOOTSTRAP_FAILED", + message: "Copying compatible legacy state failed before publication", + recovery: ["Retry the same start command after correcting the copy failure"], + }, + writes: [{ target: "managed-state", operation: "delete", id: "stack-main-default" }], + runtimeEffects: [{ operation: "delete", stackId: "stack-main-default" }], + details: { + active_target_exists: false, + registry_record_published: false, + legacy_state_mutated: false, + }, + output: { + api: { + outcome: "error", + code: "LEGACY_BOOTSTRAP_FAILED", + activeTargetExists: false, + registryRecordPublished: false, + retryable: true, + recovery: ["Retry the same start command after correcting the copy failure"], + }, + }, + }, + }, + { + id: "bootstrap.retry-after-failed-copy-succeeds", + title: "The same start succeeds after a failed bootstrap was rolled back", + area: "bootstrap", + given: [ + { + kind: "operation-result", + operation: "legacy-bootstrap", + stackId: "stack-main-default", + outcome: "rolled-back", + }, + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + retry_after_rollback: true, + same_start_request: true, + legacy_state_mutated: false, + }, + output: { + api: { + outcome: "create", + stackId: "stack-main-default", + bootstrap: "copied", + }, + }, + }, + }, + { + id: "bootstrap.managed-and-legacy-diverge-after-copy", + title: "Managed starts never reread legacy state after a successful bootstrap", + area: "bootstrap", + given: [ + ...mainCheckoutContextFacts, + { kind: "managed-target", stackId: "stack-main-default", exists: true }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "incompatible", + credentials: "incompatible", + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { legacy_state_read: false, legacy_state_mutated: false, timelines_diverged: true }, + output: { + human: { + summary: "Started main/default from independent managed state", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "reuse", + stack_id: "stack-main-default", + bootstrap: "not-attempted", + timelines_diverged: true, + }, + }, + }, + }, + { + id: "credentials.configured-values-are-authoritative", + title: "Configured auth values are authoritative and persist globally only by reference", + area: "credentials", + given: [ + ...freshMainManagedStartFacts, + { kind: "credential-state", source: "configured", valuesId: "configured-auth-v1" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default", auth: "configured-auth-v1" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + credential_values_id: "configured-auth-v1", + source: "configured", + global_credentials_reference: "configured-auth-v1", + plaintext_secrets_in_global_state: false, + }, + output: { + api: { + stackId: "stack-main-default", + credentialsSource: "configured", + credentialsValuesId: "configured-auth-v1", + }, + }, + }, + }, + { + id: "credentials.omitted-values-use-stable-defaults", + title: "Omitted auth values use stable local defaults", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + ...freshMainManagedStartFacts, + { kind: "credential-state", source: "local-default", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "create", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "create", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { + credential_values_id: "stable-local-defaults-v1", + generated_per_start: false, + plaintext_secrets_in_global_state: false, + }, + output: { + human: { + summary: "Created main/default with stable local credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "create", + stack_id: "stack-main-default", + credentials_source: "local-default", + credentials_stable: true, + }, + }, + }, + }, + { + id: "credentials.unchanged-values-survive-restart", + title: "Unchanged credential values remain valid across restart", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { kind: "credential-state", source: "persisted", valuesId: "stable-local-defaults-v1" }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "reuse", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { credential_values_id: "stable-local-defaults-v1", credentials_rotated: false }, + output: { + human: { + summary: "Started main/default with unchanged credentials", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { outcome: "reuse", stack_id: "stack-main-default", credentials_unchanged: true }, + }, + }, + }, + { + id: "credentials.explicit-change-applies-after-stop", + title: "An explicit auth change applies to a stopped stack on next start", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { interface: "cli", argv: ["start", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [ + { target: "managed-state", operation: "update", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + details: { plaintext_secrets_in_global_state: false }, + output: { + human: { + summary: "Updated credentials and started main/default", + fields: { stackId: "stack-main-default", stack: "default" }, + }, + json: { + outcome: "update", + stack_id: "stack-main-default", + previous_credentials_values_id: "configured-auth-v1", + credentials_values_id: "configured-auth-v2", + }, + }, + }, + }, + { + id: "credentials.running-change-reports-drift", + title: "An auth change on a running stack reports unapplied drift", + area: "credentials", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "credential-state", + source: "configured", + valuesId: "configured-auth-v2", + previousValuesId: "configured-auth-v1", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: mainDefaultSelection, + warning: { + code: "RUNNING_STACK_CREDENTIALS_DRIFT", + message: "Configured auth values differ from the running stack", + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "main/default is running with unapplied auth configuration", + fields: { stackId: "stack-main-default", drift: "true" }, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + json: { + outcome: "report", + code: "RUNNING_STACK_CREDENTIALS_DRIFT", + stack_id: "stack-main-default", + drift: true, + recovery: ["Run supabase stop --experimental, then supabase start --experimental"], + }, + }, + }, + }, + { + id: "credentials.compatible-legacy-auth-is-retained", + title: "Compatible legacy auth configuration is retained during bootstrap", + area: "credentials", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + { kind: "credential-state", source: "legacy", valuesId: "legacy-auth-v1" }, + ], + when: { + interface: "managed-api", + method: "startStack", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "create", + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + credential_values_id: "legacy-auth-v1", + legacy_state_mutated: false, + plaintext_secrets_in_global_state: false, + }, + output: { + api: { + stackId: "stack-main-default", + bootstrap: "copied", + credentialsValuesId: "legacy-auth-v1", + }, + }, + }, + }, + { + id: "credentials.plaintext-secrets-stay-out-of-global-state", + title: "Resolved plaintext secrets are absent from the global managed registry", + area: "credentials", + given: [ + { + kind: "credential-state", + source: "persisted", + valuesId: "configured-auth-v1", + plaintextPresentInGlobalState: false, + }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + ], + when: { + interface: "managed-api", + method: "inspectGlobalRecord", + input: { stackId: "stack-main-default" }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { plaintext_secrets_present: false }, + output: { + api: { + stackId: "stack-main-default", + credentialsReference: "configured-auth-v1", + plaintextSecrets: [], + }, + }, + }, + }, + { + id: "reclamation.default-stop-preserves-data", + title: "Default experimental stop preserves managed data", + area: "reclamation", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { data_preserved: true, registry_record_preserved: true }, + output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, + json: { outcome: "update", stack_id: "stack-main-default", data_preserved: true }, + }, + }, + }, + { + id: "reclamation.delete-repeat-is-idempotent", + title: "Repeating global deletion of a tombstoned stack is a successful no-op", + area: "reclamation", + given: [{ kind: "managed-record", stackId: "stack-orphan", status: "tombstoned" }], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { tombstoned: true, idempotent: true }, + output: { + human: { + summary: "Stack stack-orphan was already deleted", + fields: { stackId: "stack-orphan" }, + }, + json: { + outcome: "no-op", + stack_id: "stack-orphan", + tombstoned: true, + already_deleted: true, + }, + }, + }, + }, + { + id: "reclamation.branch-delete-does-not-delete-data", + title: "Deleting a Git branch alone never deletes its mutable stack data", + area: "reclamation", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { kind: "branch", name: "feat-a", contextId: "context-feat", checkedOut: false }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-main", + }, + { + kind: "stack", + name: "default", + stackId: "stack-feat-default", + checkoutId: "checkout-a", + contextId: "context-feat", + lifecycle: "stopped", + }, + ], + when: { interface: "git", argv: ["branch", "-D", "feat-a"], cwd: "checkout-a" }, + expected: { + outcome: "no-op", + writes: [], + runtimeEffects: [], + details: { + managed_command_ran: false, + stack_data_preserved: true, + stack_orphaned: true, + orphaned_stack_id: "stack-feat-default", + }, + output: { human: { summary: "Deleted branch feat-a", fields: {} } }, + }, + }, + { + id: "reclamation.prune-removes-metadata-only", + title: "Prune removes orphan metadata without deleting mutable stack data", + area: "reclamation", + given: [ + { kind: "managed-record", stackId: "stack-orphan", status: "orphaned" }, + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + contextId: "context-orphan", + lifecycle: "stopped", + orphaned: true, + }, + ], + when: { interface: "cli", argv: ["stack", "prune", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + writes: [{ target: "registry", operation: "delete", id: "stack-orphan" }], + runtimeEffects: [], + details: { metadata_removed: true, mutable_data_deleted: false }, + output: { + human: { summary: "Pruned 1 orphaned metadata record", fields: { dataDeleted: "false" } }, + json: { + outcome: "update", + pruned_records: ["stack-orphan"], + pruned_count: 1, + mutable_data_deleted: false, + }, + }, + }, + }, + selectorConflictFixture( + "reclamation.selectors-stack-and-stack-id-conflict", + "Named and global-ID stack selectors cannot be combined", + ["--stack", "review", "--stack-id", "stack-main-default"], + "--stack, --stack-id", + ), + selectorConflictFixture( + "reclamation.selectors-stack-and-all-conflict", + "Named and all-stack selectors cannot be combined", + ["--stack", "review", "--all"], + "--stack, --all", + ), + selectorConflictFixture( + "reclamation.selectors-stack-id-and-all-conflict", + "Global-ID and all-stack selectors cannot be combined", + ["--stack-id", "stack-main-default", "--all"], + "--stack-id, --all", + ), + { + id: "reclamation.stop-is-engine-scoped", + title: "Experimental stop affects the selected managed stack and never the legacy engine", + area: "reclamation", + given: [ + ...mainCheckoutContextFacts, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { + kind: "legacy-state", + lifecycle: "running", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { interface: "cli", argv: ["stop", "--experimental"], cwd: "checkout-a" }, + expected: { + outcome: "update", + selection: mainDefaultSelection, + writes: [{ target: "runtime-state", operation: "update", id: "stack-main-default" }], + runtimeEffects: [{ operation: "stop", stackId: "stack-main-default" }], + details: { + managed_stack_stopped: true, + legacy_stack_stopped: false, + legacy_state_mutated: false, + data_preserved: true, + registry_record_preserved: true, + }, + output: { + human: { summary: "Stopped main/default", fields: { dataPreserved: "true" } }, + json: { + outcome: "update", + stack_id: "stack-main-default", + managed_stack_stopped: true, + legacy_stack_stopped: false, + data_preserved: true, + }, + }, + }, + }, +]); + +const additionalApiBoundaryContractFixtures = defineManagedStackContractFixtures([ + { + id: "api-boundary.managed-api-accepts-injected-repository", + title: "The managed API accepts an injected repository without CLI ownership", + area: "api-boundary", + given: [ + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", + repository: "injected", + repositoryId: "test-repository", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "createManagedStackService", + input: { repository: "test-repository", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + writes: [{ target: "ephemeral-state", operation: "create", id: "test-repository" }], + runtimeEffects: [], + details: { cli_required: false, repository_injected: true }, + output: { + api: { + service: "managed-stack-service", + repository: "test-repository", + cliRequired: false, + }, + }, + }, + }, + { + id: "api-boundary.managed-api-accepts-isolated-state-root", + title: "The managed API can run against an isolated caller-provided state root", + area: "api-boundary", + given: [ + { kind: "managed-target", stackId: "stack-main-default", exists: false }, + { kind: "identity-claim", scope: "project", id: "project-a", status: "absent" }, + { kind: "identity-claim", scope: "checkout", id: "checkout-a", status: "absent" }, + { kind: "identity-claim", scope: "context", id: "context-main", status: "absent" }, + { kind: "workspace", mode: "git", path: "checkout-a" }, + { + kind: "git-state", + workspacePath: "checkout-a", + commonDirectory: "repo/.git", + gitDirectory: "repo/.git", + head: "branch", + branch: "main", + commit: "commit-a", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + stateRootPath: "/tmp/managed-contract", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "resolveStack", + input: { cwd: "checkout-a", stackName: "default", stateRoot: "/tmp/managed-contract" }, + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "git-config", operation: "create", id: "project-a", scope: "common" }, + { target: "git-checkout-id", operation: "create", id: "checkout-a" }, + { + target: "git-config", + operation: "create", + id: "context-main", + scope: "common", + owner: "main", + }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "managed-state", operation: "create", id: "stack-main-default" }, + ], + runtimeEffects: [], + details: { + state_root: "/tmp/managed-contract", + project_identity_storage: "git-local", + default_system_state_mutated: false, + }, + output: { + api: { projectId: "project-a", checkoutId: "checkout-a", stackId: "stack-main-default" }, + }, + }, + }, + { + id: "api-boundary.repository-contract-is-storage-agnostic", + title: "The same repository contract produces identical decisions across storage adapters", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "persistent-adapter", + runtime: "node", + }, + ], + when: { + interface: "managed-api", + method: "runRepositoryContract", + input: { + adapters: ["in-memory", "persistent-adapter"], + scenarioId: "identity.return-to-branch-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { decisions_equal: true, persistence_semantics_leaked: false }, + output: { + api: { + "in-memory": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + "persistent-adapter": { + outcome: "reuse", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + equal: true, + }, + }, + }, + }, + { + id: "api-boundary.cli-projects-shared-managed-results", + title: "The CLI projects one shared managed result instead of deciding identity twice", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "default", + repository: "persistent-adapter", + runtime: "bun", + }, + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { kind: "managed-record", stackId: "stack-main-default", status: "active" }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "running", + }, + { kind: "persisted-runtime", stackId: "stack-main-default", runtime: "docker" }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "report", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [], + runtimeEffects: [], + details: { identity_decisions_in_cli: 0, managed_result_projected: true }, + output: { + human: { + summary: "main/default is running", + fields: { stackId: "stack-main-default", runtime: "docker" }, + }, + json: { + outcome: "report", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + runtime: "docker", + }, + }, + }, + }, + { + id: "api-boundary.managed-surface-is-node-and-bun-portable", + title: "The managed service contract has the same public result under Node and Bun", + area: "api-boundary", + given: [ + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "node", + }, + { + kind: "managed-api-options", + stateRoot: "isolated", + repository: "in-memory", + runtime: "bun", + }, + ], + when: { + interface: "managed-api", + method: "runPortableContract", + input: { + runtimes: ["node", "bun"], + scenarioId: "identity.same-checkout-branch-and-name-reuses-stack", + }, + }, + expected: { + outcome: "report", + writes: [], + runtimeEffects: [], + details: { results_equal: true, bun_specific_state_api: false }, + output: { + api: { + node: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + bun: { + outcome: "report", + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + equal: true, + }, + }, + }, + }, +]); + +export const managedStackContractFixtures = defineManagedStackContractFixtures([ + ...additionalIdentityContractFixtures, + ...additionalPortContractFixtures, + ...additionalRuntimeContractFixtures, + ...additionalLifecycleContractFixtures, + ...additionalApiBoundaryContractFixtures, + { + id: "identity.return-to-branch-reuses-stack", + title: "Returning to a previously used branch reuses its stack", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "reuse", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [{ target: "runtime-state", operation: "start", id: "stack-main-default" }], + runtimeEffects: [{ operation: "start", stackId: "stack-main-default" }], + output: { + human: { + summary: "Reused main/default", + fields: { + branch: "main", + stack: "default", + stackId: "stack-main-default", + }, + }, + json: { + outcome: "reuse", + project_id: "project-a", + checkout_id: "checkout-a", + context_id: "context-main", + stack_id: "stack-main-default", + stack_name: "default", + }, + }, + }, + }, + { + id: "identity.branch-copy-ambiguous-read-only", + title: "An ambiguous copied branch is reported without mutation", + area: "identity", + given: [ + { + kind: "checkout", + path: "checkout-a", + projectId: "project-a", + checkoutId: "checkout-a", + }, + { + kind: "branch", + name: "main", + contextId: "context-main", + checkedOut: false, + }, + { + kind: "branch", + name: "feat-copy", + contextId: "context-main", + checkedOut: true, + }, + { + kind: "identity-transition", + operation: "branch-copy", + from: "main", + to: "feat-copy", + originalExists: true, + }, + { + kind: "identity-claim", + scope: "context", + id: "context-main", + status: "ambiguous", + }, + ], + when: { + interface: "cli", + argv: ["status", "--experimental", "--output-format", "json"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "AMBIGUOUS_CONTEXT_OWNER", + message: "Branches feat-copy and main both claim context-main", + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot determine which branch owns this stack context", + fields: { + contextId: "context-main", + branches: "feat-copy, main", + }, + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + json: { + outcome: "error", + code: "AMBIGUOUS_CONTEXT_OWNER", + context_id: "context-main", + branches: ["feat-copy", "main"], + recovery: [ + "supabase stack inspect --context-id context-main", + "supabase stack new-context --branch feat-copy", + ], + }, + }, + }, + }, + { + id: "ports.explicit-port-conflict-fails", + title: "An occupied declarative port fails without relocation", + area: "ports", + given: [ + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + }, + { + kind: "occupied-port", + port: 54321, + owner: "external-process", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + error: { + code: "EXACT_PORT_OCCUPIED", + message: "api.port requires 54321, but that port is already in use", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because configured port 54321 is in use", + fields: { + port: "54321", + configKey: "api.port", + owner: "external-process", + }, + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + owner: "external-process", + recovery: [ + "Stop the process using port 54321", + "Change api.port in supabase/config.toml", + "Remove api.port to use automatic allocation", + ], + }, + }, + }, + }, + { + id: "ports.explicit-port-conflict-with-sibling-fails", + title: "A sibling managed stack holding a declarative port is identified precisely", + area: "ports", + given: [ + { + kind: "checkout", + path: "worktree-feat-a", + projectId: "project-a", + checkoutId: "checkout-feat-a", + }, + { kind: "branch", name: "feat-a", contextId: "context-feat-a", checkedOut: true }, + { kind: "managed-target", stackId: "stack-feat-a-default", exists: false }, + { + kind: "config-port", + key: "api.port", + intent: "exact", + value: 54321, + source: "local", + }, + { + kind: "occupied-port", + port: 54321, + owner: "managed-stack", + ownerId: "stack-main-default", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "worktree-feat-a", + }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-feat-a", + contextId: "context-feat-a", + stackId: "stack-feat-a-default", + stackName: "default", + }, + error: { + code: "EXACT_PORT_OCCUPIED", + message: "api.port requires 54321, but stack-main-default already owns that port", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot start because a sibling stack uses configured port 54321", + fields: { + port: "54321", + configKey: "api.port", + owner: "managed-stack", + ownerStackId: "stack-main-default", + }, + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + json: { + outcome: "error", + code: "EXACT_PORT_OCCUPIED", + port: 54321, + config_key: "api.port", + owner: "managed-stack", + owner_stack_id: "stack-main-default", + recovery: [ + "Stop managed stack stack-main-default", + "Change api.port in supabase/config.toml", + "Remove api.port to let sibling stacks allocate independent ports", + ], + }, + }, + }, + }, + { + id: "runtime.persisted-runtime-conflict-fails", + title: "An existing stack cannot be switched to another runtime by start", + area: "runtime", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "stack", + name: "default", + stackId: "stack-main-default", + checkoutId: "checkout-a", + contextId: "context-main", + lifecycle: "stopped", + }, + { + kind: "persisted-runtime", + stackId: "stack-main-default", + runtime: "docker", + }, + { + kind: "runtime-request", + source: "cli", + runtime: "native", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental", "--runtime", "native"], + cwd: "checkout-a", + }, + expected: { + outcome: "error", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + error: { + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + message: "stack-main-default uses docker, but start requested native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + writes: [], + runtimeEffects: [], + output: { + human: { + summary: "Cannot change the runtime of an existing stack", + fields: { + stackId: "stack-main-default", + persistedRuntime: "docker", + requestedRuntime: "native", + }, + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + json: { + outcome: "error", + code: "RUNTIME_CONFLICTS_WITH_PERSISTED_STACK", + stack_id: "stack-main-default", + persisted_runtime: "docker", + requested_runtime: "native", + recovery: [ + "Start a new named stack with --stack ", + "Delete and recreate stack-main-default", + ], + }, + }, + }, + }, + { + id: "bootstrap.first-start-copies-compatible-legacy-state", + title: "First experimental start copies compatible stopped legacy state", + area: "bootstrap", + given: [ + { kind: "checkout", path: "checkout-a", projectId: "project-a", checkoutId: "checkout-a" }, + { kind: "branch", name: "main", contextId: "context-main", checkedOut: true }, + { + kind: "managed-target", + stackId: "stack-main-default", + exists: false, + }, + { + kind: "legacy-state", + lifecycle: "stopped", + database: "compatible", + storage: "compatible", + credentials: "compatible", + }, + ], + when: { + interface: "cli", + argv: ["start", "--experimental"], + cwd: "checkout-a", + }, + expected: { + outcome: "create", + selection: { + projectId: "project-a", + checkoutId: "checkout-a", + contextId: "context-main", + stackId: "stack-main-default", + stackName: "default", + }, + writes: [ + { target: "managed-state", operation: "copy", id: "stack-main-default" }, + { target: "registry", operation: "publish", id: "stack-main-default" }, + { target: "runtime-state", operation: "start", id: "stack-main-default" }, + ], + runtimeEffects: [ + { operation: "copy", stackId: "stack-main-default" }, + { operation: "start", stackId: "stack-main-default" }, + ], + details: { + bootstrap: "copied", + legacy_state_mutated: false, + credentials: "preserved", + }, + output: { + human: { + summary: "Created main/default from compatible legacy state", + fields: { + stack: "default", + stackId: "stack-main-default", + bootstrap: "copied", + credentials: "preserved", + }, + }, + json: { + outcome: "create", + bootstrap: "copied", + stack_id: "stack-main-default", + credentials: "preserved", + legacy_state_mutated: false, + }, + }, + }, + }, + { + id: "reclamation.delete-orphan-by-stack-id", + title: "An orphaned stack can be deleted globally by opaque ID", + area: "reclamation", + given: [ + { + kind: "stack", + name: "default", + stackId: "stack-orphan", + checkoutId: "checkout-orphan", + contextId: "context-orphan", + lifecycle: "running", + orphaned: true, + }, + ], + when: { + interface: "cli", + argv: ["stop", "--experimental", "--stack-id", "stack-orphan", "--no-backup"], + cwd: "outside-any-checkout", + }, + expected: { + outcome: "delete", + writes: [ + { target: "runtime-state", operation: "delete", id: "stack-orphan" }, + { target: "managed-state", operation: "delete", id: "stack-orphan" }, + { target: "registry", operation: "tombstone", id: "stack-orphan" }, + ], + runtimeEffects: [ + { operation: "stop", stackId: "stack-orphan" }, + { operation: "delete", stackId: "stack-orphan" }, + ], + details: { + tombstoned: true, + checkout_required: false, + }, + output: { + human: { + summary: "Deleted managed stack stack-orphan", + fields: { + stackId: "stack-orphan", + orphaned: "true", + tombstoned: "true", + }, + }, + json: { + outcome: "delete", + stack_id: "stack-orphan", + orphaned: true, + tombstoned: true, + }, + }, + }, + }, + { + id: "api-boundary.direct-create-stack-is-ephemeral", + title: "Direct createStack usage is isolated from managed system state", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack", "runtime"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-runtime-root-temporary", + title: "Direct createStack keeps an omitted runtime root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "explicit", + runtimeRoot: "omitted", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { + projectDir: "/work/project-a", + cacheRoot: "/work/cache", + stackRoot: "/work/stack", + startupMode: "lazy", + }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["runtime"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-create-stack-keeps-omitted-stack-root-temporary", + title: "Direct createStack keeps an omitted stack root temporary", + area: "api-boundary", + given: [ + { + kind: "direct-stack-options", + stackRoot: "omitted", + runtimeRoot: "explicit", + }, + ], + when: { + interface: "stack-api", + method: "createStack", + input: { runtimeRoot: "/work/runtime", startupMode: "lazy" }, + }, + expected: { + outcome: "create", + writes: [ + { + target: "temporary-root", + operation: "create", + id: "ephemeral-stack-root", + root: "stack", + }, + ], + runtimeEffects: [], + details: { + git_inspected: false, + identity_marker_created: false, + global_registry_mutated: false, + temporary_roots: ["stack"], + }, + output: { api: directStackApiProjection }, + }, + }, + { + id: "api-boundary.direct-dispose-removes-temporary-roots", + title: "Disposing a direct stack removes every omitted temporary root", + area: "api-boundary", + given: [ + { + kind: "direct-stack-state", + handle: "stack-handle", + temporaryRoots: [ + { root: "stack", stateId: "ephemeral-stack-root" }, + { root: "runtime", stateId: "ephemeral-runtime-root" }, + ], + lifecycle: "created", + }, + ], + when: { + interface: "stack-api", + method: "dispose", + input: {}, + }, + expected: { + outcome: "delete", + writes: [ + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-stack-root", + root: "stack", + }, + { + target: "temporary-root", + operation: "delete", + id: "ephemeral-runtime-root", + root: "runtime", + }, + ], + runtimeEffects: [], + details: { + temporary_roots_removed: true, + removed_temporary_roots: ["stack", "runtime"], + }, + output: {}, + }, + }, +]); diff --git a/packages/stack/src/managed-stack.unit.test.ts b/packages/stack/src/managed-stack.unit.test.ts deleted file mode 100644 index 8f730fc090..0000000000 --- a/packages/stack/src/managed-stack.unit.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { FileSystem, Path } from "effect"; -import type { AllocatedPorts } from "./PortAllocator.ts"; -import { resolveManagedStack } from "./managed-stack.ts"; -import { StateManager, projectStateManagerPaths, type StackState } from "./StateManager.ts"; - -const DEFAULT_PORTS: AllocatedPorts = { - apiPort: 54321, - dbPort: 54322, - authPort: 54330, - postgrestPort: 54331, - postgrestAdminPort: 54332, - edgeRuntimePort: 54338, - edgeRuntimeInspectorPort: 54339, - realtimePort: 54333, - storagePort: 54334, - imgproxyPort: 54335, - mailpitPort: 54324, - mailpitSmtpPort: 54325, - mailpitPop3Port: 54326, - pgmetaPort: 54336, - studioPort: 54323, - analyticsPort: 54327, - poolerPort: 54329, - poolerApiPort: 54337, -}; - -function makeState(overrides: Partial = {}): StackState { - return { - pid: 12345, - name: "my-project", - projectDir: "/Users/test/Code/myapp", - apiPort: 54321, - dbPort: 54322, - ports: DEFAULT_PORTS, - socketPath: "/tmp/supabase/s-123456789abc/daemon.sock", - startedAt: "2026-03-04T10:00:00Z", - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "pk_test", - secretKey: "sk_test", - anonJwt: "anon_jwt", - serviceRoleJwt: "service_role_jwt", - serviceEndpoints: {}, - services: { - postgres: "17.6.1.081", - auth: "2.188.0-rc.15", - }, - ...overrides, - }; -} - -function mockFileSystem() { - const files = new Map(); - const dirs = new Set(); - - const layer = Layer.succeed(FileSystem.FileSystem, { - [FileSystem.FileSystem.key]: FileSystem.FileSystem.key, - exists: (path: string) => Effect.succeed(files.has(path) || dirs.has(path)), - makeDirectory: (dirPath: string) => - Effect.sync(() => { - let current = dirPath; - while (current && current !== "/") { - dirs.add(current); - const parent = require("node:path").dirname(current); - if (parent === current) break; - current = parent; - } - }), - readDirectory: (dirPath: string) => - Effect.sync(() => { - const entries: string[] = []; - const prefix = dirPath.endsWith("/") ? dirPath : `${dirPath}/`; - const allKeys = Array.from(files.keys()).concat(Array.from(dirs)); - for (const key of allKeys) { - if (key.startsWith(prefix)) { - const rest = key.slice(prefix.length); - const segment = rest.split("/")[0]; - if (segment && !entries.includes(segment)) { - entries.push(segment); - } - } - } - return entries; - }), - writeFileString: (path: string, content: string) => - Effect.sync(() => { - files.set(path, content); - }), - readFileString: (path: string) => - Effect.sync(() => { - const content = files.get(path); - if (content == null) throw new Error(`File not found: ${path}`); - return content; - }), - remove: (rmPath: string) => - Effect.sync(() => { - for (const key of Array.from(files.keys())) { - if (key === rmPath || key.startsWith(`${rmPath}/`)) files.delete(key); - } - for (const key of Array.from(dirs)) { - if (key === rmPath || key.startsWith(`${rmPath}/`)) dirs.delete(key); - } - }), - rename: (oldPath: string, newPath: string) => - Effect.sync(() => { - const content = files.get(oldPath); - if (content == null) throw new Error(`File not found: ${oldPath}`); - files.delete(oldPath); - files.set(newPath, content); - }), - } as unknown as FileSystem.FileSystem); - - return { layer, files }; -} - -function mockPath() { - const nodePath = require("node:path"); - return Layer.succeed(Path.Path, { - [Path.Path.key]: Path.Path.key, - ...nodePath, - } as unknown as Path.Path); -} - -function setup() { - const fsm = mockFileSystem(); - const layer = Layer.merge(fsm.layer, mockPath()); - return { layer, files: fsm.files }; -} - -const makeStateManager = StateManager.pipe( - Effect.provide( - StateManager.make(projectStateManagerPaths("/test-home", "/Users/test/Code/myapp")), - ), -); - -describe("resolveManagedStack", () => { - it.effect("resolves a live stack by explicit name", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const mgr = yield* makeStateManager; - yield* mgr.write(makeState({ pid: process.pid })); - - const result = yield* resolveManagedStack({ - cacheRoot: "/test-home", - name: "my-project", - }); - - expect(result.alive).toBe(true); - expect(result.state.name).toBe("my-project"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("resolves a live stack by cwd walk-up", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const mgr = yield* makeStateManager; - yield* mgr.write(makeState({ pid: process.pid, projectDir: "/Users/test/Code/myapp" })); - - const result = yield* resolveManagedStack({ - cacheRoot: "/test-home", - cwd: "/Users/test/Code/myapp/src/components", - }); - - expect(result.alive).toBe(true); - expect(result.state.projectDir).toBe("/Users/test/Code/myapp"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("resolves the requested named stack within the same project", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const mgr = yield* makeStateManager; - yield* mgr.write(makeState({ name: "default", pid: 999999 })); - yield* mgr.write(makeState({ name: "preview", pid: process.pid })); - - const result = yield* resolveManagedStack({ - cacheRoot: "/test-home", - projectDir: "/Users/test/Code/myapp", - name: "preview", - }); - - expect(result.alive).toBe(true); - expect(result.state.name).toBe("preview"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("removes stale state for dead stacks", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const mgr = yield* makeStateManager; - yield* mgr.write(makeState({ pid: 999999 })); - - const result = yield* resolveManagedStack({ - cacheRoot: "/test-home", - name: "my-project", - }); - - expect(result.alive).toBe(false); - const readExit = yield* mgr.read("my-project").pipe(Effect.exit); - expect(readExit._tag).toBe("Failure"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails when no stack matches", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const exit = yield* resolveManagedStack({ - cacheRoot: "/test-home", - cwd: "/Users/test/Code/myapp", - }).pipe(Effect.exit); - - expect(exit._tag).toBe("Failure"); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts new file mode 100644 index 0000000000..80dc7e6446 --- /dev/null +++ b/packages/stack/src/managed.ts @@ -0,0 +1,32 @@ +export * from "./managed/identity.ts"; +export * from "./managed/ids.ts"; +export * from "./managed/model.ts"; +export * from "./managed/paths.ts"; +export * from "./managed/service.ts"; +// Only the repository contract is public. The port-ownership and update-guard +// helpers behind it are invariants the adapters share with each other, not API +// consumers can call meaningfully, and the in-memory adapter is a test seam +// exported through `@supabase/stack/testing` instead. +export { ManagedStackRepository } from "./managed/repository.ts"; +export type { + ClaimManagedOperationFailure, + ClaimManagedOperationInput, + ClaimManagedOperationResult, + ManagedStackRepositoryShape, + OwnedManagedStackFailure, + PrepareOrdinaryStackFailure, + PrepareOrdinaryStackInput, + PrepareOrdinaryStackResult, + ReconcileManagedOperationFailure, + ReconcileManagedOperationResult, + UpdateManagedStackFailure, + UpdateManagedStackInput, +} from "./managed/repository.ts"; +export type { + CreateManagedStackServiceOptions, + MakeManagedStackServiceOptions, + ManagedStackLayerFailure, + ManagedStackServiceHandle, + ProvisionOrdinaryStackRequest, + ReconcileAbandonedOperationsRequest, +} from "./managed/create-service.ts"; diff --git a/packages/stack/src/managed/atomic-claim.ts b/packages/stack/src/managed/atomic-claim.ts new file mode 100644 index 0000000000..34fb293d1a --- /dev/null +++ b/packages/stack/src/managed/atomic-claim.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto"; +import { link, unlink, writeFile } from "node:fs/promises"; +import { errorCode } from "./error-code.ts"; + +export type FileClaimOutcome = "claimed" | "already-exists"; + +export interface FileClaimOptions { + /** Mode for the published file; defaults to the process umask. */ + readonly mode?: number; + /** + * Distinguishes one claimant's temporary file from another's; defaults to a + * random UUID. Callers that already draw identifiers from an injected factory + * pass one from there, so a deterministic run stays deterministic. + */ + readonly temporaryId?: string; + /** + * The hardlink step, overridable so a test can drive the hardlink-less + * fallback on a filesystem that does support hardlinks. + */ + readonly linkFile?: (existingPath: string, newPath: string) => Promise; +} + +const createExclusively = async ( + targetPath: string, + content: string, + mode: number | undefined, +): Promise => { + try { + await writeFile(targetPath, content, { flag: "wx", mode }); + return "claimed"; + } catch (error: unknown) { + if (errorCode(error) === "EEXIST") { + return "already-exists"; + } + throw error; + } +}; + +/** + * Publishes `content` at `targetPath` unless a claimant got there first. + * + * The content is written to a sibling temporary file and hardlinked into place, + * because `link` publishes the whole file in one step and refuses an existing + * target: writing `targetPath` directly could crash halfway and publish a + * partial claim, and testing for the file before writing it would lose the very + * race the claim exists to settle. Filesystems without hardlinks — exFAT, + * FAT32, some network mounts — refuse `link` with `EPERM` or `ENOTSUP`; those + * fall back to an exclusive create, which still settles the race but gives up + * the all-or-nothing publish. Any other failure is a real one and propagates. + * + * A `SIGKILL` between the temporary write and its removal strands a + * `.tmp.` sibling. Nothing ever reads those, so a stranded one is junk + * rather than a claim anybody can observe, and a retry that reuses the same + * temporary id overwrites it — which is why the temporary write is not + * exclusive. + */ +export const claimFileAtomically = async ( + targetPath: string, + content: string, + options: FileClaimOptions = {}, +): Promise => { + const linkFile = options.linkFile ?? link; + const temporaryPath = `${targetPath}.tmp.${options.temporaryId ?? randomUUID()}`; + await writeFile(temporaryPath, content, { mode: options.mode }); + try { + await linkFile(temporaryPath, targetPath); + return "claimed"; + } catch (error: unknown) { + const code = errorCode(error); + if (code === "EEXIST") { + return "already-exists"; + } + if (code !== "EPERM" && code !== "ENOTSUP") { + throw error; + } + return await createExclusively(targetPath, content, options.mode); + } finally { + await unlink(temporaryPath).catch(() => undefined); + } +}; diff --git a/packages/stack/src/managed/callback.ts b/packages/stack/src/managed/callback.ts new file mode 100644 index 0000000000..20431ed174 --- /dev/null +++ b/packages/stack/src/managed/callback.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; + +/** + * The bridge every caller-supplied callback crosses on its way into the managed + * service. + * + * A callback may answer synchronously, asynchronously, or by throwing either + * way, and whatever it does becomes this effect's outcome unchanged: the + * service's handling of a refused callback is the same as it was when the + * service awaited these callbacks directly. + * + * `isAnswer` recognizes the callback's synchronous answer, and everything else + * is awaited. Testing for the synchronous shape rather than for a `Promise` is + * what makes an answer from another promise implementation — a thenable that is + * not `instanceof Promise` — awaited instead of being mistaken for work that has + * already finished. + */ +export const fromCallback = ( + run: () => A | PromiseLike, + isAnswer: (answer: A | PromiseLike) => answer is A, +): Effect.Effect => + Effect.flatMap(Effect.try({ try: run, catch: (error: unknown) => error }), (answer) => + isAnswer(answer) + ? Effect.succeed(answer) + : Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }), + ); + +/** A callback that answers by finishing, so anything else is still pending. */ +export const isFinished = (answer: void | PromiseLike): answer is void => + answer === undefined; + +export const isBooleanAnswer = (answer: boolean | PromiseLike): answer is boolean => + typeof answer === "boolean"; diff --git a/packages/stack/src/managed/create-service.ts b/packages/stack/src/managed/create-service.ts new file mode 100644 index 0000000000..719fb8b567 --- /dev/null +++ b/packages/stack/src/managed/create-service.ts @@ -0,0 +1,320 @@ +import { Context, Effect, Layer, ManagedRuntime, type FileSystem } from "effect"; +import { fromCallback, isBooleanAnswer, isFinished } from "./callback.ts"; +import { UnsafeManagedStackPathError } from "./model.ts"; +import type { + InvalidManagedOwnerPidError, + ManagedCheckoutLocation, + ManagedOperationRecord, + ManagedStackConfiguration, + ManagedStackRecord, + UnsupportedManagedRegistryVersionError, +} from "./model.ts"; +import { failsWith } from "./failure.ts"; +import { + managedRegistryPath, + requireExplicitManagedStateRoot, + resolveManagedStateRoot, +} from "./paths.ts"; +import { assertManagedOwnerPid, ManagedStackRepository } from "./repository.ts"; +import type { ManagedStackRepositoryShape } from "./repository.ts"; +import { + ManagedStackService, + type DeleteManagedStackResult, + type InspectOrdinaryWorkspaceResult, + type ManagedStackServiceOptions, + type ProvisionOrdinaryStackResult, + type ReconcileAbandonedOperationsResult, +} from "./service.ts"; + +export interface MakeManagedStackServiceOptions extends ManagedStackServiceOptions { + readonly repository: ManagedStackRepositoryShape; +} + +export interface CreateManagedStackServiceOptions { + readonly stateRoot?: string; + readonly repository?: ManagedStackRepositoryShape; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; +} + +export interface ProvisionOrdinaryStackRequest { + readonly workspacePath: string; + readonly stackName?: string; + readonly configuration?: ManagedStackConfiguration; + readonly initialize?: (stack: ManagedStackRecord) => Promise; + readonly validate?: (stack: ManagedStackRecord) => Promise; +} + +export type ReconcileAbandonedOperationsRequest = { + readonly inspectRuntime: ( + stack: ManagedStackRecord, + operation: ManagedOperationRecord, + ) => Promise<"running" | "stopped" | "unknown">; +} & ( + | { readonly startedBefore?: string; readonly force?: never } + | { + readonly startedBefore?: never; + readonly force: { readonly stackId: string; readonly operationToken: string }; + } +); + +/** + * The managed registry as a Promise API. + * + * Every method is Promise-returning, reads included: the registry lives in a + * file this process may have to wait for, so a handle that answered reads + * synchronously would only be hiding that I/O from its caller. The handle is an + * `AsyncDisposable`, so a block that acquires one with `await using` closes it + * on every path out. + */ +export interface ManagedStackServiceHandle extends AsyncDisposable { + readonly stateRoot: string; + readonly repository: ManagedStackRepositoryShape; + provisionOrdinaryStack( + options: ProvisionOrdinaryStackRequest, + ): Promise; + inspectOrdinaryWorkspace(workspacePath: string): Promise; + inspectStack(stackId: string): Promise; + listStacks(options?: { + readonly includeTombstoned?: boolean; + }): Promise>; + updateStack( + stackId: string, + configuration: ManagedStackConfiguration, + ): Promise; + deleteStack( + stackId: string, + options?: { readonly stop?: (stack: ManagedStackRecord) => Promise }, + ): Promise; + reconcileAbandonedOperations( + options: ReconcileAbandonedOperationsRequest, + ): Promise; + pruneCheckoutLocations( + shouldPrune: (location: ManagedCheckoutLocation) => boolean | Promise, + ): Promise; + close(): Promise; +} + +type InspectedManagedRuntime = "running" | "stopped" | "unknown"; + +const isInspectedRuntime = ( + answer: InspectedManagedRuntime | PromiseLike, +): answer is InspectedManagedRuntime => typeof answer === "string"; + +const managedStackServiceHandle = async ( + layer: Layer.Layer, +): Promise => { + const runtime = ManagedRuntime.make(layer); + // Acquiring the service is the I/O it always was: the registry file is opened + // and its schema read, and a cold start may wait out another process' WAL + // conversion. Awaiting it here keeps that failure at the acquisition — a + // registry this process cannot open rejects rather than surfacing at whichever + // later call happens to touch it first — without blocking the event loop. + const context = await runtime.context(); + const service = Context.get(context, ManagedStackService); + const repository = Context.get(context, ManagedStackRepository); + + /** + * Whether this handle has been closed, tracked here rather than read back out + * of the rejection a closed run produces: a disposed `ManagedRuntime` answers + * by dying with a bare string, and deciding from that string's contents would + * misreport a caller's own callback rejecting with a string that happens to + * mention disposal. + */ + let closed = false; + const dispose = (): Promise => { + closed = true; + return runtime.dispose(); + }; + + /** + * Every method's run, so a call that arrives after `close` is reported as one: + * the runtime's bare string reaches the caller as a rejection with no name, + * message, or stack. While the handle is open, every failure is the failure + * itself and passes through untouched. + */ + const run = (effect: Effect.Effect): Promise => + runtime.runPromise(effect).catch((error: unknown) => { + throw closed + ? new Error(`The managed stack service handle is closed (${String(error)})`) + : error; + }); + + return { + stateRoot: service.stateRoot, + repository, + provisionOrdinaryStack: (options) => { + const initialize = options.initialize; + const validate = options.validate; + return run( + service.provisionOrdinaryStack({ + workspacePath: options.workspacePath, + stackName: options.stackName, + configuration: options.configuration, + initialize: + initialize === undefined + ? undefined + : (stack) => fromCallback(() => initialize(stack), isFinished), + validate: + validate === undefined + ? undefined + : (stack) => fromCallback(() => validate(stack), isFinished), + }), + ); + }, + inspectOrdinaryWorkspace: (workspacePath) => + run(service.inspectOrdinaryWorkspace(workspacePath)), + inspectStack: (stackId) => run(service.inspectStack(stackId)), + listStacks: (options) => run(service.listStacks(options)), + updateStack: (stackId, configuration) => run(service.updateStack(stackId, configuration)), + deleteStack: (stackId, options) => { + const stop = options?.stop; + return run( + service.deleteStack(stackId, { + stop: + stop === undefined ? undefined : (stack) => fromCallback(() => stop(stack), isFinished), + }), + ); + }, + reconcileAbandonedOperations: (options) => { + const inspectRuntime = (stack: ManagedStackRecord, operation: ManagedOperationRecord) => + fromCallback(() => options.inspectRuntime(stack, operation), isInspectedRuntime); + return run( + service.reconcileAbandonedOperations( + options.force === undefined + ? { inspectRuntime, startedBefore: options.startedBefore } + : { inspectRuntime, force: options.force }, + ), + ); + }, + pruneCheckoutLocations: (shouldPrune) => + run( + service.pruneCheckoutLocations((location) => + fromCallback(() => shouldPrune(location), isBooleanAnswer), + ), + ), + close: dispose, + [Symbol.asyncDispose]: dispose, + }; +}; + +/** + * What building a managed stack layer can refuse. + * + * {@link UnsupportedManagedRegistryVersionError} is the one an embedder can act + * on — the registry on disk was written by a newer CLI — so it stays in the error + * channel rather than being turned into a defect: an Effect consumer must be able + * to `catchTag` it. The other two are option bugs the layer refuses to start + * with. + */ +export type ManagedStackLayerFailure = + | InvalidManagedOwnerPidError + | UnsafeManagedStackPathError + | UnsupportedManagedRegistryVersionError; + +const serviceLayer = ( + options: ManagedStackServiceOptions, + repositoryLayer: Layer.Layer, + fileSystemLayer: Layer.Layer, +): Layer.Layer => + ManagedStackService.make(options).pipe( + // Merged rather than only provided: the facade hands the very repository the + // service uses back to its caller, so an embedder can read the registry + // without opening a second handle on it. + Layer.provideMerge(repositoryLayer), + Layer.provide(fileSystemLayer), + ); + +/** + * The whole managed assembly as one layer: the policy service, the registry + * adapter it decides over, and the platform filesystem it reclaims stack state + * through, with the state root resolved by the one resolver that owns that + * policy. + * + * This is what an Effect consumer provides, and it is what the Promise facade + * runs behind its handle, so the two assemblies cannot drift apart. A caller that + * brought its own repository gets that repository instead of an opened registry + * file. + */ +export const managedStackLayerWith = ( + fileSystemLayer: Layer.Layer, + openRepository: ( + registryPath: string, + ) => Layer.Layer, + options: CreateManagedStackServiceOptions, +): Layer.Layer => + Layer.unwrap( + Effect.map( + // Resolved while the layer is built rather than while it is described, so + // an unusable root refuses the build instead of throwing at whichever + // expression happened to assemble the layer. + Effect.try({ + try: () => resolveManagedStateRoot(options), + catch: failsWith(UnsafeManagedStackPathError), + }), + (stateRoot) => { + const repository = options.repository; + return serviceLayer( + { ...options, stateRoot }, + repository === undefined + ? openRepository(managedRegistryPath(stateRoot)) + : Layer.succeed(ManagedStackRepository, repository), + fileSystemLayer, + ); + }, + ), + ); + +/** + * A managed stack service over a repository the caller already has. + * + * The state root and owner pid are validated here, before any layer is built, so + * a caller that supplied neither a usable root nor a usable pid learns about it + * from the call that made the mistake. Acquisition is asynchronous throughout, so + * that — like every other way this can fail — arrives as a rejection rather than + * as a throw the caller has to guard separately. + */ +export const makeManagedStackServiceWith = async ( + fileSystemLayer: Layer.Layer, + options: MakeManagedStackServiceOptions, +): Promise => { + const stateRoot = requireExplicitManagedStateRoot(options.stateRoot); + assertManagedOwnerPid(options.ownerPid); + return managedStackServiceHandle( + serviceLayer( + { ...options, stateRoot }, + Layer.succeed(ManagedStackRepository, options.repository), + fileSystemLayer, + ), + ); +}; + +/** + * The whole body of every runtime entrypoint's `createManagedStackService`, + * parameterized only by how a registry file is opened. Keeping it here — rather + * than duplicating it per entrypoint — makes option drift between the Bun and + * Node entries structurally impossible, and lets the Bun test suite cover the + * plumbing that the Node entry (which imports `node:sqlite`) shares. + */ +export const createManagedStackServiceWith = async ( + fileSystemLayer: Layer.Layer, + openRepository: ( + registryPath: string, + ) => Layer.Layer, + options: CreateManagedStackServiceOptions, +): Promise => { + // Validated here as well as in the layer, so a caller that supplied an + // unusable root or pid learns about it from the call that made the mistake. + const stateRoot = resolveManagedStateRoot(options); + assertManagedOwnerPid(options.ownerPid); + return managedStackServiceHandle( + managedStackLayerWith(fileSystemLayer, openRepository, { ...options, stateRoot }), + ); +}; diff --git a/packages/stack/src/managed/error-code.ts b/packages/stack/src/managed/error-code.ts new file mode 100644 index 0000000000..a77ceb2b16 --- /dev/null +++ b/packages/stack/src/managed/error-code.ts @@ -0,0 +1,12 @@ +/** + * The `code` carried by Node's filesystem/process errors and by the SQLite + * drivers. Reading it structurally keeps the managed layer free of driver + * imports and of message-text matching. + */ +export const errorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +}; diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts new file mode 100644 index 0000000000..44af9d9485 --- /dev/null +++ b/packages/stack/src/managed/failure.ts @@ -0,0 +1,50 @@ +/** + * The managed guards in `ids.ts`, `paths.ts`, and `repository.ts` are pure + * synchronous functions that throw their own tagged failures, and both registry + * adapters drive synchronous SQLite or in-memory code that raises those same + * failures. Wrapping such a call with `Effect.try` therefore only has to + * recognize the failures the call site actually expects. + * + * Rethrowing anything else is deliberate: `Effect.try` treats a `catch` handler + * that throws as a defect, so a corrupt registry row or a decoder bug stays a + * defect instead of widening a method's error channel to `unknown`. + * + * Both handlers here are therefore for `Effect.try` only. `Effect.tryPromise` + * calls its `catch` handler from inside the promise chain the runtime is + * awaiting, so a handler that rethrows there escapes into that chain instead of + * becoming a defect. An asynchronous call sorts its failures after the fact + * instead — see `identity.ts`, which recovers the effect with `Effect.catch` and + * dies on anything it does not recognize. + * + * The expected union must be named explicitly, because TypeScript infers a + * single class from a variadic list of unrelated constructors instead of + * unioning them: + * + * ```ts + * Effect.try({ + * try: () => repository.publish(stackId), + * catch: failsWith( + * ManagedOperationOwnershipError, + * ManagedStackNotFoundError, + * ), + * }) + * ``` + */ +export const failsWith = + (...failures: ReadonlyArray E>) => + (error: unknown): E => { + for (const failure of failures) { + if (error instanceof failure) { + return error; + } + } + throw error; + }; + +/** + * The `catch` handler for a synchronous call that has no domain failure at all: + * every throw is a defect. + */ +export const neverFails = (error: unknown): never => { + throw error; +}; diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts new file mode 100644 index 0000000000..22ca100cd7 --- /dev/null +++ b/packages/stack/src/managed/identity.ts @@ -0,0 +1,170 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, realpath, stat } from "node:fs/promises"; +import { dirname } from "node:path"; +import { Effect } from "effect"; +import { claimFileAtomically } from "./atomic-claim.ts"; +import { + InvalidManagedIdentityError, + ORDINARY_WORKSPACE_IDENTITY_VERSION, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { errorCode } from "./error-code.ts"; +import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; + +/** + * The marker's own failures are the only ones this module reports. Filesystem + * errors that are not part of the identity protocol — an unreadable workspace, a + * full disk — are defects: no caller can act on them, and inventing an identity + * failure for them would hide what actually went wrong. + * + * Every protocol step here is a promise, so the sorting happens after the effect + * fails rather than inside `tryPromise`'s `catch` handler: `Effect.try` turns a + * throwing handler into a defect, but a `tryPromise` handler that throws does so + * inside the promise chain the runtime is awaiting, where nothing is watching for + * it. + */ +const failsWithIdentity = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.catch(effect, (error) => + error instanceof InvalidManagedIdentityError ? Effect.fail(error) : Effect.die(error), + ); + +/** A `catch` handler that classifies nothing, so it can never throw. */ +const asRaised = (error: unknown): unknown => error; + +const identityField = (value: unknown, field: string): string => { + if (typeof value !== "object" || value === null) { + throw new InvalidManagedIdentityError({ + message: "The ordinary workspace identity must be an object", + }); + } + const fieldValue = Reflect.get(value, field); + if (typeof fieldValue !== "string") { + throw new InvalidManagedIdentityError({ message: `${field} must be an opaque UUID` }); + } + return assertManagedUuid(fieldValue, field); +}; + +const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { + let value: unknown; + try { + value = JSON.parse(content); + } catch (cause: unknown) { + throw new InvalidManagedIdentityError({ + message: `The ordinary workspace identity is not JSON: ${cause}`, + }); + } + if (typeof value !== "object" || value === null) { + throw new InvalidManagedIdentityError({ + message: "The ordinary workspace identity must be an object", + }); + } + const version = Reflect.get(value, "version"); + if (version !== ORDINARY_WORKSPACE_IDENTITY_VERSION) { + throw new InvalidManagedIdentityError({ + message: `Unsupported ordinary workspace identity version ${String(version)}`, + }); + } + return { + version, + projectId: identityField(value, "projectId"), + checkoutId: identityField(value, "checkoutId"), + contextId: identityField(value, "contextId"), + }; +}; + +export const canonicalizeOrdinaryWorkspacePath = ( + workspacePath: string, +): Effect.Effect => + failsWithIdentity( + Effect.tryPromise({ + try: async () => { + const info = await stat(workspacePath); + if (!info.isDirectory()) { + throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); + } + return realpath(workspacePath); + }, + catch: asRaised, + }), + ); + +const readIdentity = async ( + workspacePath: string, +): Promise => { + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + try { + return decodeIdentity(await readFile(markerPath, "utf8")); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return undefined; + } + throw error; + } +}; + +export const readOrdinaryWorkspaceIdentity = ( + workspacePath: string, +): Effect.Effect => + failsWithIdentity(Effect.tryPromise({ try: () => readIdentity(workspacePath), catch: asRaised })); + +export interface EnsureOrdinaryWorkspaceIdentityResult { + readonly identity: OrdinaryWorkspaceIdentity; + readonly created: boolean; + readonly markerPath: string; +} + +/** + * Claiming a workspace stays one `await` chain rather than an `Effect.gen` + * pipeline: reading the marker, publishing the claim, and re-reading the marker + * a losing claimant must adopt are a single indivisible protocol, and an + * interruption between those steps would leave the caller with an identity no + * workspace agreed to. + */ +const ensureIdentity = async ( + workspacePath: string, + idFactory: () => string, +): Promise => { + const existing = await readIdentity(workspacePath); + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + if (existing !== undefined) { + return { identity: existing, created: false, markerPath }; + } + + const identity: OrdinaryWorkspaceIdentity = { + version: ORDINARY_WORKSPACE_IDENTITY_VERSION, + projectId: createManagedUuid(idFactory, "projectId"), + checkoutId: createManagedUuid(idFactory, "checkoutId"), + contextId: createManagedUuid(idFactory, "contextId"), + }; + + await mkdir(dirname(markerPath), { recursive: true }); + const outcome = await claimFileAtomically(markerPath, `${JSON.stringify(identity, null, 2)}\n`, { + mode: 0o600, + temporaryId: createManagedUuid(idFactory, "identity temporary id"), + }); + if (outcome === "claimed") { + return { identity, created: true, markerPath }; + } + + const winner = await readIdentity(workspacePath); + if (winner === undefined) { + throw new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }); + } + return { identity: winner, created: false, markerPath }; +}; + +export const ensureOrdinaryWorkspaceIdentity = ( + workspacePath: string, + idFactory: () => string = randomUUID, +): Effect.Effect => + failsWithIdentity( + Effect.tryPromise({ + try: () => ensureIdentity(workspacePath, idFactory), + catch: asRaised, + }), + ); diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts new file mode 100644 index 0000000000..0f40ed2baa --- /dev/null +++ b/packages/stack/src/managed/ids.ts @@ -0,0 +1,13 @@ +import { InvalidManagedIdentityError } from "./model.ts"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const assertManagedUuid = (value: string, label: string): string => { + if (!UUID_PATTERN.test(value)) { + throw new InvalidManagedIdentityError({ message: `${label} must be an opaque UUID` }); + } + return value; +}; + +export const createManagedUuid = (idFactory: () => string, label: string): string => + assertManagedUuid(idFactory(), label); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts new file mode 100644 index 0000000000..0abfc34da2 --- /dev/null +++ b/packages/stack/src/managed/model.ts @@ -0,0 +1,451 @@ +import { Data } from "effect"; + +export const MANAGED_REGISTRY_SCHEMA_VERSION = 3; +export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; +export const DEFAULT_MANAGED_STACK_NAME = "default"; + +export type ManagedRuntimeRequest = "auto" | "docker" | "native"; +export type ManagedRuntime = "docker" | "native"; +export type ManagedStackStatus = "active" | "pending" | "tombstoned"; +export type ManagedStackLifecycle = "failed" | "running" | "starting" | "stopped" | "stopping"; +export type ManagedPortIntent = "automatic" | "exact"; +export type ManagedOperationKind = "delete" | "start" | "stop" | "update"; +export type ManagedOperationStatus = "active" | "completed" | "failed"; + +export interface OrdinaryWorkspaceIdentity { + readonly version: typeof ORDINARY_WORKSPACE_IDENTITY_VERSION; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; +} + +export interface ManagedStackPaths { + readonly root: string; + readonly data: string; + readonly logs: string; + readonly runtime: string; +} + +export interface ManagedPortAssignment { + readonly key: string; + readonly port: number; + readonly intent: ManagedPortIntent; +} + +export interface ManagedRuntimeMetadata { + readonly pid?: number; + readonly socketPath?: string; + readonly processIds: Readonly>; + readonly containerIds: Readonly>; +} + +export interface ManagedStackRecord { + readonly id: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly name: string; + readonly status: ManagedStackStatus; + readonly lifecycle: ManagedStackLifecycle; + readonly runtimeRequest: ManagedRuntimeRequest; + readonly runtime?: ManagedRuntime; + readonly paths: ManagedStackPaths; + readonly ports: ReadonlyArray; + readonly serviceVersions: Readonly>; + readonly runtimeMetadata: ManagedRuntimeMetadata; + readonly configFingerprint?: string; + readonly credentialsReference?: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly tombstonedAt?: string; +} + +export interface ManagedOperationRecord { + readonly token: string; + readonly stackId: string; + readonly kind: ManagedOperationKind; + readonly status: ManagedOperationStatus; + readonly ownerPid?: number; + readonly startedAt: string; + readonly finishedAt?: string; + readonly error?: string; +} + +export interface ManagedCheckoutLocation { + readonly id: string; + readonly checkoutId: string; + readonly canonicalPath: string; + readonly lastSeenAt: string; +} + +export interface ManagedStackConfiguration { + readonly runtimeRequest?: ManagedRuntimeRequest; + readonly runtime?: ManagedRuntime; + readonly ports?: ReadonlyArray; + readonly serviceVersions?: Readonly>; + readonly runtimeMetadata?: ManagedRuntimeMetadata; + readonly lifecycle?: ManagedStackLifecycle; + readonly configFingerprint?: string; + readonly credentialsReference?: string; +} + +export interface ManagedStackSelection { + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly stackId: string; + readonly stackName: string; +} + +export class InvalidManagedIdentityError extends Data.TaggedError("InvalidManagedIdentityError")<{ + readonly message: string; +}> { + readonly code = "INVALID_MANAGED_IDENTITY" as const; +} + +export class UnsupportedManagedRegistryVersionError extends Data.TaggedError( + "UnsupportedManagedRegistryVersionError", +)<{ + readonly found: number; + readonly supported: number; +}> { + readonly code = "UNSUPPORTED_MANAGED_REGISTRY_VERSION" as const; + + override get message(): string { + return `Managed registry version ${this.found} is unsupported; expected version ${this.supported}`; + } +} + +export class DuplicateManagedIdentityError extends Data.TaggedError( + "DuplicateManagedIdentityError", +)<{ + readonly identityId: string; + readonly existingClaim: string; + readonly requestedClaim: string; +}> { + readonly code = "DUPLICATE_MANAGED_IDENTITY" as const; + + override get message(): string { + return `Managed identity ${this.identityId} is already claimed by ${this.existingClaim}; refusing a second claim from ${this.requestedClaim}`; + } +} + +export class DuplicateManagedPortKeyError extends Data.TaggedError("DuplicateManagedPortKeyError")<{ + readonly key: string; +}> { + readonly code = "MANAGED_DUPLICATE_PORT_KEY" as const; + + override get message(): string { + return `Duplicate managed port key ${this.key}`; + } +} + +export class InvalidManagedStackNameError extends Data.TaggedError("InvalidManagedStackNameError")<{ + readonly stackName: string; +}> { + readonly code = "MANAGED_INVALID_STACK_NAME" as const; + + override get message(): string { + return `Invalid managed stack name: ${this.stackName}`; + } +} + +export class InvalidManagedOwnerPidError extends Data.TaggedError("InvalidManagedOwnerPidError")<{ + readonly ownerPid: number; +}> { + readonly code = "MANAGED_INVALID_OWNER_PID" as const; + + override get message(): string { + return `Invalid managed operation owner pid ${this.ownerPid}`; + } +} + +export class InvalidManagedPortError extends Data.TaggedError("InvalidManagedPortError")<{ + readonly port: number; + readonly key: string; +}> { + readonly code = "MANAGED_INVALID_PORT" as const; + + override get message(): string { + return `Invalid managed port ${this.port} for ${this.key}`; + } +} + +export class ManagedStackNotFoundError extends Data.TaggedError("ManagedStackNotFoundError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_FOUND" as const; + + override get message(): string { + return `Managed stack ${this.stackId} was not found`; + } +} + +export class ManagedStackNotStoppedError extends Data.TaggedError("ManagedStackNotStoppedError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_STOPPED" as const; + + override get message(): string { + return `Managed stack ${this.stackId} must be safely stopped before deletion`; + } +} + +export class ManagedPendingStackUpdateError extends Data.TaggedError( + "ManagedPendingStackUpdateError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_PENDING_STACK_UPDATE" as const; + + override get message(): string { + return `Managed stack ${this.stackId} is still pending publication and cannot be reconfigured through an update`; + } +} + +export class ManagedOperationInProgressError extends Data.TaggedError( + "ManagedOperationInProgressError", +)<{ + readonly stackId: string; + readonly operation: ManagedOperationRecord; +}> { + readonly code = "MANAGED_OPERATION_IN_PROGRESS" as const; + + override get message(): string { + return `Managed stack ${this.stackId} already has an active ${this.operation.kind} operation`; + } +} + +export class ManagedOperationOwnershipError extends Data.TaggedError( + "ManagedOperationOwnershipError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_OWNERSHIP_MISMATCH" as const; + + override get message(): string { + return `The active operation for managed stack ${this.stackId} is owned by another caller`; + } +} + +export class ManagedPortReservationError extends Data.TaggedError("ManagedPortReservationError")<{ + readonly port: number; + readonly ownerStackId: string; +}> { + readonly code = "MANAGED_PORT_ALREADY_RESERVED" as const; + + override get message(): string { + return `Port ${this.port} is already reserved by managed stack ${this.ownerStackId}`; + } +} + +export class ManagedRunningStackPortChangeError extends Data.TaggedError( + "ManagedRunningStackPortChangeError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE" as const; + + override get message(): string { + return `Managed stack ${this.stackId} cannot change ports while it continues to occupy them`; + } +} + +/** + * The default `reason` prefix, used by the stack-removal guard that motivated + * this failure. State-root refusals pass their own `reason`. + */ +const UNSAFE_MANAGED_STACK_PATH_REASON = "Refusing to remove an unsafe managed stack path"; + +export class UnsafeManagedStackPathError extends Data.TaggedError("UnsafeManagedStackPathError")<{ + readonly path: string; + /** + * Names which refusal this is, since the same coded failure guards both + * stack removal and state roots. Defaults to the stack-removal wording. + */ + readonly reason?: string; +}> { + readonly code = "UNSAFE_MANAGED_STACK_PATH" as const; + + /** + * The refused path is quoted rather than interpolated bare: the values worth + * refusing include blank and whitespace-only ones, which would otherwise + * render as an empty message tail. + */ + override get message(): string { + return `${this.reason ?? UNSAFE_MANAGED_STACK_PATH_REASON}: ${JSON.stringify(this.path)}`; + } +} + +export class ManagedStackInitializationError extends Data.TaggedError( + "ManagedStackInitializationError", +)<{ + readonly stackId: string; + readonly cause: unknown; + readonly cleanupErrors: ReadonlyArray; +}> { + readonly code = "MANAGED_STACK_INITIALIZATION_FAILED" as const; + + override get message(): string { + return `Managed stack ${this.stackId} could not be initialized`; + } +} + +export class ManagedStackPublicationTimeoutError extends Data.TaggedError( + "ManagedStackPublicationTimeoutError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_PUBLICATION_TIMEOUT" as const; + + override get message(): string { + return `Timed out waiting for managed stack ${this.stackId} to be published`; + } +} + +export class ManagedAbandonedOperationError extends Data.TaggedError( + "ManagedAbandonedOperationError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_REQUIRES_RECONCILIATION" as const; + + override get message(): string { + return `Managed stack ${this.stackId} has an abandoned operation that must be reconciled`; + } +} + +/** + * Any managed registry failure. + * + * Every managed failure is a `Data.TaggedError`, so they cannot share a base + * class — each one extends its own generated base. The hierarchy is therefore a + * union type rather than a root class, and {@link isManagedStackError} is the + * runtime equivalent of the old `instanceof` check. + */ +export type ManagedStackError = + | DuplicateManagedIdentityError + | DuplicateManagedPortKeyError + | InvalidManagedIdentityError + | InvalidManagedOwnerPidError + | InvalidManagedPortError + | InvalidManagedStackNameError + | ManagedAbandonedOperationError + | ManagedOperationInProgressError + | ManagedOperationOwnershipError + | ManagedPendingStackUpdateError + | ManagedPortReservationError + | ManagedRunningStackPortChangeError + | ManagedStackInitializationError + | ManagedStackNotFoundError + | ManagedStackNotStoppedError + | ManagedStackPublicationTimeoutError + | UnsafeManagedStackPathError + | UnsupportedManagedRegistryVersionError; + +/** + * Every `code` literal declared by a managed failure, and every `_tag` + * declared alongside it. + * + * Both are derived from {@link ManagedStackError} itself — indexing a + * property on a union type distributes over its members — so adding, removing, + * or renaming a failure class's `code`/`_tag` changes these unions without any + * hand-maintained list to fall out of sync. What compile-time indexing cannot + * catch is two different classes declaring the *same* `code` literal: the + * union would just collapse to one member, so that particular mistake still + * needs a runtime guard (or review) rather than the type checker. + */ +export type ManagedErrorCode = ManagedStackError["code"]; +export type ManagedErrorTag = ManagedStackError["_tag"]; + +/** + * Requires `array` to contain every member of the string-literal union `T`, + * order and duplicates aside. If `T` has a member missing from the supplied + * array, `[T] extends [U[number]]` resolves to `never`, which makes the + * parameter type `never` and turns any array literal into a type error at the + * call site — so `MANAGED_ERROR_CODES` below cannot silently drop a code. + */ +function exhaustiveArrayOf() { + return >(array: U & ([T] extends [U[number]] ? unknown : never)): U => + array; +} + +/** + * Every `code` literal declared by a managed failure, checked exhaustive + * against {@link ManagedErrorCode} at compile time by {@link exhaustiveArrayOf}. + * + * `code` is the wire-level contract: identifier minification renames the + * constructors, so a release build's telemetry and any cross-runtime consumer + * need a value the bundler cannot touch. + * + * This module must stay free of runtime-specific imports: it is published as + * `@supabase/stack/managed-model` precisely so consumers can import the codes + * under Bun and Node alike, without pulling in a SQLite driver. + */ +export const MANAGED_ERROR_CODES = exhaustiveArrayOf()([ + "DUPLICATE_MANAGED_IDENTITY", + "INVALID_MANAGED_IDENTITY", + "MANAGED_DUPLICATE_PORT_KEY", + "MANAGED_INVALID_OWNER_PID", + "MANAGED_INVALID_PORT", + "MANAGED_INVALID_STACK_NAME", + "MANAGED_OPERATION_IN_PROGRESS", + "MANAGED_OPERATION_OWNERSHIP_MISMATCH", + "MANAGED_OPERATION_REQUIRES_RECONCILIATION", + "MANAGED_PENDING_STACK_UPDATE", + "MANAGED_PORT_ALREADY_RESERVED", + "MANAGED_RUNNING_STACK_PORT_CHANGE", + "MANAGED_STACK_INITIALIZATION_FAILED", + "MANAGED_STACK_NOT_FOUND", + "MANAGED_STACK_NOT_STOPPED", + "MANAGED_STACK_PUBLICATION_TIMEOUT", + "UNSAFE_MANAGED_STACK_PATH", + "UNSUPPORTED_MANAGED_REGISTRY_VERSION", +] as const); + +/** + * The single source of truth linking each managed `code` to the `_tag` of the + * class that declares it. + * + * `_tag` is the Effect-native discriminant (`Effect.catchTag`, structural + * dispatch) and `code` is the stable wire-level contract. Consumers that key a + * table by one and dispatch on the other — the CLI's telemetry classifier is + * the motivating case — derive it from this map instead of restating all + * eighteen pairs by hand. Typing this `satisfies Record` requires every code to be present with a valid tag, so a + * new error class that is not registered here is a compile error. + */ +export const MANAGED_ERROR_TAG_BY_CODE = { + DUPLICATE_MANAGED_IDENTITY: "DuplicateManagedIdentityError", + INVALID_MANAGED_IDENTITY: "InvalidManagedIdentityError", + MANAGED_DUPLICATE_PORT_KEY: "DuplicateManagedPortKeyError", + MANAGED_INVALID_OWNER_PID: "InvalidManagedOwnerPidError", + MANAGED_INVALID_PORT: "InvalidManagedPortError", + MANAGED_INVALID_STACK_NAME: "InvalidManagedStackNameError", + MANAGED_OPERATION_IN_PROGRESS: "ManagedOperationInProgressError", + MANAGED_OPERATION_OWNERSHIP_MISMATCH: "ManagedOperationOwnershipError", + MANAGED_OPERATION_REQUIRES_RECONCILIATION: "ManagedAbandonedOperationError", + MANAGED_PENDING_STACK_UPDATE: "ManagedPendingStackUpdateError", + MANAGED_PORT_ALREADY_RESERVED: "ManagedPortReservationError", + MANAGED_RUNNING_STACK_PORT_CHANGE: "ManagedRunningStackPortChangeError", + MANAGED_STACK_INITIALIZATION_FAILED: "ManagedStackInitializationError", + MANAGED_STACK_NOT_FOUND: "ManagedStackNotFoundError", + MANAGED_STACK_NOT_STOPPED: "ManagedStackNotStoppedError", + MANAGED_STACK_PUBLICATION_TIMEOUT: "ManagedStackPublicationTimeoutError", + UNSAFE_MANAGED_STACK_PATH: "UnsafeManagedStackPathError", + UNSUPPORTED_MANAGED_REGISTRY_VERSION: "UnsupportedManagedRegistryVersionError", +} as const satisfies Record; + +const MANAGED_ERROR_TAGS: ReadonlySet = new Set(Object.values(MANAGED_ERROR_TAG_BY_CODE)); + +/** + * Whether a value is a managed registry failure. Replaces the `instanceof` + * check against the removed `ManagedStackError` root class: the union's members + * each extend their own `Data.TaggedError` base, so the shared discriminator is + * the tag rather than a prototype chain. + */ +export function isManagedStackError(error: unknown): error is ManagedStackError { + if (!(error instanceof Error) || !("_tag" in error)) return false; + const tag: unknown = error._tag; + return typeof tag === "string" && MANAGED_ERROR_TAGS.has(tag); +} diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts new file mode 100644 index 0000000000..a34838f3a6 --- /dev/null +++ b/packages/stack/src/managed/paths.ts @@ -0,0 +1,125 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { assertManagedUuid } from "./ids.ts"; +import { UnsafeManagedStackPathError, type ManagedStackPaths } from "./model.ts"; + +export interface ManagedStateRootOptions { + readonly stateRoot?: string; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; +} + +const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +}; + +const requireManagedStateRoot = (stateRoot: string): string => { + const trimmed = nonEmpty(stateRoot); + if (trimmed === undefined) { + throw new UnsafeManagedStackPathError({ + path: stateRoot, + reason: "Refusing a blank managed state root", + }); + } + return resolve(trimmed); +}; + +/** + * Every caller- or environment-supplied root is anchored to the working + * directory once, here. A relative root would otherwise be reinterpreted + * against whatever the process' cwd happens to be at each later use, so a + * chdir would split persisted stack state across directories and make + * {@link assertManagedStackRoot} accept a same-shaped path under the new cwd. + * `homedir()` is absolute by definition and needs no anchoring. + * + * An explicit root is a decision, so a blank one is a caller bug and fails + * rather than falling back: `resolve("")` silently yields the process' working + * directory, which would scatter managed state across whatever directory a + * caller happened to start in. Environment values are configuration that may + * legitimately be present but empty, so a blank one is treated as unset and + * falls through to the next source. + */ +export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { + if (options.stateRoot !== undefined) { + return requireManagedStateRoot(options.stateRoot); + } + + const env = options.env ?? process.env; + const configuredHome = nonEmpty(env["SUPABASE_HOME"]); + if (configuredHome !== undefined) { + return join(resolve(configuredHome), "managed"); + } + + const platform = options.platform ?? process.platform; + const userHome = options.homeDir ?? homedir(); + if (platform === "darwin") { + return join(userHome, "Library", "Application Support", "supabase", "managed"); + } + if (platform === "win32") { + const localAppData = nonEmpty(env["LOCALAPPDATA"]); + return join( + localAppData === undefined ? join(userHome, "AppData", "Local") : resolve(localAppData), + "Supabase", + "managed", + ); + } + + const stateHome = nonEmpty(env["XDG_STATE_HOME"]); + return join( + stateHome === undefined ? join(userHome, ".local", "state") : resolve(stateHome), + "supabase", + "managed", + ); +}; + +/** + * The state root a managed stack service must be started with. + * + * `stateRoot` is required wherever a service is built, but a caller bypassing + * the type system (or a plain-JS caller) could still pass `undefined`, which + * would make {@link resolveManagedStateRoot} silently fall back to + * `SUPABASE_HOME` or the user's home directory instead of failing loudly. A root + * is a decision the caller owes the service, so a missing one is refused here + * rather than guessed. + */ +export const requireExplicitManagedStateRoot = (stateRoot: string | undefined): string => { + if (stateRoot === undefined) { + throw new UnsafeManagedStackPathError({ + path: String(stateRoot), + reason: "Refusing to start a managed stack service without an explicit state root", + }); + } + return resolveManagedStateRoot({ stateRoot }); +}; + +export const managedRegistryPath = (stateRoot: string): string => + join(stateRoot, "registry-v3.sqlite3"); + +export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { + assertManagedUuid(stackId, "stackId"); + const root = join(stateRoot, "stacks", stackId); + return { + root, + data: join(root, "data"), + logs: join(root, "logs"), + runtime: join(root, "runtime"), + }; +}; + +export const assertManagedStackRoot = ( + stateRoot: string, + stackId: string, + stackRoot: string, +): string => { + const expected = resolve(managedStackPaths(stateRoot, stackId).root); + const actual = resolve(stackRoot); + if (actual !== expected) { + throw new UnsafeManagedStackPathError({ path: stackRoot }); + } + return actual; +}; + +export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => + join(workspacePath, ".supabase", "identity.json"); diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts new file mode 100644 index 0000000000..4ddebecaa7 --- /dev/null +++ b/packages/stack/src/managed/repository-memory.ts @@ -0,0 +1,597 @@ +import { Effect } from "effect"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationRecord, + type ManagedRuntimeMetadata, + type ManagedStackConfiguration, + type ManagedStackRecord, +} from "./model.ts"; +import { failsWith } from "./failure.ts"; +import { + assertManagedOwnerPid, + assertManagedStackUpdatable, + compareManagedText, + managedStackOccupiesPorts, + reconcileManagedPortAssignments, + validateManagedPortAssignments, + type ClaimManagedOperationFailure, + type ClaimManagedOperationInput, + type ClaimManagedOperationResult, + type ManagedStackRepositoryShape, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type PrepareOrdinaryStackInput, + type PrepareOrdinaryStackResult, + type ReconcileManagedOperationFailure, + type ReconcileManagedOperationResult, + type UpdateManagedStackFailure, + type UpdateManagedStackInput, +} from "./repository.ts"; + +interface InMemoryCheckout { + readonly id: string; + readonly projectId: string; +} + +interface InMemoryContext { + readonly id: string; + readonly checkoutId: string; +} + +const stackIdentityKey = (checkoutId: string, contextId: string, stackName: string): string => + `${checkoutId}\u0000${contextId}\u0000${stackName}`; + +const copy = (value: A): A => structuredClone(value); + +const applyConfiguration = ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration, + now: string, +): ManagedStackRecord => { + const lifecycle = configuration.lifecycle ?? stack.lifecycle; + return { + ...stack, + lifecycle, + runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, + runtime: configuration.runtime ?? stack.runtime, + ports: reconcileManagedPortAssignments(stack, configuration.ports, lifecycle), + serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, + runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, + configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, + credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, + updatedAt: now, + }; +}; + +const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ + processIds: {}, + containerIds: {}, +}); + +/** + * A test seam, exported only through `@supabase/stack/testing`: it lets + * consumers exercise the managed service without a SQLite driver, and it is the + * parity reference the persistent adapters are tested against. Production code + * must go through a persistent adapter instead. + * + * The registry decisions themselves stay synchronous — the store is a set of + * maps, and {@link atomic} rolls them back by snapshot — so each contract method + * is that synchronous decision lifted into an `Effect`. + */ +export const createInMemoryManagedStackRepository = (): ManagedStackRepositoryShape => { + const projects = new Set(); + const checkouts = new Map(); + const contexts = new Map(); + const locations = new Map(); + const stacks = new Map(); + const stackIdentities = new Map(); + const operations = new Map(); + const activeOperationByStack = new Map(); + const portOwners = new Map(); + + const atomic = (run: () => A): A => { + const snapshot = { + projects: structuredClone([...projects]), + checkouts: structuredClone([...checkouts]), + contexts: structuredClone([...contexts]), + locations: structuredClone([...locations]), + stacks: structuredClone([...stacks]), + stackIdentities: structuredClone([...stackIdentities]), + operations: structuredClone([...operations]), + activeOperationByStack: structuredClone([...activeOperationByStack]), + portOwners: structuredClone([...portOwners]), + }; + try { + return run(); + } catch (error: unknown) { + projects.clear(); + for (const project of snapshot.projects) projects.add(project); + checkouts.clear(); + for (const [key, value] of snapshot.checkouts) checkouts.set(key, value); + contexts.clear(); + for (const [key, value] of snapshot.contexts) contexts.set(key, value); + locations.clear(); + for (const [key, value] of snapshot.locations) locations.set(key, value); + stacks.clear(); + for (const [key, value] of snapshot.stacks) stacks.set(key, value); + stackIdentities.clear(); + for (const [key, value] of snapshot.stackIdentities) stackIdentities.set(key, value); + operations.clear(); + for (const [key, value] of snapshot.operations) operations.set(key, value); + activeOperationByStack.clear(); + for (const [key, value] of snapshot.activeOperationByStack) { + activeOperationByStack.set(key, value); + } + portOwners.clear(); + for (const [key, value] of snapshot.portOwners) portOwners.set(key, value); + throw error; + } + }; + + const requireStack = (stackId: string): ManagedStackRecord => { + const stack = stacks.get(stackId); + if (stack === undefined) { + throw new ManagedStackNotFoundError({ stackId }); + } + return stack; + }; + + const requireOwnedOperation = ( + stackId: string, + operationToken: string, + ): ManagedOperationRecord => { + const activeToken = activeOperationByStack.get(stackId); + const operation = operations.get(operationToken); + if ( + activeToken !== operationToken || + operation === undefined || + operation.stackId !== stackId || + operation.status !== "active" + ) { + throw new ManagedOperationOwnershipError({ stackId }); + } + return operation; + }; + + const transitionPortOwnership = ( + current: ManagedStackRecord | undefined, + next: ManagedStackRecord, + ): void => { + validateManagedPortAssignments(next.id, next.ports); + if (managedStackOccupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: owner }); + } + } + } + if (current !== undefined && managedStackOccupiesPorts(current.lifecycle)) { + for (const assignment of current.ports) { + if (portOwners.get(assignment.port) === current.id) { + portOwners.delete(assignment.port); + } + } + } + if (managedStackOccupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: owner }); + } + portOwners.set(assignment.port, next.id); + } + } + }; + + /** + * Tears an unpublished stack out of every index it was registered in and + * releases its claim, so the identity is immediately free to retry. Shared by + * the explicit abort path and by recovery's pending branch. + */ + const discardPendingStack = (stack: ManagedStackRecord, operationToken: string): void => { + transitionPortOwnership(stack, { ...stack, lifecycle: "stopped", ports: [] }); + stacks.delete(stack.id); + stackIdentities.delete(stackIdentityKey(stack.checkoutId, stack.contextId, stack.name)); + operations.delete(operationToken); + activeOperationByStack.delete(stack.id); + }; + + const claimOperation = (input: ClaimManagedOperationInput): ClaimManagedOperationResult => { + assertManagedOwnerPid(input.ownerPid); + requireStack(input.stackId); + const activeToken = activeOperationByStack.get(input.stackId); + if (activeToken !== undefined) { + const active = operations.get(activeToken); + if (active !== undefined) { + return { acquired: false, operation: copy(active) }; + } + } + + const operation: ManagedOperationRecord = { + token: input.token, + stackId: input.stackId, + kind: input.kind, + status: "active", + ownerPid: input.ownerPid, + startedAt: input.now, + }; + operations.set(operation.token, operation); + activeOperationByStack.set(operation.stackId, operation.token); + return { acquired: true, operation: copy(operation) }; + }; + + const prepareOrdinaryStack = (input: PrepareOrdinaryStackInput): PrepareOrdinaryStackResult => { + assertManagedOwnerPid(input.ownerPid); + return atomic(() => { + projects.add(input.identity.projectId); + const checkout = checkouts.get(input.identity.checkoutId); + if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: checkout.projectId, + requestedClaim: input.identity.projectId, + }); + } + checkouts.set(input.identity.checkoutId, { + id: input.identity.checkoutId, + projectId: input.identity.projectId, + }); + + const context = contexts.get(input.identity.contextId); + if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: context.checkoutId, + requestedClaim: input.identity.checkoutId, + }); + } + contexts.set(input.identity.contextId, { + id: input.identity.contextId, + checkoutId: input.identity.checkoutId, + }); + + const existingLocation = [...locations.values()].find( + (location) => location.checkoutId === input.identity.checkoutId, + ); + if ( + existingLocation !== undefined && + existingLocation.canonicalPath !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: existingLocation.canonicalPath, + requestedClaim: input.canonicalPath, + }); + } + const pathOwner = [...locations.values()].find( + (location) => location.canonicalPath === input.canonicalPath, + ); + if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: pathOwner.checkoutId, + requestedClaim: input.identity.checkoutId, + }); + } + locations.set(existingLocation?.id ?? input.locationId, { + id: existingLocation?.id ?? input.locationId, + checkoutId: input.identity.checkoutId, + canonicalPath: input.canonicalPath, + lastSeenAt: input.now, + }); + + const identityKey = stackIdentityKey( + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + ); + const existingStackId = stackIdentities.get(identityKey); + if (existingStackId !== undefined) { + const stack = requireStack(existingStackId); + const activeToken = activeOperationByStack.get(stack.id); + const operation = activeToken === undefined ? undefined : operations.get(activeToken); + return { + outcome: "existing", + stack: copy(stack), + operation: operation === undefined ? undefined : copy(operation), + }; + } + + const baseStack: ManagedStackRecord = { + id: input.stackId, + projectId: input.identity.projectId, + checkoutId: input.identity.checkoutId, + contextId: input.identity.contextId, + name: input.stackName, + status: "pending", + lifecycle: "stopped", + runtimeRequest: input.configuration.runtimeRequest ?? "auto", + runtime: input.configuration.runtime, + paths: input.paths, + ports: [], + serviceVersions: {}, + runtimeMetadata: emptyRuntimeMetadata(), + createdAt: input.now, + updatedAt: input.now, + }; + const stack = applyConfiguration(baseStack, input.configuration, input.now); + transitionPortOwnership(undefined, stack); + stacks.set(stack.id, stack); + stackIdentities.set(identityKey, stack.id); + const claimed = claimOperation({ + token: input.operationToken, + stackId: stack.id, + kind: "start", + ownerPid: input.ownerPid, + now: input.now, + }); + if (!claimed.acquired) { + throw new ManagedOperationOwnershipError({ stackId: stack.id }); + } + return { outcome: "create", stack: copy(stack), operation: claimed.operation }; + }); + }; + + const publishPendingStack = ( + stackId: string, + operationToken: string, + now: string, + ): ManagedStackRecord => { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "active", + updatedAt: now, + }; + stacks.set(stackId, next); + const operation = operations.get(operationToken); + if (operation !== undefined) { + operations.set(operationToken, { + ...operation, + status: "completed", + finishedAt: now, + }); + } + activeOperationByStack.delete(stackId); + return copy(next); + }; + + const abortPendingStack = (stackId: string, operationToken: string): void => { + requireOwnedOperation(stackId, operationToken); + const stack = requireStack(stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError({ stackId }); + } + discardPendingStack(stack, operationToken); + }; + + const finishOperation = ( + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, + ): void => { + const operation = requireOwnedOperation(stackId, operationToken); + operations.set(operationToken, { + ...operation, + status: outcome, + finishedAt: now, + error, + }); + activeOperationByStack.delete(stackId); + }; + + const updateStack = (input: UpdateManagedStackInput): ManagedStackRecord => { + requireOwnedOperation(input.stackId, input.operationToken); + const current = requireStack(input.stackId); + assertManagedStackUpdatable(current); + const next = applyConfiguration(current, input, input.now); + transitionPortOwnership(current, next); + stacks.set(current.id, next); + return copy(next); + }; + + const reconcileOperation = ( + stackId: string, + operationToken: string, + lifecycle: ManagedStackRecord["lifecycle"], + now: string, + ): ReconcileManagedOperationResult => { + const operation = requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: "Recovered after an abandoned deletion", + }); + activeOperationByStack.delete(stackId); + return { outcome: "tombstoned", stack: copy(current) }; + } + if (current.status === "pending" && lifecycle === "stopped") { + discardPendingStack(current, operationToken); + return { outcome: "discarded" }; + } + const next: ManagedStackRecord = { + ...current, + status: current.status === "pending" ? "active" : current.status, + lifecycle, + updatedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: `Recovered after runtime reconciliation (${lifecycle})`, + }); + activeOperationByStack.delete(stackId); + return { outcome: "recovered", stack: copy(next) }; + }; + + const tombstoneStack = ( + stackId: string, + operationToken: string, + now: string, + ): ManagedStackRecord => { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "tombstoned", + lifecycle: "stopped", + ports: [], + runtimeMetadata: emptyRuntimeMetadata(), + updatedAt: now, + tombstonedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); + return copy(next); + }; + + return { + prepareOrdinaryStack: (input) => + Effect.try({ + try: () => prepareOrdinaryStack(input), + catch: failsWith( + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + }), + publishPendingStack: (stackId, operationToken, now) => + Effect.try({ + try: () => publishPendingStack(stackId, operationToken, now), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + abortPendingStack: (stackId, operationToken) => + Effect.try({ + try: () => abortPendingStack(stackId, operationToken), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + getStack: (stackId) => + Effect.sync(() => { + const stack = stacks.get(stackId); + return stack === undefined ? undefined : copy(stack); + }), + listStacks: (options) => + Effect.sync(() => + [...stacks.values()] + .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") + .sort( + (left, right) => + compareManagedText(left.createdAt, right.createdAt) || + compareManagedText(left.id, right.id), + ) + .map(copy), + ), + claimOperation: (input) => + Effect.try({ + try: () => claimOperation(input), + catch: failsWith( + InvalidManagedOwnerPidError, + ManagedStackNotFoundError, + ), + }), + finishOperation: (stackId, operationToken, outcome, now, error) => + Effect.try({ + try: () => finishOperation(stackId, operationToken, outcome, now, error), + catch: failsWith(ManagedOperationOwnershipError), + }), + updateStack: (input) => + Effect.try({ + try: () => updateStack(input), + catch: failsWith( + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + ), + }), + listActiveOperations: (startedBefore) => + Effect.sync(() => + [...activeOperationByStack.values()] + .flatMap((token) => { + const operation = operations.get(token); + return operation === undefined ? [] : [operation]; + }) + .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) + // Recovery walks this list, so claims sharing one `startedAt` must not + // fall back to insertion order: the token breaks the tie in both adapters. + .sort( + (left, right) => + compareManagedText(left.startedAt, right.startedAt) || + compareManagedText(left.token, right.token), + ) + .map(copy), + ), + reconcileOperation: (stackId, operationToken, lifecycle, now) => + Effect.try({ + try: () => reconcileOperation(stackId, operationToken, lifecycle, now), + catch: failsWith( + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + }), + tombstoneStack: (stackId, operationToken, now) => + Effect.try({ + try: () => tombstoneStack(stackId, operationToken, now), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + listCheckoutLocations: () => + Effect.sync(() => + [...locations.values()] + .sort((left, right) => compareManagedText(left.canonicalPath, right.canonicalPath)) + .map(copy), + ), + pruneCheckoutLocations: (locationIds) => + Effect.sync(() => { + let removed = 0; + for (const id of new Set(locationIds)) { + if (locations.delete(id)) { + removed += 1; + } + } + return removed; + }), + }; +}; diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts new file mode 100644 index 0000000000..58b8d0bfc2 --- /dev/null +++ b/packages/stack/src/managed/repository.ts @@ -0,0 +1,305 @@ +import { Context, type Effect } from "effect"; +import { + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedPortAssignment, + type ManagedStackConfiguration, + type ManagedStackLifecycle, + type ManagedStackPaths, + type ManagedStackRecord, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import type { DuplicateManagedIdentityError, ManagedOperationOwnershipError } from "./model.ts"; + +export interface PrepareOrdinaryStackInput { + readonly identity: OrdinaryWorkspaceIdentity; + readonly canonicalPath: string; + readonly locationId: string; + readonly stackId: string; + readonly stackName: string; + readonly paths: ManagedStackPaths; + readonly operationToken: string; + readonly ownerPid?: number; + readonly now: string; + readonly configuration: ManagedStackConfiguration; +} + +export type PrepareOrdinaryStackResult = + | { + readonly outcome: "create"; + readonly stack: ManagedStackRecord; + readonly operation: ManagedOperationRecord; + } + | { + readonly outcome: "existing"; + readonly stack: ManagedStackRecord; + readonly operation?: ManagedOperationRecord; + }; + +export interface ClaimManagedOperationInput { + readonly token: string; + readonly stackId: string; + readonly kind: ManagedOperationKind; + readonly ownerPid?: number; + readonly now: string; +} + +export type ClaimManagedOperationResult = + | { readonly acquired: true; readonly operation: ManagedOperationRecord } + | { readonly acquired: false; readonly operation: ManagedOperationRecord }; + +export interface UpdateManagedStackInput extends ManagedStackConfiguration { + readonly stackId: string; + readonly operationToken: string; + readonly now: string; +} + +/** + * How an abandoned operation was settled against observed runtime state. + * + * Recovery treats the three shapes differently: an adopted stack is reported as + * recovered, a discarded pending row frees its identity for a retry, and a + * tombstoned row means a crashed deletion — its registry state is already final + * and only the leaked stack directory still needs reclaiming. + */ +export type ReconcileManagedOperationResult = + | { readonly outcome: "recovered"; readonly stack: ManagedStackRecord } + | { readonly outcome: "discarded" } + | { readonly outcome: "tombstoned"; readonly stack: ManagedStackRecord }; + +/** Failures both adapters raise while registering an ordinary workspace stack. */ +export type PrepareOrdinaryStackFailure = + | DuplicateManagedIdentityError + | DuplicateManagedPortKeyError + | InvalidManagedOwnerPidError + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPortReservationError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while claiming an operation for a stack. */ +export type ClaimManagedOperationFailure = + | InvalidManagedOwnerPidError + | ManagedOperationOwnershipError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while reconfiguring a published stack. */ +export type UpdateManagedStackFailure = + | DuplicateManagedPortKeyError + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPendingStackUpdateError + | ManagedPortReservationError + | ManagedRunningStackPortChangeError + | ManagedStackNotFoundError; + +/** + * Failures both adapters raise while settling an abandoned operation. Adopting a + * stack re-reserves the ports it claims, so another stack holding one of them + * fails the reconciliation rather than stealing the lease. + */ +export type ReconcileManagedOperationFailure = + | DuplicateManagedPortKeyError + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPortReservationError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while resolving a stack under a live claim. */ +export type OwnedManagedStackFailure = ManagedOperationOwnershipError | ManagedStackNotFoundError; + +/** + * The registry contract shared by the persistent SQLite adapters and the + * in-memory test seam. + * + * Every method is an `Effect` whose error channel names the domain failures that + * decision can reach. Storage-level failures — a corrupt row, an unexpected + * driver error — are defects instead: they are not outcomes a caller can act on. + */ +export interface ManagedStackRepositoryShape { + readonly prepareOrdinaryStack: ( + input: PrepareOrdinaryStackInput, + ) => Effect.Effect; + readonly publishPendingStack: ( + stackId: string, + operationToken: string, + now: string, + ) => Effect.Effect; + readonly abortPendingStack: ( + stackId: string, + operationToken: string, + ) => Effect.Effect; + readonly getStack: (stackId: string) => Effect.Effect; + readonly listStacks: (options?: { + readonly includeTombstoned?: boolean; + }) => Effect.Effect>; + readonly claimOperation: ( + input: ClaimManagedOperationInput, + ) => Effect.Effect; + readonly finishOperation: ( + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, + ) => Effect.Effect; + readonly updateStack: ( + input: UpdateManagedStackInput, + ) => Effect.Effect; + readonly listActiveOperations: ( + startedBefore?: string, + ) => Effect.Effect>; + readonly reconcileOperation: ( + stackId: string, + operationToken: string, + lifecycle: ManagedStackLifecycle, + now: string, + ) => Effect.Effect; + readonly tombstoneStack: ( + stackId: string, + operationToken: string, + now: string, + ) => Effect.Effect; + readonly listCheckoutLocations: () => Effect.Effect>; + readonly pruneCheckoutLocations: (locationIds: ReadonlyArray) => Effect.Effect; +} + +/** + * The registry a managed stack service reads and writes. + * + * A persistent adapter owns a database handle, so it is provided as a scoped + * layer that closes the handle when the layer's scope closes; there is no + * `close` method on the contract for a caller to forget. + */ +export class ManagedStackRepository extends Context.Service< + ManagedStackRepository, + ManagedStackRepositoryShape +>()("stack/managed/ManagedStackRepository") {} + +export const managedStackOccupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => + lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; + +/** + * An operation's owner pid is only useful because recovery asks the operating + * system whether that process is still alive, and a value that is not a pid + * cannot be asked about: `kill(0, 0)` signals the caller's own process group + * and a fractional pid throws, either of which would report a dead owner as + * alive and wedge the claim forever. `undefined` is a valid answer — it records + * that no owner is known — so it is not usable, but it is not invalid either. + */ +export const isUsableManagedOwnerPid = (ownerPid: number | undefined): ownerPid is number => + ownerPid !== undefined && Number.isSafeInteger(ownerPid) && ownerPid > 0; + +/** + * Rejects a pid that could never be probed, at the boundary that would persist + * it. Shared so both adapters refuse the same inputs and no registry row can + * carry a pid that recovery cannot reason about. + */ +export const assertManagedOwnerPid = (ownerPid: number | undefined): void => { + if (ownerPid !== undefined && !isUsableManagedOwnerPid(ownerPid)) { + throw new InvalidManagedOwnerPidError({ ownerPid }); + } +}; + +/** + * Ordering shared by both adapters. SQLite compares TEXT with BINARY + * collation, so the in-memory repository must compare code points too: + * `localeCompare` folds case and would disagree on mixed-case paths. + */ +export const compareManagedText = (left: string, right: string): number => { + if (left < right) return -1; + return left > right ? 1 : 0; +}; + +const portNumbersEqual = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + if (left.length !== right.length) { + return false; + } + const byKey = new Map(right.map((assignment) => [assignment.key, assignment])); + return left.every((assignment) => { + const candidate = byKey.get(assignment.key); + return candidate !== undefined && assignment.port === candidate.port; + }); +}; + +export const validateManagedPortAssignments = ( + stackId: string, + ports: ReadonlyArray, +): void => { + const keys = new Set(); + const numbers = new Set(); + for (const assignment of ports) { + if (!Number.isInteger(assignment.port) || assignment.port < 1 || assignment.port > 65_535) { + throw new InvalidManagedPortError({ port: assignment.port, key: assignment.key }); + } + if (keys.has(assignment.key)) { + throw new DuplicateManagedPortKeyError({ key: assignment.key }); + } + if (numbers.has(assignment.port)) { + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: stackId }); + } + keys.add(assignment.key); + numbers.add(assignment.port); + } +}; + +export const reconcileManagedPortAssignments = ( + stack: ManagedStackRecord, + requested: ReadonlyArray | undefined, + targetLifecycle: ManagedStackLifecycle = stack.lifecycle, +): ReadonlyArray => { + if (requested === undefined) { + return stack.ports; + } + validateManagedPortAssignments(stack.id, requested); + const persisted = new Map(stack.ports.map((assignment) => [assignment.key, assignment])); + // Sorted by key here, in the shared reconciler: SQLite reads its port rows + // back with `ORDER BY key`, so leaving the caller's request order in place + // would make the same request produce differently ordered records per adapter. + const reconciled = requested + .map((assignment) => { + const current = persisted.get(assignment.key); + return assignment.intent === "automatic" && current !== undefined + ? { ...assignment, port: current.port } + : assignment; + }) + .sort((left, right) => compareManagedText(left.key, right.key)); + if ( + managedStackOccupiesPorts(stack.lifecycle) && + managedStackOccupiesPorts(targetLifecycle) && + !portNumbersEqual(stack.ports, reconciled) + ) { + throw new ManagedRunningStackPortChangeError({ stackId: stack.id }); + } + return reconciled; +}; + +/** + * The stack states `updateStack` refuses, shared so both adapters reject the + * same targets: + * + * - a tombstone is deleted state, and a caller holding a stale ID must never + * resurrect it into a port-occupying lifecycle; + * - a pending row is still owned by its publisher's provisioning flow, which + * publishes or aborts it as a whole. Reconfiguring it would hand a + * port-occupying lease to a stack no reader can see yet. + */ +export const assertManagedStackUpdatable = (stack: ManagedStackRecord): void => { + if (stack.status === "tombstoned") { + throw new ManagedStackNotFoundError({ stackId: stack.id }); + } + if (stack.status === "pending") { + throw new ManagedPendingStackUpdateError({ stackId: stack.id }); + } +}; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts new file mode 100644 index 0000000000..8eac3e742a --- /dev/null +++ b/packages/stack/src/managed/service.ts @@ -0,0 +1,1025 @@ +import { randomUUID } from "node:crypto"; +import { + Cause, + Context, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Schedule, +} from "effect"; +import { + DEFAULT_MANAGED_STACK_NAME, + InvalidManagedIdentityError, + InvalidManagedOwnerPidError, + InvalidManagedStackNameError, + ManagedAbandonedOperationError, + ManagedOperationInProgressError, + ManagedOperationOwnershipError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackNotStoppedError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedStackConfiguration, + type ManagedStackLifecycle, + type ManagedStackRecord, + type ManagedStackSelection, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import { + canonicalizeOrdinaryWorkspacePath, + ensureOrdinaryWorkspaceIdentity, + readOrdinaryWorkspaceIdentity, +} from "./identity.ts"; +import { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { + assertManagedStackRoot, + managedStackPaths, + requireExplicitManagedStateRoot, +} from "./paths.ts"; +import { fromCallback, isBooleanAnswer } from "./callback.ts"; +import { errorCode } from "./error-code.ts"; +import { failsWith } from "./failure.ts"; +import { + assertManagedOwnerPid, + isUsableManagedOwnerPid, + ManagedStackRepository, + type ClaimManagedOperationFailure, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type UpdateManagedStackFailure, +} from "./repository.ts"; + +export interface ManagedStackServiceOptions { + readonly stateRoot: string; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; +} + +export interface ProvisionOrdinaryStackOptions { + readonly workspacePath: string; + readonly stackName?: string; + readonly configuration?: ManagedStackConfiguration; + /** + * Provisioning steps a caller owns. Their failures never reach the caller as + * themselves: whatever they fail with becomes the `cause` of a + * {@link ManagedStackInitializationError} once the pending stack is rolled + * back, so the error channel here is deliberately open. + */ + readonly initialize?: (stack: ManagedStackRecord) => Effect.Effect; + readonly validate?: (stack: ManagedStackRecord) => Effect.Effect; +} + +export interface ProvisionOrdinaryStackResult { + readonly outcome: "create" | "reuse"; + readonly selection: ManagedStackSelection; + readonly stack: ManagedStackRecord; + readonly identityMarkerCreated: boolean; +} + +export interface InspectOrdinaryWorkspaceResult { + readonly registered: boolean; + readonly identity?: OrdinaryWorkspaceIdentity; + readonly stacks: ReadonlyArray; +} + +export interface DeleteManagedStackResult { + readonly outcome: "delete" | "no-op"; + readonly stack: ManagedStackRecord; + readonly dataReclamation: + | { readonly outcome: "removed" } + | { readonly outcome: "retained"; readonly error: unknown }; +} + +interface ReconcileAbandonedOperationsBaseOptions { + readonly inspectRuntime: ( + stack: ManagedStackRecord, + operation: ManagedOperationRecord, + ) => Effect.Effect<"running" | "stopped" | "unknown", E>; +} + +export type ReconcileAbandonedOperationsOptions = + ReconcileAbandonedOperationsBaseOptions & + ( + | { + readonly startedBefore?: string; + readonly force?: never; + } + | { + readonly startedBefore?: never; + readonly force: { + readonly stackId: string; + readonly operationToken: string; + }; + } + ); + +export interface RetainedManagedOperation { + readonly operation: ManagedOperationRecord; + readonly reason: + | "owner-alive" + | "owner-liveness-unknown" + | "runtime-inspection-failed" + | "runtime-unknown"; + readonly error?: unknown; +} + +export interface ManagedOperationRecoveryFailure { + readonly operation: ManagedOperationRecord; + readonly phase: "reconciliation" | "state-reclamation"; + readonly operationReleased: boolean; + readonly error: unknown; +} + +export interface ReconcileAbandonedOperationsResult { + readonly recovered: ReadonlyArray; + /** + * Discarded pending stacks whose leaked provisioning data was removed. A stack + * whose removal failed is reported under `failures` with the + * `state-reclamation` phase instead, never here: this list means the data is + * gone. + */ + readonly abortedStackIds: ReadonlyArray; + /** + * Tombstoned stacks whose abandoned deletion recovery finished, with the same + * removal-succeeded guarantee as {@link abortedStackIds}. The registry + * tombstone is deliberately preserved so repeated deletion stays idempotent; + * only the leaked stack directory is reclaimed. + */ + readonly reclaimedStackIds: ReadonlyArray; + readonly retained: ReadonlyArray; + readonly skippedOperationIds: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +/** Claiming an operation on behalf of a caller, including a refused claim. */ +type RequireManagedOperationFailure = + | ClaimManagedOperationFailure + | InvalidManagedIdentityError + | ManagedOperationInProgressError; + +export type UpdateManagedStackConfigurationFailure = + | RequireManagedOperationFailure + | UpdateManagedStackFailure; + +export type ProvisionManagedStackFailure = + | InvalidManagedIdentityError + | InvalidManagedStackNameError + | ManagedAbandonedOperationError + | ManagedOperationInProgressError + | ManagedStackInitializationError + | ManagedStackNotFoundError + | ManagedStackPublicationTimeoutError + | PrepareOrdinaryStackFailure + | UpdateManagedStackConfigurationFailure; + +export type DeleteManagedStackFailure = + | ManagedStackNotFoundError + | ManagedStackNotStoppedError + | OwnedManagedStackFailure + | RequireManagedOperationFailure + | UpdateManagedStackFailure; + +export interface ManagedStackServiceShape { + readonly stateRoot: string; + readonly provisionOrdinaryStack: ( + options: ProvisionOrdinaryStackOptions, + ) => Effect.Effect; + readonly inspectOrdinaryWorkspace: ( + workspacePath: string, + ) => Effect.Effect; + readonly inspectStack: (stackId: string) => Effect.Effect; + readonly listStacks: (options?: { + readonly includeTombstoned?: boolean; + }) => Effect.Effect>; + readonly updateStack: ( + stackId: string, + configuration: ManagedStackConfiguration, + ) => Effect.Effect; + /** + * The `stop` callback's failure reaches the caller unchanged — a stack that + * refused to stop was not deleted — so its error type flows through. + */ + readonly deleteStack: ( + stackId: string, + options?: { readonly stop?: (stack: ManagedStackRecord) => Effect.Effect }, + ) => Effect.Effect; + /** + * Recovery reports rather than fails: a runtime it could not inspect is a + * retained operation, and a reclamation it could not finish is a reported + * failure. Only a forced target that is not a pair of managed UUIDs refuses + * the whole pass. + */ + readonly reconcileAbandonedOperations: ( + options: ReconcileAbandonedOperationsOptions, + ) => Effect.Effect; + readonly pruneCheckoutLocations: ( + shouldPrune: (location: ManagedCheckoutLocation) => Effect.Effect, + ) => Effect.Effect; +} + +const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => ({ + projectId: stack.projectId, + checkoutId: stack.checkoutId, + contextId: stack.contextId, + stackId: stack.id, + stackName: stack.name, +}); + +const provisionResult = ( + outcome: ProvisionOrdinaryStackResult["outcome"], + stack: ManagedStackRecord, + identityMarkerCreated: boolean, +): ProvisionOrdinaryStackResult => ({ + outcome, + selection: selectionForStack(stack), + stack, + identityMarkerCreated, +}); + +const deletionResult = ( + outcome: DeleteManagedStackResult["outcome"], + stack: ManagedStackRecord, + dataReclamation: DeleteManagedStackResult["dataReclamation"], +): DeleteManagedStackResult => ({ outcome, stack, dataReclamation }); + +const dataRemoved: DeleteManagedStackResult["dataReclamation"] = { outcome: "removed" }; + +const dataRetained = (error: unknown): DeleteManagedStackResult["dataReclamation"] => ({ + outcome: "retained", + error, +}); + +const unregisteredWorkspace: InspectOrdinaryWorkspaceResult = { registered: false, stacks: [] }; + +/** + * How recovery and best-effort cleanup absorb a step that refused. + * + * Whatever the registry, the filesystem, or a caller's seam raised becomes part + * of the report — that is what makes these paths best-effort — but an interrupted + * step has no outcome to report at all: recording one would invent a refusal that + * never happened, mark a stack failed on behalf of a caller that has gone away, + * and make the operation the next pass should still recover look like one + * recovery already gave up on. So interruption is re-raised instead. + */ +const recordUnlessInterrupted = + (record: (cause: Cause.Cause) => Effect.Effect) => + (self: Effect.Effect): Effect.Effect => + Effect.catchCause(self, (cause) => + Cause.hasInterruptsOnly(cause) ? Effect.interrupt : record(cause), + ); + +/** + * The error an absorbed step refused with, for a report entry to carry — or an + * interruption, re-raised before any entry is built. It is the rule + * {@link recordUnlessInterrupted} applies to a whole step, applied where the + * step's exit is inspected instead: an interrupted step has no outcome, so it + * must not become a report entry either way. + */ +const absorbedError = (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) ? Effect.interrupt : Effect.succeed(Cause.squash(cause)); + +/** What one look at a stack awaiting publication can refuse to wait for. */ +type PublicationPollFailure = ManagedAbandonedOperationError | ManagedStackNotFoundError; + +/** Ceiling for the publication poll's backoff. */ +const MAX_PUBLICATION_POLL_MS = 250; + +const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** + * Deliberately conservative: only a definite `ESRCH` proves the owner is gone, + * so a permission error (`EPERM`) keeps the claim rather than stealing it. It + * must never be asked about a value that is not a pid — `kill(0, 0)` signals + * the caller's own process group, and a fractional pid throws, either of which + * would report a dead owner as alive and wedge recovery forever. Callers + * therefore filter pids through {@link isUsableManagedOwnerPid} first. + */ +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return errorCode(error) !== "ESRCH"; + } +}; + +/** + * The managed registry's policy layer: identity marker handling, provisioning + * order, publication waiting, deletion, and recovery of abandoned operations. + */ +export class ManagedStackService extends Context.Service< + ManagedStackService, + ManagedStackServiceShape +>()("stack/managed/ManagedStackService") { + static make( + options: ManagedStackServiceOptions, + ): Layer.Layer< + ManagedStackService, + InvalidManagedOwnerPidError | UnsafeManagedStackPathError, + FileSystem.FileSystem | ManagedStackRepository + > { + return Layer.effect( + this, + Effect.gen(function* () { + const repository = yield* ManagedStackRepository; + const fs = yield* FileSystem.FileSystem; + // Anchored and validated once, at the boundary, through the one resolver + // that owns state-root policy: a relative root injected here would be + // reinterpreted against the process' cwd at every later use, and a blank + // or missing one would anchor every managed path to it. + const stateRoot = yield* Effect.try({ + try: () => requireExplicitManagedStateRoot(options.stateRoot), + catch: failsWith(UnsafeManagedStackPathError), + }); + // Validated here as well as in the repository: the pid is this service's + // own option, so the failure belongs to the caller that supplied it. + yield* Effect.try({ + try: () => { + assertManagedOwnerPid(options.ownerPid); + }, + catch: failsWith(InvalidManagedOwnerPidError), + }); + + const idFactory = options.idFactory ?? randomUUID; + const clock = options.clock ?? (() => new Date()); + const ownerPid = options.ownerPid ?? process.pid; + const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; + const publicationPollMs = options.publicationPollMs ?? 10; + const isProcessAlive = options.isProcessAlive ?? processIsAlive; + const now = (): string => clock().toISOString(); + + const managedUuid = (label: string): Effect.Effect => + Effect.try({ + try: () => createManagedUuid(idFactory, label), + catch: failsWith(InvalidManagedIdentityError), + }); + + const requireManagedUuid = ( + value: string, + label: string, + ): Effect.Effect => + Effect.try({ + try: () => assertManagedUuid(value, label), + catch: failsWith(InvalidManagedIdentityError), + }); + + /** + * `isProcessAlive` is a caller-supplied seam that may answer + * synchronously or asynchronously, and may refuse to answer at all. + * Recovery reports a refusal as a retained operation, so the refusal is + * kept in the error channel here rather than being turned into a defect. + */ + const probeProcessAlive = (pid: number): Effect.Effect => + fromCallback(() => isProcessAlive(pid), isBooleanAnswer); + + /** + * A stack's directory is only ever removed through the path guard, so a + * forged or stale record cannot make recovery delete something outside the + * state root. Both refusals — the guard's and the filesystem's — are + * reported as retained data rather than propagated. + */ + const removeStackState = (stack: ManagedStackRecord) => + Effect.flatMap( + Effect.try({ + try: () => assertManagedStackRoot(stateRoot, stack.id, stack.paths.root), + catch: failsWith(UnsafeManagedStackPathError), + }), + (root) => fs.remove(root, { force: true, recursive: true }), + ); + + const reclaimStackState = ( + stack: ManagedStackRecord, + ): Effect.Effect => + removeStackState(stack).pipe( + Effect.as(dataRemoved), + recordUnlessInterrupted((cause) => Effect.succeed(dataRetained(Cause.squash(cause)))), + ); + + /** + * Marks an operation failed as part of a recovery report, answering + * whether the claim was actually released — a claim that could not be + * released is reported, not hidden. Interruption is re-raised, because + * this is a recording site: there is no report to put an interrupted + * step in. + */ + const finishOperationBestEffort = ( + stackId: string, + operationToken: string, + error: unknown, + ): Effect.Effect => + repository.finishOperation(stackId, operationToken, "failed", now(), String(error)).pipe( + Effect.as(true), + // Preserve the operation's original failure when ownership changed concurrently. + recordUnlessInterrupted(() => Effect.succeed(false)), + ); + + /** + * Releases this call's claim on the way out of a failed operation, then + * re-raises the cause that got here. + * + * The release absorbs everything it can raise, its own interruption + * included: the caller's outcome is the failure the operation suffered, + * and an embedder repository that reports interruption from + * `finishOperation` would otherwise replace that failure with an + * interruption the caller never asked for. That is the opposite of the + * recording sites above, where an interrupted step has no outcome and + * interruption is the only honest answer. + */ + const releasingClaimOnFailure = + (stackId: string, operationToken: string) => + (self: Effect.Effect): Effect.Effect => + Effect.catchCause(self, (cause) => + repository + .finishOperation( + stackId, + operationToken, + "failed", + now(), + String(Cause.squash(cause)), + ) + .pipe( + Effect.catchCause(() => Effect.void), + Effect.flatMap(() => Effect.failCause(cause)), + ), + ); + + /** + * A concurrent forced recovery can resolve this same operation before + * this call closes it out, but only after the delete's own data removal + * already ran — so the delete is provably done and its ownership race + * must not be reported as a failure. Any other error still propagates, + * since only that specific race is known to be harmless. + */ + const finishDeleteOperationTolerantly = ( + stackId: string, + operationToken: string, + ): Effect.Effect => + repository + .finishOperation(stackId, operationToken, "completed", now()) + .pipe(Effect.catchTag("ManagedOperationOwnershipError", () => Effect.void)); + + const failRecoveryBestEffort = ( + stack: ManagedStackRecord | undefined, + operation: ManagedOperationRecord, + error: unknown, + ): Effect.Effect => { + if (stack === undefined || stack.status === "pending") { + return Effect.succeed(false); + } + return repository + .updateStack({ + stackId: operation.stackId, + operationToken: operation.token, + lifecycle: "failed", + now: now(), + }) + .pipe( + // Releasing the abandoned claim is still useful if the failed lifecycle cannot be recorded. + recordUnlessInterrupted(() => Effect.void), + Effect.flatMap(() => + finishOperationBestEffort(operation.stackId, operation.token, error), + ), + ); + }; + + const requireOperation = ( + stackId: string, + kind: ManagedOperationKind, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* managedUuid("operation token"); + const claimed = yield* repository.claimOperation({ + token, + stackId, + kind, + ownerPid, + now: now(), + }); + if (!claimed.acquired) { + return yield* Effect.fail( + new ManagedOperationInProgressError({ stackId, operation: claimed.operation }), + ); + } + return claimed.operation; + }); + + // Publication normally lands within the first poll, so start tight and + // back off: a slow publisher must not be polled hundreds of times per + // second for the whole timeout window. The ceiling only ever slows + // polling down, so a caller asking for a slower interval keeps its own. + const publicationPollCeiling = Math.max(MAX_PUBLICATION_POLL_MS, publicationPollMs); + const publicationPollSchedule = Schedule.exponential( + Duration.millis(publicationPollMs), + ).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed( + Duration.millis(Math.min(Duration.toMillis(duration), publicationPollCeiling)), + ), + ), + ); + + /** + * One look at a stack a caller is waiting for. `Option.none()` is the + * retryable answer — the row is still pending, so the poll schedules + * another look — while the two failures are final answers about a + * publisher that will never arrive. + */ + const pollPublication = ( + pending: ManagedStackRecord, + ): Effect.Effect, PublicationPollFailure> => + Effect.flatMap( + repository.getStack(pending.id), + (current): Effect.Effect, PublicationPollFailure> => { + if (current === undefined) { + return Effect.fail(new ManagedAbandonedOperationError({ stackId: pending.id })); + } + if (current.status === "active") { + return Effect.succeed(Option.some(current)); + } + if (current.status === "tombstoned") { + return Effect.fail(new ManagedStackNotFoundError({ stackId: current.id })); + } + return Effect.succeed(Option.none()); + }, + ); + + const awaitPublication = ( + pending: ManagedStackRecord, + ): Effect.Effect< + ManagedStackRecord, + | ManagedAbandonedOperationError + | ManagedStackNotFoundError + | ManagedStackPublicationTimeoutError + > => + pollPublication(pending).pipe( + Effect.repeat({ + schedule: publicationPollSchedule, + while: (answer: Option.Option) => Option.isNone(answer), + }), + // The timeout is the caller's bound on the whole wait, so it + // interrupts the poll rather than being checked between polls. + Effect.timeoutOrElse({ + duration: Duration.millis(publicationTimeoutMs), + orElse: () => + Effect.fail(new ManagedStackPublicationTimeoutError({ stackId: pending.id })), + }), + // Only an unbounded schedule guarantees the repeat stops on a + // published stack, and this one is unbounded. A recurrence bound + // added later would hand back the final `None` instead, so the + // answer is checked rather than asserted through a refinement: a + // schedule that gave up is a bug in this module, not an outcome a + // caller could act on. + Effect.flatMap((published) => + Option.isNone(published) + ? Effect.die( + new Error( + `The publication poll for ${pending.id} stopped before the stack was published`, + ), + ) + : Effect.succeed(published.value), + ), + ); + + const updateStackRecord = ( + stackId: string, + configuration: ManagedStackConfiguration, + ): Effect.Effect => + Effect.gen(function* () { + const operation = yield* requireOperation(stackId, "update"); + return yield* repository + .updateStack({ + stackId, + operationToken: operation.token, + now: now(), + ...configuration, + }) + .pipe( + Effect.tap(() => + repository.finishOperation(stackId, operation.token, "completed", now()), + ), + releasingClaimOnFailure(stackId, operation.token), + ); + }); + + /** + * Reused stacks adopt the caller's requested configuration regardless of + * whether the record was already published or was awaited while another + * caller published it, so the outcome never depends on that timing. + */ + const applyRequestedConfiguration = ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration | undefined, + ): Effect.Effect => + configuration === undefined || Object.keys(configuration).length === 0 + ? Effect.succeed(stack) + : updateStackRecord(stack.id, configuration); + + const provisionOrdinaryStack = ( + provisionOptions: ProvisionOrdinaryStackOptions, + ): Effect.Effect => + Effect.gen(function* () { + const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; + if (!stackNamePattern.test(stackName)) { + return yield* Effect.fail(new InvalidManagedStackNameError({ stackName })); + } + const canonicalPath = yield* canonicalizeOrdinaryWorkspacePath( + provisionOptions.workspacePath, + ); + const marker = yield* ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); + const stackId = yield* managedUuid("stackId"); + const locationId = yield* managedUuid("checkout location id"); + const operationToken = yield* managedUuid("operation token"); + const paths = yield* Effect.try({ + try: () => managedStackPaths(stateRoot, stackId), + catch: failsWith(InvalidManagedIdentityError), + }); + const prepared = yield* repository.prepareOrdinaryStack({ + identity: marker.identity, + canonicalPath, + locationId, + stackId, + stackName, + paths, + operationToken, + ownerPid, + now: now(), + configuration: provisionOptions.configuration ?? {}, + }); + + if (prepared.outcome === "existing") { + if (prepared.stack.status === "active") { + if (prepared.operation !== undefined) { + return yield* Effect.fail( + new ManagedOperationInProgressError({ + stackId: prepared.stack.id, + operation: prepared.operation, + }), + ); + } + const stack = yield* applyRequestedConfiguration( + prepared.stack, + provisionOptions.configuration, + ); + return provisionResult("reuse", stack, marker.created); + } + if (prepared.operation === undefined) { + return yield* Effect.fail( + new ManagedAbandonedOperationError({ stackId: prepared.stack.id }), + ); + } + // A stored pid that is not a usable pid means there is no owner to + // wait for, exactly as a missing one does: probing it could report + // a dead publisher as alive and make this caller wait out the whole + // publication timeout instead of reporting the abandoned claim. + // Provisioning has no report to put a refused probe in, so a seam + // that cannot answer is a defect here rather than an outcome. + if ( + !isUsableManagedOwnerPid(prepared.operation.ownerPid) || + !(yield* Effect.orDie(probeProcessAlive(prepared.operation.ownerPid))) + ) { + return yield* Effect.fail( + new ManagedAbandonedOperationError({ stackId: prepared.stack.id }), + ); + } + const awaited = yield* awaitPublication(prepared.stack); + const published = yield* applyRequestedConfiguration( + awaited, + provisionOptions.configuration, + ); + return provisionResult("reuse", published, marker.created); + } + + const pending = prepared.stack; + const operation = prepared.operation; + // Between preparing the pending row and publishing it, this call + // owns a registry row, an operation claim, and the directories it + // created, so the compensation has to run even when the fiber is + // interrupted: a caller that times out or closes the service must + // not leave a pending stack and a leaked directory behind. Only the + // provisioning steps are interruptible; the rollback is not. + // + // The mask starts after `prepareOrdinaryStack`, so it covers the row + // this call owns but not the act of creating it. That is sound only + // because every repository this package ships decides synchronously: + // both adapters run the pending row and its claim as one SQLite + // transaction or one in-memory mutation, with no suspension point an + // interruption could land on. An asynchronous embedder repository + // breaks that assumption — interrupted mid-prepare it would leave a + // pending row and a claim nothing compensates — so the mask must be + // extended to cover row creation before async repositories become + // real. `deleteStack`'s claim has the same shape. + return yield* Effect.uninterruptibleMask((restore) => + restore( + Effect.gen(function* () { + yield* fs.makeDirectory(pending.paths.data, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.logs, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.runtime, { recursive: true, mode: 0o700 }); + if (provisionOptions.initialize !== undefined) { + yield* provisionOptions.initialize(pending); + } + if (provisionOptions.validate !== undefined) { + yield* provisionOptions.validate(pending); + } + const published = yield* repository.publishPendingStack( + pending.id, + operation.token, + now(), + ); + return provisionResult("create", published, marker.created); + }), + ).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const cleanupErrors: Array = []; + const aborted = yield* Effect.exit( + repository.abortPendingStack(pending.id, operation.token), + ); + if (Exit.isFailure(aborted)) { + cleanupErrors.push(Cause.squash(aborted.cause)); + } else { + const reclaimed = yield* Effect.exit(removeStackState(pending)); + if (Exit.isFailure(reclaimed)) { + cleanupErrors.push(Cause.squash(reclaimed.cause)); + } + } + // A provision the caller abandoned is not an initialization + // that failed: the interruption is the outcome, and + // reporting it as a failure would tell the caller its own + // timeout was the stack's fault. + return yield* Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.fail( + new ManagedStackInitializationError({ + stackId: pending.id, + cause: Cause.squash(cause), + cleanupErrors, + }), + ); + }), + ), + ), + ); + }); + + const inspectOrdinaryWorkspace = ( + workspacePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const canonicalPath = yield* canonicalizeOrdinaryWorkspacePath(workspacePath); + const identity = yield* readOrdinaryWorkspaceIdentity(canonicalPath); + if (identity === undefined) { + return unregisteredWorkspace; + } + const stacks = (yield* repository.listStacks()).filter( + (stack) => + stack.projectId === identity.projectId && + stack.checkoutId === identity.checkoutId && + stack.contextId === identity.contextId, + ); + return { registered: stacks.length > 0, identity, stacks }; + }); + + const deleteStack = ( + stackId: string, + deleteOptions?: { + readonly stop?: (stack: ManagedStackRecord) => Effect.Effect; + }, + ): Effect.Effect => + Effect.gen(function* () { + const existing = yield* repository.getStack(stackId); + if (existing === undefined) { + return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); + } + if (existing.status === "tombstoned") { + return deletionResult("no-op", existing, yield* reclaimStackState(existing)); + } + const operation = yield* requireOperation(stackId, "delete"); + // The claim belongs to this call, so releasing it has to survive an + // interruption too: a caller that gave up mid-delete must not leave + // the stack claimed by an operation nobody will ever finish. The + // original cause is re-raised either way, so an interrupted delete + // stays interrupted. + return yield* Effect.uninterruptibleMask((restore) => + restore( + Effect.gen(function* () { + const current = yield* repository.getStack(stackId); + if (current === undefined) { + return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); + } + if (current.status === "tombstoned") { + const dataReclamation = yield* reclaimStackState(current); + yield* repository.finishOperation(stackId, operation.token, "completed", now()); + return deletionResult("no-op", current, dataReclamation); + } + if (current.lifecycle !== "stopped") { + const stop = deleteOptions?.stop; + if (stop === undefined) { + return yield* Effect.fail(new ManagedStackNotStoppedError({ stackId })); + } + yield* stop(current); + yield* repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + lifecycle: "stopped", + runtimeMetadata: { processIds: {}, containerIds: {} }, + }); + } + const tombstoned = yield* repository.tombstoneStack( + stackId, + operation.token, + now(), + ); + const dataReclamation = yield* reclaimStackState(tombstoned); + yield* finishDeleteOperationTolerantly(stackId, operation.token); + return deletionResult("delete", tombstoned, dataReclamation); + }), + ).pipe(releasingClaimOnFailure(stackId, operation.token)), + ); + }); + + const reconcileAbandonedOperations = ( + reconcileOptions: ReconcileAbandonedOperationsOptions, + ): Effect.Effect => + Effect.gen(function* () { + const recovered: Array = []; + const abortedStackIds: Array = []; + const reclaimedStackIds: Array = []; + const retained: Array = []; + const skippedOperationIds: Array = []; + const failures: Array = []; + const forcedOperation = reconcileOptions.force; + if (forcedOperation !== undefined) { + yield* requireManagedUuid(forcedOperation.stackId, "forced recovery stackId"); + yield* requireManagedUuid( + forcedOperation.operationToken, + "forced recovery operation token", + ); + } + const operations = (yield* repository.listActiveOperations( + forcedOperation === undefined ? reconcileOptions.startedBefore : undefined, + )).filter( + (operation) => + forcedOperation === undefined || + (operation.stackId === forcedOperation.stackId && + operation.token === forcedOperation.operationToken), + ); + + const settleOperation = (operation: ManagedOperationRecord): Effect.Effect => + Effect.gen(function* () { + // A persisted pid that is not a usable pid is treated as no owner + // at all: asking the liveness probe about it could report a live + // owner and wedge this claim forever, which is the failure + // recovery exists to fix. + if (forcedOperation === undefined && isUsableManagedOwnerPid(operation.ownerPid)) { + const alive = yield* Effect.exit(probeProcessAlive(operation.ownerPid)); + if (Exit.isFailure(alive)) { + const error = yield* absorbedError(alive.cause); + retained.push({ operation, reason: "owner-liveness-unknown", error }); + return; + } + if (alive.value) { + retained.push({ operation, reason: "owner-alive" }); + return; + } + } + let claimedStack: ManagedStackRecord | undefined; + yield* Effect.gen(function* () { + const stack = yield* repository.getStack(operation.stackId); + claimedStack = stack; + if (stack === undefined) { + skippedOperationIds.push(operation.token); + return; + } + // A tombstoned row is a deletion that died before releasing its + // claim. Its registry state is already final, so + // `reconcileOperation` ignores the lifecycle for it — and + // tombstoning zeroed the runtime metadata an inspector would + // need, so asking could only answer "unknown" and leak the + // stack directory forever. + let lifecycle: ManagedStackLifecycle = "stopped"; + if (stack.status !== "tombstoned") { + const inspected = yield* Effect.exit( + reconcileOptions.inspectRuntime(stack, operation), + ); + if (Exit.isFailure(inspected)) { + const error = yield* absorbedError(inspected.cause); + retained.push({ operation, reason: "runtime-inspection-failed", error }); + return; + } + if (inspected.value === "unknown") { + retained.push({ operation, reason: "runtime-unknown" }); + return; + } + lifecycle = inspected.value === "running" ? "running" : "stopped"; + } + const reconciled = yield* repository.reconcileOperation( + stack.id, + operation.token, + lifecycle, + now(), + ); + if (reconciled.outcome === "recovered") { + recovered.push(reconciled.stack); + return; + } + // Both remaining outcomes leave state on disk that no registry + // row will ever point at again: a discarded pending stack's + // partial provisioning, or the data a crashed deletion never + // got to remove. The stack is reported under either id list + // only once that data is actually gone; otherwise the removal + // failure is the whole report. + const removal = yield* Effect.exit(removeStackState(stack)); + if (Exit.isFailure(removal)) { + const error = yield* absorbedError(removal.cause); + failures.push({ + operation, + phase: "state-reclamation", + operationReleased: true, + error, + }); + return; + } + if (reconciled.outcome === "discarded") { + abortedStackIds.push(stack.id); + return; + } + reclaimedStackIds.push(stack.id); + }).pipe( + recordUnlessInterrupted((cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + if ( + error instanceof ManagedOperationOwnershipError || + error instanceof ManagedStackNotFoundError + ) { + skippedOperationIds.push(operation.token); + return; + } + failures.push({ + operation, + phase: "reconciliation", + operationReleased: yield* failRecoveryBestEffort( + claimedStack, + operation, + error, + ), + error, + }); + }), + ), + ); + }); + + for (const operation of operations) { + yield* settleOperation(operation); + } + return { + recovered, + abortedStackIds, + reclaimedStackIds, + retained, + skippedOperationIds, + failures, + }; + }); + + const pruneCheckoutLocations = ( + shouldPrune: (location: ManagedCheckoutLocation) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const stale: Array = []; + for (const location of yield* repository.listCheckoutLocations()) { + if (yield* shouldPrune(location)) { + stale.push(location.id); + } + } + return yield* repository.pruneCheckoutLocations(stale); + }); + + return { + stateRoot, + provisionOrdinaryStack, + inspectOrdinaryWorkspace, + inspectStack: (stackId) => repository.getStack(stackId), + listStacks: (listOptions) => repository.listStacks(listOptions), + updateStack: updateStackRecord, + deleteStack, + reconcileAbandonedOperations, + pruneCheckoutLocations, + }; + }), + ); + } +} diff --git a/packages/stack/src/managed/sqlite-bun.ts b/packages/stack/src/managed/sqlite-bun.ts new file mode 100644 index 0000000000..bf4780e04b --- /dev/null +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -0,0 +1,41 @@ +import { Database } from "bun:sqlite"; +import type { Layer } from "effect"; +import type { UnsupportedManagedRegistryVersionError } from "./model.ts"; +import type { ManagedStackRepository } from "./repository.ts"; +import { + hardenManagedRegistryFile, + sqliteManagedStackRepositoryLayer, + type ManagedSqliteDatabase, +} from "./sqlite.ts"; + +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); + const database = new Database(path, { create: true }); + return { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.query(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + return statement.get(...parameters) ?? undefined; + }, + all(parameters = []) { + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }; +}; + +export const bunSqliteManagedStackRepositoryLayer = ( + path: string, +): Layer.Layer => + sqliteManagedStackRepositoryLayer(() => openDatabase(path)); diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts new file mode 100644 index 0000000000..1e5b265d9f --- /dev/null +++ b/packages/stack/src/managed/sqlite-node.ts @@ -0,0 +1,41 @@ +import { DatabaseSync } from "node:sqlite"; +import type { Layer } from "effect"; +import type { UnsupportedManagedRegistryVersionError } from "./model.ts"; +import type { ManagedStackRepository } from "./repository.ts"; +import { + hardenManagedRegistryFile, + sqliteManagedStackRepositoryLayer, + type ManagedSqliteDatabase, +} from "./sqlite.ts"; + +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); + const database = new DatabaseSync(path); + return { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.prepare(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + return statement.get(...parameters) ?? undefined; + }, + all(parameters = []) { + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }; +}; + +export const nodeSqliteManagedStackRepositoryLayer = ( + path: string, +): Layer.Layer => + sqliteManagedStackRepositoryLayer(() => openDatabase(path)); diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts new file mode 100644 index 0000000000..cdb25fe7f5 --- /dev/null +++ b/packages/stack/src/managed/sqlite.ts @@ -0,0 +1,1174 @@ +import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; +import { dirname } from "node:path"; +import { Duration, Effect, Layer, Schedule, Schema } from "effect"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + MANAGED_REGISTRY_SCHEMA_VERSION, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + UnsupportedManagedRegistryVersionError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedOperationStatus, + type ManagedPortAssignment, + type ManagedPortIntent, + type ManagedRuntime, + type ManagedRuntimeMetadata, + type ManagedRuntimeRequest, + type ManagedStackLifecycle, + type ManagedStackPaths, + type ManagedStackRecord, + type ManagedStackStatus, +} from "./model.ts"; +import type { + ClaimManagedOperationFailure, + ClaimManagedOperationInput, + ClaimManagedOperationResult, + ManagedStackRepositoryShape, + OwnedManagedStackFailure, + PrepareOrdinaryStackFailure, + PrepareOrdinaryStackInput, + PrepareOrdinaryStackResult, + ReconcileManagedOperationFailure, + ReconcileManagedOperationResult, + UpdateManagedStackFailure, + UpdateManagedStackInput, +} from "./repository.ts"; +import { + assertManagedOwnerPid, + assertManagedStackUpdatable, + managedStackOccupiesPorts, + ManagedStackRepository, + reconcileManagedPortAssignments, + validateManagedPortAssignments, +} from "./repository.ts"; +import { errorCode } from "./error-code.ts"; +import { failsWith, neverFails } from "./failure.ts"; + +type SqliteValue = null | number | string; + +interface ManagedSqliteStatement { + run(parameters?: ReadonlyArray): void; + get(parameters?: ReadonlyArray): unknown; + all(parameters?: ReadonlyArray): ReadonlyArray; +} + +export interface ManagedSqliteDatabase { + exec(sql: string): void; + prepare(sql: string): ManagedSqliteStatement; + close(): void; +} + +const stringRecordSchema = Schema.Record(Schema.String, Schema.String); +const numberRecordSchema = Schema.Record(Schema.String, Schema.Number); +const runtimeMetadataSchema = Schema.Struct({ + pid: Schema.optional(Schema.Number), + socketPath: Schema.optional(Schema.String), + processIds: numberRecordSchema, + containerIds: stringRecordSchema, +}); +const decodeStringRecord = Schema.decodeUnknownSync(stringRecordSchema); +const decodeRuntimeMetadata = Schema.decodeUnknownSync(runtimeMetadataSchema); + +const getField = (row: unknown, field: string): unknown => { + if (typeof row !== "object" || row === null) { + throw new Error(`SQLite row is missing ${field}`); + } + return Reflect.get(row, field); +}; + +const getString = (row: unknown, field: string): string => { + const value = getField(row, field); + if (typeof value !== "string") { + throw new Error(`SQLite column ${field} is not a string`); + } + return value; +}; + +const getOptionalString = (row: unknown, field: string): string | undefined => { + const value = getField(row, field); + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`SQLite column ${field} is not a nullable string`); + } + return value; +}; + +const getNumber = (row: unknown, field: string): number => { + const value = getField(row, field); + if (typeof value !== "number") { + throw new Error(`SQLite column ${field} is not a number`); + } + return value; +}; + +const getOptionalNumber = (row: unknown, field: string): number | undefined => { + const value = getField(row, field); + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "number") { + throw new Error(`SQLite column ${field} is not a nullable number`); + } + return value; +}; + +const parseJson = (value: string): unknown => JSON.parse(value); + +const managedRuntimeRequest = (value: string): ManagedRuntimeRequest => { + if (value === "auto" || value === "docker" || value === "native") { + return value; + } + throw new Error(`Unknown managed runtime request ${value}`); +}; + +const managedRuntime = (value: string | undefined): ManagedRuntime | undefined => { + if (value === undefined || value === "docker" || value === "native") { + return value; + } + throw new Error(`Unknown managed runtime ${value}`); +}; + +const managedStackStatus = (value: string): ManagedStackStatus => { + if (value === "active" || value === "pending" || value === "tombstoned") { + return value; + } + throw new Error(`Unknown managed stack status ${value}`); +}; + +const managedStackLifecycle = (value: string): ManagedStackLifecycle => { + if ( + value === "failed" || + value === "running" || + value === "starting" || + value === "stopped" || + value === "stopping" + ) { + return value; + } + throw new Error(`Unknown managed stack lifecycle ${value}`); +}; + +const managedOperationKind = (value: string): ManagedOperationKind => { + if (value === "delete" || value === "start" || value === "stop" || value === "update") { + return value; + } + throw new Error(`Unknown managed operation kind ${value}`); +}; + +const managedOperationStatus = (value: string): ManagedOperationStatus => { + if (value === "active" || value === "completed" || value === "failed") { + return value; + } + throw new Error(`Unknown managed operation status ${value}`); +}; + +const managedPortIntent = (value: string): ManagedPortIntent => { + if (value === "automatic" || value === "exact") { + return value; + } + throw new Error(`Unknown managed port intent ${value}`); +}; + +const isSqliteBusy = (error: unknown): boolean => { + const code = errorCode(error); + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") { + return true; + } + return error instanceof Error && /database is (?:busy|locked)/i.test(error.message); +}; + +const WAL_CONVERSION_RETRY_MS = 10; +const WAL_CONVERSION_RETRY_CEILING_MS = 100; +const WAL_CONVERSION_BUDGET_MS = 4_000; + +/** + * Converting a fresh registry to WAL can lose a race with another process doing + * the same thing, and SQLite reports that as a busy error instead of waiting it + * out under `busy_timeout`. The conversion is therefore retried on a schedule: + * tight at first, capped so a long contention window is not polled every 10 ms, + * and bounded by a total budget. The retry is a schedule rather than a blocking + * wait, so a cold start under contention suspends the fiber instead of stalling + * the event loop that is driving every other caller of this process. + */ +const walConversionSchedule = Schedule.exponential(Duration.millis(WAL_CONVERSION_RETRY_MS)).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed( + Duration.millis(Math.min(Duration.toMillis(duration), WAL_CONVERSION_RETRY_CEILING_MS)), + ), + ), + Schedule.upTo({ duration: Duration.millis(WAL_CONVERSION_BUDGET_MS) }), +); + +const enableWriteAheadLogging = (database: ManagedSqliteDatabase): Effect.Effect => + Effect.try({ + try: () => { + database.exec("PRAGMA journal_mode = WAL"); + }, + catch: (error: unknown) => error, + }).pipe( + Effect.retry({ while: isSqliteBusy, schedule: walConversionSchedule }), + // Contention that never clears within the budget is not a managed failure a + // caller could recover from, so the driver's own error stays a defect — + // exactly as an immediate non-busy failure of this pragma always has. + Effect.orDie, + ); + +const rollbackPreservingCause = (database: ManagedSqliteDatabase): void => { + try { + database.exec("ROLLBACK"); + } catch { + // The original transaction error is more useful than a secondary rollback failure. + } +}; + +const commitPreservingCause = (database: ManagedSqliteDatabase): void => { + try { + database.exec("COMMIT"); + } catch (error: unknown) { + rollbackPreservingCause(database); + throw error; + } +}; + +/** The handles currently between `BEGIN` and `COMMIT` — see {@link runTransaction}. */ +const openTransactions = new WeakSet(); + +/** + * `BEGIN`, the decision's statements, and `COMMIT` as one synchronous block. + * + * Atomicity here rests on the drivers being synchronous and the handle being + * single-threaded: nothing else can run between the statements, so a partially + * applied decision is never observable. That only holds while the whole block is + * one JavaScript turn — splitting the boundary across effects would reintroduce + * a suspension point where the fiber scheduler could preempt at its operation + * budget and let another fiber `BEGIN` on this very handle. + * + * What synchrony cannot rule out is a decision that re-enters the repository: + * SQLite has no nested transactions, so the inner `BEGIN` would refuse with a + * driver message about the outer one, and unwinding the inner attempt would + * `ROLLBACK` the outer transaction's writes. Reentrancy is a bug in the calling + * code rather than a condition to recover from, so it is refused here — before + * any statement runs, and without touching the transaction already in flight. + */ +const runTransaction = ( + database: ManagedSqliteDatabase, + begin: "BEGIN" | "BEGIN IMMEDIATE", + run: () => A, +): A => { + if (openTransactions.has(database)) { + throw new Error("A registry transaction is already open on this database handle"); + } + database.exec(begin); + openTransactions.add(database); + try { + let decided: A; + try { + decided = run(); + } catch (error: unknown) { + rollbackPreservingCause(database); + throw error; + } + commitPreservingCause(database); + return decided; + } finally { + openTransactions.delete(database); + } +}; + +/** + * The schema migration is a registry decision like any other, so it runs through + * {@link runTransaction}: it is the first thing an opened handle does, before the + * repository it initializes exists, so no transaction can be open on the handle + * yet. An already-current registry returns without writing and the transaction + * commits nothing. + */ +const migrateSchema = (database: ManagedSqliteDatabase): void => + runTransaction(database, "BEGIN IMMEDIATE", () => { + const versionRow = database.prepare("PRAGMA user_version").get(); + const version = getNumber(versionRow, "user_version"); + if (version !== 0 && version !== MANAGED_REGISTRY_SCHEMA_VERSION) { + throw new UnsupportedManagedRegistryVersionError({ + found: version, + supported: MANAGED_REGISTRY_SCHEMA_VERSION, + }); + } + if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { + return; + } + database.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL + ); + + CREATE TABLE checkouts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE checkout_locations ( + id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + canonical_path TEXT NOT NULL UNIQUE, + last_seen_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX one_ordinary_location_per_checkout + ON checkout_locations(checkout_id); + + CREATE TABLE contexts ( + id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE stacks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + context_id TEXT NOT NULL REFERENCES contexts(id), + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'tombstoned')), + lifecycle TEXT NOT NULL CHECK (lifecycle IN ('stopped', 'starting', 'running', 'stopping', 'failed')), + runtime_request TEXT NOT NULL CHECK (runtime_request IN ('auto', 'docker', 'native')), + runtime TEXT CHECK (runtime IN ('docker', 'native')), + root_path TEXT NOT NULL, + data_path TEXT NOT NULL, + logs_path TEXT NOT NULL, + runtime_path TEXT NOT NULL, + config_fingerprint TEXT, + credentials_reference TEXT, + service_versions_json TEXT NOT NULL, + runtime_metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + tombstoned_at TEXT + ); + CREATE UNIQUE INDEX one_live_stack_per_identity + ON stacks(checkout_id, context_id, name) + WHERE status != 'tombstoned'; + + CREATE TABLE ports ( + stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, + key TEXT NOT NULL, + port INTEGER NOT NULL, + intent TEXT NOT NULL CHECK (intent IN ('automatic', 'exact')), + PRIMARY KEY (stack_id, key) + ); + CREATE INDEX port_assignments_by_port ON ports(port); + + CREATE TABLE operations ( + token TEXT PRIMARY KEY, + stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('start', 'stop', 'delete', 'update')), + status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'failed')), + owner_pid INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT, + error TEXT + ); + CREATE UNIQUE INDEX one_active_operation_per_stack + ON operations(stack_id) + WHERE status = 'active'; + + PRAGMA user_version = ${MANAGED_REGISTRY_SCHEMA_VERSION}; + `); + }); + +/** + * Prepares a freshly opened handle for use as the registry. + * + * `busy_timeout` is set first so every later statement waits out a writer on its + * own, then the file is converted to WAL, and only then is the schema read and + * created. A registry written by an unsupported version is the one outcome a + * caller can act on, so it is the only failure this reports; everything else the + * driver raises stays a defect. + */ +const initializeRegistry = ( + database: ManagedSqliteDatabase, +): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => { + database.exec("PRAGMA busy_timeout = 5000"); + database.exec("PRAGMA foreign_keys = ON"); + }); + yield* enableWriteAheadLogging(database); + yield* Effect.try({ + try: () => { + migrateSchema(database); + }, + catch: failsWith( + UnsupportedManagedRegistryVersionError, + ), + }); + }); + +/** + * Runs one registry decision inside a transaction. `catchFailure` names the + * domain failures the decision raises; anything else is a defect, and either way + * the statement batch has already rolled back. + */ +const transaction = ( + database: ManagedSqliteDatabase, + run: () => A, + catchFailure: (error: unknown) => E, +): Effect.Effect => + Effect.try({ + try: () => runTransaction(database, "BEGIN IMMEDIATE", run), + catch: catchFailure, + }); + +const readTransaction = ( + database: ManagedSqliteDatabase, + run: () => A, +): Effect.Effect => + Effect.try({ try: () => runTransaction(database, "BEGIN", run), catch: neverFails }); + +const decodePort = (row: unknown): ManagedPortAssignment => ({ + key: getString(row, "key"), + port: getNumber(row, "port"), + intent: managedPortIntent(getString(row, "intent")), +}); + +const queryPorts = ( + database: ManagedSqliteDatabase, + stackId: string, +): ReadonlyArray => + database + .prepare("SELECT key, port, intent FROM ports WHERE stack_id = ? ORDER BY key") + .all([stackId]) + .map(decodePort); + +/** + * Ports for many stacks in one statement, so listing N stacks costs two queries + * instead of N + 1. + */ +const queryPortsByStack = ( + database: ManagedSqliteDatabase, + stackIds: ReadonlyArray, +): Map> => { + const byStack = new Map>(); + if (stackIds.length === 0) { + return byStack; + } + const placeholders = stackIds.map(() => "?").join(", "); + const rows = database + .prepare( + `SELECT stack_id, key, port, intent FROM ports + WHERE stack_id IN (${placeholders}) + ORDER BY stack_id, key`, + ) + .all([...stackIds]); + for (const row of rows) { + const stackId = getString(row, "stack_id"); + const assignments = byStack.get(stackId); + if (assignments === undefined) { + byStack.set(stackId, [decodePort(row)]); + continue; + } + assignments.push(decodePort(row)); + } + return byStack; +}; + +const decodeStackWithPorts = ( + row: unknown, + ports: ReadonlyArray, +): ManagedStackRecord => { + const id = getString(row, "id"); + const paths: ManagedStackPaths = { + root: getString(row, "root_path"), + data: getString(row, "data_path"), + logs: getString(row, "logs_path"), + runtime: getString(row, "runtime_path"), + }; + return { + id, + projectId: getString(row, "project_id"), + checkoutId: getString(row, "checkout_id"), + contextId: getString(row, "context_id"), + name: getString(row, "name"), + status: managedStackStatus(getString(row, "status")), + lifecycle: managedStackLifecycle(getString(row, "lifecycle")), + runtimeRequest: managedRuntimeRequest(getString(row, "runtime_request")), + runtime: managedRuntime(getOptionalString(row, "runtime")), + paths, + ports, + serviceVersions: decodeStringRecord(parseJson(getString(row, "service_versions_json"))), + runtimeMetadata: decodeRuntimeMetadata(parseJson(getString(row, "runtime_metadata_json"))), + configFingerprint: getOptionalString(row, "config_fingerprint"), + credentialsReference: getOptionalString(row, "credentials_reference"), + createdAt: getString(row, "created_at"), + updatedAt: getString(row, "updated_at"), + tombstonedAt: getOptionalString(row, "tombstoned_at"), + }; +}; + +const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStackRecord => + decodeStackWithPorts(row, queryPorts(database, getString(row, "id"))); + +const decodeOperation = (row: unknown): ManagedOperationRecord => ({ + token: getString(row, "token"), + stackId: getString(row, "stack_id"), + kind: managedOperationKind(getString(row, "kind")), + status: managedOperationStatus(getString(row, "status")), + ownerPid: getOptionalNumber(row, "owner_pid"), + startedAt: getString(row, "started_at"), + finishedAt: getOptionalString(row, "finished_at"), + error: getOptionalString(row, "error"), +}); + +const getStack = ( + database: ManagedSqliteDatabase, + stackId: string, +): ManagedStackRecord | undefined => { + const row = database.prepare("SELECT * FROM stacks WHERE id = ?").get([stackId]); + return row === undefined ? undefined : decodeStack(database, row); +}; + +const requireStack = (database: ManagedSqliteDatabase, stackId: string): ManagedStackRecord => { + const stack = getStack(database, stackId); + if (stack === undefined) { + throw new ManagedStackNotFoundError({ stackId }); + } + return stack; +}; + +const getActiveOperation = ( + database: ManagedSqliteDatabase, + stackId: string, +): ManagedOperationRecord | undefined => { + const row = database + .prepare("SELECT * FROM operations WHERE stack_id = ? AND status = 'active'") + .get([stackId]); + return row === undefined ? undefined : decodeOperation(row); +}; + +const requireOwnedOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, +): ManagedOperationRecord => { + const operation = getActiveOperation(database, stackId); + if (operation === undefined || operation.token !== operationToken) { + throw new ManagedOperationOwnershipError({ stackId }); + } + return operation; +}; + +const replacePorts = ( + database: ManagedSqliteDatabase, + stackId: string, + ports: ReadonlyArray, + lifecycle: ManagedStackLifecycle, +): void => { + validateManagedPortAssignments(stackId, ports); + if (managedStackOccupiesPorts(lifecycle)) { + for (const assignment of ports) { + const owner = database + .prepare( + `SELECT ports.stack_id + FROM ports + JOIN stacks ON stacks.id = ports.stack_id + WHERE ports.port = ? AND ports.stack_id != ? + AND stacks.status != 'tombstoned' + AND stacks.lifecycle IN ('starting', 'running', 'stopping')`, + ) + .get([assignment.port, stackId]); + if (owner !== undefined) { + throw new ManagedPortReservationError({ + port: assignment.port, + ownerStackId: getString(owner, "stack_id"), + }); + } + } + } + database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); + const insert = database.prepare( + "INSERT INTO ports (stack_id, key, port, intent) VALUES (?, ?, ?, ?)", + ); + for (const assignment of ports) { + insert.run([stackId, assignment.key, assignment.port, assignment.intent]); + } +}; + +const claimOperation = ( + database: ManagedSqliteDatabase, + input: ClaimManagedOperationInput, +): ClaimManagedOperationResult => { + requireStack(database, input.stackId); + const active = getActiveOperation(database, input.stackId); + if (active !== undefined) { + return { acquired: false, operation: active }; + } + database + .prepare( + `INSERT INTO operations + (token, stack_id, kind, status, owner_pid, started_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + ) + .run([input.token, input.stackId, input.kind, input.ownerPid ?? null, input.now]); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); + } + return { acquired: true, operation }; +}; + +const insertConfiguration = ( + database: ManagedSqliteDatabase, + input: PrepareOrdinaryStackInput, +): void => { + const runtimeMetadata: ManagedRuntimeMetadata = input.configuration.runtimeMetadata ?? { + processIds: {}, + containerIds: {}, + }; + database + .prepare( + `INSERT INTO stacks ( + id, project_id, checkout_id, context_id, name, status, lifecycle, + runtime_request, runtime, root_path, data_path, logs_path, runtime_path, + config_fingerprint, credentials_reference, service_versions_json, + runtime_metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run([ + input.stackId, + input.identity.projectId, + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + input.configuration.lifecycle ?? "stopped", + input.configuration.runtimeRequest ?? "auto", + input.configuration.runtime ?? null, + input.paths.root, + input.paths.data, + input.paths.logs, + input.paths.runtime, + input.configuration.configFingerprint ?? null, + input.configuration.credentialsReference ?? null, + JSON.stringify(input.configuration.serviceVersions ?? {}), + JSON.stringify(runtimeMetadata), + input.now, + input.now, + ]); + replacePorts( + database, + input.stackId, + input.configuration.ports ?? [], + input.configuration.lifecycle ?? "stopped", + ); +}; + +const prepareOrdinaryStack = ( + database: ManagedSqliteDatabase, + input: PrepareOrdinaryStackInput, +): PrepareOrdinaryStackResult => { + database + .prepare("INSERT OR IGNORE INTO projects (id, created_at) VALUES (?, ?)") + .run([input.identity.projectId, input.now]); + + const checkoutRow = database + .prepare("SELECT project_id FROM checkouts WHERE id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutRow !== undefined && + getString(checkoutRow, "project_id") !== input.identity.projectId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutRow, "project_id"), + requestedClaim: input.identity.projectId, + }); + } + database + .prepare("INSERT OR IGNORE INTO checkouts (id, project_id, created_at) VALUES (?, ?, ?)") + .run([input.identity.checkoutId, input.identity.projectId, input.now]); + + const contextRow = database + .prepare("SELECT checkout_id FROM contexts WHERE id = ?") + .get([input.identity.contextId]); + if ( + contextRow !== undefined && + getString(contextRow, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: getString(contextRow, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); + } + database + .prepare(`INSERT OR IGNORE INTO contexts (id, checkout_id, created_at) VALUES (?, ?, ?)`) + .run([input.identity.contextId, input.identity.checkoutId, input.now]); + + const checkoutLocation = database + .prepare("SELECT * FROM checkout_locations WHERE checkout_id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutLocation !== undefined && + getString(checkoutLocation, "canonical_path") !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutLocation, "canonical_path"), + requestedClaim: input.canonicalPath, + }); + } + const pathLocation = database + .prepare("SELECT * FROM checkout_locations WHERE canonical_path = ?") + .get([input.canonicalPath]); + if ( + pathLocation !== undefined && + getString(pathLocation, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: getString(pathLocation, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); + } + if (checkoutLocation === undefined) { + database + .prepare( + `INSERT INTO checkout_locations + (id, checkout_id, canonical_path, last_seen_at) + VALUES (?, ?, ?, ?)`, + ) + .run([input.locationId, input.identity.checkoutId, input.canonicalPath, input.now]); + } else { + database + .prepare("UPDATE checkout_locations SET last_seen_at = ? WHERE id = ?") + .run([input.now, getString(checkoutLocation, "id")]); + } + + const existingRow = database + .prepare( + `SELECT * FROM stacks + WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, + ) + .get([input.identity.checkoutId, input.identity.contextId, input.stackName]); + if (existingRow !== undefined) { + const stack = decodeStack(database, existingRow); + const operation = getActiveOperation(database, stack.id); + return { outcome: "existing", stack, operation }; + } + + insertConfiguration(database, input); + database + .prepare( + `INSERT INTO operations + (token, stack_id, kind, status, owner_pid, started_at) + VALUES (?, ?, 'start', 'active', ?, ?)`, + ) + .run([input.operationToken, input.stackId, input.ownerPid ?? null, input.now]); + const stack = requireStack(database, input.stackId); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); + } + return { outcome: "create", stack, operation }; +}; + +const publishPendingStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + now: string, +): ManagedStackRecord => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare("UPDATE stacks SET status = 'active', updated_at = ? WHERE id = ?") + .run([now, stackId]); + database + .prepare( + `UPDATE operations + SET status = 'completed', finished_at = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([now, operationToken, stackId]); + return requireStack(database, stackId); +}; + +const abortPendingStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, +): void => { + requireOwnedOperation(database, stackId, operationToken); + const stack = requireStack(database, stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError({ stackId }); + } + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); +}; + +const selectStacks = ( + database: ManagedSqliteDatabase, + options?: { readonly includeTombstoned?: boolean }, +): ReadonlyArray => { + const rows = + options?.includeTombstoned === true + ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() + : database + .prepare("SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id") + .all(); + const portsByStack = queryPortsByStack( + database, + rows.map((row) => getString(row, "id")), + ); + return rows.map((row) => decodeStackWithPorts(row, portsByStack.get(getString(row, "id")) ?? [])); +}; + +const finishOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, +): void => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare( + `UPDATE operations + SET status = ?, finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([outcome, now, error ?? null, operationToken, stackId]); +}; + +const updateStack = ( + database: ManagedSqliteDatabase, + input: UpdateManagedStackInput, +): ManagedStackRecord => { + requireOwnedOperation(database, input.stackId, input.operationToken); + const current = requireStack(database, input.stackId); + assertManagedStackUpdatable(current); + const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; + const runtime = input.runtime ?? current.runtime; + const lifecycle = input.lifecycle ?? current.lifecycle; + const serviceVersions = input.serviceVersions ?? current.serviceVersions; + const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; + const configFingerprint = input.configFingerprint ?? current.configFingerprint; + const credentialsReference = input.credentialsReference ?? current.credentialsReference; + const ports = reconcileManagedPortAssignments(current, input.ports, lifecycle); + database + .prepare( + `UPDATE stacks SET + lifecycle = ?, runtime_request = ?, runtime = ?, + service_versions_json = ?, runtime_metadata_json = ?, + config_fingerprint = ?, credentials_reference = ?, updated_at = ? + WHERE id = ?`, + ) + .run([ + lifecycle, + runtimeRequest, + runtime ?? null, + JSON.stringify(serviceVersions), + JSON.stringify(runtimeMetadata), + configFingerprint ?? null, + credentialsReference ?? null, + input.now, + input.stackId, + ]); + replacePorts(database, input.stackId, ports, lifecycle); + return requireStack(database, input.stackId); +}; + +const selectActiveOperations = ( + database: ManagedSqliteDatabase, + startedBefore?: string, +): ReadonlyArray => { + // The token tie-break keeps claims that share one `startedAt` in a + // defined order instead of whatever order the sorter happens to emit. + const rows = + startedBefore === undefined + ? database + .prepare("SELECT * FROM operations WHERE status = 'active' ORDER BY started_at, token") + .all() + : database + .prepare( + `SELECT * FROM operations + WHERE status = 'active' AND started_at < ? ORDER BY started_at, token`, + ) + .all([startedBefore]); + return rows.map(decodeOperation); +}; + +const reconcileOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + lifecycle: ManagedStackLifecycle, + now: string, +): ReconcileManagedOperationResult => { + requireOwnedOperation(database, stackId, operationToken); + const current = requireStack(database, stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + database + .prepare( + `UPDATE operations SET + status = 'failed', finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([now, "Recovered after an abandoned deletion", operationToken, stackId]); + return { outcome: "tombstoned", stack: current }; + } + if (current.status === "pending" && lifecycle === "stopped") { + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); + return { outcome: "discarded" }; + } + replacePorts(database, stackId, current.ports, lifecycle); + database + .prepare( + `UPDATE stacks SET + status = CASE WHEN status = 'pending' THEN 'active' ELSE status END, + lifecycle = ?, updated_at = ? + WHERE id = ?`, + ) + .run([lifecycle, now, stackId]); + database + .prepare( + `UPDATE operations SET + status = 'failed', finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([now, `Recovered after runtime reconciliation (${lifecycle})`, operationToken, stackId]); + return { outcome: "recovered", stack: requireStack(database, stackId) }; +}; + +const tombstoneStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + now: string, +): ManagedStackRecord => { + requireOwnedOperation(database, stackId, operationToken); + requireStack(database, stackId); + database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); + database + .prepare( + `UPDATE stacks SET + status = 'tombstoned', lifecycle = 'stopped', + runtime_metadata_json = ?, updated_at = ?, tombstoned_at = ? + WHERE id = ?`, + ) + .run([JSON.stringify({ processIds: {}, containerIds: {} }), now, now, stackId]); + return requireStack(database, stackId); +}; + +const selectCheckoutLocations = ( + database: ManagedSqliteDatabase, +): ReadonlyArray => + database + .prepare("SELECT * FROM checkout_locations ORDER BY canonical_path") + .all() + .map( + (row): ManagedCheckoutLocation => ({ + id: getString(row, "id"), + checkoutId: getString(row, "checkout_id"), + canonicalPath: getString(row, "canonical_path"), + lastSeenAt: getString(row, "last_seen_at"), + }), + ); + +const pruneCheckoutLocations = ( + database: ManagedSqliteDatabase, + locationIds: ReadonlyArray, +): number => { + let removed = 0; + const statement = database.prepare("DELETE FROM checkout_locations WHERE id = ?"); + for (const id of new Set(locationIds)) { + const existing = database.prepare("SELECT id FROM checkout_locations WHERE id = ?").get([id]); + if (existing !== undefined) { + statement.run([id]); + removed += 1; + } + } + return removed; +}; + +/** + * The owner pid is validated before the transaction opens: it is the caller's + * own input, not a decision about persisted state, and a value recovery could + * never probe must not even begin a write. + */ +const requireOwnerPid = ( + ownerPid: number | undefined, +): Effect.Effect => + Effect.try({ + try: () => { + assertManagedOwnerPid(ownerPid); + }, + catch: failsWith(InvalidManagedOwnerPidError), + }); + +/** + * Binds the registry contract to an open SQLite handle. + * + * The schema is initialized as part of building the repository, so a registry + * written by an unsupported version fails here rather than at the first query. + * Closing the handle belongs to the layer that opened it — see + * {@link sqliteManagedStackRepositoryLayer} — so the contract has no `close` + * method for a caller to forget. + */ +const createSqliteManagedStackRepository = ( + database: ManagedSqliteDatabase, +): Effect.Effect => + Effect.gen(function* () { + yield* initializeRegistry(database); + + return { + prepareOrdinaryStack: (input) => + Effect.flatMap(requireOwnerPid(input.ownerPid), () => + transaction( + database, + () => prepareOrdinaryStack(database, input), + failsWith( + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + ), + ), + publishPendingStack: (stackId, operationToken, now) => + transaction( + database, + () => publishPendingStack(database, stackId, operationToken, now), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + abortPendingStack: (stackId, operationToken) => + transaction( + database, + () => abortPendingStack(database, stackId, operationToken), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + getStack: (stackId) => readTransaction(database, () => getStack(database, stackId)), + listStacks: (options) => readTransaction(database, () => selectStacks(database, options)), + claimOperation: (input) => + Effect.flatMap(requireOwnerPid(input.ownerPid), () => + transaction( + database, + () => claimOperation(database, input), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + ), + finishOperation: (stackId, operationToken, outcome, now, error) => + transaction( + database, + () => finishOperation(database, stackId, operationToken, outcome, now, error), + failsWith(ManagedOperationOwnershipError), + ), + updateStack: (input) => + transaction( + database, + () => updateStack(database, input), + failsWith( + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + ), + ), + listActiveOperations: (startedBefore) => + Effect.sync(() => selectActiveOperations(database, startedBefore)), + reconcileOperation: (stackId, operationToken, lifecycle, now) => + transaction( + database, + () => reconcileOperation(database, stackId, operationToken, lifecycle, now), + failsWith( + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + ), + tombstoneStack: (stackId, operationToken, now) => + transaction( + database, + () => tombstoneStack(database, stackId, operationToken, now), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + listCheckoutLocations: () => Effect.sync(() => selectCheckoutLocations(database)), + pruneCheckoutLocations: (locationIds) => + transaction(database, () => pruneCheckoutLocations(database, locationIds), neverFails), + }; + }); + +/** + * The registry stores workspace paths, ports, and credential references that + * other local users must not read. Pre-create the database file with an + * owner-only mode so it never exists with umask-derived permissions, and + * retighten both it and a directory left looser by an earlier build. Doing this + * before the WAL conversion also makes the -wal/-shm sidecars inherit the + * owner-only mode. + */ +export const hardenManagedRegistryFile = (path: string): void => { + if (path === ":memory:") { + return; + } + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + chmodSync(dirname(path), 0o700); + closeSync(openSync(path, "a", 0o600)); + chmodSync(path, 0o600); +}; + +/** + * The registry as a scoped layer: the handle is opened when the layer is built + * and closed when its scope closes, including when schema initialization refuses + * the registry, so no failure path can leak an open database. + * + * Building this layer is I/O and may suspend: a cold start racing another + * process' WAL conversion waits on a schedule before trying again, so the layer + * must be built through a runner that can suspend rather than `Effect.runSync`. + */ +export const sqliteManagedStackRepositoryLayer = ( + openDatabase: () => ManagedSqliteDatabase, +): Layer.Layer => + Layer.effect( + ManagedStackRepository, + Effect.gen(function* () { + // Opening the handle and registering its close are one acquisition, so no + // interruption can land between them and leak the open database. + const database = yield* Effect.acquireRelease(Effect.sync(openDatabase), (open) => + Effect.sync(() => { + open.close(); + }), + ); + return yield* createSqliteManagedStackRepository(database); + }), + ); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts index 96d6252165..bbd2cb2551 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -16,7 +16,7 @@ export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { const requestInit: BunUnixRequestInit = { ...init, unix: socketPath }; return fetch(`http://localhost${path}`, requestInit); }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause, reason: "transport" }), }), }); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index bdcc9c85b3..22454111c8 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -91,7 +91,7 @@ export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { request.end(body); }); }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause, reason: "transport" }), }), }); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 206459eeeb..6d4d02e961 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,3 +1,22 @@ /** Test-only service tags for building deterministic consumer layers. */ export { DaemonServer } from "./DaemonServer.ts"; +export type { + ManagedStackContractArea, + ManagedStackContractAction, + ManagedStackContractEffects, + ManagedStackContractExpectation, + ManagedStackContractFact, + ManagedStackContractJson, + ManagedStackContractOutput, + ManagedStackContractScenario, + ManagedNativeServiceMatrix, +} from "./managed-stack-contract.ts"; +export { + managedNativePlatformByNodeTarget, + managedNativePlatformFromNode, + managedNativeServiceMatrix, + managedStackContractFixtures, +} from "./managed-stack-contract.ts"; +export { validateManagedStackContractFixtures } from "./managed-stack-contract-validation.ts"; +export { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4624e456dd..b61fdda892 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,13 +224,16 @@ importers: version: 7.0.1 semantic-release: specifier: ^25.0.8 - version: 25.0.8(typescript@7.0.2) + version: 25.0.8(@typescript/typescript6@6.0.2) smol-toml: specifier: ^1.7.1 version: 1.7.1 tldts: specifier: 'catalog:' version: 7.4.9 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -8776,7 +8779,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.8(typescript@7.0.2))': + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.8(@typescript/typescript6@6.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -8786,13 +8789,13 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 micromatch: 4.0.8 - semantic-release: 25.0.8(typescript@7.0.2) + semantic-release: 25.0.8(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color '@semantic-release/error@4.0.0': {} - '@semantic-release/github@12.0.9(semantic-release@25.0.8(typescript@7.0.2))': + '@semantic-release/github@12.0.9(semantic-release@25.0.8(@typescript/typescript6@6.0.2))': dependencies: '@octokit/core': 7.0.6 '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) @@ -8808,7 +8811,7 @@ snapshots: lodash-es: 4.18.1 mime: 4.1.0 p-filter: 4.1.0 - semantic-release: 25.0.8(typescript@7.0.2) + semantic-release: 25.0.8(@typescript/typescript6@6.0.2) tinyglobby: 0.2.17 undici: 7.29.0 url-join: 5.0.0 @@ -8816,7 +8819,7 @@ snapshots: - kerberos - supports-color - '@semantic-release/npm@13.1.5(semantic-release@25.0.8(typescript@7.0.2))': + '@semantic-release/npm@13.1.5(semantic-release@25.0.8(@typescript/typescript6@6.0.2))': dependencies: '@actions/core': 3.0.1 '@semantic-release/error': 4.0.0 @@ -8831,11 +8834,11 @@ snapshots: rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 - semantic-release: 25.0.8(typescript@7.0.2) + semantic-release: 25.0.8(@typescript/typescript6@6.0.2) semver: 7.8.5 tempy: 3.2.0 - '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.8(typescript@7.0.2))': + '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.8(@typescript/typescript6@6.0.2))': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 8.4.0 @@ -8845,7 +8848,7 @@ snapshots: import-from-esm: 2.0.0 lodash-es: 4.18.1 read-package-up: 11.0.0 - semantic-release: 25.0.8(typescript@7.0.2) + semantic-release: 25.0.8(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color @@ -9934,14 +9937,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig@9.0.2(typescript@7.0.2): + cosmiconfig@9.0.2(@typescript/typescript6@6.0.2): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' cross-spawn@7.0.6: dependencies: @@ -12903,15 +12906,15 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - semantic-release@25.0.8(typescript@7.0.2): + semantic-release@25.0.8(@typescript/typescript6@6.0.2): dependencies: - '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.8(typescript@7.0.2)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.8(@typescript/typescript6@6.0.2)) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 12.0.9(semantic-release@25.0.8(typescript@7.0.2)) - '@semantic-release/npm': 13.1.5(semantic-release@25.0.8(typescript@7.0.2)) - '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.8(typescript@7.0.2)) + '@semantic-release/github': 12.0.9(semantic-release@25.0.8(@typescript/typescript6@6.0.2)) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.8(@typescript/typescript6@6.0.2)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.8(@typescript/typescript6@6.0.2)) aggregate-error: 5.0.0 - cosmiconfig: 9.0.2(typescript@7.0.2) + cosmiconfig: 9.0.2(@typescript/typescript6@6.0.2) debug: 4.4.3(supports-color@7.2.0) env-ci: 11.2.0 execa: 9.6.1